diff --git a/core/core.services.yml b/core/core.services.yml index 0ecb95d3cb2723437a49c1517ab5d0e4a8477851..6d26ba9ce8aaf0110d7806988aa56b3d152f895b 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1886,3 +1886,6 @@ services: tags: - { name: twig.loader, priority: 5 } Drupal\Core\EventSubscriber\CsrfExceptionSubscriber: ~ + Drupal\Core\Asset\ImportMapsManagerInterface: + class: Drupal\Core\Asset\ImportMapsManager + autowire: true diff --git a/core/lib/Drupal/Core/Asset/ImportMapsManager.php b/core/lib/Drupal/Core/Asset/ImportMapsManager.php new file mode 100644 index 0000000000000000000000000000000000000000..4588eca12a38c264dc5afcb732297ca3982edcc5 --- /dev/null +++ b/core/lib/Drupal/Core/Asset/ImportMapsManager.php @@ -0,0 +1,321 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Asset; + +use Drupal\Component\FileCache\FileCacheFactory; +use Drupal\Component\FileCache\FileCacheInterface; +use Drupal\Component\Utility\NestedArray; +use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Cache\CacheCollector; +use Drupal\Core\Extension\ExtensionPathResolver; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\ThemeExtensionList; +use Drupal\Core\Extension\ThemeHandlerInterface; +use Drupal\Core\File\FileUrlGeneratorInterface; +use Drupal\Core\Lock\LockBackendInterface; +use Drupal\Core\Logger\LoggerChannelInterface; +use Drupal\Core\Serialization\Yaml; +use Drupal\Core\Theme\ThemeManagerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +/** + * Defines a plugin manager for import maps. + * + * To declare an import map, add a file named MODULE_NAME.importmaps.yml to the + * root of your module. The file should contain a top level key 'imports'. Each + * entry under 'imports' represents an entry in the import map. + * + * @code + * imports: + * my-library: + * path: js/dist/my-library.js + * another-library: + * path: js/dist/another-library.js + * @endcode + * + * JavaScript code that wishes to consume your imports in an ES module. + * + * @code + * // Note the naked import here, no path - simply 'my-library'. + * import myLibrary from 'my-library'; + * @endcode + * + * If making use of a bundler to build the consuming code, you will need to + * configure the build step to mark these imports as external. For example with + * Vite. + * + * @code + * const viteConfig = { + * // ... + * build: { + * rollupOptions: { + * external: ["my-library", "another-library"], + * }, + * // ... + * } + * @endcode + * + * This will make sure that the naked import is retained in the built code. + * Similar options exist for other front-end bundlers. + * + * Consuming code can make use of the attributes key in libraries.yml to set + * type="module" on the script tag. + * + * @code + * cards: + * css: + * component: + * css/card.css: {} + * js: + * // This script will be rendered as <script type="module"> and hence be + * // treated as an ES module. + * js/dist/card.js: { minified: true, attributes: { type: module } }} + * @endcode + * + * If you need to load two different versions of a module in an import map you + * can also add a top-level 'scopes' key to the MODULE_NAME.importmaps.yml file + * + * @code + * imports: + * my-library: + * path: js/dist/my-library-v2.js + * scopes: + * js/v1: + * my-library: + * path: js/dist/my-library-v1.js + * @endcode + * + * In this example, consuming code from inside js/v1 (relative to the module + * declaring the importmaps.yml file) will resolve 'my-library' to + * js/dist/my-library-v1.js. All other consuming code that imports from + * 'my-library' will resolve to js/dist/my-library-v2.js. + * + * The MODULE_NAME.importmaps.yml file must contain at least one of the 'scopes' + * and 'imports' top-level keys. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap + * @see https://rollupjs.org/configuration-options/#external + * @see https://webpack.js.org/configuration/externals/#externals + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules + */ +final class ImportMapsManager extends CacheCollector implements ImportMapsManagerInterface { + + protected const MODULE_IMPORT_MAPS = '__modules'; + + /** + * File cache. + * + * @var \Drupal\Component\FileCache\FileCacheInterface + */ + protected readonly FileCacheInterface $fileCache; + + /** + * Constructs an ImportMapsManager object. + */ + public function __construct( + #[Autowire('@cache.discovery')] + CacheBackendInterface $cache, + #[Autowire('@lock')] + LockBackendInterface $lock, + #[Autowire('%app.root%')] + protected readonly string $rootPath, + protected readonly ModuleHandlerInterface $moduleHandler, + protected readonly ThemeHandlerInterface $themeHandler, + protected readonly ExtensionPathResolver $extensionPathResolver, + protected readonly ThemeExtensionList $themeExtensionList, + #[Autowire('@logger.channel.default')] + protected readonly LoggerChannelInterface $loggerChannel, + protected readonly FileUrlGeneratorInterface $fileUrlGenerator, + protected readonly ThemeManagerInterface $themeManager, + ) { + parent::__construct('importmaps', $cache, $lock, ['importmaps']); + $this->fileCache = FileCacheFactory::get('importmaps'); + } + + /** + * {@inheritdoc} + */ + protected function resolveCacheMiss($key): array { + $this->storage[$key] = $this->buildImportMaps($key); + $this->persist($key); + + return $this->storage[$key]; + } + + /** + * {@inheritdoc} + */ + public function getImportMapForTheme(string $theme): array { + // The returned map combines import maps for the installed modules and those + // from the given theme (and any of its base themes). + return NestedArray::mergeDeep($this->get(self::MODULE_IMPORT_MAPS), $this->get($theme)); + } + + /** + * Build import maps for the given key. + * + * @param string $key + * Either self::MODULE_IMPORT_MAPS or a theme machine name. + * + * @return array + * Built import maps, with any paths resolved. + */ + protected function buildImportMaps(string $key): array { + $import_maps = []; + if ($key === self::MODULE_IMPORT_MAPS) { + // We need to build for modules. + foreach ($this->moduleHandler->getModuleList() as $extension => $info) { + $import_maps = NestedArray::mergeDeep($import_maps, $this->buildImportMapsForExtensionNameAndPath($extension, $info->getPath())); + } + return $import_maps; + } + // Build import maps for the given theme. + $base_theme_import_maps = \array_reduce($this->collectBaseThemes($key), fn (array $carry, string $base_theme) => NestedArray::mergeDeep($carry, $this->get($base_theme)), []); + $theme = $this->themeHandler->getTheme($key); + $import_maps = NestedArray::mergeDeep($base_theme_import_maps, $this->buildImportMapsForExtensionNameAndPath($key, $theme->getPath())); + return $import_maps; + } + + /** + * Collects base themes for a given theme. + * + * @param string $theme + * Theme machine name. + * + * @return array + * Array of base themes. + */ + protected function collectBaseThemes(string $theme): array { + $base_themes = []; + $info = $this->themeExtensionList->getExtensionInfo($theme); + if (\array_key_exists('base theme', $info)) { + $base_themes[] = $info['base theme']; + $base_themes = \array_merge($base_themes, $this->collectBaseThemes($info['base theme'])); + } + return $base_themes; + } + + /** + * Builds import maps for given extension name and path. + * + * @param string $extension + * Extension machine name. + * @param string $directory + * Directory of the extension. + * + * @return array + * Import maps. + * + * @see \Drupal\Core\Asset\ImportMapsManagerInterface::getImportMapForTheme() + */ + protected function buildImportMapsForExtensionNameAndPath(string $extension, string $directory): array { + $import_maps_file = $this->rootPath . '/' . $directory . '/' . $extension . '.importmaps.yml'; + if (!\file_exists($import_maps_file)) { + return []; + } + $import_maps = $this->fileCache->get($import_maps_file); + if ($import_maps === NULL) { + $file_contents = \file_get_contents($import_maps_file); + if ($file_contents === FALSE) { + $this->loggerChannel->error('Error reading import maps file @file', ['@file' => $import_maps_file]); + return []; + } + $import_maps = Yaml::decode($file_contents) ?? []; + $this->fileCache->set($import_maps_file, $import_maps); + } + + if (!\array_key_exists('imports', $import_maps) && !\array_key_exists('scopes', $import_maps)) { + $this->loggerChannel->warning('Import maps file missing both scopes and imports keys: @file', ['@file' => $import_maps_file]); + return []; + } + + // Remove any irrelevant keys. + $import_maps = \array_intersect_key($import_maps, \array_flip([ + 'scopes', + 'imports', + ])); + if (\array_key_exists('scopes', $import_maps)) { + $resolved_scopes = []; + foreach ($import_maps['scopes'] as $scope => $entries) { + $resolved_scope = \rtrim($this->resolvePath($scope, $directory, FALSE), '/') . '/'; + foreach ($entries as $name => $entry) { + if (!\array_key_exists('path', $entry) || !is_string($entry['path'])) { + $this->loggerChannel->warning('Missing or invalid path entry for @scope/@import entry in @file', [ + '@file' => $import_maps_file, + '@import' => $name, + '@scope' => $scope, + ]); + continue; + } + $resolved_scopes[$resolved_scope][$name] = $this->resolvePath($entry['path'], $directory); + } + } + $import_maps['scopes'] = $resolved_scopes; + } + if (\array_key_exists('imports', $import_maps)) { + $resolved_imports = []; + foreach ($import_maps['imports'] as $name => $entry) { + if (!\array_key_exists('path', $entry) || !is_string($entry['path'])) { + $this->loggerChannel->warning('Missing or invalid path entry for @import entry in @file', [ + '@file' => $import_maps_file, + '@import' => $name, + ]); + continue; + } + $resolved_imports[$name] = $this->resolvePath($entry['path'], $directory); + } + $import_maps['imports'] = $resolved_imports; + } + + $import_maps = \array_filter($import_maps); + + // Allow modules and themes to alter import maps. + $this->moduleHandler->alter('importmaps', $import_maps, $extension); + $this->themeManager->alter('importmaps', $import_maps, $extension); + + return $import_maps; + } + + /** + * Resolves the path entry in an import or scope entry. + * + * The path may be: + * - a fully formed URL, e.g. http://example.com/some.js - in this case we + * return the path as is. An external URL is not allowed as a key a scope + * entry. + * - an absolute path, e.g. /absolute/file.js - in this case we return the + * path as is. + * - a relative path e.g. js/file.js - this is relative to the module or theme + * that declared the import map. + * + * @param string $path + * Path to resolve. + * @param string $directory + * Extension directory. + * @param bool $allow_external + * TRUE if external URLS are allowed. + * + * @return string + * The resolved path. + */ + protected function resolvePath(string $path, string $directory, bool $allow_external = TRUE): string { + $scheme = \parse_url($path, PHP_URL_SCHEME); + if ($scheme !== FALSE && $scheme !== 'NULL' && \in_array($scheme, UrlHelper::getAllowedProtocols(), TRUE)) { + // External URL. + if (!$allow_external) { + throw new \LogicException(\sprintf('Scope keys cannot be external URLS - invalid entry %s in import map in %s', $path, $path)); + } + return $path; + } + if (\substr($path, 0, 1) === '/') { + // Absolute path. + return $this->fileUrlGenerator->generateString($path); + } + // Relative to module. + return $this->fileUrlGenerator->generateString($directory . '/' . $path); + } + +} diff --git a/core/lib/Drupal/Core/Asset/ImportMapsManagerInterface.php b/core/lib/Drupal/Core/Asset/ImportMapsManagerInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..c082c51a7974eb6fc7cf483a6d65e7a990a79c05 --- /dev/null +++ b/core/lib/Drupal/Core/Asset/ImportMapsManagerInterface.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Asset; + +use Drupal\Core\Cache\CacheCollectorInterface; +use Drupal\Core\DestructableInterface; + +/** + * Defines an interface for import maps manager. + */ +interface ImportMapsManagerInterface extends CacheCollectorInterface, DestructableInterface { + + /** + * Gets import maps for given theme. + * + * @param string $theme + * Theme machine name. + * + * @return array + * Array of import maps with keys 'imports' and 'scopes'. + */ + public function getImportMapForTheme(string $theme): array; + +} diff --git a/core/lib/Drupal/Core/Render/theme.api.php b/core/lib/Drupal/Core/Render/theme.api.php index fa447c20063018f00c6622743ea56abe9ff26d97..904bfb0f3e9189566f90060af00217209d8d2e55 100644 --- a/core/lib/Drupal/Core/Render/theme.api.php +++ b/core/lib/Drupal/Core/Render/theme.api.php @@ -1362,6 +1362,44 @@ function hook_template_preprocess_default_variables_alter(&$variables) { $variables['is_admin'] = \Drupal::currentUser()->hasPermission('access administration pages'); } +/** + * Alter importmaps. + * + * Allows modules and themes to change importmaps. + * + * @param array $import_maps + * Array of importmaps information. Will contain two top level keys 'imports' + * and 'scopes'. Entries under 'imports' is a resolved URL to the file to + * import and are keyed by the import names. Each entry under 'scopes' has + * the same shape as 'imports' i.e. an array keyed by import identifiers with + * each entry a resolved URL to the file to import. Entries under 'scopes' are + * keyed by the path to consuming modules under which the scope rule applies. + * @code + * imports: + * identifier1: '/absolute/url/to/import1.js' + * identifier2: '/absolute/url/to/import2.js' + * scopes: + * /when/loading/code/is/here: + * identifier1: '/has/a/different/path.js' + * @endcode + * @param string $extension + * The module or theme machine name that provided the import maps being + * altered. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap + * @see \Drupal\Core\Asset\ImportMapsManagerInterface + */ +function hook_importmaps_alter(array &$import_maps, string $extension): void { + if ($extension !== 'react') { + return; + } + // Swap out the path to react-dom. + if (\array_key_exists('react-dom', $import_maps['imports'])) { + // @cspell:ignore esmodule + $import_maps['imports']['react-dom'] = \Drupal::service(\Drupal\Core\File\FileUrlGeneratorInterface::class)->generateString('/my/react-dom-esmodule.js'); + } +} + /** * @} End of "addtogroup hooks". */ diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt index 13cbc4e61866667e97a2d49e095ec12aa644d0b3..a6c9dfaa1ec3bb51945f4dbf28f5fafffc504844 100644 --- a/core/misc/cspell/dictionary.txt +++ b/core/misc/cspell/dictionary.txt @@ -272,6 +272,8 @@ idekey iframeupload imagecache imagetextalternative +importmap +importmaps indexname inited inlines diff --git a/core/modules/system/system.module b/core/modules/system/system.module index 50c90b57721b9c639c188d50cdae08b77fe736a0..b76b5184c0f1f8cd5609ccb84f22c6254ad71916 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -8,8 +8,10 @@ use Drupal\Component\FileSecurity\FileSecurity; use Drupal\Component\Gettext\PoItem; use Drupal\Component\Render\PlainTextOutput; +use Drupal\Component\Serialization\Json; use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Asset\AttachedAssetsInterface; +use Drupal\Core\Asset\ImportMapsManagerInterface; use Drupal\Core\Block\BlockPluginInterface; use Drupal\Core\Cache\Cache; use Drupal\Core\Database\Query\AlterableInterface; @@ -1111,7 +1113,7 @@ function system_theme_registry_alter(array &$theme_registry) { /** * Implements hook_page_top(). */ -function system_page_top() { +function system_page_top(array &$page_top) { /** @var \Drupal\Core\Routing\AdminContext $admin_context */ $admin_context = \Drupal::service('router.admin_context'); if ($admin_context->isAdminRoute() && \Drupal::currentUser()->hasPermission('administer site configuration')) { @@ -1147,6 +1149,17 @@ function system_page_top() { } } } + $map = \Drupal::service(ImportMapsManagerInterface::class)->getImportMapForTheme(\Drupal::theme()->getActiveTheme()->getName()); + if (\array_key_exists('imports', $map) && \count($map['imports']) > 0) { + $page_top['importmaps'] = [ + '#type' => 'html_tag', + '#tag' => 'script', + '#value' => Json::encode($map), + '#attributes' => ['type' => 'importmap'], + // This varies by route because of the active theme resolution. + '#cache' => ['contexts' => ['route']], + ]; + } } /** diff --git a/core/modules/system/tests/modules/importmaps_test/importmaps_test.importmaps.yml b/core/modules/system/tests/modules/importmaps_test/importmaps_test.importmaps.yml new file mode 100644 index 0000000000000000000000000000000000000000..8cd3cba5a5aa2285c37a7aff6a8d39dfb5ed2231 --- /dev/null +++ b/core/modules/system/tests/modules/importmaps_test/importmaps_test.importmaps.yml @@ -0,0 +1,7 @@ +imports: + bar: + path: js/bar.js +scopes: + js/scope: + bar: + path: js/bar-scoped.js diff --git a/core/modules/system/tests/modules/importmaps_test/importmaps_test.info.yml b/core/modules/system/tests/modules/importmaps_test/importmaps_test.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..614aef0bf4c9c89071c2c82a244e047fc5c85afc --- /dev/null +++ b/core/modules/system/tests/modules/importmaps_test/importmaps_test.info.yml @@ -0,0 +1,4 @@ +name: Import maps test +description: 'Provides test functionality for import maps' +type: module +version: VERSION diff --git a/core/modules/system/tests/modules/importmaps_test/importmaps_test.libraries.yml b/core/modules/system/tests/modules/importmaps_test/importmaps_test.libraries.yml new file mode 100644 index 0000000000000000000000000000000000000000..4a9b60fa212d252ba3cf05caea37a0bf31157c2c --- /dev/null +++ b/core/modules/system/tests/modules/importmaps_test/importmaps_test.libraries.yml @@ -0,0 +1,7 @@ +foo: + js: + js/foo.js: { attributes: { type: module }, minified: true } + +foo-scoped: + js: + js/scope/foo.js: { attributes: { type: module }, minified: true } diff --git a/core/modules/system/tests/modules/importmaps_test/importmaps_test.module b/core/modules/system/tests/modules/importmaps_test/importmaps_test.module new file mode 100644 index 0000000000000000000000000000000000000000..e967fb19eec146f1b48d7e3fe4c29136b26bf194 --- /dev/null +++ b/core/modules/system/tests/modules/importmaps_test/importmaps_test.module @@ -0,0 +1,17 @@ +<?php + +/** + * @file + * Contains main module functions. + */ + +declare(strict_types=1); + +/** + * Implements hook_importmaps_alter(). + */ +function importmaps_test_importmaps_alter(array &$import_maps, string $extension): void { + if ($extension === 'importmaps_test_theme') { + $import_maps['imports']['foo'] = '/absolute/js/foo.js'; + } +} diff --git a/core/modules/system/tests/modules/importmaps_test/importmaps_test.routing.yml b/core/modules/system/tests/modules/importmaps_test/importmaps_test.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..73a64744dc34e45d50f7c45edc44523bbddde79e --- /dev/null +++ b/core/modules/system/tests/modules/importmaps_test/importmaps_test.routing.yml @@ -0,0 +1,7 @@ +importmaps_test.test: + path: '/importmaps-test' + defaults: + _controller: '\Drupal\importmaps_test\TestController' + _title: 'Importmaps test' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/modules/importmaps_test/js/bar-scoped.js b/core/modules/system/tests/modules/importmaps_test/js/bar-scoped.js new file mode 100644 index 0000000000000000000000000000000000000000..1deb323e738a556a31317b46dd61a3d019fe3b34 --- /dev/null +++ b/core/modules/system/tests/modules/importmaps_test/js/bar-scoped.js @@ -0,0 +1,11 @@ +class Bar { + constructor(el) { + this.el = el; + } + + init() { + this.el.textContent = 'Scoped bar'; + } +} + +export default Bar; diff --git a/core/modules/system/tests/modules/importmaps_test/js/bar.js b/core/modules/system/tests/modules/importmaps_test/js/bar.js new file mode 100644 index 0000000000000000000000000000000000000000..60937a20fec8ad8fda0efbd9578dcb1c7260ac26 --- /dev/null +++ b/core/modules/system/tests/modules/importmaps_test/js/bar.js @@ -0,0 +1,11 @@ +class Bar { + constructor(el) { + this.el = el; + } + + init() { + this.el.textContent = 'Root level bar'; + } +} + +export default Bar; diff --git a/core/modules/system/tests/modules/importmaps_test/js/foo.js b/core/modules/system/tests/modules/importmaps_test/js/foo.js new file mode 100644 index 0000000000000000000000000000000000000000..6340413a415ec0c1b535ba90b43ab266a4a1a257 --- /dev/null +++ b/core/modules/system/tests/modules/importmaps_test/js/foo.js @@ -0,0 +1,3 @@ +import Bar from 'bar'; // eslint-disable-line import/no-unresolved + +new Bar(document.getElementById('importmaps-test')).init(); diff --git a/core/modules/system/tests/modules/importmaps_test/js/scope/foo.js b/core/modules/system/tests/modules/importmaps_test/js/scope/foo.js new file mode 100644 index 0000000000000000000000000000000000000000..6340413a415ec0c1b535ba90b43ab266a4a1a257 --- /dev/null +++ b/core/modules/system/tests/modules/importmaps_test/js/scope/foo.js @@ -0,0 +1,3 @@ +import Bar from 'bar'; // eslint-disable-line import/no-unresolved + +new Bar(document.getElementById('importmaps-test')).init(); diff --git a/core/modules/system/tests/modules/importmaps_test/src/TestController.php b/core/modules/system/tests/modules/importmaps_test/src/TestController.php new file mode 100644 index 0000000000000000000000000000000000000000..56185c1ee6899de90eea513a58383bb23e3d0347 --- /dev/null +++ b/core/modules/system/tests/modules/importmaps_test/src/TestController.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\importmaps_test; + +use Symfony\Component\HttpFoundation\Request; + +/** + * Defines a controller for testing importmaps. + */ +final class TestController { + + /** + * Invoke the controller. + */ + public function __invoke(Request $request): array { + return [ + '#markup' => '<div id="importmaps-test"></div>', + '#cache' => [ + 'contexts' => ['url.query_args:scoped'], + ], + '#attached' => [ + 'library' => ['importmaps_test/foo' . ($request->query->has('scoped') ? '-scoped' : '')], + ], + ]; + } + +} diff --git a/core/modules/system/tests/modules/importmaps_test_imports_only/importmaps_test_imports_only.importmaps.yml b/core/modules/system/tests/modules/importmaps_test_imports_only/importmaps_test_imports_only.importmaps.yml new file mode 100644 index 0000000000000000000000000000000000000000..f6d5b79f56f7a996601a1883490a9597232893f3 --- /dev/null +++ b/core/modules/system/tests/modules/importmaps_test_imports_only/importmaps_test_imports_only.importmaps.yml @@ -0,0 +1,3 @@ +imports: + whiz: + path: js/whiz.js diff --git a/core/modules/system/tests/modules/importmaps_test_imports_only/importmaps_test_imports_only.info.yml b/core/modules/system/tests/modules/importmaps_test_imports_only/importmaps_test_imports_only.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..bff5f6ff0cb08116fc7712dbfdc98935fb9e1919 --- /dev/null +++ b/core/modules/system/tests/modules/importmaps_test_imports_only/importmaps_test_imports_only.info.yml @@ -0,0 +1,4 @@ +name: Import maps test, imports only +description: '' +type: module +version: VERSION diff --git a/core/modules/system/tests/modules/importmaps_test_scopes_only/importmaps_test_scopes_only.importmaps.yml b/core/modules/system/tests/modules/importmaps_test_scopes_only/importmaps_test_scopes_only.importmaps.yml new file mode 100644 index 0000000000000000000000000000000000000000..8212c3558e526b3c03956853f37fd701301e14a5 --- /dev/null +++ b/core/modules/system/tests/modules/importmaps_test_scopes_only/importmaps_test_scopes_only.importmaps.yml @@ -0,0 +1,7 @@ +scopes: + js/bar: + whiz: + path: js/bar/whiz.js + /absolute/js/bar: + whiz: + path: /absolute/library/whiz.js diff --git a/core/modules/system/tests/modules/importmaps_test_scopes_only/importmaps_test_scopes_only.info.yml b/core/modules/system/tests/modules/importmaps_test_scopes_only/importmaps_test_scopes_only.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..f1248a907093d27792a9a3b2f782721f39d7296e --- /dev/null +++ b/core/modules/system/tests/modules/importmaps_test_scopes_only/importmaps_test_scopes_only.info.yml @@ -0,0 +1,4 @@ +name: Import maps test, scopes only +description: '' +type: module +version: VERSION diff --git a/core/tests/Drupal/FunctionalJavascriptTests/ImportMapsTest.php b/core/tests/Drupal/FunctionalJavascriptTests/ImportMapsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..ad22823d972aca8bfef0a4985bb0eac90bdb6511 --- /dev/null +++ b/core/tests/Drupal/FunctionalJavascriptTests/ImportMapsTest.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\FunctionalJavascriptTests; + +use Drupal\Core\Url; + +/** + * Tests importmap and scopes. + * + * @group importmaps + */ +final class ImportMapsTest extends WebDriverTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = ['importmaps_test']; + + /** + * Tests importmap functionality. + */ + public function testImportMaps(): void { + $url = Url::fromRoute('importmaps_test.test'); + $this->drupalGet($url); + $this->assertSession()->elementExists('css', '#importmaps-test'); + $this->assertSession()->waitForText('Root level bar'); + $this->assertSession()->pageTextNotContains('Scoped bar'); + + $this->drupalGet($url->setOption('query', ['scoped' => 1])); + $this->assertSession()->elementExists('css', '#importmaps-test'); + $this->assertSession()->waitForText('Scoped bar'); + $this->assertSession()->pageTextNotContains('Root level bar'); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Asset/ImportMapsManagerTest.php b/core/tests/Drupal/KernelTests/Core/Asset/ImportMapsManagerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..fe7465365150ddd8ed04525afd27822a3c644827 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Asset/ImportMapsManagerTest.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Asset; + +use Drupal\Component\Utility\NestedArray; +use Drupal\Core\Asset\ImportMapsManagerInterface; +use Drupal\Core\Extension\ExtensionPathResolver; +use Drupal\Core\Theme\ThemeInitializationInterface; +use Drupal\Core\Theme\ThemeManagerInterface; +use Drupal\KernelTests\KernelTestBase; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Tests ImportMapsManager. + * + * @group importmaps + * @covers \Drupal\Core\Asset\ImportMapsManager + */ +final class ImportMapsManagerTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'importmaps_test', + 'importmaps_test_imports_only', + 'importmaps_test_scopes_only', + 'system', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->container->get('theme_installer')->install([ + 'importmaps_test_theme', + 'importmaps_test_basetheme', + ]); + /** @var \Drupal\Core\Theme\ThemeInitializationInterface $theme_initializer */ + $theme_initializer = $this->container->get(ThemeInitializationInterface::class); + /** @var \Drupal\Core\Theme\ThemeManagerInterface $theme_manager */ + $theme_manager = $this->container->get(ThemeManagerInterface::class); + $theme_manager->setActiveTheme($theme_initializer->getActiveThemeByName('importmaps_test_theme')); + } + + /** + * Tests import map discovery. + */ + public function testImportDiscovery(): void { + /** @var \Drupal\Core\Asset\ImportMapsManagerInterface $manager */ + $manager = $this->container->get(ImportMapsManagerInterface::class); + + $extension_path_resolver = $this->container->get(ExtensionPathResolver::class); + $import_maps_test_path = $extension_path_resolver->getPath('module', 'importmaps_test'); + $import_maps_test_imports_only_path = $extension_path_resolver->getPath('module', 'importmaps_test_imports_only'); + $import_maps_test_scopes_only_path = $extension_path_resolver->getPath('module', 'importmaps_test_scopes_only'); + $import_maps_test_theme_path = $extension_path_resolver->getPath('theme', 'importmaps_test_theme'); + $import_maps_test_basetheme_path = $extension_path_resolver->getPath('theme', 'importmaps_test_basetheme'); + + $basePath = $this->container->get(RequestStack::class)->getCurrentRequest()->getBasePath(); + + $base_import_maps = [ + 'imports' => [ + 'wow' => "{$basePath}/$import_maps_test_basetheme_path/js/wow.js", + 'absolute' => "{$basePath}/dist/absolute.js", + 'url' => "https://example.com/url.js", + 'bar' => "{$basePath}/$import_maps_test_path/js/bar.js", + 'whiz' => "{$basePath}/$import_maps_test_imports_only_path/js/whiz.js", + ], + 'scopes' => [ + "{$basePath}/$import_maps_test_path/js/scope/" => [ + 'bar' => "{$basePath}/$import_maps_test_path/js/bar-scoped.js", + ], + "{$basePath}/$import_maps_test_scopes_only_path/js/bar/" => [ + 'whiz' => "{$basePath}/$import_maps_test_scopes_only_path/js/bar/whiz.js", + ], + "{$basePath}/absolute/js/bar/" => [ + 'whiz' => "{$basePath}/absolute/library/whiz.js", + ], + ], + ]; + + $import_maps = $manager->getImportMapForTheme('importmaps_test_basetheme'); + self::assertEquals($base_import_maps, $import_maps); + + $theme_imports = NestedArray::mergeDeep($base_import_maps, [ + 'imports' => [ + 'baz' => "{$basePath}/$import_maps_test_theme_path/js/baz.js", + // @see \importmaps_test_importmaps_alter() + 'foo' => "{$basePath}/absolute/js/foo.js", + ], + 'scopes' => [ + "{$basePath}/$import_maps_test_theme_path/js/whiz/" => [ + 'baz' => "{$basePath}/$import_maps_test_theme_path/js/whiz/baz.js", + ], + ], + ]); + + $import_maps = $manager->getImportMapForTheme('importmaps_test_theme'); + self::assertEquals($theme_imports, $import_maps); + } + +}