handlerResource = $handlerResource; $this->items = new JavaScriptItems(); $this->importMap = GeneralUtility::makeInstance(ImportMapFactory::class)->create(); } public function addGlobalAssignment(array $payload): void { $this->items->addGlobalAssignment($payload); } public function addJavaScriptModuleInstruction(JavaScriptModuleInstruction $instruction): void { if ($instruction->shallLoadImportMap()) { $this->importMap->includeImportsFor($instruction->getName()); } $this->javaScriptModuleInstructionFlags |= $instruction->getFlags(); if ($instruction->getItems() !== []) { $this->instructionsWithItems++; } $this->items->addJavaScriptModuleInstruction($instruction); } public function hasImportMap(): bool { return ($this->javaScriptModuleInstructionFlags & JavaScriptModuleInstruction::FLAG_LOAD_IMPORTMAP) === JavaScriptModuleInstruction::FLAG_LOAD_IMPORTMAP; } /** * HEADS UP: Do only use in authenticated mode as this discloses as installed extensions */ public function includeAllImports(): void { $this->importMap->includeAllImports(); } public function includeTaggedImports(string $tag): void { $this->importMap->includeTaggedImports($tag); } /** * @return list * @internal */ public function toArray(): array { if ($this->isEmpty()) { return []; } return $this->items->toArray(); } /** * @throws \InvalidArgumentException when a JavaScript module could not be resolved (no src URL in import map) */ public function render(string|ConsumableNonce|null $nonce, string $uriPrefix): string { if ($this->isEmpty()) { return ''; } $scriptTags = []; $modules = []; $dynamicInstructions = []; foreach ($this->items->getJavascriptModuleInstructions() as $instruction) { $moduleName = $instruction->getName(); $url = $this->importMap->resolveImport($moduleName, true, $uriPrefix); if ($url === null) { throw new \InvalidArgumentException( sprintf( 'JavaScript module "%s" could not be resolved. (Missing entry in Configuration/JavaScriptModules.php?).', $moduleName ), 1728220800 ); } if ( $instruction->getItems() !== [] || ($instruction->getFlags() & JavaScriptModuleInstruction::FLAG_USE_TOP_WINDOW) !== 0 ) { $dynamicInstructions[] = [ 'type' => 'javaScriptModuleInstruction', 'payload' => $instruction, ]; } else { $modules[$moduleName] = $url; } } $globalAssignments = $this->mergeGlobalAssignments($this->items->getGlobalAssignments()); if ($globalAssignments !== []) { $scriptTags[] = $this->createScriptElement( ['nonce' => $nonce instanceof ConsumableNonce ? $nonce->consumeInline(Directive::ScriptSrcElem) : (string)$nonce], sprintf('Object.assign(globalThis, %s)', $this->jsonEncode($globalAssignments)) ); } $scriptTags = [ ...$scriptTags, ...array_map( fn(string $url): string => $this->createScriptElement([ 'type' => 'module', 'async' => 'async', 'src' => $url, ]), $modules ), ]; if ($dynamicInstructions !== []) { $scriptTags[] = $this->createItemHandlerElement($dynamicInstructions, true, $nonce, $uriPrefix); } return implode(PHP_EOL, $scriptTags); } public function renderImportMap(string $uriPrefix, string|ConsumableNonce|null $nonce = null): string { if (!$this->isEmpty() && ($this->instructionsWithItems > 0 || $this->items->getGlobalAssignments() !== [])) { $this->importMap->includeImportsFor('@typo3/core/java-script-item-handler.js'); } return $this->importMap->render($uriPrefix, $nonce); } protected function isEmpty(): bool { return $this->items->isEmpty(); } protected function createItemHandlerElement(array $payload, bool $async, string|ConsumableNonce|null $nonce, string $uriPrefix): string { // actual JSON payload is stored as comment in `script.textContent` // and consumed by java-script-item-handler.js return $this->createScriptElement( [ 'src' => PathUtility::getSystemResourceUri( $this->handlerResource, null, new UriGenerationOptions( uriPrefix: $uriPrefix, cacheBusting: false, ) ), 'nonce' => (string)$nonce, 'async' => $async ? 'async' : '', ], '/* ' . $this->jsonEncode($payload) . ' */' ); } protected function createScriptElement(array $attributes, string $textContent = ''): string { if (empty($attributes)) { return ''; } $attributesPart = GeneralUtility::implodeAttributes($attributes, true); return sprintf('%s', $attributesPart ? ' ' : '', $attributesPart, $textContent); } protected function jsonEncode($value): string { return (string)json_encode($value, JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG); } protected function mergeGlobalAssignments(array $assignments): array { $globalAssignments = []; foreach ($assignments as $assignment) { // Merge `window` one level up, as we target `globalThis` which is window. // Needed because we assign to globalThis and must not overwrite window entirely, // but only merge to it and because we want to forbid nested assignments like // `window.parent.foo` below. if (isset($assignment['window'])) { $assignment = [ ...$assignment, ...$assignment['window'], ]; unset($assignment['window']); } $globalAssignments = array_merge_recursive($globalAssignments, $assignment); } // deny indirect global assignments (not for security reasons, but for reducing // the chance of hard-to-debug side-effects) unset($globalAssignments['window']); unset($globalAssignments['parent']); unset($globalAssignments['globalThis']); unset($globalAssignments['document']); // filter potential prototype pollution side-effects return ArrayUtility::filterRecursive( $globalAssignments, static fn(string $key): bool => match ($key) { '__proto__', 'prototype', 'constructor' => false, default => true, }, ARRAY_FILTER_USE_KEY ); } /** * @internal */ public function updateState(array $state): void { foreach ($state as $var => $value) { switch ($var) { case 'items': $this->items->updateState($value); break; case 'importMap': $this->importMap->updateState($value); break; default: $this->{$var} = $value; break; } } } /** * @internal */ public function getState(): array { $state = []; foreach (get_object_vars($this) as $var => $value) { switch ($var) { case 'items': $state[$var] = $this->items->getState(); break; case 'importMap': $state[$var] = $this->importMap->getState(); break; default: $state[$var] = $value; break; } } return $state; } }