diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php index d31573031c656..046bd8c6c5fde 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php @@ -31,6 +31,7 @@ use Symfony\Component\AssetMapper\Factory\MappedAssetFactory; use Symfony\Component\AssetMapper\ImportMap\ImportMapAuditor; use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader; +use Symfony\Component\AssetMapper\ImportMap\ImportMapGenerator; use Symfony\Component\AssetMapper\ImportMap\ImportMapManager; use Symfony\Component\AssetMapper\ImportMap\ImportMapRenderer; use Symfony\Component\AssetMapper\ImportMap\ImportMapUpdateChecker; @@ -101,7 +102,7 @@ ->args([ service('asset_mapper.public_assets_path_resolver'), service('asset_mapper'), - service('asset_mapper.importmap.manager'), + service('asset_mapper.importmap.generator'), service('filesystem'), param('kernel.project_dir'), abstract_arg('public directory name'), @@ -137,7 +138,7 @@ ->set('asset_mapper.compiler.javascript_import_path_compiler', JavaScriptImportPathCompiler::class) ->args([ - service('asset_mapper.importmap.manager'), + service('asset_mapper.importmap.config_reader'), abstract_arg('missing import mode'), service('logger'), ]) @@ -153,13 +154,19 @@ ->set('asset_mapper.importmap.manager', ImportMapManager::class) ->args([ service('asset_mapper'), - service('asset_mapper.public_assets_path_resolver'), service('asset_mapper.importmap.config_reader'), service('asset_mapper.importmap.remote_package_downloader'), service('asset_mapper.importmap.resolver'), ]) ->alias(ImportMapManager::class, 'asset_mapper.importmap.manager') + ->set('asset_mapper.importmap.generator', ImportMapGenerator::class) + ->args([ + service('asset_mapper'), + service('asset_mapper.public_assets_path_resolver'), + service('asset_mapper.importmap.config_reader'), + ]) + ->set('asset_mapper.importmap.remote_package_storage', RemotePackageStorage::class) ->args([ abstract_arg('vendor directory'), @@ -183,7 +190,7 @@ ->set('asset_mapper.importmap.renderer', ImportMapRenderer::class) ->args([ - service('asset_mapper.importmap.manager'), + service('asset_mapper.importmap.generator'), service('assets.packages')->nullOnInvalid(), param('kernel.charset'), abstract_arg('polyfill URL'), @@ -205,7 +212,6 @@ ->set('asset_mapper.importmap.command.require', ImportMapRequireCommand::class) ->args([ service('asset_mapper.importmap.manager'), - param('kernel.project_dir'), service('asset_mapper.importmap.version_checker'), ]) ->tag('console.command') diff --git a/src/Symfony/Component/AssetMapper/Command/AssetMapperCompileCommand.php b/src/Symfony/Component/AssetMapper/Command/AssetMapperCompileCommand.php index a2fbc2d3e4709..ab5bf73916cfe 100644 --- a/src/Symfony/Component/AssetMapper/Command/AssetMapperCompileCommand.php +++ b/src/Symfony/Component/AssetMapper/Command/AssetMapperCompileCommand.php @@ -14,7 +14,7 @@ use Symfony\Component\AssetMapper\AssetMapper; use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\AssetMapper\Event\PreAssetsCompileEvent; -use Symfony\Component\AssetMapper\ImportMap\ImportMapManager; +use Symfony\Component\AssetMapper\ImportMap\ImportMapGenerator; use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -38,7 +38,7 @@ final class AssetMapperCompileCommand extends Command public function __construct( private readonly PublicAssetsPathResolverInterface $publicAssetsPathResolver, private readonly AssetMapperInterface $assetMapper, - private readonly ImportMapManager $importMapManager, + private readonly ImportMapGenerator $importMapGenerator, private readonly Filesystem $filesystem, private readonly string $projectDir, private readonly string $publicDirName, @@ -81,12 +81,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $manifestPath = $outputDir.'/'.AssetMapper::MANIFEST_FILE_NAME; $files[] = $manifestPath; - $importMapPath = $outputDir.'/'.ImportMapManager::IMPORT_MAP_CACHE_FILENAME; + $importMapPath = $outputDir.'/'.ImportMapGenerator::IMPORT_MAP_CACHE_FILENAME; $files[] = $importMapPath; $entrypointFilePaths = []; - foreach ($this->importMapManager->getEntrypointNames() as $entrypointName) { - $dumpedEntrypointPath = $outputDir.'/'.sprintf(ImportMapManager::ENTRYPOINT_CACHE_FILENAME_PATTERN, $entrypointName); + foreach ($this->importMapGenerator->getEntrypointNames() as $entrypointName) { + $dumpedEntrypointPath = $outputDir.'/'.sprintf(ImportMapGenerator::ENTRYPOINT_CACHE_FILENAME_PATTERN, $entrypointName); $files[] = $dumpedEntrypointPath; $entrypointFilePaths[$entrypointName] = $dumpedEntrypointPath; } @@ -105,12 +105,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->filesystem->dumpFile($manifestPath, json_encode($manifest, \JSON_PRETTY_PRINT)); $io->comment(sprintf('Manifest written to %s', $this->shortenPath($manifestPath))); - $this->filesystem->dumpFile($importMapPath, json_encode($this->importMapManager->getRawImportMapData(), \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_HEX_TAG)); + $this->filesystem->dumpFile($importMapPath, json_encode($this->importMapGenerator->getRawImportMapData(), \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_HEX_TAG)); $io->comment(sprintf('Import map data written to %s.', $this->shortenPath($importMapPath))); - $entrypointNames = $this->importMapManager->getEntrypointNames(); + $entrypointNames = $this->importMapGenerator->getEntrypointNames(); foreach ($entrypointFilePaths as $entrypointName => $path) { - $this->filesystem->dumpFile($path, json_encode($this->importMapManager->getEntrypointMetadata($entrypointName), \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_HEX_TAG)); + $this->filesystem->dumpFile($path, json_encode($this->importMapGenerator->findEagerEntrypointImports($entrypointName), \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_HEX_TAG)); } $styledEntrypointNames = array_map(fn (string $entrypointName) => sprintf('%s', $entrypointName), $entrypointNames); $io->comment(sprintf('Entrypoint metadata written for %d entrypoints (%s).', \count($entrypointNames), implode(', ', $styledEntrypointNames))); diff --git a/src/Symfony/Component/AssetMapper/Command/ImportMapRequireCommand.php b/src/Symfony/Component/AssetMapper/Command/ImportMapRequireCommand.php index a7402ee92020a..19b5dfbbe4ba6 100644 --- a/src/Symfony/Component/AssetMapper/Command/ImportMapRequireCommand.php +++ b/src/Symfony/Component/AssetMapper/Command/ImportMapRequireCommand.php @@ -33,7 +33,6 @@ final class ImportMapRequireCommand extends Command public function __construct( private readonly ImportMapManager $importMapManager, - private readonly string $projectDir, private readonly ImportMapVersionChecker $importMapVersionChecker, ) { parent::__construct(); diff --git a/src/Symfony/Component/AssetMapper/Compiler/JavaScriptImportPathCompiler.php b/src/Symfony/Component/AssetMapper/Compiler/JavaScriptImportPathCompiler.php index f1f33d71eedc0..481cbb9b90a69 100644 --- a/src/Symfony/Component/AssetMapper/Compiler/JavaScriptImportPathCompiler.php +++ b/src/Symfony/Component/AssetMapper/Compiler/JavaScriptImportPathCompiler.php @@ -15,7 +15,7 @@ use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\AssetMapper\Exception\CircularAssetsException; use Symfony\Component\AssetMapper\Exception\RuntimeException; -use Symfony\Component\AssetMapper\ImportMap\ImportMapManager; +use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader; use Symfony\Component\AssetMapper\ImportMap\JavaScriptImport; use Symfony\Component\AssetMapper\MappedAsset; @@ -32,7 +32,7 @@ final class JavaScriptImportPathCompiler implements AssetCompilerInterface private const IMPORT_PATTERN = '/(?:import\s*(?:(?:\*\s*as\s+\w+|[\w\s{},*]+)\s*from\s*)?|\bimport\()\s*[\'"`](\.\/[^\'"`]+|(\.\.\/)*[^\'"`]+)[\'"`]\s*[;\)]?/m'; public function __construct( - private readonly ImportMapManager $importMapManager, + private readonly ImportMapConfigReader $importMapConfigReader, private readonly string $missingImportMode = self::MISSING_IMPORT_WARN, private readonly ?LoggerInterface $logger = null, ) { @@ -139,7 +139,7 @@ private function isCommentedOut(mixed $offsetStart, string $fullContent): bool private function findAssetForBareImport(string $importedModule, AssetMapperInterface $assetMapper): ?MappedAsset { - if (!$importMapEntry = $this->importMapManager->findRootImportMapEntry($importedModule)) { + if (!$importMapEntry = $this->importMapConfigReader->findRootImportMapEntry($importedModule)) { // don't warn on missing non-relative (bare) imports: these could be valid URLs return null; diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapConfigReader.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapConfigReader.php index 8aaee7a3e1646..d282fe1bac747 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapConfigReader.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapConfigReader.php @@ -130,6 +130,13 @@ public function writeEntries(ImportMapEntries $entries): void EOF); } + public function findRootImportMapEntry(string $moduleName): ?ImportMapEntry + { + $entries = $this->getEntries(); + + return $entries->has($moduleName) ? $entries->get($moduleName) : null; + } + public function createRemoteEntry(string $importName, ImportMapType $type, string $version, string $packageModuleSpecifier, bool $isEntrypoint): ImportMapEntry { $path = $this->remotePackageStorage->getDownloadPath($packageModuleSpecifier, $type); diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapGenerator.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapGenerator.php new file mode 100644 index 0000000000000..bca5897c03c53 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapGenerator.php @@ -0,0 +1,255 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\ImportMap; + +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\AssetMapper\Exception\LogicException; +use Symfony\Component\AssetMapper\MappedAsset; +use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface; + +/** + * Provides data needed to write the importmap & preloads. + */ +class ImportMapGenerator +{ + public const IMPORT_MAP_CACHE_FILENAME = 'importmap.json'; + public const ENTRYPOINT_CACHE_FILENAME_PATTERN = 'entrypoint.%s.json'; + + public function __construct( + private readonly AssetMapperInterface $assetMapper, + private readonly PublicAssetsPathResolverInterface $assetsPathResolver, + private readonly ImportMapConfigReader $importMapConfigReader, + ) { + } + + /** + * @internal + */ + public function getEntrypointNames(): array + { + $rootEntries = $this->importMapConfigReader->getEntries(); + $entrypointNames = []; + foreach ($rootEntries as $entry) { + if ($entry->isEntrypoint) { + $entrypointNames[] = $entry->importName; + } + } + + return $entrypointNames; + } + + /** + * @param string[] $entrypointNames + * + * @return array + * + * @internal + */ + public function getImportMapData(array $entrypointNames): array + { + $rawImportMapData = $this->getRawImportMapData(); + $finalImportMapData = []; + foreach ($entrypointNames as $entrypointName) { + $entrypointImports = $this->findEagerEntrypointImports($entrypointName); + // Entrypoint modules must be preloaded before their dependencies + foreach ([$entrypointName, ...$entrypointImports] as $import) { + if (isset($finalImportMapData[$import])) { + continue; + } + + // Missing dependency - rely on browser or compilers to warn + if (!isset($rawImportMapData[$import])) { + continue; + } + + $finalImportMapData[$import] = $rawImportMapData[$import]; + $finalImportMapData[$import]['preload'] = true; + unset($rawImportMapData[$import]); + } + } + + return array_merge($finalImportMapData, $rawImportMapData); + } + + /** + * @internal + * + * @return array + */ + public function getRawImportMapData(): array + { + $dumpedImportMapPath = $this->assetsPathResolver->getPublicFilesystemPath().'/'.self::IMPORT_MAP_CACHE_FILENAME; + if (is_file($dumpedImportMapPath)) { + return json_decode(file_get_contents($dumpedImportMapPath), true, 512, \JSON_THROW_ON_ERROR); + } + + $allEntries = []; + foreach ($this->importMapConfigReader->getEntries() as $rootEntry) { + $allEntries[$rootEntry->importName] = $rootEntry; + $allEntries = $this->addImplicitEntries($rootEntry, $allEntries); + } + + $rawImportMapData = []; + foreach ($allEntries as $entry) { + $asset = $this->findAsset($entry->path); + if (!$asset) { + throw $this->createMissingImportMapAssetException($entry); + } + + $path = $asset->publicPath; + $data = ['path' => $path, 'type' => $entry->type->value]; + $rawImportMapData[$entry->importName] = $data; + } + + return $rawImportMapData; + } + + /** + * Given an importmap entry name, finds all the non-lazy module imports in its chain. + * + * @internal + * + * @return array The array of import names + */ + public function findEagerEntrypointImports(string $entryName): array + { + $dumpedEntrypointPath = $this->assetsPathResolver->getPublicFilesystemPath().'/'.sprintf(self::ENTRYPOINT_CACHE_FILENAME_PATTERN, $entryName); + if (is_file($dumpedEntrypointPath)) { + return json_decode(file_get_contents($dumpedEntrypointPath), true, 512, \JSON_THROW_ON_ERROR); + } + + $rootImportEntries = $this->importMapConfigReader->getEntries(); + if (!$rootImportEntries->has($entryName)) { + throw new \InvalidArgumentException(sprintf('The entrypoint "%s" does not exist in "importmap.php".', $entryName)); + } + + if (!$rootImportEntries->get($entryName)->isEntrypoint) { + throw new \InvalidArgumentException(sprintf('The entrypoint "%s" is not an entry point in "importmap.php". Set "entrypoint" => true to make it available as an entrypoint.', $entryName)); + } + + if ($rootImportEntries->get($entryName)->isRemotePackage()) { + throw new \InvalidArgumentException(sprintf('The entrypoint "%s" is a remote package and cannot be used as an entrypoint.', $entryName)); + } + + $asset = $this->findAsset($rootImportEntries->get($entryName)->path); + if (!$asset) { + throw new \InvalidArgumentException(sprintf('The path "%s" of the entrypoint "%s" mentioned in "importmap.php" cannot be found in any asset map paths.', $rootImportEntries->get($entryName)->path, $entryName)); + } + + return $this->findEagerImports($asset); + } + + /** + * Adds "implicit" entries to the importmap. + * + * This recursively searches the dependencies of the given entry + * (i.e. it looks for modules imported from other modules) + * and adds them to the importmap. + * + * @param array $currentImportEntries + * + * @return array + */ + private function addImplicitEntries(ImportMapEntry $entry, array $currentImportEntries): array + { + // only process import dependencies for JS files + if (ImportMapType::JS !== $entry->type) { + return $currentImportEntries; + } + + if (!$asset = $this->findAsset($entry->path)) { + // should only be possible at this point for root importmap.php entries + throw $this->createMissingImportMapAssetException($entry); + } + + foreach ($asset->getJavaScriptImports() as $javaScriptImport) { + $importName = $javaScriptImport->importName; + + if (isset($currentImportEntries[$importName])) { + // entry already exists + continue; + } + + // check if this import requires an automatic importmap entry + if ($javaScriptImport->addImplicitlyToImportMap && $javaScriptImport->asset) { + $nextEntry = ImportMapEntry::createLocal( + $importName, + ImportMapType::tryFrom($javaScriptImport->asset->publicExtension) ?: ImportMapType::JS, + $javaScriptImport->asset->logicalPath, + false, + ); + + $currentImportEntries[$importName] = $nextEntry; + } else { + $nextEntry = $this->importMapConfigReader->findRootImportMapEntry($importName); + } + + // unless there was some missing importmap entry, recurse + if ($nextEntry) { + $currentImportEntries = $this->addImplicitEntries($nextEntry, $currentImportEntries); + } + } + + return $currentImportEntries; + } + + private function findRootImportMapEntry(string $moduleName): ?ImportMapEntry + { + $entries = $this->importMapConfigReader->getEntries(); + + return $entries->has($moduleName) ? $entries->get($moduleName) : null; + } + + /** + * Finds the MappedAsset allowing for a "logical path", relative or absolute filesystem path. + */ + private function findAsset(string $path): ?MappedAsset + { + if ($asset = $this->assetMapper->getAsset($path)) { + return $asset; + } + + if (str_starts_with($path, '.')) { + $path = $this->importMapConfigReader->getRootDirectory().'/'.$path; + } + + return $this->assetMapper->getAssetFromSourcePath($path); + } + + private function findEagerImports(MappedAsset $asset): array + { + $dependencies = []; + foreach ($asset->getJavaScriptImports() as $javaScriptImport) { + if ($javaScriptImport->isLazy) { + continue; + } + + $dependencies[] = $javaScriptImport->importName; + + // the import is for a MappedAsset? Follow its imports! + if ($javaScriptImport->asset) { + $dependencies = array_merge($dependencies, $this->findEagerImports($javaScriptImport->asset)); + } + } + + return $dependencies; + } + + private function createMissingImportMapAssetException(ImportMapEntry $entry): \InvalidArgumentException + { + if ($entry->isRemotePackage()) { + throw new LogicException(sprintf('The "%s" vendor asset is missing. Try running the "importmap:install" command.', $entry->importName)); + } + + throw new LogicException(sprintf('The asset "%s" cannot be found in any asset map paths.', $entry->path)); + } +} diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php index fc6a27bcc2dc8..4d06087a5542e 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php @@ -12,10 +12,8 @@ namespace Symfony\Component\AssetMapper\ImportMap; use Symfony\Component\AssetMapper\AssetMapperInterface; -use Symfony\Component\AssetMapper\Exception\LogicException; use Symfony\Component\AssetMapper\ImportMap\Resolver\PackageResolverInterface; use Symfony\Component\AssetMapper\MappedAsset; -use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface; /** * @author Kévin Dunglas @@ -25,12 +23,8 @@ */ class ImportMapManager { - public const IMPORT_MAP_CACHE_FILENAME = 'importmap.json'; - public const ENTRYPOINT_CACHE_FILENAME_PATTERN = 'entrypoint.%s.json'; - public function __construct( private readonly AssetMapperInterface $assetMapper, - private readonly PublicAssetsPathResolverInterface $assetsPathResolver, private readonly ImportMapConfigReader $importMapConfigReader, private readonly RemotePackageDownloader $packageDownloader, private readonly PackageResolverInterface $resolver, @@ -69,104 +63,6 @@ public function update(array $packages = []): array return $this->updateImportMapConfig(true, [], [], $packages); } - public function findRootImportMapEntry(string $moduleName): ?ImportMapEntry - { - $entries = $this->importMapConfigReader->getEntries(); - - return $entries->has($moduleName) ? $entries->get($moduleName) : null; - } - - /** - * @internal - * - * @param string[] $entrypointNames - * - * @return array - */ - public function getImportMapData(array $entrypointNames): array - { - $rawImportMapData = $this->getRawImportMapData(); - $finalImportMapData = []; - foreach ($entrypointNames as $entrypointName) { - $entrypointImports = $this->findEagerEntrypointImports($entrypointName); - // Entrypoint modules must be preloaded before their dependencies - foreach ([$entrypointName, ...$entrypointImports] as $import) { - if (isset($finalImportMapData[$import])) { - continue; - } - - // Missing dependency - rely on browser or compilers to warn - if (!isset($rawImportMapData[$import])) { - continue; - } - - $finalImportMapData[$import] = $rawImportMapData[$import]; - $finalImportMapData[$import]['preload'] = true; - unset($rawImportMapData[$import]); - } - } - - return array_merge($finalImportMapData, $rawImportMapData); - } - - /** - * @internal - */ - public function getEntrypointMetadata(string $entrypointName): array - { - return $this->findEagerEntrypointImports($entrypointName); - } - - /** - * @internal - */ - public function getEntrypointNames(): array - { - $rootEntries = $this->importMapConfigReader->getEntries(); - $entrypointNames = []; - foreach ($rootEntries as $entry) { - if ($entry->isEntrypoint) { - $entrypointNames[] = $entry->importName; - } - } - - return $entrypointNames; - } - - /** - * @internal - * - * @return array - */ - public function getRawImportMapData(): array - { - $dumpedImportMapPath = $this->assetsPathResolver->getPublicFilesystemPath().'/'.self::IMPORT_MAP_CACHE_FILENAME; - if (is_file($dumpedImportMapPath)) { - return json_decode(file_get_contents($dumpedImportMapPath), true, 512, \JSON_THROW_ON_ERROR); - } - - $rootEntries = $this->importMapConfigReader->getEntries(); - $allEntries = []; - foreach ($rootEntries as $rootEntry) { - $allEntries[$rootEntry->importName] = $rootEntry; - $allEntries = $this->addImplicitEntries($rootEntry, $allEntries, $rootEntries); - } - - $rawImportMapData = []; - foreach ($allEntries as $entry) { - $asset = $this->findAsset($entry->path); - if (!$asset) { - throw $this->createMissingImportMapAssetException($entry); - } - - $path = $asset->publicPath; - $data = ['path' => $path, 'type' => $entry->type->value]; - $rawImportMapData[$entry->importName] = $data; - } - - return $rawImportMapData; - } - /** * @internal */ @@ -303,112 +199,6 @@ private function cleanupPackageFiles(ImportMapEntry $entry): void } } - /** - * Adds "implicit" entries to the importmap. - * - * This recursively searches the dependencies of the given entry - * (i.e. it looks for modules imported from other modules) - * and adds them to the importmap. - * - * @param array $currentImportEntries - * - * @return array - */ - private function addImplicitEntries(ImportMapEntry $entry, array $currentImportEntries, ImportMapEntries $rootEntries): array - { - // only process import dependencies for JS files - if (ImportMapType::JS !== $entry->type) { - return $currentImportEntries; - } - - if (!$asset = $this->findAsset($entry->path)) { - // should only be possible at this point for root importmap.php entries - throw $this->createMissingImportMapAssetException($entry); - } - - foreach ($asset->getJavaScriptImports() as $javaScriptImport) { - $importName = $javaScriptImport->importName; - - if (isset($currentImportEntries[$importName])) { - // entry already exists - continue; - } - - // check if this import requires an automatic importmap entry - if ($javaScriptImport->addImplicitlyToImportMap && $javaScriptImport->asset) { - $nextEntry = ImportMapEntry::createLocal( - $importName, - ImportMapType::tryFrom($javaScriptImport->asset->publicExtension) ?: ImportMapType::JS, - $javaScriptImport->asset->logicalPath, - false, - ); - - $currentImportEntries[$importName] = $nextEntry; - } else { - $nextEntry = $this->findRootImportMapEntry($importName); - } - - // unless there was some missing importmap entry, recurse - if ($nextEntry) { - $currentImportEntries = $this->addImplicitEntries($nextEntry, $currentImportEntries, $rootEntries); - } - } - - return $currentImportEntries; - } - - /** - * Given an importmap entry name, finds all the non-lazy module imports in its chain. - * - * @return array The array of import names - */ - private function findEagerEntrypointImports(string $entryName): array - { - $dumpedEntrypointPath = $this->assetsPathResolver->getPublicFilesystemPath().'/'.sprintf(self::ENTRYPOINT_CACHE_FILENAME_PATTERN, $entryName); - if (is_file($dumpedEntrypointPath)) { - return json_decode(file_get_contents($dumpedEntrypointPath), true, 512, \JSON_THROW_ON_ERROR); - } - - $rootImportEntries = $this->importMapConfigReader->getEntries(); - if (!$rootImportEntries->has($entryName)) { - throw new \InvalidArgumentException(sprintf('The entrypoint "%s" does not exist in "importmap.php".', $entryName)); - } - - if (!$rootImportEntries->get($entryName)->isEntrypoint) { - throw new \InvalidArgumentException(sprintf('The entrypoint "%s" is not an entry point in "importmap.php". Set "entrypoint" => true to make it available as an entrypoint.', $entryName)); - } - - if ($rootImportEntries->get($entryName)->isRemotePackage()) { - throw new \InvalidArgumentException(sprintf('The entrypoint "%s" is a remote package and cannot be used as an entrypoint.', $entryName)); - } - - $asset = $this->findAsset($rootImportEntries->get($entryName)->path); - if (!$asset) { - throw new \InvalidArgumentException(sprintf('The path "%s" of the entrypoint "%s" mentioned in "importmap.php" cannot be found in any asset map paths.', $rootImportEntries->get($entryName)->path, $entryName)); - } - - return $this->findEagerImports($asset); - } - - private function findEagerImports(MappedAsset $asset): array - { - $dependencies = []; - foreach ($asset->getJavaScriptImports() as $javaScriptImport) { - if ($javaScriptImport->isLazy) { - continue; - } - - $dependencies[] = $javaScriptImport->importName; - - // the import is for a MappedAsset? Follow its imports! - if ($javaScriptImport->asset) { - $dependencies = array_merge($dependencies, $this->findEagerImports($javaScriptImport->asset)); - } - } - - return $dependencies; - } - private static function getImportMapTypeFromFilename(string $path): ImportMapType { return str_ends_with($path, '.css') ? ImportMapType::CSS : ImportMapType::JS; @@ -429,13 +219,4 @@ private function findAsset(string $path): ?MappedAsset return $this->assetMapper->getAssetFromSourcePath($path); } - - private function createMissingImportMapAssetException(ImportMapEntry $entry): \InvalidArgumentException - { - if ($entry->isRemotePackage()) { - throw new LogicException(sprintf('The "%s" vendor asset is missing. Try running the "importmap:install" command.', $entry->importName)); - } - - throw new LogicException(sprintf('The asset "%s" cannot be found in any asset map paths.', $entry->path)); - } } diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php index ca5fe2e6b2888..839bac2b2ef37 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php @@ -30,7 +30,7 @@ class ImportMapRenderer private const DEFAULT_ES_MODULE_SHIMS_POLYFILL_URL = 'https://ga.jspm.io/npm:es-module-shims@1.8.0/dist/es-module-shims.js'; public function __construct( - private readonly ImportMapManager $importMapManager, + private readonly ImportMapGenerator $importMapGenerator, private readonly ?Packages $assetPackages = null, private readonly string $charset = 'UTF-8', private readonly string|false $polyfillImportName = false, @@ -43,7 +43,7 @@ public function render(string|array $entryPoint, array $attributes = []): string { $entryPoint = (array) $entryPoint; - $importMapData = $this->importMapManager->getImportMapData($entryPoint); + $importMapData = $this->importMapGenerator->getImportMapData($entryPoint); $importMap = []; $modulePreloads = []; $cssLinks = []; diff --git a/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php b/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php index bf4c79e25bc1c..ba3a7f7e419ff 100644 --- a/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php @@ -18,8 +18,8 @@ use Symfony\Component\AssetMapper\Compiler\JavaScriptImportPathCompiler; use Symfony\Component\AssetMapper\Exception\CircularAssetsException; use Symfony\Component\AssetMapper\Exception\RuntimeException; +use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader; use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; -use Symfony\Component\AssetMapper\ImportMap\ImportMapManager; use Symfony\Component\AssetMapper\ImportMap\ImportMapType; use Symfony\Component\AssetMapper\MappedAsset; @@ -32,8 +32,8 @@ public function testCompile(string $sourceLogicalName, string $input, array $exp { $asset = new MappedAsset($sourceLogicalName, 'anything', '/assets/'.$sourceLogicalName); - $importMapManager = $this->createMock(ImportMapManager::class); - $importMapManager->expects($this->any()) + $importMapConfigReader = $this->createMock(ImportMapConfigReader::class); + $importMapConfigReader->expects($this->any()) ->method('findRootImportMapEntry') ->willReturnCallback(function ($importName) { return match ($importName) { @@ -43,7 +43,7 @@ public function testCompile(string $sourceLogicalName, string $input, array $exp default => null, }; }); - $compiler = new JavaScriptImportPathCompiler($importMapManager); + $compiler = new JavaScriptImportPathCompiler($importMapConfigReader); // compile - and check that content doesn't change $this->assertSame($input, $compiler->compile($input, $asset, $this->createAssetMapper())); $actualImports = []; @@ -311,7 +311,7 @@ public function testImportPathsCanUpdate(string $sourceLogicalName, string $inpu ->method('getAsset') ->willReturn($importedAsset); - $compiler = new JavaScriptImportPathCompiler($this->createMock(ImportMapManager::class)); + $compiler = new JavaScriptImportPathCompiler($this->createMock(ImportMapConfigReader::class)); $this->assertSame($expectedOutput, $compiler->compile($input, $asset, $assetMapper)); } @@ -380,7 +380,7 @@ public function testMissingImportMode(string $sourceLogicalName, string $input, $logger = $this->createMock(LoggerInterface::class); $compiler = new JavaScriptImportPathCompiler( - $this->createMock(ImportMapManager::class), + $this->createMock(ImportMapConfigReader::class), AssetCompilerInterface::MISSING_IMPORT_STRICT, $logger ); @@ -430,7 +430,7 @@ public function testErrorMessageAvoidsCircularException() }); $asset = new MappedAsset('htmx.js', '/path/to/app.js'); - $compiler = new JavaScriptImportPathCompiler($this->createMock(ImportMapManager::class)); + $compiler = new JavaScriptImportPathCompiler($this->createMock(ImportMapConfigReader::class)); $content = '//** @type {import("./htmx").HtmxApi} */'; $compiled = $compiler->compile($content, $asset, $assetMapper); // To form a good exception message, the compiler will check for the diff --git a/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php b/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php index d4131ae39c377..fd211dbf1d14d 100644 --- a/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php @@ -20,7 +20,7 @@ use Symfony\Component\AssetMapper\Compiler\JavaScriptImportPathCompiler; use Symfony\Component\AssetMapper\Exception\RuntimeException; use Symfony\Component\AssetMapper\Factory\MappedAssetFactory; -use Symfony\Component\AssetMapper\ImportMap\ImportMapManager; +use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader; use Symfony\Component\AssetMapper\MappedAsset; use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface; @@ -124,7 +124,7 @@ public function testCreateMappedAssetInVendor() private function createFactory(AssetCompilerInterface $extraCompiler = null): MappedAssetFactory { $compilers = [ - new JavaScriptImportPathCompiler($this->createMock(ImportMapManager::class)), + new JavaScriptImportPathCompiler($this->createMock(ImportMapConfigReader::class)), new CssAssetUrlCompiler(), ]; if ($extraCompiler) { diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapConfigReaderTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapConfigReaderTest.php index 5cfbf76c5e70b..a70da9dc22aa2 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapConfigReaderTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapConfigReaderTest.php @@ -114,4 +114,12 @@ public function testGetRootDirectory() $configReader = new ImportMapConfigReader(__DIR__.'/../fixtures/importmap.php', $this->createMock(RemotePackageStorage::class)); $this->assertSame(__DIR__.'/../fixtures', $configReader->getRootDirectory()); } + + public function testFindRootImportMapEntry() + { + $configReader = new ImportMapConfigReader(__DIR__.'/../fixtures/importmap.php', $this->createMock(RemotePackageStorage::class)); + $entry = $configReader->findRootImportMapEntry('file2'); + $this->assertSame('file2', $entry->importName); + $this->assertSame('file2.js', $entry->path); + } } diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapGeneratorTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapGeneratorTest.php new file mode 100644 index 0000000000000..036d1449b2311 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapGeneratorTest.php @@ -0,0 +1,769 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Tests\ImportMap; + +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader; +use Symfony\Component\AssetMapper\ImportMap\ImportMapEntries; +use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; +use Symfony\Component\AssetMapper\ImportMap\ImportMapGenerator; +use Symfony\Component\AssetMapper\ImportMap\ImportMapType; +use Symfony\Component\AssetMapper\ImportMap\JavaScriptImport; +use Symfony\Component\AssetMapper\MappedAsset; +use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface; +use Symfony\Component\Filesystem\Filesystem; + +class ImportMapGeneratorTest extends TestCase +{ + private AssetMapperInterface&MockObject $assetMapper; + private PublicAssetsPathResolverInterface&MockObject $pathResolver; + private ImportMapConfigReader&MockObject $configReader; + private ImportMapGenerator $importMapGenerator; + + private Filesystem $filesystem; + private static string $writableRoot = __DIR__.'/../fixtures/importmaps_for_writing'; + + protected function setUp(): void + { + $this->filesystem = new Filesystem(); + if (!file_exists(__DIR__.'/../fixtures/importmaps_for_writing')) { + $this->filesystem->mkdir(self::$writableRoot); + } + if (!file_exists(__DIR__.'/../fixtures/importmaps_for_writing/assets')) { + $this->filesystem->mkdir(self::$writableRoot.'/assets'); + } + } + + protected function tearDown(): void + { + $this->filesystem->remove(self::$writableRoot); + } + + public function testGetEntrypointNames() + { + $manager = $this->createImportMapGenerator(); + $this->mockImportMap([ + ImportMapEntry::createLocal('entry1', ImportMapType::JS, path: '/any', isEntrypoint: true), + ImportMapEntry::createLocal('entry2', ImportMapType::JS, path: '/any', isEntrypoint: true), + ImportMapEntry::createLocal('not_entrypoint', ImportMapType::JS, path: '/any', isEntrypoint: false), + ]); + + $this->assertEquals(['entry1', 'entry2'], $manager->getEntrypointNames()); + } + + public function testGetImportMapData() + { + $manager = $this->createImportMapGenerator(); + $this->mockImportMap([ + self::createLocalEntry( + 'entry1', + path: 'entry1.js', + isEntrypoint: true, + ), + self::createLocalEntry( + 'entry2', + path: 'entry2.js', + isEntrypoint: true, + ), + self::createLocalEntry( + 'entry3', + path: 'entry3.js', + isEntrypoint: true, + ), + self::createLocalEntry( + 'normal_js_file', + path: 'normal_js_file.js', + ), + self::createLocalEntry( + 'css_in_importmap', + path: 'styles/css_in_importmap.css', + type: ImportMapType::CSS, + ), + self::createLocalEntry( + 'never_imported_css', + path: 'styles/never_imported_css.css', + type: ImportMapType::CSS, + ), + ]); + + $importedFile1 = new MappedAsset( + 'imported_file1.js', + publicPathWithoutDigest: '/assets/imported_file1.js', + publicPath: '/assets/imported_file1-d1g35t.js', + ); + $importedFile2 = new MappedAsset( + 'imported_file2.js', + publicPathWithoutDigest: '/assets/imported_file2.js', + publicPath: '/assets/imported_file2-d1g35t.js', + ); + $importedFile3 = new MappedAsset( + 'imported_file3.js', + publicPathWithoutDigest: '/assets/imported_file3.js', + publicPath: '/assets/imported_file3-d1g35t.js', + ); + $normalJsFile = new MappedAsset( + 'normal_js_file.js', + publicPathWithoutDigest: '/assets/normal_js_file.js', + publicPath: '/assets/normal_js_file-d1g35t.js', + ); + $importedCss1 = new MappedAsset( + 'styles/file1.css', + publicPathWithoutDigest: '/assets/styles/file1.css', + publicPath: '/assets/styles/file1-d1g35t.css', + ); + $importedCss2 = new MappedAsset( + 'styles/file2.css', + publicPathWithoutDigest: '/assets/styles/file2.css', + publicPath: '/assets/styles/file2-d1g35t.css', + ); + $importedCssInImportmap = new MappedAsset( + 'styles/css_in_importmap.css', + publicPathWithoutDigest: '/assets/styles/css_in_importmap.css', + publicPath: '/assets/styles/css_in_importmap-d1g35t.css', + ); + $neverImportedCss = new MappedAsset( + 'styles/never_imported_css.css', + publicPathWithoutDigest: '/assets/styles/never_imported_css.css', + publicPath: '/assets/styles/never_imported_css-d1g35t.css', + ); + $this->mockAssetMapper([ + new MappedAsset( + 'entry1.js', + publicPath: '/assets/entry1-d1g35t.js', + javaScriptImports: [ + new JavaScriptImport('/assets/imported_file1.js', isLazy: false, asset: $importedFile1, addImplicitlyToImportMap: true), + new JavaScriptImport('/assets/styles/file1.css', isLazy: false, asset: $importedCss1, addImplicitlyToImportMap: true), + new JavaScriptImport('normal_js_file', isLazy: false, asset: $normalJsFile), + ] + ), + new MappedAsset( + 'entry2.js', + publicPath: '/assets/entry2-d1g35t.js', + javaScriptImports: [ + new JavaScriptImport('/assets/imported_file2.js', isLazy: false, asset: $importedFile2, addImplicitlyToImportMap: true), + new JavaScriptImport('css_in_importmap', isLazy: false, asset: $importedCssInImportmap), + new JavaScriptImport('/assets/styles/file2.css', isLazy: false, asset: $importedCss2, addImplicitlyToImportMap: true), + ] + ), + new MappedAsset( + 'entry3.js', + publicPath: '/assets/entry3-d1g35t.js', + javaScriptImports: [ + new JavaScriptImport('/assets/imported_file3.js', isLazy: false, asset: $importedFile3), + ], + ), + $importedFile1, + $importedFile2, + // $importedFile3, + $normalJsFile, + $importedCss1, + $importedCss2, + $importedCssInImportmap, + $neverImportedCss, + ]); + + $actualImportMapData = $manager->getImportMapData(['entry2', 'entry1']); + + $this->assertEquals([ + 'entry1' => [ + 'path' => '/assets/entry1-d1g35t.js', + 'type' => 'js', + 'preload' => true, // Rendered entry points are preloaded + ], + '/assets/imported_file1.js' => [ + 'path' => '/assets/imported_file1-d1g35t.js', + 'type' => 'js', + 'preload' => true, + ], + 'entry2' => [ + 'path' => '/assets/entry2-d1g35t.js', + 'type' => 'js', + 'preload' => true, // Rendered entry points are preloaded + ], + '/assets/imported_file2.js' => [ + 'path' => '/assets/imported_file2-d1g35t.js', + 'type' => 'js', + 'preload' => true, + ], + 'normal_js_file' => [ + 'path' => '/assets/normal_js_file-d1g35t.js', + 'type' => 'js', + 'preload' => true, // preloaded as it's a non-lazy dependency of an entry + ], + '/assets/styles/file1.css' => [ + 'path' => '/assets/styles/file1-d1g35t.css', + 'type' => 'css', + 'preload' => true, + ], + '/assets/styles/file2.css' => [ + 'path' => '/assets/styles/file2-d1g35t.css', + 'type' => 'css', + 'preload' => true, + ], + 'css_in_importmap' => [ + 'path' => '/assets/styles/css_in_importmap-d1g35t.css', + 'type' => 'css', + 'preload' => true, + ], + 'entry3' => [ + 'path' => '/assets/entry3-d1g35t.js', + 'type' => 'js', // No preload (entry point not "rendered") + ], + 'never_imported_css' => [ + 'path' => '/assets/styles/never_imported_css-d1g35t.css', + 'type' => 'css', + ], + ], $actualImportMapData); + + // now check the order + $this->assertEquals([ + // entry2 & its dependencies + 'entry2', + '/assets/imported_file2.js', + 'css_in_importmap', // in the importmap, but brought earlier because it's a dependency of entry2 + '/assets/styles/file2.css', + + // entry1 & its dependencies + 'entry1', + '/assets/imported_file1.js', + '/assets/styles/file1.css', + 'normal_js_file', + + // importmap entries never imported + 'entry3', + 'never_imported_css', + ], array_keys($actualImportMapData)); + } + + /** + * @dataProvider getRawImportMapDataTests + */ + public function testGetRawImportMapData(array $importMapEntries, array $mappedAssets, array $expectedData) + { + $manager = $this->createImportMapGenerator(); + $this->mockImportMap($importMapEntries); + $this->mockAssetMapper($mappedAssets); + $this->configReader->expects($this->any()) + ->method('getRootDirectory') + ->willReturn('/fake/root'); + + $this->assertEquals($expectedData, $manager->getRawImportMapData()); + } + + public function getRawImportMapDataTests(): iterable + { + yield 'it returns remote downloaded entry' => [ + [ + self::createRemoteEntry( + '@hotwired/stimulus', + version: '1.2.3', + path: '/assets/vendor/stimulus.js' + ), + ], + [ + new MappedAsset( + 'vendor/@hotwired/stimulus.js', + '/assets/vendor/stimulus.js', + publicPath: '/assets/vendor/@hotwired/stimulus-d1g35t.js', + ), + ], + [ + '@hotwired/stimulus' => [ + 'path' => '/assets/vendor/@hotwired/stimulus-d1g35t.js', + 'type' => 'js', + ], + ], + ]; + + yield 'it returns basic local javascript file' => [ + [ + self::createLocalEntry( + 'app', + path: 'app.js' + ), + ], + [ + new MappedAsset( + 'app.js', + publicPath: '/assets/app-d13g35t.js', + ), + ], + [ + 'app' => [ + 'path' => '/assets/app-d13g35t.js', + 'type' => 'js', + ], + ], + ]; + + yield 'it returns basic local css file' => [ + [ + self::createLocalEntry( + 'app.css', + path: 'styles/app.css', + type: ImportMapType::CSS, + ), + ], + [ + new MappedAsset( + 'styles/app.css', + publicPath: '/assets/styles/app-d13g35t.css', + ), + ], + [ + 'app.css' => [ + 'path' => '/assets/styles/app-d13g35t.css', + 'type' => 'css', + ], + ], + ]; + + $simpleAsset = new MappedAsset( + 'simple.js', + publicPathWithoutDigest: '/assets/simple.js', + publicPath: '/assets/simple-d1g3st.js', + ); + yield 'it adds dependency to the importmap' => [ + [ + self::createLocalEntry( + 'app', + path: 'app.js', + ), + ], + [ + new MappedAsset( + 'app.js', + publicPath: '/assets/app-d1g3st.js', + javaScriptImports: [new JavaScriptImport('/assets/simple.js', isLazy: false, asset: $simpleAsset, addImplicitlyToImportMap: true)] + ), + $simpleAsset, + ], + [ + 'app' => [ + 'path' => '/assets/app-d1g3st.js', + 'type' => 'js', + ], + '/assets/simple.js' => [ + 'path' => '/assets/simple-d1g3st.js', + 'type' => 'js', + ], + ], + ]; + + yield 'it adds dependency to the importmap from a remote asset' => [ + [ + self::createRemoteEntry( + 'bootstrap', + version: '1.2.3', + path: '/assets/vendor/bootstrap.js' + ), + ], + [ + new MappedAsset( + 'app.js', + sourcePath: '/assets/vendor/bootstrap.js', + publicPath: '/assets/vendor/bootstrap-d1g3st.js', + javaScriptImports: [new JavaScriptImport('/assets/simple.js', isLazy: false, asset: $simpleAsset, addImplicitlyToImportMap: true)] + ), + $simpleAsset, + ], + [ + 'bootstrap' => [ + 'path' => '/assets/vendor/bootstrap-d1g3st.js', + 'type' => 'js', + ], + '/assets/simple.js' => [ + 'path' => '/assets/simple-d1g3st.js', + 'type' => 'js', + ], + ], + ]; + + $eagerImportsSimpleAsset = new MappedAsset( + 'imports_simple.js', + publicPathWithoutDigest: '/assets/imports_simple.js', + publicPath: '/assets/imports_simple-d1g3st.js', + javaScriptImports: [new JavaScriptImport('/assets/simple.js', isLazy: false, asset: $simpleAsset, addImplicitlyToImportMap: true)] + ); + yield 'it processes imports recursively' => [ + [ + self::createLocalEntry( + 'app', + path: 'app.js', + ), + ], + [ + new MappedAsset( + 'app.js', + publicPath: '/assets/app-d1g3st.js', + javaScriptImports: [new JavaScriptImport('/assets/imports_simple.js', isLazy: true, asset: $eagerImportsSimpleAsset, addImplicitlyToImportMap: true)] + ), + $eagerImportsSimpleAsset, + $simpleAsset, + ], + [ + 'app' => [ + 'path' => '/assets/app-d1g3st.js', + 'type' => 'js', + ], + '/assets/imports_simple.js' => [ + 'path' => '/assets/imports_simple-d1g3st.js', + 'type' => 'js', + ], + '/assets/simple.js' => [ + 'path' => '/assets/simple-d1g3st.js', + 'type' => 'js', + ], + ], + ]; + + yield 'it process can skip adding one importmap entry but still add a child' => [ + [ + self::createLocalEntry( + 'app', + path: 'app.js', + ), + self::createLocalEntry( + 'imports_simple', + path: 'imports_simple.js', + ), + ], + [ + new MappedAsset( + 'app.js', + publicPath: '/assets/app-d1g3st.js', + javaScriptImports: [new JavaScriptImport('imports_simple', isLazy: true, asset: $eagerImportsSimpleAsset, addImplicitlyToImportMap: false)] + ), + $eagerImportsSimpleAsset, + $simpleAsset, + ], + [ + 'app' => [ + 'path' => '/assets/app-d1g3st.js', + 'type' => 'js', + ], + '/assets/simple.js' => [ + 'path' => '/assets/simple-d1g3st.js', + 'type' => 'js', + ], + 'imports_simple' => [ + 'path' => '/assets/imports_simple-d1g3st.js', + 'type' => 'js', + ], + ], + ]; + + yield 'imports with a module name are not added to the importmap' => [ + [ + self::createLocalEntry( + 'app', + path: 'app.js', + ), + ], + [ + new MappedAsset( + 'app.js', + publicPath: '/assets/app-d1g3st.js', + javaScriptImports: [new JavaScriptImport('simple', isLazy: false, asset: $simpleAsset)] + ), + $simpleAsset, + ], + [ + 'app' => [ + 'path' => '/assets/app-d1g3st.js', + 'type' => 'js', + ], + ], + ]; + + yield 'it does not process dependencies of CSS files' => [ + [ + self::createLocalEntry( + 'app.css', + path: 'app.css', + type: ImportMapType::CSS, + ), + ], + [ + new MappedAsset( + 'app.css', + publicPath: '/assets/app-d1g3st.css', + javaScriptImports: [new JavaScriptImport('/assets/simple.js', asset: $simpleAsset)] + ), + ], + [ + 'app.css' => [ + 'path' => '/assets/app-d1g3st.css', + 'type' => 'css', + ], + ], + ]; + + yield 'it handles a relative path file' => [ + [ + self::createLocalEntry( + 'app', + path: './assets/app.js', + ), + ], + [ + new MappedAsset( + 'app.js', + // /fake/root is the mocked root directory + '/fake/root/assets/app.js', + publicPath: '/assets/app-d1g3st.js', + ), + ], + [ + 'app' => [ + 'path' => '/assets/app-d1g3st.js', + 'type' => 'js', + ], + ], + ]; + + yield 'it handles an absolute path file' => [ + [ + self::createLocalEntry( + 'app', + path: '/some/path/assets/app.js', + ), + ], + [ + new MappedAsset( + 'app.js', + '/some/path/assets/app.js', + publicPath: '/assets/app-d1g3st.js', + ), + ], + [ + 'app' => [ + 'path' => '/assets/app-d1g3st.js', + 'type' => 'js', + ], + ], + ]; + } + + public function testGetRawImportDataUsesCacheFile() + { + $manager = $this->createImportMapGenerator(); + $importmapData = [ + 'app' => [ + 'path' => 'app.js', + 'entrypoint' => true, + ], + '@hotwired/stimulus' => [ + 'path' => 'https://anyurl.com/stimulus', + ], + ]; + $this->writeFile('public/assets/importmap.json', json_encode($importmapData)); + $this->pathResolver->expects($this->once()) + ->method('getPublicFilesystemPath') + ->willReturn(self::$writableRoot.'/public/assets'); + + $this->assertEquals($importmapData, $manager->getRawImportMapData()); + } + + /** + * @dataProvider getEagerEntrypointImportsTests + */ + public function testFindEagerEntrypointImports(MappedAsset $entryAsset, array $expected) + { + $manager = $this->createImportMapGenerator(); + $this->mockAssetMapper([$entryAsset]); + // put the entry asset in the importmap + $this->mockImportMap([ + ImportMapEntry::createLocal('the_entrypoint_name', ImportMapType::JS, path: $entryAsset->logicalPath, isEntrypoint: true), + ]); + + $this->assertEquals($expected, $manager->findEagerEntrypointImports('the_entrypoint_name')); + } + + public function getEagerEntrypointImportsTests(): iterable + { + yield 'an entry with no dependencies' => [ + new MappedAsset( + 'app.js', + publicPath: '/assets/app.js', + ), + [], + ]; + + $simpleAsset = new MappedAsset( + 'simple.js', + publicPathWithoutDigest: '/assets/simple.js', + ); + yield 'an entry with a non-lazy dependency is included' => [ + new MappedAsset( + 'app.js', + publicPath: '/assets/app.js', + javaScriptImports: [new JavaScriptImport('/assets/simple.js', isLazy: false, asset: $simpleAsset)] + ), + ['/assets/simple.js'], // path is the key in the importmap + ]; + + yield 'an entry with a non-lazy dependency with module name is included' => [ + new MappedAsset( + 'app.js', + publicPath: '/assets/app.js', + javaScriptImports: [new JavaScriptImport('simple', isLazy: false, asset: $simpleAsset)] + ), + ['simple'], // path is the key in the importmap + ]; + + yield 'an entry with a lazy dependency is not included' => [ + new MappedAsset( + 'app.js', + publicPath: '/assets/app.js', + javaScriptImports: [new JavaScriptImport('/assets/simple.js', isLazy: true, asset: $simpleAsset)] + ), + [], + ]; + + $importsSimpleAsset = new MappedAsset( + 'imports_simple.js', + publicPathWithoutDigest: '/assets/imports_simple.js', + javaScriptImports: [new JavaScriptImport('/assets/simple.js', isLazy: false, asset: $simpleAsset)] + ); + yield 'an entry follows through dependencies recursively' => [ + new MappedAsset( + 'app.js', + publicPath: '/assets/app.js', + javaScriptImports: [new JavaScriptImport('/assets/imports_simple.js', isLazy: false, asset: $importsSimpleAsset)] + ), + ['/assets/imports_simple.js', '/assets/simple.js'], + ]; + } + + public function testFindEagerEntrypointImportsUsesCacheFile() + { + $manager = $this->createImportMapGenerator(); + $entrypointData = [ + 'app', + '/assets/foo.js', + ]; + $this->writeFile('public/assets/entrypoint.foo.json', json_encode($entrypointData)); + $this->pathResolver->expects($this->once()) + ->method('getPublicFilesystemPath') + ->willReturn(self::$writableRoot.'/public/assets'); + + $this->assertEquals($entrypointData, $manager->findEagerEntrypointImports('foo')); + } + + private function createImportMapGenerator(): ImportMapGenerator + { + $this->pathResolver = $this->createMock(PublicAssetsPathResolverInterface::class); + $this->assetMapper = $this->createMock(AssetMapperInterface::class); + $this->configReader = $this->createMock(ImportMapConfigReader::class); + + // mock this to behave like normal + $this->configReader->expects($this->any()) + ->method('createRemoteEntry') + ->willReturnCallback(function (string $importName, ImportMapType $type, string $version, string $packageModuleSpecifier, bool $isEntrypoint) { + $path = '/path/to/vendor/'.$packageModuleSpecifier.'.js'; + + return ImportMapEntry::createRemote($importName, $type, $path, $version, $packageModuleSpecifier, $isEntrypoint); + }); + + return $this->importMapGenerator = new ImportMapGenerator( + $this->assetMapper, + $this->pathResolver, + $this->configReader, + ); + } + + private function mockImportMap(array $importMapEntries): void + { + $this->configReader->expects($this->any()) + ->method('getEntries') + ->willReturn(new ImportMapEntries($importMapEntries)) + ; + } + + private function writeFile(string $filename, string $content): void + { + $path = \dirname(self::$writableRoot.'/'.$filename); + if (!is_dir($path)) { + mkdir($path, 0777, true); + } + file_put_contents(self::$writableRoot.'/'.$filename, $content); + } + + private static function createLocalEntry(string $importName, string $path, ImportMapType $type = ImportMapType::JS, bool $isEntrypoint = false): ImportMapEntry + { + return ImportMapEntry::createLocal($importName, $type, path: $path, isEntrypoint: $isEntrypoint); + } + + private static function createRemoteEntry(string $importName, string $version, string $path = null, ImportMapType $type = ImportMapType::JS, string $packageSpecifier = null): ImportMapEntry + { + $packageSpecifier = $packageSpecifier ?? $importName; + $path = $path ?? '/vendor/any-path.js'; + + return ImportMapEntry::createRemote($importName, $type, path: $path, version: $version, packageModuleSpecifier: $packageSpecifier, isEntrypoint: false); + } + + /** + * @param MappedAsset[] $mappedAssets + */ + private function mockAssetMapper(array $mappedAssets): void + { + $this->assetMapper->expects($this->any()) + ->method('getAsset') + ->willReturnCallback(function (string $logicalPath) use ($mappedAssets) { + foreach ($mappedAssets as $asset) { + if ($asset->logicalPath === $logicalPath) { + return $asset; + } + } + + return null; + }) + ; + + $this->assetMapper->expects($this->any()) + ->method('getAssetFromSourcePath') + ->willReturnCallback(function (string $sourcePath) use ($mappedAssets) { + // collapse ../ in paths and ./ in paths to mimic the realpath AssetMapper uses + $unCollapsePath = function (string $path) { + $parts = explode('/', $path); + $newParts = []; + foreach ($parts as $part) { + if ('..' === $part) { + array_pop($newParts); + + continue; + } + + if ('.' !== $part) { + $newParts[] = $part; + } + } + + return implode('/', $newParts); + }; + + $sourcePath = $unCollapsePath($sourcePath); + + foreach ($mappedAssets as $asset) { + if (isset($asset->sourcePath) && $unCollapsePath($asset->sourcePath) === $sourcePath) { + return $asset; + } + } + + return null; + }) + ; + } +} diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php index 6c42c6df051e3..357350aef9c7b 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php @@ -19,19 +19,16 @@ use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; use Symfony\Component\AssetMapper\ImportMap\ImportMapManager; use Symfony\Component\AssetMapper\ImportMap\ImportMapType; -use Symfony\Component\AssetMapper\ImportMap\JavaScriptImport; use Symfony\Component\AssetMapper\ImportMap\PackageRequireOptions; use Symfony\Component\AssetMapper\ImportMap\RemotePackageDownloader; use Symfony\Component\AssetMapper\ImportMap\Resolver\PackageResolverInterface; use Symfony\Component\AssetMapper\ImportMap\Resolver\ResolvedImportMapPackage; use Symfony\Component\AssetMapper\MappedAsset; -use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface; use Symfony\Component\Filesystem\Filesystem; class ImportMapManagerTest extends TestCase { private AssetMapperInterface&MockObject $assetMapper; - private PublicAssetsPathResolverInterface&MockObject $pathResolver; private PackageResolverInterface&MockObject $packageResolver; private ImportMapConfigReader&MockObject $configReader; private RemotePackageDownloader&MockObject $remotePackageDownloader; @@ -56,628 +53,6 @@ protected function tearDown(): void $this->filesystem->remove(self::$writableRoot); } - /** - * @dataProvider getRawImportMapDataTests - */ - public function testGetRawImportMapData(array $importMapEntries, array $mappedAssets, array $expectedData) - { - $manager = $this->createImportMapManager(); - $this->mockImportMap($importMapEntries); - $this->mockAssetMapper($mappedAssets); - $this->configReader->expects($this->any()) - ->method('getRootDirectory') - ->willReturn('/fake/root'); - - $this->assertEquals($expectedData, $manager->getRawImportMapData()); - } - - public function getRawImportMapDataTests(): iterable - { - yield 'it returns remote downloaded entry' => [ - [ - self::createRemoteEntry( - '@hotwired/stimulus', - version: '1.2.3', - path: '/assets/vendor/stimulus.js' - ), - ], - [ - new MappedAsset( - 'vendor/@hotwired/stimulus.js', - '/assets/vendor/stimulus.js', - publicPath: '/assets/vendor/@hotwired/stimulus-d1g35t.js', - ), - ], - [ - '@hotwired/stimulus' => [ - 'path' => '/assets/vendor/@hotwired/stimulus-d1g35t.js', - 'type' => 'js', - ], - ], - ]; - - yield 'it returns basic local javascript file' => [ - [ - self::createLocalEntry( - 'app', - path: 'app.js' - ), - ], - [ - new MappedAsset( - 'app.js', - publicPath: '/assets/app-d13g35t.js', - ), - ], - [ - 'app' => [ - 'path' => '/assets/app-d13g35t.js', - 'type' => 'js', - ], - ], - ]; - - yield 'it returns basic local css file' => [ - [ - self::createLocalEntry( - 'app.css', - path: 'styles/app.css', - type: ImportMapType::CSS, - ), - ], - [ - new MappedAsset( - 'styles/app.css', - publicPath: '/assets/styles/app-d13g35t.css', - ), - ], - [ - 'app.css' => [ - 'path' => '/assets/styles/app-d13g35t.css', - 'type' => 'css', - ], - ], - ]; - - $simpleAsset = new MappedAsset( - 'simple.js', - publicPathWithoutDigest: '/assets/simple.js', - publicPath: '/assets/simple-d1g3st.js', - ); - yield 'it adds dependency to the importmap' => [ - [ - self::createLocalEntry( - 'app', - path: 'app.js', - ), - ], - [ - new MappedAsset( - 'app.js', - publicPath: '/assets/app-d1g3st.js', - javaScriptImports: [new JavaScriptImport('/assets/simple.js', isLazy: false, asset: $simpleAsset, addImplicitlyToImportMap: true)] - ), - $simpleAsset, - ], - [ - 'app' => [ - 'path' => '/assets/app-d1g3st.js', - 'type' => 'js', - ], - '/assets/simple.js' => [ - 'path' => '/assets/simple-d1g3st.js', - 'type' => 'js', - ], - ], - ]; - - yield 'it adds dependency to the importmap from a remote asset' => [ - [ - self::createRemoteEntry( - 'bootstrap', - version: '1.2.3', - path: '/assets/vendor/bootstrap.js' - ), - ], - [ - new MappedAsset( - 'app.js', - sourcePath: '/assets/vendor/bootstrap.js', - publicPath: '/assets/vendor/bootstrap-d1g3st.js', - javaScriptImports: [new JavaScriptImport('/assets/simple.js', isLazy: false, asset: $simpleAsset, addImplicitlyToImportMap: true)] - ), - $simpleAsset, - ], - [ - 'bootstrap' => [ - 'path' => '/assets/vendor/bootstrap-d1g3st.js', - 'type' => 'js', - ], - '/assets/simple.js' => [ - 'path' => '/assets/simple-d1g3st.js', - 'type' => 'js', - ], - ], - ]; - - $eagerImportsSimpleAsset = new MappedAsset( - 'imports_simple.js', - publicPathWithoutDigest: '/assets/imports_simple.js', - publicPath: '/assets/imports_simple-d1g3st.js', - javaScriptImports: [new JavaScriptImport('/assets/simple.js', isLazy: false, asset: $simpleAsset, addImplicitlyToImportMap: true)] - ); - yield 'it processes imports recursively' => [ - [ - self::createLocalEntry( - 'app', - path: 'app.js', - ), - ], - [ - new MappedAsset( - 'app.js', - publicPath: '/assets/app-d1g3st.js', - javaScriptImports: [new JavaScriptImport('/assets/imports_simple.js', isLazy: true, asset: $eagerImportsSimpleAsset, addImplicitlyToImportMap: true)] - ), - $eagerImportsSimpleAsset, - $simpleAsset, - ], - [ - 'app' => [ - 'path' => '/assets/app-d1g3st.js', - 'type' => 'js', - ], - '/assets/imports_simple.js' => [ - 'path' => '/assets/imports_simple-d1g3st.js', - 'type' => 'js', - ], - '/assets/simple.js' => [ - 'path' => '/assets/simple-d1g3st.js', - 'type' => 'js', - ], - ], - ]; - - yield 'it process can skip adding one importmap entry but still add a child' => [ - [ - self::createLocalEntry( - 'app', - path: 'app.js', - ), - self::createLocalEntry( - 'imports_simple', - path: 'imports_simple.js', - ), - ], - [ - new MappedAsset( - 'app.js', - publicPath: '/assets/app-d1g3st.js', - javaScriptImports: [new JavaScriptImport('imports_simple', isLazy: true, asset: $eagerImportsSimpleAsset, addImplicitlyToImportMap: false)] - ), - $eagerImportsSimpleAsset, - $simpleAsset, - ], - [ - 'app' => [ - 'path' => '/assets/app-d1g3st.js', - 'type' => 'js', - ], - '/assets/simple.js' => [ - 'path' => '/assets/simple-d1g3st.js', - 'type' => 'js', - ], - 'imports_simple' => [ - 'path' => '/assets/imports_simple-d1g3st.js', - 'type' => 'js', - ], - ], - ]; - - yield 'imports with a module name are not added to the importmap' => [ - [ - self::createLocalEntry( - 'app', - path: 'app.js', - ), - ], - [ - new MappedAsset( - 'app.js', - publicPath: '/assets/app-d1g3st.js', - javaScriptImports: [new JavaScriptImport('simple', isLazy: false, asset: $simpleAsset)] - ), - $simpleAsset, - ], - [ - 'app' => [ - 'path' => '/assets/app-d1g3st.js', - 'type' => 'js', - ], - ], - ]; - - yield 'it does not process dependencies of CSS files' => [ - [ - self::createLocalEntry( - 'app.css', - path: 'app.css', - type: ImportMapType::CSS, - ), - ], - [ - new MappedAsset( - 'app.css', - publicPath: '/assets/app-d1g3st.css', - javaScriptImports: [new JavaScriptImport('/assets/simple.js', asset: $simpleAsset)] - ), - ], - [ - 'app.css' => [ - 'path' => '/assets/app-d1g3st.css', - 'type' => 'css', - ], - ], - ]; - - yield 'it handles a relative path file' => [ - [ - self::createLocalEntry( - 'app', - path: './assets/app.js', - ), - ], - [ - new MappedAsset( - 'app.js', - // /fake/root is the mocked root directory - '/fake/root/assets/app.js', - publicPath: '/assets/app-d1g3st.js', - ), - ], - [ - 'app' => [ - 'path' => '/assets/app-d1g3st.js', - 'type' => 'js', - ], - ], - ]; - - yield 'it handles an absolute path file' => [ - [ - self::createLocalEntry( - 'app', - path: '/some/path/assets/app.js', - ), - ], - [ - new MappedAsset( - 'app.js', - '/some/path/assets/app.js', - publicPath: '/assets/app-d1g3st.js', - ), - ], - [ - 'app' => [ - 'path' => '/assets/app-d1g3st.js', - 'type' => 'js', - ], - ], - ]; - } - - public function testGetRawImportDataUsesCacheFile() - { - $manager = $this->createImportMapManager(); - $importmapData = [ - 'app' => [ - 'path' => 'app.js', - 'entrypoint' => true, - ], - '@hotwired/stimulus' => [ - 'path' => 'https://anyurl.com/stimulus', - ], - ]; - $this->writeFile('public/assets/importmap.json', json_encode($importmapData)); - $this->pathResolver->expects($this->once()) - ->method('getPublicFilesystemPath') - ->willReturn(self::$writableRoot.'/public/assets'); - - $this->assertEquals($importmapData, $manager->getRawImportMapData()); - } - - /** - * @dataProvider getEntrypointMetadataTests - */ - public function testGetEntrypointMetadata(MappedAsset $entryAsset, array $expected) - { - $manager = $this->createImportMapManager(); - $this->mockAssetMapper([$entryAsset]); - // put the entry asset in the importmap - $this->mockImportMap([ - ImportMapEntry::createLocal('the_entrypoint_name', ImportMapType::JS, path: $entryAsset->logicalPath, isEntrypoint: true), - ]); - - $this->assertEquals($expected, $manager->getEntrypointMetadata('the_entrypoint_name')); - } - - public function getEntrypointMetadataTests(): iterable - { - yield 'an entry with no dependencies' => [ - new MappedAsset( - 'app.js', - publicPath: '/assets/app.js', - ), - [], - ]; - - $simpleAsset = new MappedAsset( - 'simple.js', - publicPathWithoutDigest: '/assets/simple.js', - ); - yield 'an entry with a non-lazy dependency is included' => [ - new MappedAsset( - 'app.js', - publicPath: '/assets/app.js', - javaScriptImports: [new JavaScriptImport('/assets/simple.js', isLazy: false, asset: $simpleAsset)] - ), - ['/assets/simple.js'], // path is the key in the importmap - ]; - - yield 'an entry with a non-lazy dependency with module name is included' => [ - new MappedAsset( - 'app.js', - publicPath: '/assets/app.js', - javaScriptImports: [new JavaScriptImport('simple', isLazy: false, asset: $simpleAsset)] - ), - ['simple'], // path is the key in the importmap - ]; - - yield 'an entry with a lazy dependency is not included' => [ - new MappedAsset( - 'app.js', - publicPath: '/assets/app.js', - javaScriptImports: [new JavaScriptImport('/assets/simple.js', isLazy: true, asset: $simpleAsset)] - ), - [], - ]; - - $importsSimpleAsset = new MappedAsset( - 'imports_simple.js', - publicPathWithoutDigest: '/assets/imports_simple.js', - javaScriptImports: [new JavaScriptImport('/assets/simple.js', isLazy: false, asset: $simpleAsset)] - ); - yield 'an entry follows through dependencies recursively' => [ - new MappedAsset( - 'app.js', - publicPath: '/assets/app.js', - javaScriptImports: [new JavaScriptImport('/assets/imports_simple.js', isLazy: false, asset: $importsSimpleAsset)] - ), - ['/assets/imports_simple.js', '/assets/simple.js'], - ]; - } - - public function testGetEntrypointMetadataUsesCacheFile() - { - $manager = $this->createImportMapManager(); - $entrypointData = [ - 'app', - '/assets/foo.js', - ]; - $this->writeFile('public/assets/entrypoint.foo.json', json_encode($entrypointData)); - $this->pathResolver->expects($this->once()) - ->method('getPublicFilesystemPath') - ->willReturn(self::$writableRoot.'/public/assets'); - - $this->assertEquals($entrypointData, $manager->getEntrypointMetadata('foo')); - } - - public function testGetImportMapData() - { - $manager = $this->createImportMapManager(); - $this->mockImportMap([ - self::createLocalEntry( - 'entry1', - path: 'entry1.js', - isEntrypoint: true, - ), - self::createLocalEntry( - 'entry2', - path: 'entry2.js', - isEntrypoint: true, - ), - self::createLocalEntry( - 'entry3', - path: 'entry3.js', - isEntrypoint: true, - ), - self::createLocalEntry( - 'normal_js_file', - path: 'normal_js_file.js', - ), - self::createLocalEntry( - 'css_in_importmap', - path: 'styles/css_in_importmap.css', - type: ImportMapType::CSS, - ), - self::createLocalEntry( - 'never_imported_css', - path: 'styles/never_imported_css.css', - type: ImportMapType::CSS, - ), - ]); - - $importedFile1 = new MappedAsset( - 'imported_file1.js', - publicPathWithoutDigest: '/assets/imported_file1.js', - publicPath: '/assets/imported_file1-d1g35t.js', - ); - $importedFile2 = new MappedAsset( - 'imported_file2.js', - publicPathWithoutDigest: '/assets/imported_file2.js', - publicPath: '/assets/imported_file2-d1g35t.js', - ); - $importedFile3 = new MappedAsset( - 'imported_file3.js', - publicPathWithoutDigest: '/assets/imported_file3.js', - publicPath: '/assets/imported_file3-d1g35t.js', - ); - $normalJsFile = new MappedAsset( - 'normal_js_file.js', - publicPathWithoutDigest: '/assets/normal_js_file.js', - publicPath: '/assets/normal_js_file-d1g35t.js', - ); - $importedCss1 = new MappedAsset( - 'styles/file1.css', - publicPathWithoutDigest: '/assets/styles/file1.css', - publicPath: '/assets/styles/file1-d1g35t.css', - ); - $importedCss2 = new MappedAsset( - 'styles/file2.css', - publicPathWithoutDigest: '/assets/styles/file2.css', - publicPath: '/assets/styles/file2-d1g35t.css', - ); - $importedCssInImportmap = new MappedAsset( - 'styles/css_in_importmap.css', - publicPathWithoutDigest: '/assets/styles/css_in_importmap.css', - publicPath: '/assets/styles/css_in_importmap-d1g35t.css', - ); - $neverImportedCss = new MappedAsset( - 'styles/never_imported_css.css', - publicPathWithoutDigest: '/assets/styles/never_imported_css.css', - publicPath: '/assets/styles/never_imported_css-d1g35t.css', - ); - $this->mockAssetMapper([ - new MappedAsset( - 'entry1.js', - publicPath: '/assets/entry1-d1g35t.js', - javaScriptImports: [ - new JavaScriptImport('/assets/imported_file1.js', isLazy: false, asset: $importedFile1, addImplicitlyToImportMap: true), - new JavaScriptImport('/assets/styles/file1.css', isLazy: false, asset: $importedCss1, addImplicitlyToImportMap: true), - new JavaScriptImport('normal_js_file', isLazy: false, asset: $normalJsFile), - ] - ), - new MappedAsset( - 'entry2.js', - publicPath: '/assets/entry2-d1g35t.js', - javaScriptImports: [ - new JavaScriptImport('/assets/imported_file2.js', isLazy: false, asset: $importedFile2, addImplicitlyToImportMap: true), - new JavaScriptImport('css_in_importmap', isLazy: false, asset: $importedCssInImportmap), - new JavaScriptImport('/assets/styles/file2.css', isLazy: false, asset: $importedCss2, addImplicitlyToImportMap: true), - ] - ), - new MappedAsset( - 'entry3.js', - publicPath: '/assets/entry3-d1g35t.js', - javaScriptImports: [ - new JavaScriptImport('/assets/imported_file3.js', isLazy: false, asset: $importedFile3), - ], - ), - $importedFile1, - $importedFile2, - // $importedFile3, - $normalJsFile, - $importedCss1, - $importedCss2, - $importedCssInImportmap, - $neverImportedCss, - ]); - - $actualImportMapData = $manager->getImportMapData(['entry2', 'entry1']); - - $this->assertEquals([ - 'entry1' => [ - 'path' => '/assets/entry1-d1g35t.js', - 'type' => 'js', - 'preload' => true, // Rendered entry points are preloaded - ], - '/assets/imported_file1.js' => [ - 'path' => '/assets/imported_file1-d1g35t.js', - 'type' => 'js', - 'preload' => true, - ], - 'entry2' => [ - 'path' => '/assets/entry2-d1g35t.js', - 'type' => 'js', - 'preload' => true, // Rendered entry points are preloaded - ], - '/assets/imported_file2.js' => [ - 'path' => '/assets/imported_file2-d1g35t.js', - 'type' => 'js', - 'preload' => true, - ], - 'normal_js_file' => [ - 'path' => '/assets/normal_js_file-d1g35t.js', - 'type' => 'js', - 'preload' => true, // preloaded as it's a non-lazy dependency of an entry - ], - '/assets/styles/file1.css' => [ - 'path' => '/assets/styles/file1-d1g35t.css', - 'type' => 'css', - 'preload' => true, - ], - '/assets/styles/file2.css' => [ - 'path' => '/assets/styles/file2-d1g35t.css', - 'type' => 'css', - 'preload' => true, - ], - 'css_in_importmap' => [ - 'path' => '/assets/styles/css_in_importmap-d1g35t.css', - 'type' => 'css', - 'preload' => true, - ], - 'entry3' => [ - 'path' => '/assets/entry3-d1g35t.js', - 'type' => 'js', // No preload (entry point not "rendered") - ], - 'never_imported_css' => [ - 'path' => '/assets/styles/never_imported_css-d1g35t.css', - 'type' => 'css', - ], - ], $actualImportMapData); - - // now check the order - $this->assertEquals([ - // entry2 & its dependencies - 'entry2', - '/assets/imported_file2.js', - 'css_in_importmap', // in the importmap, but brought earlier because it's a dependency of entry2 - '/assets/styles/file2.css', - - // entry1 & its dependencies - 'entry1', - '/assets/imported_file1.js', - '/assets/styles/file1.css', - 'normal_js_file', - - // importmap entries never imported - 'entry3', - 'never_imported_css', - ], array_keys($actualImportMapData)); - } - - public function testFindRootImportMapEntry() - { - $manager = $this->createImportMapManager(); - $entry1 = ImportMapEntry::createLocal('entry1', ImportMapType::JS, '/any/path', isEntrypoint: true); - $this->mockImportMap([$entry1]); - - $this->assertSame($entry1, $manager->findRootImportMapEntry('entry1')); - $this->assertNull($manager->findRootImportMapEntry('entry2')); - } - - public function testGetEntrypointNames() - { - $manager = $this->createImportMapManager(); - $this->mockImportMap([ - ImportMapEntry::createLocal('entry1', ImportMapType::JS, path: '/any', isEntrypoint: true), - ImportMapEntry::createLocal('entry2', ImportMapType::JS, path: '/any', isEntrypoint: true), - ImportMapEntry::createLocal('not_entrypoint', ImportMapType::JS, path: '/any', isEntrypoint: false), - ]); - - $this->assertEquals(['entry1', 'entry2'], $manager->getEntrypointNames()); - } - /** * @dataProvider getRequirePackageTests */ @@ -987,7 +362,6 @@ public static function getPackageNameTests(): iterable private function createImportMapManager(): ImportMapManager { - $this->pathResolver = $this->createMock(PublicAssetsPathResolverInterface::class); $this->assetMapper = $this->createMock(AssetMapperInterface::class); $this->configReader = $this->createMock(ImportMapConfigReader::class); $this->packageResolver = $this->createMock(PackageResolverInterface::class); @@ -1004,7 +378,6 @@ private function createImportMapManager(): ImportMapManager return $this->importMapManager = new ImportMapManager( $this->assetMapper, - $this->pathResolver, $this->configReader, $this->remotePackageDownloader, $this->packageResolver, @@ -1028,59 +401,6 @@ private function mockImportMap(array $importMapEntries): void ; } - /** - * @param MappedAsset[] $mappedAssets - */ - private function mockAssetMapper(array $mappedAssets): void - { - $this->assetMapper->expects($this->any()) - ->method('getAsset') - ->willReturnCallback(function (string $logicalPath) use ($mappedAssets) { - foreach ($mappedAssets as $asset) { - if ($asset->logicalPath === $logicalPath) { - return $asset; - } - } - - return null; - }) - ; - - $this->assetMapper->expects($this->any()) - ->method('getAssetFromSourcePath') - ->willReturnCallback(function (string $sourcePath) use ($mappedAssets) { - // collapse ../ in paths and ./ in paths to mimic the realpath AssetMapper uses - $unCollapsePath = function (string $path) { - $parts = explode('/', $path); - $newParts = []; - foreach ($parts as $part) { - if ('..' === $part) { - array_pop($newParts); - - continue; - } - - if ('.' !== $part) { - $newParts[] = $part; - } - } - - return implode('/', $newParts); - }; - - $sourcePath = $unCollapsePath($sourcePath); - - foreach ($mappedAssets as $asset) { - if (isset($asset->sourcePath) && $unCollapsePath($asset->sourcePath) === $sourcePath) { - return $asset; - } - } - - return null; - }) - ; - } - private function writeFile(string $filename, string $content): void { $path = \dirname(self::$writableRoot.'/'.$filename); diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php index 3d729d8c8caf7..a0d90e0cc5c15 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php @@ -13,7 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Asset\Packages; -use Symfony\Component\AssetMapper\ImportMap\ImportMapManager; +use Symfony\Component\AssetMapper\ImportMap\ImportMapGenerator; use Symfony\Component\AssetMapper\ImportMap\ImportMapRenderer; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -23,8 +23,8 @@ class ImportMapRendererTest extends TestCase { public function testBasicRender() { - $importMapManager = $this->createMock(ImportMapManager::class); - $importMapManager->expects($this->once()) + $importMapGenerator = $this->createMock(ImportMapGenerator::class); + $importMapGenerator->expects($this->once()) ->method('getImportMapData') ->with(['app']) ->willReturn([ @@ -68,7 +68,7 @@ public function testBasicRender() return '/subdirectory/'.$path; }); - $renderer = new ImportMapRenderer($importMapManager, $assetPackages, polyfillImportName: 'es-module-shim'); + $renderer = new ImportMapRenderer($importMapGenerator, $assetPackages, polyfillImportName: 'es-module-shim'); $html = $renderer->render(['app']); $this->assertStringContainsString('", $renderer->render('application')); - $renderer = new ImportMapRenderer($this->createBasicImportMapManager()); + $renderer = new ImportMapRenderer($this->createBasicImportMapGenerator()); $this->assertStringContainsString("", $renderer->render("application's")); - $renderer = new ImportMapRenderer($this->createBasicImportMapManager()); + $renderer = new ImportMapRenderer($this->createBasicImportMapGenerator()); $html = $renderer->render(['foo', 'bar']); $this->assertStringContainsString("import 'foo';", $html); $this->assertStringContainsString("import 'bar';", $html); } - private function createBasicImportMapManager(): ImportMapManager + private function createBasicImportMapGenerator(): ImportMapGenerator { - $importMapManager = $this->createMock(ImportMapManager::class); - $importMapManager->expects($this->once()) + $importMapGenerator = $this->createMock(ImportMapGenerator::class); + $importMapGenerator->expects($this->once()) ->method('getImportMapData') ->willReturn([ 'app' => [ @@ -159,13 +159,13 @@ private function createBasicImportMapManager(): ImportMapManager ]) ; - return $importMapManager; + return $importMapGenerator; } public function testItAddsPreloadLinks() { - $importMapManager = $this->createMock(ImportMapManager::class); - $importMapManager->expects($this->once()) + $importMapGenerator = $this->createMock(ImportMapGenerator::class); + $importMapGenerator->expects($this->once()) ->method('getImportMapData') ->willReturn([ 'app_js_preload' => [ @@ -188,7 +188,7 @@ public function testItAddsPreloadLinks() $requestStack = new RequestStack(); $requestStack->push($request); - $renderer = new ImportMapRenderer($importMapManager, requestStack: $requestStack); + $renderer = new ImportMapRenderer($importMapGenerator, requestStack: $requestStack); $renderer->render(['app']); $linkProvider = $request->attributes->get('_links');