$packages */ public function __construct( protected readonly HashService $hashService, protected readonly array $packages, protected readonly ?PolicyRegistry $policyRegistry = null, protected readonly ?FrontendInterface $cache = null, protected readonly string $cacheIdentifier = '', protected readonly ?EventDispatcherInterface $eventDispatcher = null, protected readonly bool $bustSuffix = true ) {} /** * HEADS UP: Do only use in authenticated mode as this discloses as installed extensions */ public function includeAllImports(): void { $this->extensionsToLoad['*'] = true; } public function includeTaggedImports(string $tag): void { if (isset($this->extensionsToLoad['*'])) { return; } foreach ($this->getImportMaps() as $package => $config) { $tags = $config['tags'] ?? []; if (in_array($tag, $tags, true)) { $this->loadDependency($package); } } } public function includeImportsFor(string $specifier): void { if (!isset($this->extensionsToLoad['*'])) { $this->resolveImport($specifier, true); } else { $this->dispatchResolveJavaScriptImportEvent($specifier, true); } } /** * @return ?non-empty-string */ public function resolveImport( string $specifier, bool $loadImportConfiguration = true, string $uriPrefix = '/' ): ?string { $resolution = $this->dispatchResolveJavaScriptImportEvent($specifier, $loadImportConfiguration); if ($resolution !== null) { return $resolution; } foreach (array_reverse($this->getImportMaps()) as $package => $config) { $imports = $config['imports'] ?? []; if (isset($imports[$specifier])) { if ($loadImportConfiguration) { $this->loadDependency($package); } return $this->getResourceUri($imports[$specifier], $uriPrefix); } $specifierParts = explode('/', $specifier); $specifierPartCount = count($specifierParts); for ($i = 1; $i < $specifierPartCount; ++$i) { $prefix = implode('/', array_slice($specifierParts, 0, $i)) . '/'; if (isset($imports[$prefix])) { if ($loadImportConfiguration) { $this->loadDependency($package); } return $this->getResourceUri($imports[$prefix] . implode('/', array_slice($specifierParts, $i)), $uriPrefix); } } } return null; } public function render( string $uriPrefix, string|ConsumableNonce|null $nonce ): string { if (count($this->extensionsToLoad) === 0 || count($this->getImportMaps()) === 0) { return ''; } $html = []; $importMap = $this->composeImportMap($uriPrefix); $json = json_encode( $importMap, JSON_FORCE_OBJECT | JSON_UNESCAPED_SLASHES | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_THROW_ON_ERROR ); $attributes = [ 'type' => 'importmap', ]; if ($nonce !== null) { $attributes['nonce'] = $nonce instanceof ConsumableNonce ? $nonce->consumeInline(Directive::ScriptSrcElem) : $nonce; } else { $this->policyRegistry?->appendMutationCollection( new MutationCollection( new Mutation(MutationMode::Extend, Directive::ScriptSrcElem, HashValue::hash($json)) ) ); } $html[] = sprintf( '', GeneralUtility::implodeAttributes($attributes, true), $json ); return implode(PHP_EOL, $html) . PHP_EOL; } public function warmupCaches(): void { $this->computeImportMaps(); } protected function getImportMaps(): array { return $this->importMaps ?? $this->getFromCache() ?? $this->computeImportMaps(); } protected function getFromCache(): ?array { if ($this->cache === null) { return null; } if (!$this->cache->has($this->cacheIdentifier)) { return null; } $importMaps = $this->cache->get($this->cacheIdentifier); if ($importMaps === false) { // Cache entry has been removed in the meantime return null; } if (!is_array($importMaps)) { // An invalid result is to be ignored (cache will be recreated) return null; } $this->importMaps = $importMaps; return $importMaps; } protected function computeImportMaps(): array { $extensionVersions = []; $importMaps = []; foreach ($this->packages as $package) { $configurationFile = $package->getPackagePath() . 'Configuration/JavaScriptModules.php'; if (!is_readable($configurationFile)) { continue; } $extensionVersions[$package->getPackageKey()] = implode(':', [ $package->getPackageKey(), $package->getPackageMetadata()->getVersion(), ]); $packageConfiguration = require($configurationFile); $importMaps[$package->getPackageKey()] = $packageConfiguration ?? []; } $isDevelopment = Environment::getContext()->isDevelopment(); if ($isDevelopment) { $bust = (string)$GLOBALS['EXEC_TIME']; } else { $bust = $this->hashService->hmac( Environment::getProjectPath() . implode('|', $extensionVersions), self::class ); } foreach ($importMaps as $packageName => $config) { $importMaps[$packageName]['imports'] = $this->resolvePaths( $config['imports'] ?? [], $this->bustSuffix ? $bust : null ); } $this->importMaps = $importMaps; if ($this->cache !== null) { $this->cache->set($this->cacheIdentifier, $importMaps); } return $importMaps; } protected function resolveRecursiveImportMap( string $prefix, string $pathResourceIdentifier, array $exclude, string $bust ): array { // @todo resolve with resource directory object once available $absolutePath = GeneralUtility::getFileAbsFileName($pathResourceIdentifier); if (!$absolutePath || @!is_dir($absolutePath)) { return []; } $exclude = array_map( static fn(string $excludePath): string => GeneralUtility::getFileAbsFileName($excludePath), $exclude ); $fileIterator = new \RegexIterator( new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($absolutePath) ), '#^' . preg_quote($absolutePath, '#') . '(.+\.js)$#', \RegexIterator::GET_MATCH ); $map = []; foreach ($fileIterator as $match) { $fileName = $match[0]; $specifier = $prefix . ($match[1] ?? ''); $resourceIdentifier = $pathResourceIdentifier . ($match[1] ?? ''); // @todo: Abstract into an iterator? foreach ($exclude as $excludedPath) { if (str_starts_with($fileName, $excludedPath)) { continue 2; } } $map[$specifier] = $resourceIdentifier . '?bust=' . $bust; } return $map; } protected function resolvePaths( array $imports, ?string $bust = null ): array { $cacheBustingSpecifiers = []; foreach ($imports as $specifier => $address) { if (is_string($address) && str_starts_with($address, 'VIRTUAL:')) { $imports[$specifier] = $address; continue; } if (str_ends_with($specifier, '/')) { $resourceIdentifier = is_array($address) ? ($address['path'] ?? '') : $address; $exclude = is_array($address) ? ($address['exclude'] ?? []) : []; if ($bust !== null) { // Resolve recursive importmap in order to add a bust suffix // to each file. $cacheBustingSpecifiers[] = $this->resolveRecursiveImportMap($specifier, $resourceIdentifier, $exclude, $bust); } } else { $resourceIdentifier = $address; if ($bust !== null) { $resourceIdentifier .= '?bust=' . $bust; } } $imports[$specifier] = $resourceIdentifier; } return $imports + array_merge(...$cacheBustingSpecifiers); } protected function loadDependency(string $packageName): void { if (isset($this->extensionsToLoad[$packageName])) { return; } $this->extensionsToLoad[$packageName] = true; $dependencies = $this->getImportMaps()[$packageName]['dependencies'] ?? []; foreach ($dependencies as $dependency) { $this->loadDependency($dependency); } } protected function composeImportMap(string $uriPrefix): array { $importMaps = $this->getImportMaps(); if (!isset($this->extensionsToLoad['*'])) { $importMaps = array_intersect_key($importMaps, $this->extensionsToLoad); } $importMap = []; foreach ($importMaps as $singleImportMap) { ArrayUtility::mergeRecursiveWithOverrule($importMap, $singleImportMap); } unset($importMap['dependencies']); unset($importMap['tags']); foreach ($importMap['imports'] ?? [] as $specifier => $resourceIdentifier) { if (str_starts_with($resourceIdentifier, 'VIRTUAL:')) { $virtualName = substr($resourceIdentifier, 8); $resolved = $this->dispatchResolveVirtualJavaScriptImportEvent($virtualName); if ($resolved === null) { unset($importMap['imports'][$specifier]); continue; } } else { $resolved = $this->getResourceUri($resourceIdentifier, $uriPrefix); } $importMap['imports'][$specifier] = $resolved; } return $importMap; } /** * @throws CanNotResolvePublicResourceException * @throws CanNotResolveSystemResourceException */ protected function getResourceUri(string $resourceIdentifier, $uriPrefix): string { return (string)PathUtility::getSystemResourceUri( $resourceIdentifier, null, new UriGenerationOptions( uriPrefix: $uriPrefix, cacheBusting: false, ) ); } /** * @return ?non-empty-string */ protected function dispatchResolveJavaScriptImportEvent( string $specifier, bool $loadImportConfiguration = true ): ?string { if ($this->eventDispatcher === null) { return null; } return $this->eventDispatcher->dispatch( new ResolveJavaScriptImportEvent($specifier, $loadImportConfiguration, $this) )->resolution; } /** * @return ?non-empty-string */ protected function dispatchResolveVirtualJavaScriptImportEvent( string $specifier, ): ?string { if ($this->eventDispatcher === null) { return null; } return $this->eventDispatcher->dispatch( new ResolveVirtualJavaScriptImportEvent($specifier, $this) )->resolution; } /** * @internal */ public function updateState(array $state): void { $this->extensionsToLoad = $state['extensionsToLoad'] ?? []; } /** * @internal */ public function getState(): array { return [ 'extensionsToLoad' => $this->extensionsToLoad, ]; } }