diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 7592ed38d2e48..ae342f1c6b918 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -1363,8 +1363,8 @@ private function registerAssetMapperConfiguration(array $config, ContainerBuilde ->setArgument(1, $config['missing_import_mode']); $container - ->getDefinition('asset_mapper.importmap.remote_package_downloader') - ->replaceArgument(2, $config['vendor_dir']) + ->getDefinition('asset_mapper.importmap.remote_package_storage') + ->replaceArgument(0, $config['vendor_dir']) ; $container ->getDefinition('asset_mapper.mapped_asset_factory') diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php index 296358cfcf72c..2a3ca8b6e9887 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php @@ -35,6 +35,7 @@ use Symfony\Component\AssetMapper\ImportMap\ImportMapRenderer; use Symfony\Component\AssetMapper\ImportMap\ImportMapUpdateChecker; use Symfony\Component\AssetMapper\ImportMap\RemotePackageDownloader; +use Symfony\Component\AssetMapper\ImportMap\RemotePackageStorage; use Symfony\Component\AssetMapper\ImportMap\Resolver\JsDelivrEsmResolver; use Symfony\Component\AssetMapper\MapperAwareAssetPackage; use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolver; @@ -145,6 +146,7 @@ ->set('asset_mapper.importmap.config_reader', ImportMapConfigReader::class) ->args([ abstract_arg('importmap.php path'), + service('asset_mapper.importmap.remote_package_storage'), ]) ->set('asset_mapper.importmap.manager', ImportMapManager::class) @@ -157,11 +159,16 @@ ]) ->alias(ImportMapManager::class, 'asset_mapper.importmap.manager') + ->set('asset_mapper.importmap.remote_package_storage', RemotePackageStorage::class) + ->args([ + abstract_arg('vendor directory'), + ]) + ->set('asset_mapper.importmap.remote_package_downloader', RemotePackageDownloader::class) ->args([ + service('asset_mapper.importmap.remote_package_storage'), service('asset_mapper.importmap.config_reader'), service('asset_mapper.importmap.resolver'), - abstract_arg('vendor directory'), ]) ->set('asset_mapper.importmap.resolver', JsDelivrEsmResolver::class) diff --git a/src/Symfony/Component/AssetMapper/Command/ImportMapOutdatedCommand.php b/src/Symfony/Component/AssetMapper/Command/ImportMapOutdatedCommand.php index 2f1c6d64d5353..ac188a009520a 100644 --- a/src/Symfony/Component/AssetMapper/Command/ImportMapOutdatedCommand.php +++ b/src/Symfony/Component/AssetMapper/Command/ImportMapOutdatedCommand.php @@ -73,7 +73,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - $displayData = array_map(fn ($importName, $packageUpdateInfo) => [ + $displayData = array_map(fn (string $importName, PackageUpdateInfo $packageUpdateInfo) => [ 'name' => $importName, 'current' => $packageUpdateInfo->currentVersion, 'latest' => $packageUpdateInfo->latestVersion, diff --git a/src/Symfony/Component/AssetMapper/Command/ImportMapRequireCommand.php b/src/Symfony/Component/AssetMapper/Command/ImportMapRequireCommand.php index 3f297039e81f9..6a5fb54e2781a 100644 --- a/src/Symfony/Component/AssetMapper/Command/ImportMapRequireCommand.php +++ b/src/Symfony/Component/AssetMapper/Command/ImportMapRequireCommand.php @@ -54,14 +54,10 @@ protected function configure(): void php %command.full_name% "chart.js/auto" -Or download one package/file, but alias its name in your import map: +Or require one package/file, but alias its name in your import map: php %command.full_name% "vue/dist/vue.esm-bundler.js=vue" -The download option will download the package locally and point the -importmap to it. Use this if you want to avoid using a CDN or if you want to -ensure that the package is available even if the CDN is down. - Sometimes, a package may require other packages and multiple new items may be added to the import map. @@ -69,6 +65,10 @@ protected function configure(): void php %command.full_name% "lodash@^4.15" "@hotwired/stimulus" +To add an importmap entry pointing to a local file, use the path option: + + php %command.full_name% "any_module_name" --path=./assets/some_file.js + EOT ); } @@ -87,15 +87,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $path = $input->getOption('path'); - if (!is_file($path)) { - $path = $this->projectDir.'/'.$path; - - if (!is_file($path)) { - $io->error(sprintf('The path "%s" does not exist.', $input->getOption('path'))); - - return Command::FAILURE; - } - } } $packages = []; @@ -110,7 +101,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $packages[] = new PackageRequireOptions( $parts['package'], $parts['version'] ?? null, - $parts['alias'] ?? $parts['package'], + $parts['alias'] ?? null, $path, $input->getOption('entrypoint'), ); diff --git a/src/Symfony/Component/AssetMapper/Compiler/JavaScriptImportPathCompiler.php b/src/Symfony/Component/AssetMapper/Compiler/JavaScriptImportPathCompiler.php index 147d63a40b82c..f1f33d71eedc0 100644 --- a/src/Symfony/Component/AssetMapper/Compiler/JavaScriptImportPathCompiler.php +++ b/src/Symfony/Component/AssetMapper/Compiler/JavaScriptImportPathCompiler.php @@ -28,8 +28,8 @@ final class JavaScriptImportPathCompiler implements AssetCompilerInterface { use AssetCompilerPathResolverTrait; - // https://regex101.com/r/5Q38tj/1 - private const IMPORT_PATTERN = '/(?:import\s+(?:(?:\*\s+as\s+\w+|[\w\s{},*]+)\s+from\s+)?|\bimport\()\s*[\'"`](\.\/[^\'"`]+|(\.\.\/)*[^\'"`]+)[\'"`]\s*[;\)]?/m'; + // https://regex101.com/r/fquriB/1 + 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, @@ -145,12 +145,11 @@ private function findAssetForBareImport(string $importedModule, AssetMapperInter return null; } - // remote entries have no MappedAsset - if ($importMapEntry->isRemotePackage()) { - return null; + if ($asset = $assetMapper->getAsset($importMapEntry->path)) { + return $asset; } - return $assetMapper->getAsset($importMapEntry->path); + return $assetMapper->getAssetFromSourcePath($importMapEntry->path); } private function findAssetForRelativeImport(string $importedModule, MappedAsset $asset, AssetMapperInterface $assetMapper): ?MappedAsset diff --git a/src/Symfony/Component/AssetMapper/Exception/LogicException.php b/src/Symfony/Component/AssetMapper/Exception/LogicException.php new file mode 100644 index 0000000000000..c4cce726f3d2b --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Exception/LogicException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Exception; + +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapAuditor.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapAuditor.php index 1d49e0c77055b..1597884b215bf 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapAuditor.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapAuditor.php @@ -35,10 +35,6 @@ public function audit(): array { $entries = $this->configReader->getEntries(); - if (!$entries) { - return []; - } - /** @var array> $installed */ $packageAudits = []; @@ -51,14 +47,19 @@ public function audit(): array } $version = $entry->version; - $installed[$entry->importName] ??= []; - $installed[$entry->importName][] = $version; + $packageName = $entry->getPackageName(); + $installed[$packageName] ??= []; + $installed[$packageName][] = $version; - $packageVersion = $entry->importName.($version ? '@'.$version : ''); - $packageAudits[$packageVersion] ??= new ImportMapPackageAudit($entry->importName, $version); + $packageVersion = $packageName.'@'.$version; + $packageAudits[$packageVersion] ??= new ImportMapPackageAudit($packageName, $version); $affectsQuery[] = $packageVersion; } + if (!$affectsQuery) { + return []; + } + // @see https://docs.github.com/en/rest/security-advisories/global-advisories?apiVersion=2022-11-28#list-global-security-advisories $response = $this->httpClient->request('GET', self::AUDIT_URL, [ 'query' => ['affects' => implode(',', $affectsQuery)], @@ -81,7 +82,7 @@ public function audit(): array if (!$version || !$this->versionMatches($version, $vulnerability['vulnerable_version_range'] ?? '>= *')) { continue; } - $packageAudits[$package.($version ? '@'.$version : '')] = $packageAudits[$package.($version ? '@'.$version : '')]->withVulnerability( + $packageAudits[$package.'@'.$version] = $packageAudits[$package.'@'.$version]->withVulnerability( new ImportMapPackageAuditVulnerability( $advisory['ghsa_id'], $advisory['cve_id'], diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapConfigReader.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapConfigReader.php index 5b2a8240f7f4b..8aaee7a3e1646 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapConfigReader.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapConfigReader.php @@ -23,8 +23,10 @@ class ImportMapConfigReader { private ImportMapEntries $rootImportMapEntries; - public function __construct(private readonly string $importMapConfigPath) - { + public function __construct( + private readonly string $importMapConfigPath, + private readonly RemotePackageStorage $remotePackageStorage, + ) { } public function getEntries(): ImportMapEntries @@ -38,7 +40,7 @@ public function getEntries(): ImportMapEntries $entries = new ImportMapEntries(); foreach ($importMapConfig ?? [] as $importName => $data) { - $validKeys = ['path', 'version', 'type', 'entrypoint', 'url']; + $validKeys = ['path', 'version', 'type', 'entrypoint', 'url', 'package_specifier']; if ($invalidKeys = array_diff(array_keys($data), $validKeys)) { throw new \InvalidArgumentException(sprintf('The following keys are not valid for the importmap entry "%s": "%s". Valid keys are: "%s".', $importName, implode('", "', $invalidKeys), implode('", "', $validKeys))); } @@ -49,36 +51,33 @@ public function getEntries(): ImportMapEntries } $type = isset($data['type']) ? ImportMapType::tryFrom($data['type']) : ImportMapType::JS; - $isEntry = $data['entrypoint'] ?? false; + $isEntrypoint = $data['entrypoint'] ?? false; + + if (isset($data['path'])) { + if (isset($data['version'])) { + throw new RuntimeException(sprintf('The importmap entry "%s" cannot have both a "path" and "version" option.', $importName)); + } + if (isset($data['package_specifier'])) { + throw new RuntimeException(sprintf('The importmap entry "%s" cannot have both a "path" and "package_specifier" option.', $importName)); + } + + $entries->add(ImportMapEntry::createLocal($importName, $type, $data['path'], $isEntrypoint)); - if ($isEntry && ImportMapType::JS !== $type) { - throw new RuntimeException(sprintf('The "entrypoint" option can only be used with the "js" type. Found "%s" in importmap.php for key "%s".', $importName, $type->value)); + continue; } - $path = $data['path'] ?? null; $version = $data['version'] ?? null; if (null === $version && ($data['url'] ?? null)) { // BC layer for 6.3->6.4 $version = $this->extractVersionFromLegacyUrl($data['url']); } - if (null === $version && null === $path) { + + if (null === $version) { throw new RuntimeException(sprintf('The importmap entry "%s" must have either a "path" or "version" option.', $importName)); } - if (null !== $version && null !== $path) { - throw new RuntimeException(sprintf('The importmap entry "%s" cannot have both a "path" and "version" option.', $importName)); - } - [$packageName, $filePath] = self::splitPackageNameAndFilePath($importName); - - $entries->add(new ImportMapEntry( - $importName, - path: $path, - version: $version, - type: $type, - isEntrypoint: $isEntry, - packageName: $packageName, - filePath: $filePath, - )); + $packageModuleSpecifier = $data['package_specifier'] ?? $importName; + $entries->add($this->createRemoteEntry($importName, $type, $version, $packageModuleSpecifier, $isEntrypoint)); } return $this->rootImportMapEntries = $entries; @@ -91,12 +90,13 @@ public function writeEntries(ImportMapEntries $entries): void $importMapConfig = []; foreach ($entries as $entry) { $config = []; - if ($entry->path) { - $path = $entry->path; - $config['path'] = $path; - } - if ($entry->version) { + if ($entry->isRemotePackage()) { $config['version'] = $entry->version; + if ($entry->packageModuleSpecifier !== $entry->importName) { + $config['package_specifier'] = $entry->packageModuleSpecifier; + } + } else { + $config['path'] = $entry->path; } if (ImportMapType::JS !== $entry->type) { $config['type'] = $entry->type->value; @@ -104,6 +104,7 @@ public function writeEntries(ImportMapEntries $entries): void if ($entry->isEntrypoint) { $config['entrypoint'] = true; } + $importMapConfig[$entry->importName] = $config; } @@ -129,6 +130,13 @@ public function writeEntries(ImportMapEntries $entries): void EOF); } + public function createRemoteEntry(string $importName, ImportMapType $type, string $version, string $packageModuleSpecifier, bool $isEntrypoint): ImportMapEntry + { + $path = $this->remotePackageStorage->getDownloadPath($packageModuleSpecifier, $type); + + return ImportMapEntry::createRemote($importName, $type, $path, $version, $packageModuleSpecifier, $isEntrypoint); + } + public function getRootDirectory(): string { return \dirname($this->importMapConfigPath); @@ -148,18 +156,4 @@ private function extractVersionFromLegacyUrl(string $url): ?string return substr($url, $lastAt + 1, $nextSlash - $lastAt - 1); } - - public static function splitPackageNameAndFilePath(string $packageName): array - { - $filePath = ''; - $i = strpos($packageName, '/'); - - if ($i && (!str_starts_with($packageName, '@') || $i = strpos($packageName, '/', $i + 1))) { - // @vendor/package/filepath or package/filepath - $filePath = substr($packageName, $i); - $packageName = substr($packageName, 0, $i); - } - - return [$packageName, $filePath]; - } } diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapEntry.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapEntry.php index a2a92e9ed21e0..086dd2152c03b 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapEntry.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapEntry.php @@ -18,22 +18,65 @@ */ final class ImportMapEntry { - public function __construct( + private function __construct( public readonly string $importName, + public readonly ImportMapType $type, /** - * The path to the asset if local or downloaded. + * A logical path, relative path or absolute path to the file. */ - public readonly ?string $path = null, - public readonly ?string $version = null, - public readonly ImportMapType $type = ImportMapType::JS, - public readonly bool $isEntrypoint = false, - public readonly ?string $packageName = null, - public readonly ?string $filePath = null, + public readonly string $path, + public readonly bool $isEntrypoint, + /** + * The version of the package (remote only). + */ + public readonly ?string $version, + /** + * The full "package-name/path" (remote only). + */ + public readonly ?string $packageModuleSpecifier, ) { } + public static function createLocal(string $importName, ImportMapType $importMapType, string $path, bool $isEntrypoint): self + { + return new self($importName, $importMapType, $path, $isEntrypoint, null, null); + } + + public static function createRemote(string $importName, ImportMapType $importMapType, string $path, string $version, string $packageModuleSpecifier, bool $isEntrypoint): self + { + return new self($importName, $importMapType, $path, $isEntrypoint, $version, $packageModuleSpecifier); + } + + public function getPackageName(): string + { + return self::splitPackageNameAndFilePath($this->packageModuleSpecifier)[0]; + } + + public function getPackagePathString(): string + { + return self::splitPackageNameAndFilePath($this->packageModuleSpecifier)[1]; + } + + /** + * @psalm-assert-if-true !null $this->version + * @psalm-assert-if-true !null $this->packageModuleSpecifier + */ public function isRemotePackage(): bool { return null !== $this->version; } + + public static function splitPackageNameAndFilePath(string $packageModuleSpecifier): array + { + $filePath = ''; + $i = strpos($packageModuleSpecifier, '/'); + + if ($i && (!str_starts_with($packageModuleSpecifier, '@') || $i = strpos($packageModuleSpecifier, '/', $i + 1))) { + // @vendor/package/filepath or package/filepath + $filePath = substr($packageModuleSpecifier, $i); + $packageModuleSpecifier = substr($packageModuleSpecifier, 0, $i); + } + + return [$packageModuleSpecifier, $filePath]; + } } diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php index 6144eab323f37..da16fffb769d0 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php @@ -12,6 +12,7 @@ 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; @@ -154,19 +155,9 @@ public function getRawImportMapData(): array $rawImportMapData = []; foreach ($allEntries as $entry) { - if ($entry->path) { - $asset = $this->findAsset($entry->path); - - if (!$asset) { - throw new \InvalidArgumentException(sprintf('The asset "%s" cannot be found in any asset map paths.', $entry->path)); - } - } else { - $sourcePath = $this->packageDownloader->getDownloadedPath($entry->importName); - $asset = $this->assetMapper->getAssetFromSourcePath($sourcePath); - - if (!$asset) { - throw new \InvalidArgumentException(sprintf('The "%s" vendor asset is missing. Run "php bin/console importmap:install".', $entry->importName)); - } + $asset = $this->findAsset($entry->path); + if (!$asset) { + throw $this->createMissingImportMapAssetException($entry); } $path = $asset->publicPath; @@ -222,12 +213,8 @@ private function updateImportMapConfig(bool $update, array $packagesToRequire, a continue; } - // assume the import name === package name, unless we can parse - // the true package name from the URL - $packageName = $importName; - $packagesToRequire[] = new PackageRequireOptions( - $packageName, + $entry->packageModuleSpecifier, null, $importName, ); @@ -267,7 +254,7 @@ private function requirePackages(array $packagesToRequire, ImportMapEntries $imp $path = $requireOptions->path; if (!$asset = $this->findAsset($path)) { - throw new \LogicException(sprintf('The path "%s" of the package "%s" cannot be found: either pass the logical name of the asset or a relative path starting with "./".', $requireOptions->path, $requireOptions->packageName)); + throw new \LogicException(sprintf('The path "%s" of the package "%s" cannot be found: either pass the logical name of the asset or a relative path starting with "./".', $requireOptions->path, $requireOptions->importName)); } $rootImportMapDir = $this->importMapConfigReader->getRootDirectory(); @@ -277,11 +264,11 @@ private function requirePackages(array $packagesToRequire, ImportMapEntries $imp $path = './'.substr(realpath($asset->sourcePath), \strlen(realpath($rootImportMapDir)) + 1); } - $newEntry = new ImportMapEntry( - $requireOptions->packageName, - path: $path, - type: self::getImportMapTypeFromFilename($requireOptions->path), - isEntrypoint: $requireOptions->entrypoint, + $newEntry = ImportMapEntry::createLocal( + $requireOptions->importName, + self::getImportMapTypeFromFilename($requireOptions->path), + $path, + $requireOptions->entrypoint, ); $importMapEntries->add($newEntry); $addedEntries[] = $newEntry; @@ -294,14 +281,12 @@ private function requirePackages(array $packagesToRequire, ImportMapEntries $imp $resolvedPackages = $this->resolver->resolvePackages($packagesToRequire); foreach ($resolvedPackages as $resolvedPackage) { - $importName = $resolvedPackage->requireOptions->importName ?: $resolvedPackage->requireOptions->packageName; - - $newEntry = new ImportMapEntry( - $importName, - path: $resolvedPackage->requireOptions->path, - version: $resolvedPackage->version, - type: $resolvedPackage->type, - isEntrypoint: $resolvedPackage->requireOptions->entrypoint, + $newEntry = $this->importMapConfigReader->createRemoteEntry( + $resolvedPackage->requireOptions->importName, + $resolvedPackage->type, + $resolvedPackage->version, + $resolvedPackage->requireOptions->packageModuleSpecifier, + $resolvedPackage->requireOptions->entrypoint, ); $importMapEntries->add($newEntry); $addedEntries[] = $newEntry; @@ -312,17 +297,9 @@ private function requirePackages(array $packagesToRequire, ImportMapEntries $imp private function cleanupPackageFiles(ImportMapEntry $entry): void { - if (null === $entry->path) { - return; - } - $asset = $this->findAsset($entry->path); - if (!$asset) { - throw new \LogicException(sprintf('The path "%s" of the package "%s" cannot be found in any asset map paths.', $entry->path, $entry->importName)); - } - - if (is_file($asset->sourcePath)) { + if ($asset && is_file($asset->sourcePath)) { @unlink($asset->sourcePath); } } @@ -345,14 +322,9 @@ private function addImplicitEntries(ImportMapEntry $entry, array $currentImportE return $currentImportEntries; } - // remote packages aren't in the asset mapper & so don't have dependencies - if ($entry->isRemotePackage()) { - return $currentImportEntries; - } - if (!$asset = $this->findAsset($entry->path)) { // should only be possible at this point for root importmap.php entries - throw new \InvalidArgumentException(sprintf('The asset "%s" mentioned in "importmap.php" cannot be found in any asset map paths.', $entry->path)); + throw $this->createMissingImportMapAssetException($entry); } foreach ($asset->getJavaScriptImports() as $javaScriptImport) { @@ -363,14 +335,15 @@ private function addImplicitEntries(ImportMapEntry $entry, array $currentImportE continue; } - // check if this import requires an automatic importmap name + // check if this import requires an automatic importmap entry if ($javaScriptImport->addImplicitlyToImportMap && $javaScriptImport->asset) { - $nextEntry = new ImportMapEntry( + $nextEntry = ImportMapEntry::createLocal( $importName, - path: $javaScriptImport->asset->logicalPath, - type: ImportMapType::tryFrom($javaScriptImport->asset->publicExtension) ?: ImportMapType::JS, - isEntrypoint: false, + ImportMapType::tryFrom($javaScriptImport->asset->publicExtension) ?: ImportMapType::JS, + $javaScriptImport->asset->logicalPath, + false, ); + $currentImportEntries[$importName] = $nextEntry; } else { $nextEntry = $this->findRootImportMapEntry($importName); @@ -457,4 +430,13 @@ 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/ImportMapUpdateChecker.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapUpdateChecker.php index 0a77f8e7ba038..b64a067609850 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapUpdateChecker.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapUpdateChecker.php @@ -34,27 +34,30 @@ public function getAvailableUpdates(array $packages = []): array $updateInfos = []; $responses = []; foreach ($entries as $entry) { - if (null === $entry->packageName || null === $entry->version) { + if (!$entry->isRemotePackage()) { continue; } - if (\count($packages) && !\in_array($entry->packageName, $packages, true)) { + if ($packages + && !\in_array($entry->getPackageName(), $packages, true) + && !\in_array($entry->importName, $packages, true) + ) { continue; } - $responses[$entry->importName] = $this->httpClient->request('GET', sprintf(self::URL_PACKAGE_METADATA, $entry->packageName), ['headers' => ['Accept' => 'application/vnd.npm.install-v1+json']]); + $responses[$entry->importName] = $this->httpClient->request('GET', sprintf(self::URL_PACKAGE_METADATA, $entry->getPackageName()), ['headers' => ['Accept' => 'application/vnd.npm.install-v1+json']]); } foreach ($responses as $importName => $response) { $entry = $entries->get($importName); if (200 !== $response->getStatusCode()) { - throw new \RuntimeException(sprintf('Unable to get latest version for package "%s".', $entry->packageName)); + throw new \RuntimeException(sprintf('Unable to get latest version for package "%s".', $entry->getPackageName())); } - $updateInfo = new PackageUpdateInfo($entry->packageName, $entry->version); + $updateInfo = new PackageUpdateInfo($entry->getPackageName(), $entry->version); try { $updateInfo->latestVersion = json_decode($response->getContent(), true)['dist-tags']['latest']; $updateInfo->updateType = $this->getUpdateType($updateInfo->currentVersion, $updateInfo->latestVersion); } catch (\Exception $e) { - throw new \RuntimeException(sprintf('Unable to get latest version for package "%s".', $entry->packageName), 0, $e); + throw new \RuntimeException(sprintf('Unable to get latest version for package "%s".', $entry->getPackageName()), 0, $e); } $updateInfos[$importName] = $updateInfo; } diff --git a/src/Symfony/Component/AssetMapper/ImportMap/JavaScriptImport.php b/src/Symfony/Component/AssetMapper/ImportMap/JavaScriptImport.php index d7d070e587bd1..12030934ff3b9 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/JavaScriptImport.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/JavaScriptImport.php @@ -19,9 +19,10 @@ final class JavaScriptImport { /** - * @param string $importName The name of the import needed in the importmap, e.g. "/foo.js" or "react". - * @param bool $isLazy whether this import was lazy or eager - * @param bool $addImplicitlyToImportMap whether this import should be added to the importmap automatically + * @param string $importName The name of the import needed in the importmap, e.g. "/foo.js" or "react" + * @param bool $isLazy Whether this import was lazy or eager + * @param MappedAsset|null $asset The asset that was imported, if known - needed to add to the importmap, also used to find further imports for preloading + * @param bool $addImplicitlyToImportMap Whether this import should be added to the importmap automatically */ public function __construct( public readonly string $importName, diff --git a/src/Symfony/Component/AssetMapper/ImportMap/PackageRequireOptions.php b/src/Symfony/Component/AssetMapper/ImportMap/PackageRequireOptions.php index 095533c69f07c..6875bca9d1e59 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/PackageRequireOptions.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/PackageRequireOptions.php @@ -18,12 +18,18 @@ */ final class PackageRequireOptions { + public readonly string $importName; + public function __construct( - public readonly string $packageName, + /** + * The "package-name/path" of the remote package. + */ + public readonly string $packageModuleSpecifier, public readonly ?string $versionConstraint = null, - public readonly ?string $importName = null, + string $importName = null, public readonly ?string $path = null, public readonly bool $entrypoint = false, ) { + $this->importName = $importName ?: $packageModuleSpecifier; } } diff --git a/src/Symfony/Component/AssetMapper/ImportMap/RemotePackageDownloader.php b/src/Symfony/Component/AssetMapper/ImportMap/RemotePackageDownloader.php index a3440473ab792..577abfd5e7236 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/RemotePackageDownloader.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/RemotePackageDownloader.php @@ -21,9 +21,9 @@ class RemotePackageDownloader private array $installed; public function __construct( + private readonly RemotePackageStorage $remotePackageStorage, private readonly ImportMapConfigReader $importMapConfigReader, private readonly PackageResolverInterface $packageResolver, - private readonly string $vendorDir, ) { } @@ -51,7 +51,7 @@ public function downloadPackages(callable $progressCallback = null): array if ( isset($installed[$entry->importName]) && $installed[$entry->importName]['version'] === $entry->version - && file_exists($this->vendorDir.'/'.$installed[$entry->importName]['path']) + && $this->remotePackageStorage->isDownloaded($entry) ) { $newInstalled[$entry->importName] = $installed[$entry->importName]; continue; @@ -71,9 +71,8 @@ public function downloadPackages(callable $progressCallback = null): array throw new \LogicException(sprintf('The package "%s" was not downloaded.', $package)); } - $filename = $this->savePackage($package, $contents[$package], $entry->type); + $this->remotePackageStorage->save($entry, $contents[$package]); $newInstalled[$package] = [ - 'path' => $filename, 'version' => $entry->version, ]; @@ -90,33 +89,9 @@ public function downloadPackages(callable $progressCallback = null): array return $downloadedPackages; } - public function getDownloadedPath(string $importName): string - { - $installed = $this->loadInstalled(); - if (!isset($installed[$importName])) { - throw new \InvalidArgumentException(sprintf('The "%s" vendor asset is missing. Run "php bin/console importmap:install".', $importName)); - } - - return $this->vendorDir.'/'.$installed[$importName]['path']; - } - public function getVendorDir(): string { - return $this->vendorDir; - } - - private function savePackage(string $packageName, string $packageContents, ImportMapType $importMapType): string - { - $filename = $packageName; - if (!str_contains(basename($packageName), '.')) { - $filename .= '.'.$importMapType->value; - } - $vendorPath = $this->vendorDir.'/'.$filename; - - @mkdir(\dirname($vendorPath), 0777, true); - file_put_contents($vendorPath, $packageContents); - - return $filename; + return $this->remotePackageStorage->getStorageDir(); } /** @@ -128,31 +103,21 @@ private function loadInstalled(): array return $this->installed; } - $installedPath = $this->vendorDir.'/installed.php'; + $installedPath = $this->remotePackageStorage->getStorageDir().'/installed.php'; $installed = is_file($installedPath) ? (static fn () => include $installedPath)() : []; foreach ($installed as $package => $data) { - if (!isset($data['path'])) { - throw new \InvalidArgumentException(sprintf('The package "%s" is missing its path.', $package)); - } - if (!isset($data['version'])) { throw new \InvalidArgumentException(sprintf('The package "%s" is missing its version.', $package)); } - - if (!is_file($this->vendorDir.'/'.$data['path'])) { - unset($installed[$package]); - } } - $this->installed = $installed; - - return $installed; + return $this->installed = $installed; } private function saveInstalled(array $installed): void { $this->installed = $installed; - file_put_contents($this->vendorDir.'/installed.php', sprintf('remotePackageStorage->getStorageDir().'/installed.php', sprintf(' + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\ImportMap; + +/** + * Manages the local storage of remote/vendor importmap packages. + */ +class RemotePackageStorage +{ + public function __construct(private readonly string $vendorDir) + { + } + + public function getStorageDir(): string + { + return $this->vendorDir; + } + + public function isDownloaded(ImportMapEntry $entry): bool + { + if (!$entry->isRemotePackage()) { + throw new \InvalidArgumentException(sprintf('The entry "%s" is not a remote package.', $entry->importName)); + } + + return is_file($this->getDownloadPath($entry->packageModuleSpecifier, $entry->type)); + } + + public function save(ImportMapEntry $entry, string $contents): void + { + if (!$entry->isRemotePackage()) { + throw new \InvalidArgumentException(sprintf('The entry "%s" is not a remote package.', $entry->importName)); + } + + $vendorPath = $this->getDownloadPath($entry->packageModuleSpecifier, $entry->type); + + @mkdir(\dirname($vendorPath), 0777, true); + file_put_contents($vendorPath, $contents); + } + + /** + * The local file path where a downloaded package should be stored. + */ + public function getDownloadPath(string $packageModuleSpecifier, ImportMapType $importMapType): string + { + [$packageName, $packagePathString] = ImportMapEntry::splitPackageNameAndFilePath($packageModuleSpecifier); + $filename = $packageName; + if ($packagePathString) { + $filename .= '/'.ltrim($packagePathString, '/'); + } else { + // if we're requiring a bare package, we put it into the directory + // (in case we also import other files from the package) and arbitrarily + // name it the same as the package name + ".index" + $filename .= '/'.basename($packageName).'.index'; + } + + if (!str_ends_with($filename, '.'.$importMapType->value)) { + $filename .= '.'.$importMapType->value; + } + + return $this->vendorDir.'/'.$filename; + } +} diff --git a/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php b/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php index a14a8f0ac5e7b..9b9f395a3a0aa 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php @@ -12,7 +12,6 @@ namespace Symfony\Component\AssetMapper\ImportMap\Resolver; use Symfony\Component\AssetMapper\Exception\RuntimeException; -use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader; use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; use Symfony\Component\AssetMapper\ImportMap\ImportMapType; use Symfony\Component\AssetMapper\ImportMap\PackageRequireOptions; @@ -49,26 +48,26 @@ public function resolvePackages(array $packagesToRequire): array // request the version of each package $requiredPackages = []; foreach ($packagesToRequire as $options) { - $packageName = trim($options->packageName, '/'); + $packageSpecifier = trim($options->packageModuleSpecifier, '/'); $constraint = $options->versionConstraint ?? '*'; // avoid resolving the same package twice - if (isset($resolvedPackages[$packageName])) { + if (isset($resolvedPackages[$packageSpecifier])) { continue; } - [$packageName, $filePath] = ImportMapConfigReader::splitPackageNameAndFilePath($packageName); + [$packageName, $filePath] = ImportMapEntry::splitPackageNameAndFilePath($packageSpecifier); $response = $this->httpClient->request('GET', sprintf($this->versionUrlPattern, $packageName, urlencode($constraint))); $requiredPackages[] = [$options, $response, $packageName, $filePath, /* resolved version */ null]; } - // grab the version of each package & request the contents - $errors = []; - $cssEntrypointResponses = []; + // use the version of each package to request the contents + $findVersionErrors = []; + $entrypointResponses = []; foreach ($requiredPackages as $i => [$options, $response, $packageName, $filePath]) { if (200 !== $response->getStatusCode()) { - $errors[] = [$options->packageName, $response]; + $findVersionErrors[] = [$packageName, $response]; continue; } @@ -78,49 +77,49 @@ public function resolvePackages(array $packagesToRequire): array $requiredPackages[$i][4] = $version; if (!$filePath) { - $cssEntrypointResponses[$packageName] = $this->httpClient->request('GET', sprintf(self::URL_PATTERN_ENTRYPOINT, $packageName, $version)); + $entrypointResponses[$packageName] = [$this->httpClient->request('GET', sprintf(self::URL_PATTERN_ENTRYPOINT, $packageName, $version)), $version]; } } try { - ($errors[0][1] ?? null)?->getHeaders(); + ($findVersionErrors[0][1] ?? null)?->getHeaders(); } catch (HttpExceptionInterface $e) { $response = $e->getResponse(); - $packages = implode('", "', array_column($errors, 0)); + $packages = implode('", "', array_column($findVersionErrors, 0)); - throw new RuntimeException(sprintf('Error %d finding version from jsDelivr for "%s". Check your package names. Response: ', $response->getStatusCode(), $packages).$response->getContent(false), 0, $e); + throw new RuntimeException(sprintf('Error %d finding version from jsDelivr for the following packages: "%s". Check your package names. Response: ', $response->getStatusCode(), $packages).$response->getContent(false), 0, $e); } // process the contents of each package & add the resolved package $packagesToRequire = []; + $getContentErrors = []; foreach ($requiredPackages as [$options, $response, $packageName, $filePath, $version]) { if (200 !== $response->getStatusCode()) { - $errors[] = [$options->packageName, $response]; + $getContentErrors[] = [$options->packageModuleSpecifier, $response]; continue; } - $packageName = trim($options->packageName, '/'); $contentType = $response->getHeaders()['content-type'][0] ?? ''; $type = str_starts_with($contentType, 'text/css') ? ImportMapType::CSS : ImportMapType::JS; - $resolvedPackages[$packageName] = new ResolvedImportMapPackage($options, $version, $type); + $resolvedPackages[$options->packageModuleSpecifier] = new ResolvedImportMapPackage($options, $version, $type); $packagesToRequire = array_merge($packagesToRequire, $this->fetchPackageRequirementsFromImports($response->getContent())); } try { - ($errors[0][1] ?? null)?->getHeaders(); + ($getContentErrors[0][1] ?? null)?->getHeaders(); } catch (HttpExceptionInterface $e) { $response = $e->getResponse(); - $packages = implode('", "', array_column($errors, 0)); + $packages = implode('", "', array_column($getContentErrors, 0)); throw new RuntimeException(sprintf('Error %d requiring packages from jsDelivr for "%s". Check your package names. Response: ', $response->getStatusCode(), $packages).$response->getContent(false), 0, $e); } // process any pending CSS entrypoints - $errors = []; - foreach ($cssEntrypointResponses as $package => $cssEntrypointResponse) { + $entrypointErrors = []; + foreach ($entrypointResponses as $package => [$cssEntrypointResponse, $version]) { if (200 !== $cssEntrypointResponse->getStatusCode()) { - $errors[] = [$package, $cssEntrypointResponse]; + $entrypointErrors[] = [$package, $cssEntrypointResponse]; continue; } @@ -135,12 +134,12 @@ public function resolvePackages(array $packagesToRequire): array } try { - ($errors[0][1] ?? null)?->getHeaders(); + ($entrypointErrors[0][1] ?? null)?->getHeaders(); } catch (HttpExceptionInterface $e) { $response = $e->getResponse(); - $packages = implode('", "', array_column($errors, 0)); + $packages = implode('", "', array_column($entrypointErrors, 0)); - throw new RuntimeException(sprintf('Error %d checking for a CSS entrypoint for packages from jsDelivr for "%s". Response: ', $response->getStatusCode(), $packages).$response->getContent(false), 0, $e); + throw new RuntimeException(sprintf('Error %d checking for a CSS entrypoint for "%s". Response: ', $response->getStatusCode(), $packages).$response->getContent(false), 0, $e); } if ($packagesToRequire) { @@ -160,8 +159,12 @@ public function downloadPackages(array $importMapEntries, callable $progressCall $responses = []; foreach ($importMapEntries as $package => $entry) { + if (!$entry->isRemotePackage()) { + throw new \InvalidArgumentException(sprintf('The entry "%s" is not a remote package.', $entry->importName)); + } + $pattern = ImportMapType::CSS === $entry->type ? $this->distUrlCssPattern : $this->distUrlPattern; - $url = sprintf($pattern, $entry->packageName, $entry->version, $entry->filePath); + $url = sprintf($pattern, $entry->getPackageName(), $entry->version, $entry->getPackagePathString()); $responses[$package] = $this->httpClient->request('GET', $url); } diff --git a/src/Symfony/Component/AssetMapper/Tests/Command/AssetMapperCompileCommandTest.php b/src/Symfony/Component/AssetMapper/Tests/Command/AssetMapperCompileCommandTest.php index 74642c012ee3e..544d0c543a034 100644 --- a/src/Symfony/Component/AssetMapper/Tests/Command/AssetMapperCompileCommandTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/Command/AssetMapperCompileCommandTest.php @@ -78,10 +78,10 @@ public function testAssetsAreCompiled() 'file2.js', 'file3.css', 'file4.js', - 'lodash.js', - 'stimulus.js', 'subdir/file5.js', 'subdir/file6.js', + 'vendor/@hotwired/stimulus/stimulus.index.js', + 'vendor/lodash/lodash.index.js', ], array_keys(json_decode(file_get_contents($targetBuildDir.'/manifest.json'), true))); $this->assertFileExists($targetBuildDir.'/importmap.json'); diff --git a/src/Symfony/Component/AssetMapper/Tests/Command/DebugAssetsMapperCommandTest.php b/src/Symfony/Component/AssetMapper/Tests/Command/DebugAssetsMapperCommandTest.php index 8f375876078ff..3d91d2f7d2d13 100644 --- a/src/Symfony/Component/AssetMapper/Tests/Command/DebugAssetsMapperCommandTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/Command/DebugAssetsMapperCommandTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Command; +namespace Symfony\Component\AssetMapper\Tests\Command; use PHPUnit\Framework\TestCase; use Symfony\Bundle\FrameworkBundle\Console\Application; diff --git a/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php b/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php index e30c4361e93dc..bf4c79e25bc1c 100644 --- a/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php @@ -20,6 +20,7 @@ use Symfony\Component\AssetMapper\Exception\RuntimeException; use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; use Symfony\Component\AssetMapper\ImportMap\ImportMapManager; +use Symfony\Component\AssetMapper\ImportMap\ImportMapType; use Symfony\Component\AssetMapper\MappedAsset; class JavaScriptImportPathCompilerTest extends TestCase @@ -35,15 +36,12 @@ public function testCompile(string $sourceLogicalName, string $input, array $exp $importMapManager->expects($this->any()) ->method('findRootImportMapEntry') ->willReturnCallback(function ($importName) { - if ('module_in_importmap_local_asset' === $importName) { - return new ImportMapEntry('module_in_importmap_local_asset', 'module_in_importmap_local_asset.js'); - } - - if ('module_in_importmap_remote' === $importName) { - return new ImportMapEntry('module_in_importmap_local_asset', version: '1.2.3'); - } - - return null; + return match ($importName) { + 'module_in_importmap_local_asset' => ImportMapEntry::createLocal('module_in_importmap_local_asset', ImportMapType::JS, 'module_in_importmap_local_asset.js', false), + 'module_in_importmap_remote' => ImportMapEntry::createRemote('module_in_importmap_remote', ImportMapType::JS, '/path/to/vendor/module_in_importmap_remote.js', '1.2.3', 'could_be_anything', false), + '@popperjs/core' => ImportMapEntry::createRemote('@popperjs/core', ImportMapType::JS, '/path/to/vendor/@popperjs/core.js', '1.2.3', 'could_be_anything', false), + default => null, + }; }); $compiler = new JavaScriptImportPathCompiler($importMapManager); // compile - and check that content doesn't change @@ -284,7 +282,7 @@ public static function provideCompileTests(): iterable yield 'bare_import_in_importmap_but_remote' => [ 'sourceLogicalName' => 'app.js', 'input' => 'import "module_in_importmap_remote";', - 'expectedJavaScriptImports' => ['module_in_importmap_remote' => ['lazy' => false, 'asset' => null, 'add' => false]], + 'expectedJavaScriptImports' => ['module_in_importmap_remote' => ['lazy' => false, 'asset' => 'module_in_importmap_remote.js', 'add' => false]], ]; yield 'absolute_import_added_as_dependency_only' => [ @@ -292,6 +290,12 @@ public static function provideCompileTests(): iterable 'input' => 'import "https://example.com/module.js";', 'expectedJavaScriptImports' => ['https://example.com/module.js' => ['lazy' => false, 'asset' => null, 'add' => false]], ]; + + yield 'bare_import_with_minimal_spaces' => [ + 'sourceLogicalName' => 'app.js', + 'input' => 'import*as t from"@popperjs/core";', + 'expectedJavaScriptImports' => ['@popperjs/core' => ['lazy' => false, 'asset' => 'assets/vendor/@popperjs/core.js', 'add' => false]], + ]; } /** @@ -450,6 +454,16 @@ private function createAssetMapper(): AssetMapperInterface }; }); + $assetMapper->expects($this->any()) + ->method('getAssetFromSourcePath') + ->willReturnCallback(function ($path) { + return match ($path) { + '/path/to/vendor/module_in_importmap_remote.js' => new MappedAsset('module_in_importmap_remote.js', publicPathWithoutDigest: '/assets/module_in_importmap_remote.js'), + '/path/to/vendor/@popperjs/core.js' => new MappedAsset('assets/vendor/@popperjs/core.js', publicPathWithoutDigest: '/assets/@popperjs/core.js'), + default => null, + }; + }); + return $assetMapper; } } diff --git a/src/Symfony/Component/AssetMapper/Tests/Factory/CachedMappedAssetFactoryTest.php b/src/Symfony/Component/AssetMapper/Tests/Factory/CachedMappedAssetFactoryTest.php index 36b8a0578a76c..d334e3863954d 100644 --- a/src/Symfony/Component/AssetMapper/Tests/Factory/CachedMappedAssetFactoryTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/Factory/CachedMappedAssetFactoryTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Factory; +namespace Symfony\Component\AssetMapper\Tests\Factory; use PHPUnit\Framework\TestCase; use Symfony\Component\AssetMapper\Factory\CachedMappedAssetFactory; diff --git a/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php b/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php index c4b09bec5056a..d4131ae39c377 100644 --- a/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Factory; +namespace Symfony\Component\AssetMapper\Tests\Factory; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -116,7 +116,7 @@ public function testCreateMappedAssetWithPredigested() public function testCreateMappedAssetInVendor() { $assetMapper = $this->createFactory(); - $asset = $assetMapper->createMappedAsset('lodash.js', __DIR__.'/../fixtures/assets/vendor/lodash.js'); + $asset = $assetMapper->createMappedAsset('lodash.js', __DIR__.'/../fixtures/assets/vendor/lodash/lodash.index.js'); $this->assertSame('lodash.js', $asset->logicalPath); $this->assertTrue($asset->isVendor); } diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapAuditorTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapAuditorTest.php index 07e6512696dea..952f9987b3012 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapAuditorTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapAuditorTest.php @@ -19,6 +19,7 @@ use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; use Symfony\Component\AssetMapper\ImportMap\ImportMapPackageAudit; use Symfony\Component\AssetMapper\ImportMap\ImportMapPackageAuditVulnerability; +use Symfony\Component\AssetMapper\ImportMap\ImportMapType; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -65,18 +66,9 @@ public function testAudit() ], ]))); $this->importMapConfigReader->method('getEntries')->willReturn(new ImportMapEntries([ - '@hotwired/stimulus' => new ImportMapEntry( - importName: '@hotwired/stimulus', - version: '3.2.1', - ), - 'json5' => new ImportMapEntry( - importName: 'json5', - version: '1.0.0', - ), - 'lodash' => new ImportMapEntry( - importName: 'lodash', - version: '4.17.21', - ), + self::createRemoteEntry('@hotwired/stimulus', '3.2.1'), + self::createRemoteEntry('json5/some/file', '1.0.0'), + self::createRemoteEntry('lodash', '4.17.21'), ])); $audit = $this->importMapAuditor->audit(); @@ -118,10 +110,7 @@ public function testAuditWithVersionRange(bool $expectMatch, string $version, ?s ], ]))); $this->importMapConfigReader->method('getEntries')->willReturn(new ImportMapEntries([ - 'json5' => new ImportMapEntry( - importName: 'json5', - version: $version, - ), + self::createRemoteEntry('json5', $version), ])); $audit = $this->importMapAuditor->audit(); @@ -146,10 +135,7 @@ public function testAuditError() { $this->httpClient->setResponseFactory(new MockResponse('Server error', ['http_code' => 500])); $this->importMapConfigReader->method('getEntries')->willReturn(new ImportMapEntries([ - 'json5' => new ImportMapEntry( - importName: 'json5', - version: '1.0.0', - ), + self::createRemoteEntry('json5', '1.0.0'), ])); $this->expectException(RuntimeException::class); @@ -157,4 +143,16 @@ public function testAuditError() $this->importMapAuditor->audit(); } + + private static function createRemoteEntry(string $packageSpecifier, string $version): ImportMapEntry + { + return ImportMapEntry::createRemote( + 'could_by_anything'.md5($packageSpecifier.$version), + ImportMapType::JS, + '/any/path', + $version, + $packageSpecifier, + false + ); + } } diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapConfigReaderTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapConfigReaderTest.php index 1598f9b1acb30..5cfbf76c5e70b 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapConfigReaderTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapConfigReaderTest.php @@ -15,6 +15,8 @@ use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader; use Symfony\Component\AssetMapper\ImportMap\ImportMapEntries; use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; +use Symfony\Component\AssetMapper\ImportMap\ImportMapType; +use Symfony\Component\AssetMapper\ImportMap\RemotePackageStorage; use Symfony\Component\Filesystem\Filesystem; class ImportMapConfigReaderTest extends TestCase @@ -59,43 +61,43 @@ public function testGetEntriesAndWriteEntries() 'package/with_file.js' => [ 'version' => '1.0.0', ], - '@vendor/package/path/to/file.js' => [ - 'version' => '1.0.0', - ], ]; EOF; file_put_contents(__DIR__.'/../fixtures/importmaps_for_writing/importmap.php', $importMap); - $reader = new ImportMapConfigReader(__DIR__.'/../fixtures/importmaps_for_writing/importmap.php'); + $remotePackageStorage = $this->createMock(RemotePackageStorage::class); + $remotePackageStorage->expects($this->any()) + ->method('getDownloadPath') + ->willReturnCallback(static function (string $packageModuleSpecifier, ImportMapType $type) { + return '/path/to/vendor/'.$packageModuleSpecifier.'.'.$type->value; + }); + $reader = new ImportMapConfigReader( + __DIR__.'/../fixtures/importmaps_for_writing/importmap.php', + $remotePackageStorage, + ); $entries = $reader->getEntries(); $this->assertInstanceOf(ImportMapEntries::class, $entries); /** @var ImportMapEntry[] $allEntries */ $allEntries = iterator_to_array($entries); - $this->assertCount(6, $allEntries); + $this->assertCount(5, $allEntries); $remotePackageEntry = $allEntries[0]; $this->assertSame('remote_package', $remotePackageEntry->importName); - $this->assertNull($remotePackageEntry->path); + $this->assertSame('/path/to/vendor/remote_package.js', $remotePackageEntry->path); $this->assertSame('3.2.1', $remotePackageEntry->version); $this->assertSame('js', $remotePackageEntry->type->value); $this->assertFalse($remotePackageEntry->isEntrypoint); - $this->assertSame('remote_package', $remotePackageEntry->packageName); - $this->assertEquals('', $remotePackageEntry->filePath); + $this->assertSame('remote_package', $remotePackageEntry->packageModuleSpecifier); $localPackageEntry = $allEntries[1]; - $this->assertNull($localPackageEntry->version); + $this->assertFalse($localPackageEntry->isRemotePackage()); $this->assertSame('app.js', $localPackageEntry->path); $typeCssEntry = $allEntries[2]; $this->assertSame('css', $typeCssEntry->type->value); $packageWithFileEntry = $allEntries[4]; - $this->assertSame('package', $packageWithFileEntry->packageName); - $this->assertSame('/with_file.js', $packageWithFileEntry->filePath); - - $packageWithFileEntry = $allEntries[5]; - $this->assertSame('@vendor/package', $packageWithFileEntry->packageName); - $this->assertSame('/path/to/file.js', $packageWithFileEntry->filePath); + $this->assertSame('package/with_file.js', $packageWithFileEntry->packageModuleSpecifier); // now save the original raw data from importmap.php and delete the file $originalImportMapData = (static fn () => include __DIR__.'/../fixtures/importmaps_for_writing/importmap.php')(); @@ -109,7 +111,7 @@ public function testGetEntriesAndWriteEntries() public function testGetRootDirectory() { - $configReader = new ImportMapConfigReader(__DIR__.'/../fixtures/importmap.php'); + $configReader = new ImportMapConfigReader(__DIR__.'/../fixtures/importmap.php', $this->createMock(RemotePackageStorage::class)); $this->assertSame(__DIR__.'/../fixtures', $configReader->getRootDirectory()); } } diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapEntriesTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapEntriesTest.php index c7cdfeda9e42d..6de9a6c2a4197 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapEntriesTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapEntriesTest.php @@ -14,13 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\AssetMapper\ImportMap\ImportMapEntries; use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; +use Symfony\Component\AssetMapper\ImportMap\ImportMapType; class ImportMapEntriesTest extends TestCase { public function testGetIterator() { - $entry1 = new ImportMapEntry('entry1', 'path1'); - $entry2 = new ImportMapEntry('entry2', 'path2'); + $entry1 = ImportMapEntry::createLocal('entry1', ImportMapType::JS, 'path1', true); + $entry2 = ImportMapEntry::createLocal('entry2', ImportMapType::CSS, 'path2', false); $entries = new ImportMapEntries([$entry1]); $entries->add($entry2); @@ -30,7 +31,7 @@ public function testGetIterator() public function testHas() { - $entries = new ImportMapEntries([new ImportMapEntry('entry1', 'path1')]); + $entries = new ImportMapEntries([ImportMapEntry::createLocal('entry1', ImportMapType::JS, 'path1', true)]); $this->assertTrue($entries->has('entry1')); $this->assertFalse($entries->has('entry2')); @@ -38,7 +39,7 @@ public function testHas() public function testGet() { - $entry = new ImportMapEntry('entry1', 'path1'); + $entry = ImportMapEntry::createLocal('entry1', ImportMapType::JS, 'path1', false); $entries = new ImportMapEntries([$entry]); $this->assertSame($entry, $entries->get('entry1')); @@ -46,7 +47,7 @@ public function testGet() public function testRemove() { - $entries = new ImportMapEntries([new ImportMapEntry('entry1', 'path1')]); + $entries = new ImportMapEntries([ImportMapEntry::createLocal('entry1', ImportMapType::JS, 'path1', true)]); $entries->remove('entry1'); $this->assertFalse($entries->has('entry1')); diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapEntryTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapEntryTest.php new file mode 100644 index 0000000000000..808fd1adcad76 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapEntryTest.php @@ -0,0 +1,85 @@ + + * + * 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\TestCase; +use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; +use Symfony\Component\AssetMapper\ImportMap\ImportMapType; + +class ImportMapEntryTest extends TestCase +{ + public function testCreateLocal() + { + $entry = ImportMapEntry::createLocal('foo', ImportMapType::JS, 'foo.js', true); + $this->assertSame('foo', $entry->importName); + $this->assertSame(ImportMapType::JS, $entry->type); + $this->assertSame('foo.js', $entry->path); + $this->assertTrue($entry->isEntrypoint); + $this->assertFalse($entry->isRemotePackage()); + } + + public function testCreateRemote() + { + $entry = ImportMapEntry::createRemote('foo', ImportMapType::JS, 'foo.js', '1.0.0', 'foo/bar', true); + $this->assertSame('foo', $entry->importName); + $this->assertSame(ImportMapType::JS, $entry->type); + $this->assertSame('foo.js', $entry->path); + $this->assertTrue($entry->isEntrypoint); + $this->assertTrue($entry->isRemotePackage()); + $this->assertSame('1.0.0', $entry->version); + $this->assertSame('foo/bar', $entry->packageModuleSpecifier); + } + + /** + * @dataProvider getSplitPackageNameTests + */ + public function testSplitPackageNameAndFilePath(string $packageModuleSpecifier, string $expectedPackage, string $expectedPath) + { + [$actualPackage, $actualPath] = ImportMapEntry::splitPackageNameAndFilePath($packageModuleSpecifier); + $this->assertSame($expectedPackage, $actualPackage); + $this->assertSame($expectedPath, $actualPath); + } + + public static function getSplitPackageNameTests() + { + yield 'package-name' => [ + 'package-name', + 'package-name', + '', + ]; + + yield 'package-name/path' => [ + 'package-name/path', + 'package-name', + '/path', + ]; + + yield '@scope/package-name' => [ + '@scope/package-name', + '@scope/package-name', + '', + ]; + + yield '@scope/package-name/path' => [ + '@scope/package-name/path', + '@scope/package-name', + '/path', + ]; + } + + public function testGetPackageNameAndPackagePath() + { + $entry = ImportMapEntry::createRemote('foo', ImportMapType::JS, 'foo.js', '1.0.0', 'foo/bar', true); + $this->assertSame('foo', $entry->getPackageName()); + $this->assertSame('/bar', $entry->getPackagePathString()); + } +} diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php index 80b9cd47602ea..6c42c6df051e3 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php @@ -64,7 +64,6 @@ public function testGetRawImportMapData(array $importMapEntries, array $mappedAs $manager = $this->createImportMapManager(); $this->mockImportMap($importMapEntries); $this->mockAssetMapper($mappedAssets); - $this->mockDownloader($importMapEntries); $this->configReader->expects($this->any()) ->method('getRootDirectory') ->willReturn('/fake/root'); @@ -76,16 +75,16 @@ public function getRawImportMapDataTests(): iterable { yield 'it returns remote downloaded entry' => [ [ - new ImportMapEntry( + self::createRemoteEntry( '@hotwired/stimulus', version: '1.2.3', - packageName: '@hotwired/stimulus', + path: '/assets/vendor/stimulus.js' ), ], [ new MappedAsset( 'vendor/@hotwired/stimulus.js', - self::$writableRoot.'/assets/vendor/@hotwired/stimulus.js', + '/assets/vendor/stimulus.js', publicPath: '/assets/vendor/@hotwired/stimulus-d1g35t.js', ), ], @@ -99,20 +98,20 @@ public function getRawImportMapDataTests(): iterable yield 'it returns basic local javascript file' => [ [ - new ImportMapEntry( + self::createLocalEntry( 'app', - path: 'app.js', + path: 'app.js' ), ], [ new MappedAsset( 'app.js', - publicPath: '/assets/app.js', + publicPath: '/assets/app-d13g35t.js', ), ], [ 'app' => [ - 'path' => '/assets/app.js', + 'path' => '/assets/app-d13g35t.js', 'type' => 'js', ], ], @@ -120,7 +119,7 @@ public function getRawImportMapDataTests(): iterable yield 'it returns basic local css file' => [ [ - new ImportMapEntry( + self::createLocalEntry( 'app.css', path: 'styles/app.css', type: ImportMapType::CSS, @@ -129,12 +128,12 @@ public function getRawImportMapDataTests(): iterable [ new MappedAsset( 'styles/app.css', - publicPath: '/assets/styles/app.css', + publicPath: '/assets/styles/app-d13g35t.css', ), ], [ 'app.css' => [ - 'path' => '/assets/styles/app.css', + 'path' => '/assets/styles/app-d13g35t.css', 'type' => 'css', ], ], @@ -147,7 +146,7 @@ public function getRawImportMapDataTests(): iterable ); yield 'it adds dependency to the importmap' => [ [ - new ImportMapEntry( + self::createLocalEntry( 'app', path: 'app.js', ), @@ -155,14 +154,43 @@ public function getRawImportMapDataTests(): iterable [ new MappedAsset( 'app.js', - publicPath: '/assets/app.js', + publicPath: '/assets/app-d1g3st.js', javaScriptImports: [new JavaScriptImport('/assets/simple.js', isLazy: false, asset: $simpleAsset, addImplicitlyToImportMap: true)] ), $simpleAsset, ], [ 'app' => [ - 'path' => '/assets/app.js', + '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' => [ @@ -180,7 +208,7 @@ public function getRawImportMapDataTests(): iterable ); yield 'it processes imports recursively' => [ [ - new ImportMapEntry( + self::createLocalEntry( 'app', path: 'app.js', ), @@ -188,7 +216,7 @@ public function getRawImportMapDataTests(): iterable [ new MappedAsset( 'app.js', - publicPath: '/assets/app.js', + publicPath: '/assets/app-d1g3st.js', javaScriptImports: [new JavaScriptImport('/assets/imports_simple.js', isLazy: true, asset: $eagerImportsSimpleAsset, addImplicitlyToImportMap: true)] ), $eagerImportsSimpleAsset, @@ -196,7 +224,7 @@ public function getRawImportMapDataTests(): iterable ], [ 'app' => [ - 'path' => '/assets/app.js', + 'path' => '/assets/app-d1g3st.js', 'type' => 'js', ], '/assets/imports_simple.js' => [ @@ -212,11 +240,11 @@ public function getRawImportMapDataTests(): iterable yield 'it process can skip adding one importmap entry but still add a child' => [ [ - new ImportMapEntry( + self::createLocalEntry( 'app', path: 'app.js', ), - new ImportMapEntry( + self::createLocalEntry( 'imports_simple', path: 'imports_simple.js', ), @@ -224,7 +252,7 @@ public function getRawImportMapDataTests(): iterable [ new MappedAsset( 'app.js', - publicPath: '/assets/app.js', + publicPath: '/assets/app-d1g3st.js', javaScriptImports: [new JavaScriptImport('imports_simple', isLazy: true, asset: $eagerImportsSimpleAsset, addImplicitlyToImportMap: false)] ), $eagerImportsSimpleAsset, @@ -232,7 +260,7 @@ public function getRawImportMapDataTests(): iterable ], [ 'app' => [ - 'path' => '/assets/app.js', + 'path' => '/assets/app-d1g3st.js', 'type' => 'js', ], '/assets/simple.js' => [ @@ -248,7 +276,7 @@ public function getRawImportMapDataTests(): iterable yield 'imports with a module name are not added to the importmap' => [ [ - new ImportMapEntry( + self::createLocalEntry( 'app', path: 'app.js', ), @@ -256,14 +284,14 @@ public function getRawImportMapDataTests(): iterable [ new MappedAsset( 'app.js', - publicPath: '/assets/app.js', + publicPath: '/assets/app-d1g3st.js', javaScriptImports: [new JavaScriptImport('simple', isLazy: false, asset: $simpleAsset)] ), $simpleAsset, ], [ 'app' => [ - 'path' => '/assets/app.js', + 'path' => '/assets/app-d1g3st.js', 'type' => 'js', ], ], @@ -271,7 +299,7 @@ public function getRawImportMapDataTests(): iterable yield 'it does not process dependencies of CSS files' => [ [ - new ImportMapEntry( + self::createLocalEntry( 'app.css', path: 'app.css', type: ImportMapType::CSS, @@ -280,13 +308,13 @@ public function getRawImportMapDataTests(): iterable [ new MappedAsset( 'app.css', - publicPath: '/assets/app.css', + publicPath: '/assets/app-d1g3st.css', javaScriptImports: [new JavaScriptImport('/assets/simple.js', asset: $simpleAsset)] ), ], [ 'app.css' => [ - 'path' => '/assets/app.css', + 'path' => '/assets/app-d1g3st.css', 'type' => 'css', ], ], @@ -294,7 +322,7 @@ public function getRawImportMapDataTests(): iterable yield 'it handles a relative path file' => [ [ - new ImportMapEntry( + self::createLocalEntry( 'app', path: './assets/app.js', ), @@ -304,12 +332,12 @@ public function getRawImportMapDataTests(): iterable 'app.js', // /fake/root is the mocked root directory '/fake/root/assets/app.js', - publicPath: '/assets/app.js', + publicPath: '/assets/app-d1g3st.js', ), ], [ 'app' => [ - 'path' => '/assets/app.js', + 'path' => '/assets/app-d1g3st.js', 'type' => 'js', ], ], @@ -317,7 +345,7 @@ public function getRawImportMapDataTests(): iterable yield 'it handles an absolute path file' => [ [ - new ImportMapEntry( + self::createLocalEntry( 'app', path: '/some/path/assets/app.js', ), @@ -326,12 +354,12 @@ public function getRawImportMapDataTests(): iterable new MappedAsset( 'app.js', '/some/path/assets/app.js', - publicPath: '/assets/app.js', + publicPath: '/assets/app-d1g3st.js', ), ], [ 'app' => [ - 'path' => '/assets/app.js', + 'path' => '/assets/app-d1g3st.js', 'type' => 'js', ], ], @@ -367,7 +395,7 @@ public function testGetEntrypointMetadata(MappedAsset $entryAsset, array $expect $this->mockAssetMapper([$entryAsset]); // put the entry asset in the importmap $this->mockImportMap([ - new ImportMapEntry('the_entrypoint_name', path: $entryAsset->logicalPath, isEntrypoint: true), + ImportMapEntry::createLocal('the_entrypoint_name', ImportMapType::JS, path: $entryAsset->logicalPath, isEntrypoint: true), ]); $this->assertEquals($expected, $manager->getEntrypointMetadata('the_entrypoint_name')); @@ -448,31 +476,31 @@ public function testGetImportMapData() { $manager = $this->createImportMapManager(); $this->mockImportMap([ - new ImportMapEntry( + self::createLocalEntry( 'entry1', path: 'entry1.js', isEntrypoint: true, ), - new ImportMapEntry( + self::createLocalEntry( 'entry2', path: 'entry2.js', isEntrypoint: true, ), - new ImportMapEntry( + self::createLocalEntry( 'entry3', path: 'entry3.js', isEntrypoint: true, ), - new ImportMapEntry( + self::createLocalEntry( 'normal_js_file', path: 'normal_js_file.js', ), - new ImportMapEntry( + self::createLocalEntry( 'css_in_importmap', path: 'styles/css_in_importmap.css', type: ImportMapType::CSS, ), - new ImportMapEntry( + self::createLocalEntry( 'never_imported_css', path: 'styles/never_imported_css.css', type: ImportMapType::CSS, @@ -631,7 +659,7 @@ public function testGetImportMapData() public function testFindRootImportMapEntry() { $manager = $this->createImportMapManager(); - $entry1 = new ImportMapEntry('entry1', isEntrypoint: true); + $entry1 = ImportMapEntry::createLocal('entry1', ImportMapType::JS, '/any/path', isEntrypoint: true); $this->mockImportMap([$entry1]); $this->assertSame($entry1, $manager->findRootImportMapEntry('entry1')); @@ -642,9 +670,9 @@ public function testGetEntrypointNames() { $manager = $this->createImportMapManager(); $this->mockImportMap([ - new ImportMapEntry('entry1', isEntrypoint: true), - new ImportMapEntry('entry2', isEntrypoint: true), - new ImportMapEntry('not_entrypoint'), + 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()); @@ -678,6 +706,7 @@ public function testRequire(array $packages, int $expectedProviderPackageArgumen ->method('getEntries') ->willReturn(new ImportMapEntries()) ; + $this->configReader->expects($this->once()) ->method('writeEntries') ->with($this->callback(function (ImportMapEntries $entries) use ($expectedImportMap) { @@ -686,13 +715,14 @@ public function testRequire(array $packages, int $expectedProviderPackageArgumen $simplifiedEntries = []; foreach ($entries as $entry) { $simplifiedEntries[$entry->importName] = [ - 'version' => $entry->version, 'path' => $entry->path, 'type' => $entry->type->value, 'entrypoint' => $entry->isEntrypoint, - 'packageName' => $entry->packageName, - 'filePath' => $entry->packageName, ]; + if ($entry->isRemotePackage()) { + $simplifiedEntries[$entry->importName]['version'] = $entry->version; + $simplifiedEntries[$entry->importName]['packageModuleSpecifier'] = $entry->packageModuleSpecifier; + } } $this->assertSame(array_keys($expectedImportMap), array_keys($simplifiedEntries)); @@ -798,16 +828,11 @@ public function testRemove() { $manager = $this->createImportMapManager(); $this->mockImportMap([ - new ImportMapEntry('lodash', version: '1.2.3'), - new ImportMapEntry('cowsay', version: '4.5.6'), - new ImportMapEntry('chance', version: '7.8.9'), - new ImportMapEntry('app', path: 'app.js'), - new ImportMapEntry('other', path: 'other.js'), - ]); - - $this->mockAssetMapper([ - new MappedAsset('vendor/moo.js', self::$writableRoot.'/assets/vendor/moo.js'), - new MappedAsset('app.js', self::$writableRoot.'/assets/app.js'), + self::createRemoteEntry('lodash', version: '1.2.3', path: '/vendor/lodash.js'), + self::createRemoteEntry('cowsay', version: '4.5.6', path: '/vendor/cowsay.js'), + self::createRemoteEntry('chance', version: '7.8.9', path: '/vendor/chance.js'), + self::createLocalEntry('app', path: './app.js'), + self::createLocalEntry('other', path: './other.js'), ]); $this->configReader->expects($this->once()) @@ -829,9 +854,9 @@ public function testUpdateAll() { $manager = $this->createImportMapManager(); $this->mockImportMap([ - new ImportMapEntry('lodash', version: '1.2.3'), - new ImportMapEntry('bootstrap', version: '5.1.3'), - new ImportMapEntry('app', path: 'app.js'), + self::createRemoteEntry('lodash', version: '1.2.3', path: '/vendor/lodash.js'), + self::createRemoteEntry('bootstrap', version: '5.1.3', path: '/vendor/bootstrap.js'), + self::createLocalEntry('app', path: 'app.js'), ]); $this->packageResolver->expects($this->once()) @@ -841,8 +866,8 @@ public function testUpdateAll() /* @var PackageRequireOptions[] $packages */ $this->assertCount(2, $packages); - $this->assertSame('lodash', $packages[0]->packageName); - $this->assertSame('bootstrap', $packages[1]->packageName); + $this->assertSame('lodash', $packages[0]->packageModuleSpecifier); + $this->assertSame('bootstrap', $packages[1]->packageModuleSpecifier); return true; })) @@ -874,10 +899,10 @@ public function testUpdateWithSpecificPackages() { $manager = $this->createImportMapManager(); $this->mockImportMap([ - new ImportMapEntry('lodash', version: '1.2.3'), - new ImportMapEntry('cowsay', version: '4.5.6'), - new ImportMapEntry('bootstrap', version: '5.1.3'), - new ImportMapEntry('app', path: 'app.js'), + self::createRemoteEntry('lodash', version: '1.2.3'), + self::createRemoteEntry('cowsay', version: '4.5.6'), + self::createRemoteEntry('bootstrap', version: '5.1.3'), + self::createLocalEntry('app', path: 'app.js'), ]); $this->packageResolver->expects($this->once()) @@ -968,6 +993,15 @@ private function createImportMapManager(): ImportMapManager $this->packageResolver = $this->createMock(PackageResolverInterface::class); $this->remotePackageDownloader = $this->createMock(RemotePackageDownloader::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->importMapManager = new ImportMapManager( $this->assetMapper, $this->pathResolver, @@ -997,7 +1031,7 @@ private function mockImportMap(array $importMapEntries): void /** * @param MappedAsset[] $mappedAssets */ - private function mockAssetMapper(array $mappedAssets, bool $mockGetAssetFromSourcePath = true): void + private function mockAssetMapper(array $mappedAssets): void { $this->assetMapper->expects($this->any()) ->method('getAsset') @@ -1012,10 +1046,6 @@ private function mockAssetMapper(array $mappedAssets, bool $mockGetAssetFromSour }) ; - if (!$mockGetAssetFromSourcePath) { - return; - } - $this->assetMapper->expects($this->any()) ->method('getAssetFromSourcePath') ->willReturnCallback(function (string $sourcePath) use ($mappedAssets) { @@ -1051,25 +1081,6 @@ private function mockAssetMapper(array $mappedAssets, bool $mockGetAssetFromSour ; } - /** - * @param ImportMapEntry[] $importMapEntries - */ - private function mockDownloader(array $importMapEntries): void - { - $this->remotePackageDownloader->expects($this->any()) - ->method('getDownloadedPath') - ->willReturnCallback(function (string $importName) use ($importMapEntries) { - foreach ($importMapEntries as $entry) { - if ($entry->importName === $importName) { - return self::$writableRoot.'/assets/vendor/'.$importName.'.js'; - } - } - - return null; - }) - ; - } - private function writeFile(string $filename, string $content): void { $path = \dirname(self::$writableRoot.'/'.$filename); @@ -1078,4 +1089,17 @@ private function writeFile(string $filename, string $content): void } 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); + } } diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapUpdateCheckerTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapUpdateCheckerTest.php index c8ceb69987fa8..d134e9b8d7968 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapUpdateCheckerTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapUpdateCheckerTest.php @@ -37,36 +37,38 @@ protected function setUp(): void public function testGetAvailableUpdates() { $this->importMapConfigReader->method('getEntries')->willReturn(new ImportMapEntries([ - '@hotwired/stimulus' => new ImportMapEntry( + '@hotwired/stimulus' => self::createRemoteEntry( importName: '@hotwired/stimulus', version: '3.2.1', - packageName: '@hotwired/stimulus', + packageSpecifier: '@hotwired/stimulus', ), - 'json5' => new ImportMapEntry( + 'json5' => self::createRemoteEntry( importName: 'json5', version: '1.0.0', - packageName: 'json5', + packageSpecifier: 'json5', ), - 'bootstrap' => new ImportMapEntry( + 'bootstrap' => self::createRemoteEntry( importName: 'bootstrap', version: '5.3.1', - packageName: 'bootstrap', + packageSpecifier: 'bootstrap', ), - 'bootstrap/dist/css/bootstrap.min.css' => new ImportMapEntry( + 'bootstrap/dist/css/bootstrap.min.css' => self::createRemoteEntry( importName: 'bootstrap/dist/css/bootstrap.min.css', version: '5.3.1', type: ImportMapType::CSS, - packageName: 'bootstrap', + packageSpecifier: 'bootstrap', ), - 'lodash' => new ImportMapEntry( + 'lodash' => self::createRemoteEntry( importName: 'lodash', version: '4.17.21', - packageName: 'lodash', + packageSpecifier: 'lodash', ), // Local package won't appear in update list - 'app' => new ImportMapEntry( - importName: 'app', - path: 'assets/app.js', + 'app' => ImportMapEntry::createLocal( + 'app', + ImportMapType::JS, + 'assets/app.js', + false, ), ])); @@ -117,9 +119,9 @@ public function testGetAvailableUpdatesForSinglePackage(array $entries, array $e $this->importMapConfigReader->method('getEntries')->willReturn(new ImportMapEntries($entries)); if (null !== $expectedException) { $this->expectException($expectedException::class); - $this->updateChecker->getAvailableUpdates(array_map(fn ($entry) => $entry->packageName, $entries)); + $this->updateChecker->getAvailableUpdates(array_map(fn ($entry) => $entry->importName, $entries)); } else { - $update = $this->updateChecker->getAvailableUpdates(array_map(fn ($entry) => $entry->packageName, $entries)); + $update = $this->updateChecker->getAvailableUpdates(array_map(fn ($entry) => $entry->importName, $entries)); $this->assertEquals($expectedUpdateInfo, $update); } } @@ -127,10 +129,10 @@ public function testGetAvailableUpdatesForSinglePackage(array $entries, array $e private function provideImportMapEntry() { yield [ - ['@hotwired/stimulus' => new ImportMapEntry( + [self::createRemoteEntry( importName: '@hotwired/stimulus', version: '3.2.1', - packageName: '@hotwired/stimulus', + packageSpecifier: '@hotwired/stimulus', ), ], ['@hotwired/stimulus' => new PackageUpdateInfo( @@ -143,10 +145,10 @@ private function provideImportMapEntry() ]; yield [ [ - 'bootstrap/dist/css/bootstrap.min.css' => new ImportMapEntry( + self::createRemoteEntry( importName: 'bootstrap/dist/css/bootstrap.min.css', version: '5.3.1', - packageName: 'bootstrap', + packageSpecifier: 'bootstrap', ), ], ['bootstrap/dist/css/bootstrap.min.css' => new PackageUpdateInfo( @@ -159,10 +161,10 @@ private function provideImportMapEntry() ]; yield [ [ - 'bootstrap' => new ImportMapEntry( + self::createRemoteEntry( importName: 'bootstrap', version: 'not_a_version', - packageName: 'bootstrap', + packageSpecifier: 'bootstrap', ), ], [], @@ -170,10 +172,10 @@ private function provideImportMapEntry() ]; yield [ [ - new ImportMapEntry( + self::createRemoteEntry( importName: 'invalid_package_name', version: '1.0.0', - packageName: 'invalid_package_name', + packageSpecifier: 'invalid_package_name', ), ], [], @@ -201,4 +203,11 @@ private function responseFactory($method, $url): MockResponse return $map[$url] ?? new MockResponse('Not found', ['http_code' => 404]); } + + private static function createRemoteEntry(string $importName, string $version, ImportMapType $type = ImportMapType::JS, string $packageSpecifier = null): ImportMapEntry + { + $packageSpecifier = $packageSpecifier ?? $importName; + + return ImportMapEntry::createRemote($importName, $type, path: '/vendor/any-path.js', version: $version, packageModuleSpecifier: $packageSpecifier, isEntrypoint: false); + } } diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageDownloaderTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageDownloaderTest.php index 2aaee06c01793..26c5ee8769d10 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageDownloaderTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageDownloaderTest.php @@ -17,6 +17,7 @@ use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; use Symfony\Component\AssetMapper\ImportMap\ImportMapType; use Symfony\Component\AssetMapper\ImportMap\RemotePackageDownloader; +use Symfony\Component\AssetMapper\ImportMap\RemotePackageStorage; use Symfony\Component\AssetMapper\ImportMap\Resolver\PackageResolverInterface; use Symfony\Component\Filesystem\Filesystem; @@ -42,11 +43,13 @@ public function testDownloadPackagesDownloadsEverythingWithNoInstalled() { $configReader = $this->createMock(ImportMapConfigReader::class); $packageResolver = $this->createMock(PackageResolverInterface::class); + $remotePackageStorage = new RemotePackageStorage(self::$writableRoot.'/assets/vendor'); - $entry1 = new ImportMapEntry('foo', version: '1.0.0'); - $entry2 = new ImportMapEntry('bar.js/file', version: '1.0.0'); - $entry3 = new ImportMapEntry('baz', version: '1.0.0', type: ImportMapType::CSS); - $importMapEntries = new ImportMapEntries([$entry1, $entry2, $entry3]); + $entry1 = ImportMapEntry::createRemote('foo', ImportMapType::JS, path: '/any', version: '1.0.0', packageModuleSpecifier: 'foo', isEntrypoint: false); + $entry2 = ImportMapEntry::createRemote('bar.js/file', ImportMapType::JS, path: '/any', version: '1.0.0', packageModuleSpecifier: 'bar.js/file', isEntrypoint: false); + $entry3 = ImportMapEntry::createRemote('baz', ImportMapType::CSS, path: '/any', version: '1.0.0', packageModuleSpecifier: 'baz', isEntrypoint: false); + $entry4 = ImportMapEntry::createRemote('different_specifier', ImportMapType::JS, path: '/any', version: '1.0.0', packageModuleSpecifier: 'custom_specifier', isEntrypoint: false); + $importMapEntries = new ImportMapEntries([$entry1, $entry2, $entry3, $entry4]); $configReader->expects($this->once()) ->method('getEntries') @@ -56,31 +59,33 @@ public function testDownloadPackagesDownloadsEverythingWithNoInstalled() $packageResolver->expects($this->once()) ->method('downloadPackages') ->with( - ['foo' => $entry1, 'bar.js/file' => $entry2, 'baz' => $entry3], + ['foo' => $entry1, 'bar.js/file' => $entry2, 'baz' => $entry3, 'different_specifier' => $entry4], $progressCallback ) - ->willReturn(['foo' => 'foo content', 'bar.js/file' => 'bar content', 'baz' => 'baz content']); + ->willReturn(['foo' => 'foo content', 'bar.js/file' => 'bar content', 'baz' => 'baz content', 'different_specifier' => 'different content']); $downloader = new RemotePackageDownloader( + $remotePackageStorage, $configReader, $packageResolver, - self::$writableRoot.'/assets/vendor', ); $downloader->downloadPackages($progressCallback); - $this->assertFileExists(self::$writableRoot.'/assets/vendor/foo.js'); + $this->assertFileExists(self::$writableRoot.'/assets/vendor/foo/foo.index.js'); $this->assertFileExists(self::$writableRoot.'/assets/vendor/bar.js/file.js'); - $this->assertFileExists(self::$writableRoot.'/assets/vendor/baz.css'); - $this->assertEquals('foo content', file_get_contents(self::$writableRoot.'/assets/vendor/foo.js')); + $this->assertFileExists(self::$writableRoot.'/assets/vendor/baz/baz.index.css'); + $this->assertEquals('foo content', file_get_contents(self::$writableRoot.'/assets/vendor/foo/foo.index.js')); $this->assertEquals('bar content', file_get_contents(self::$writableRoot.'/assets/vendor/bar.js/file.js')); - $this->assertEquals('baz content', file_get_contents(self::$writableRoot.'/assets/vendor/baz.css')); + $this->assertEquals('baz content', file_get_contents(self::$writableRoot.'/assets/vendor/baz/baz.index.css')); + $this->assertEquals('different content', file_get_contents(self::$writableRoot.'/assets/vendor/custom_specifier/custom_specifier.index.js')); $installed = require self::$writableRoot.'/assets/vendor/installed.php'; $this->assertEquals( [ - 'foo' => ['path' => 'foo.js', 'version' => '1.0.0'], - 'bar.js/file' => ['path' => 'bar.js/file.js', 'version' => '1.0.0'], - 'baz' => ['path' => 'baz.css', 'version' => '1.0.0'], + 'foo' => ['version' => '1.0.0'], + 'bar.js/file' => ['version' => '1.0.0'], + 'baz' => ['version' => '1.0.0'], + 'different_specifier' => ['version' => '1.0.0'], ], $installed ); @@ -90,9 +95,9 @@ public function testPackagesWithCorrectInstalledVersionSkipped() { $this->filesystem->mkdir(self::$writableRoot.'/assets/vendor'); $installed = [ - 'foo' => ['path' => 'foo.js', 'version' => '1.0.0'], - 'bar.js/file' => ['path' => 'bar.js/file.js', 'version' => '1.0.0'], - 'baz' => ['path' => 'baz.css', 'version' => '1.0.0'], + 'foo' => ['version' => '1.0.0'], + 'bar.js/file' => ['version' => '1.0.0'], + 'baz' => ['version' => '1.0.0'], ]; file_put_contents( self::$writableRoot.'/assets/vendor/installed.php', @@ -103,13 +108,15 @@ public function testPackagesWithCorrectInstalledVersionSkipped() $packageResolver = $this->createMock(PackageResolverInterface::class); // matches installed version and file exists - $entry1 = new ImportMapEntry('foo', version: '1.0.0'); - file_put_contents(self::$writableRoot.'/assets/vendor/foo.js', 'original foo content'); + $entry1 = ImportMapEntry::createRemote('foo', ImportMapType::JS, path: '/any', version: '1.0.0', packageModuleSpecifier: 'foo', isEntrypoint: false); + @mkdir(self::$writableRoot.'/assets/vendor/foo', 0777, true); + file_put_contents(self::$writableRoot.'/assets/vendor/foo/foo.index.js', 'original foo content'); // matches installed version but file does not exist - $entry2 = new ImportMapEntry('bar.js/file', version: '1.0.0'); + $entry2 = ImportMapEntry::createRemote('bar.js/file', ImportMapType::JS, path: '/any', version: '1.0.0', packageModuleSpecifier: 'bar.js/file', isEntrypoint: false); // does not match installed version - $entry3 = new ImportMapEntry('baz', version: '1.1.0', type: ImportMapType::CSS); - file_put_contents(self::$writableRoot.'/assets/vendor/baz.css', 'original baz content'); + $entry3 = ImportMapEntry::createRemote('baz', ImportMapType::CSS, path: '/any', version: '1.1.0', packageModuleSpecifier: 'baz', isEntrypoint: false); + @mkdir(self::$writableRoot.'/assets/vendor/baz', 0777, true); + file_put_contents(self::$writableRoot.'/assets/vendor/baz/baz.index.css', 'original baz content'); $importMapEntries = new ImportMapEntries([$entry1, $entry2, $entry3]); $configReader->expects($this->once()) @@ -121,57 +128,38 @@ public function testPackagesWithCorrectInstalledVersionSkipped() ->willReturn(['bar.js/file' => 'new bar content', 'baz' => 'new baz content']); $downloader = new RemotePackageDownloader( + new RemotePackageStorage(self::$writableRoot.'/assets/vendor'), $configReader, $packageResolver, - self::$writableRoot.'/assets/vendor', ); $downloader->downloadPackages(); - $this->assertFileExists(self::$writableRoot.'/assets/vendor/foo.js'); + $this->assertFileExists(self::$writableRoot.'/assets/vendor/foo/foo.index.js'); $this->assertFileExists(self::$writableRoot.'/assets/vendor/bar.js/file.js'); - $this->assertFileExists(self::$writableRoot.'/assets/vendor/baz.css'); - $this->assertEquals('original foo content', file_get_contents(self::$writableRoot.'/assets/vendor/foo.js')); + $this->assertFileExists(self::$writableRoot.'/assets/vendor/baz/baz.index.css'); + $this->assertEquals('original foo content', file_get_contents(self::$writableRoot.'/assets/vendor/foo/foo.index.js')); $this->assertEquals('new bar content', file_get_contents(self::$writableRoot.'/assets/vendor/bar.js/file.js')); - $this->assertEquals('new baz content', file_get_contents(self::$writableRoot.'/assets/vendor/baz.css')); + $this->assertEquals('new baz content', file_get_contents(self::$writableRoot.'/assets/vendor/baz/baz.index.css')); $installed = require self::$writableRoot.'/assets/vendor/installed.php'; $this->assertEquals( [ - 'foo' => ['path' => 'foo.js', 'version' => '1.0.0'], - 'bar.js/file' => ['path' => 'bar.js/file.js', 'version' => '1.0.0'], - 'baz' => ['path' => 'baz.css', 'version' => '1.1.0'], + 'foo' => ['version' => '1.0.0'], + 'bar.js/file' => ['version' => '1.0.0'], + 'baz' => ['version' => '1.1.0'], ], $installed ); } - public function testGetDownloadedPath() - { - $this->filesystem->mkdir(self::$writableRoot.'/assets/vendor'); - $installed = [ - 'foo' => ['path' => 'foo-path.js', 'version' => '1.0.0'], - ]; - file_put_contents( - self::$writableRoot.'/assets/vendor/installed.php', - 'createMock(ImportMapConfigReader::class), - $this->createMock(PackageResolverInterface::class), - self::$writableRoot.'/assets/vendor', - ); - $this->assertSame(realpath(self::$writableRoot.'/assets/vendor/foo-path.js'), realpath($downloader->getDownloadedPath('foo'))); - } - public function testGetVendorDir() { + $remotePackageStorage = new RemotePackageStorage('/foo/assets/vendor'); $downloader = new RemotePackageDownloader( + $remotePackageStorage, $this->createMock(ImportMapConfigReader::class), $this->createMock(PackageResolverInterface::class), - self::$writableRoot.'/assets/vendor', ); - $this->assertSame(realpath(self::$writableRoot.'/assets/vendor'), realpath($downloader->getVendorDir())); + $this->assertSame('/foo/assets/vendor', $downloader->getVendorDir()); } } diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageStorageTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageStorageTest.php new file mode 100644 index 0000000000000..724f24f124790 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageStorageTest.php @@ -0,0 +1,100 @@ + + * + * 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\TestCase; +use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; +use Symfony\Component\AssetMapper\ImportMap\ImportMapType; +use Symfony\Component\AssetMapper\ImportMap\RemotePackageStorage; +use Symfony\Component\Filesystem\Filesystem; + +class RemotePackageStorageTest extends TestCase +{ + private Filesystem $filesystem; + private static string $writableRoot = __DIR__.'/../fixtures/importmaps_for_writing'; + + protected function setUp(): void + { + $this->filesystem = new Filesystem(); + if (!file_exists(self::$writableRoot)) { + $this->filesystem->mkdir(self::$writableRoot); + } + } + + protected function tearDown(): void + { + $this->filesystem->remove(self::$writableRoot); + } + + public function testGetStorageDir() + { + $storage = new RemotePackageStorage(self::$writableRoot.'/assets/vendor'); + $this->assertSame(realpath(self::$writableRoot.'/assets/vendor'), realpath($storage->getStorageDir())); + } + + public function testIsDownloaded() + { + $storage = new RemotePackageStorage(self::$writableRoot.'/assets/vendor'); + $entry = ImportMapEntry::createRemote('foo', ImportMapType::JS, '/does/not/matter', '1.0.0', 'module_specifier', false); + $this->assertFalse($storage->isDownloaded($entry)); + $targetPath = self::$writableRoot.'/assets/vendor/module_specifier/module_specifier.index.js'; + @mkdir(\dirname($targetPath), 0777, true); + file_put_contents($targetPath, 'any content'); + $this->assertTrue($storage->isDownloaded($entry)); + } + + public function testSave() + { + $storage = new RemotePackageStorage(self::$writableRoot.'/assets/vendor'); + $entry = ImportMapEntry::createRemote('foo', ImportMapType::JS, '/does/not/matter', '1.0.0', 'module_specifier', false); + $storage->save($entry, 'any content'); + $targetPath = self::$writableRoot.'/assets/vendor/module_specifier/module_specifier.index.js'; + $this->assertFileExists($targetPath); + $this->assertEquals('any content', file_get_contents($targetPath)); + } + + /** + * @dataProvider getDownloadPathTests + */ + public function testGetDownloadedPath(string $packageModuleSpecifier, ImportMapType $importMapType, string $expectedPath) + { + $storage = new RemotePackageStorage(self::$writableRoot.'/assets/vendor'); + $this->assertSame($expectedPath, $storage->getDownloadPath($packageModuleSpecifier, $importMapType)); + } + + public static function getDownloadPathTests() + { + yield 'javascript bare package' => [ + 'packageModuleSpecifier' => 'foo', + 'importMapType' => ImportMapType::JS, + 'expectedPath' => self::$writableRoot.'/assets/vendor/foo/foo.index.js', + ]; + + yield 'javascript package with path' => [ + 'packageModuleSpecifier' => 'foo/bar', + 'importMapType' => ImportMapType::JS, + 'expectedPath' => self::$writableRoot.'/assets/vendor/foo/bar.js', + ]; + + yield 'javascript package with path and extension' => [ + 'packageModuleSpecifier' => 'foo/bar.js', + 'importMapType' => ImportMapType::JS, + 'expectedPath' => self::$writableRoot.'/assets/vendor/foo/bar.js', + ]; + + yield 'CSS package with path' => [ + 'packageModuleSpecifier' => 'foo/bar', + 'importMapType' => ImportMapType::CSS, + 'expectedPath' => self::$writableRoot.'/assets/vendor/foo/bar.css', + ]; + } +} diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JsDelivrEsmResolverTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JsDelivrEsmResolverTest.php index b27ff210f0448..fb29df4ad53e5 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JsDelivrEsmResolverTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JsDelivrEsmResolverTest.php @@ -47,9 +47,9 @@ public function testResolvePackages(array $packages, array $expectedRequests, ar $actualResolvedPackages = $provider->resolvePackages($packages); $this->assertCount(\count($expectedResolvedPackages), $actualResolvedPackages); foreach ($actualResolvedPackages as $package) { - $packageName = $package->requireOptions->packageName; - $this->assertArrayHasKey($packageName, $expectedResolvedPackages); - $this->assertSame($expectedResolvedPackages[$packageName]['version'], $package->version); + $importName = $package->requireOptions->importName; + $this->assertArrayHasKey($importName, $expectedResolvedPackages); + $this->assertSame($expectedResolvedPackages[$importName]['version'], $package->version); } $this->assertSame(\count($expectedRequests), $httpClient->getRequestsCount()); @@ -293,7 +293,20 @@ public function testDownloadPackages(array $importMapEntries, array $expectedReq public static function provideDownloadPackagesTests() { yield 'single package' => [ - ['lodash' => new ImportMapEntry('lodash', version: '1.2.3', packageName: 'lodash')], + ['lodash' => self::createRemoteEntry('lodash', version: '1.2.3')], + [ + [ + 'url' => '/lodash@1.2.3/+esm', + 'body' => 'lodash contents', + ], + ], + [ + 'lodash' => 'lodash contents', + ], + ]; + + yield 'importName differs from package specifier' => [ + ['lodash' => self::createRemoteEntry('some_alias', version: '1.2.3', packageSpecifier: 'lodash')], [ [ 'url' => '/lodash@1.2.3/+esm', @@ -306,7 +319,7 @@ public static function provideDownloadPackagesTests() ]; yield 'package with path' => [ - ['lodash' => new ImportMapEntry('chart.js/auto', version: '4.5.6', packageName: 'chart.js', filePath: '/auto')], + ['lodash' => self::createRemoteEntry('chart.js/auto', version: '4.5.6')], [ [ 'url' => '/chart.js@4.5.6/auto/+esm', @@ -319,7 +332,7 @@ public static function provideDownloadPackagesTests() ]; yield 'css file' => [ - ['lodash' => new ImportMapEntry('bootstrap/dist/bootstrap.css', version: '5.0.6', type: ImportMapType::CSS, packageName: 'bootstrap', filePath: '/dist/bootstrap.css')], + ['lodash' => self::createRemoteEntry('bootstrap/dist/bootstrap.css', version: '5.0.6', type: ImportMapType::CSS)], [ [ 'url' => '/bootstrap@5.0.6/dist/bootstrap.css', @@ -333,9 +346,9 @@ public static function provideDownloadPackagesTests() yield 'multiple files' => [ [ - 'lodash' => new ImportMapEntry('lodash', version: '1.2.3', packageName: 'lodash'), - 'chart.js/auto' => new ImportMapEntry('chart.js/auto', version: '4.5.6', packageName: 'chart.js', filePath: '/auto'), - 'bootstrap/dist/bootstrap.css' => new ImportMapEntry('bootstrap/dist/bootstrap.css', version: '5.0.6', type: ImportMapType::CSS, packageName: 'bootstrap', filePath: '/dist/bootstrap.css'), + 'lodash' => self::createRemoteEntry('lodash', version: '1.2.3'), + 'chart.js/auto' => self::createRemoteEntry('chart.js/auto', version: '4.5.6'), + 'bootstrap/dist/bootstrap.css' => self::createRemoteEntry('bootstrap/dist/bootstrap.css', version: '5.0.6', type: ImportMapType::CSS), ], [ [ @@ -360,7 +373,7 @@ public static function provideDownloadPackagesTests() yield 'make imports relative' => [ [ - '@chart.js/auto' => new ImportMapEntry('chart.js/auto', version: '1.2.3', packageName: 'chart.js', filePath: '/auto'), + '@chart.js/auto' => self::createRemoteEntry('chart.js/auto', version: '1.2.3'), ], [ [ @@ -375,7 +388,7 @@ public static function provideDownloadPackagesTests() yield 'js importmap is removed' => [ [ - '@chart.js/auto' => new ImportMapEntry('chart.js/auto', version: '1.2.3', packageName: 'chart.js', filePath: '/auto'), + '@chart.js/auto' => self::createRemoteEntry('chart.js/auto', version: '1.2.3'), ], [ [ @@ -390,7 +403,7 @@ public static function provideDownloadPackagesTests() ]; yield 'css file removes importmap' => [ - ['lodash' => new ImportMapEntry('bootstrap/dist/bootstrap.css', version: '5.0.6', type: ImportMapType::CSS, packageName: 'bootstrap', filePath: '/dist/bootstrap.css')], + ['lodash' => self::createRemoteEntry('bootstrap/dist/bootstrap.css', version: '5.0.6', type: ImportMapType::CSS)], [ [ 'url' => '/bootstrap@5.0.6/dist/bootstrap.css', @@ -449,4 +462,11 @@ public static function provideImportRegex(): iterable ], ]; } + + private static function createRemoteEntry(string $importName, string $version, ImportMapType $type = ImportMapType::JS, string $packageSpecifier = null): ImportMapEntry + { + $packageSpecifier = $packageSpecifier ?? $importName; + + return ImportMapEntry::createRemote($importName, $type, path: 'does not matter', version: $version, packageModuleSpecifier: $packageSpecifier, isEntrypoint: false); + } } diff --git a/src/Symfony/Component/AssetMapper/Tests/fixtures/AssetMapperTestAppKernel.php b/src/Symfony/Component/AssetMapper/Tests/fixtures/AssetMapperTestAppKernel.php index 03b8212518094..aed5e2457a753 100644 --- a/src/Symfony/Component/AssetMapper/Tests/fixtures/AssetMapperTestAppKernel.php +++ b/src/Symfony/Component/AssetMapper/Tests/fixtures/AssetMapperTestAppKernel.php @@ -43,7 +43,7 @@ public function registerContainerConfiguration(LoaderInterface $loader): void 'http_client' => true, 'assets' => null, 'asset_mapper' => [ - 'paths' => ['dir1', 'dir2', 'assets/vendor'], + 'paths' => ['dir1', 'dir2', 'assets'], ], 'test' => true, ]); diff --git a/src/Symfony/Component/AssetMapper/Tests/fixtures/assets/vendor/stimulus.js b/src/Symfony/Component/AssetMapper/Tests/fixtures/assets/vendor/@hotwired/stimulus/stimulus.index.js similarity index 100% rename from src/Symfony/Component/AssetMapper/Tests/fixtures/assets/vendor/stimulus.js rename to src/Symfony/Component/AssetMapper/Tests/fixtures/assets/vendor/@hotwired/stimulus/stimulus.index.js diff --git a/src/Symfony/Component/AssetMapper/Tests/fixtures/assets/vendor/installed.php b/src/Symfony/Component/AssetMapper/Tests/fixtures/assets/vendor/installed.php index 564dfcb1286d3..1d86382dcfc3f 100644 --- a/src/Symfony/Component/AssetMapper/Tests/fixtures/assets/vendor/installed.php +++ b/src/Symfony/Component/AssetMapper/Tests/fixtures/assets/vendor/installed.php @@ -1,12 +1,10 @@ + '@hotwired/stimulus' => array ( 'version' => '3.2.1', - 'path' => 'stimulus.js', ), - 'lodash' => + 'lodash' => array ( 'version' => '4.17.21', - 'path' => 'lodash.js', ), -); \ No newline at end of file +); diff --git a/src/Symfony/Component/AssetMapper/Tests/fixtures/assets/vendor/lodash.js b/src/Symfony/Component/AssetMapper/Tests/fixtures/assets/vendor/lodash/lodash.index.js similarity index 100% rename from src/Symfony/Component/AssetMapper/Tests/fixtures/assets/vendor/lodash.js rename to src/Symfony/Component/AssetMapper/Tests/fixtures/assets/vendor/lodash/lodash.index.js