‪TYPO3CMS  ‪main
ImportMap.php
Go to the documentation of this file.
1 <?php
2 
3 declare(strict_types=1);
4 
5 /*
6  * This file is part of the TYPO3 CMS project.
7  *
8  * It is free software; you can redistribute it and/or modify it under
9  * the terms of the GNU General Public License, either version 2
10  * of the License, or any later version.
11  *
12  * For the full copyright and license information, please read the
13  * LICENSE.txt file that was distributed with this source code.
14  *
15  * The TYPO3 project - inspiring people to share!
16  */
17 
18 namespace ‪TYPO3\CMS\Core\Page;
19 
20 use Psr\EventDispatcher\EventDispatcherInterface;
28 
33 {
34  protected array ‪$extensionsToLoad = [];
35 
36  private ?array ‪$importMaps = null;
37 
41  public function ‪__construct(
42  protected readonly array $packages,
43  protected readonly ?‪FrontendInterface $cache = null,
44  protected readonly string $cacheIdentifier = '',
45  protected readonly ?EventDispatcherInterface $eventDispatcher = null,
46  protected readonly bool $bustSuffix = true
47  ) {
48  }
49 
53  public function ‪includeAllImports(): void
54  {
55  $this->extensionsToLoad['*'] = true;
56  }
57 
58  public function ‪includeTaggedImports(string $tag): void
59  {
60  if (isset($this->extensionsToLoad['*'])) {
61  return;
62  }
63 
64  foreach ($this->‪getImportMaps() as $package => $config) {
65  $tags = $config['tags'] ?? [];
66  if (in_array($tag, $tags, true)) {
67  $this->‪loadDependency($package);
68  }
69  }
70  }
71 
72  public function ‪includeImportsFor(string $specifier): void
73  {
74  if (!isset($this->extensionsToLoad['*'])) {
75  $this->‪resolveImport($specifier, true);
76  } else {
77  $this->‪dispatchResolveJavaScriptImportEvent($specifier, true);
78  }
79  }
80 
81  public function ‪resolveImport(
82  string $specifier,
83  bool $loadImportConfiguration = true
84  ): ?string {
85  $resolution = $this->‪dispatchResolveJavaScriptImportEvent($specifier, $loadImportConfiguration);
86  if ($resolution !== null) {
87  return $resolution;
88  }
89 
90  foreach (array_reverse($this->‪getImportMaps()) as $package => $config) {
91  $imports = $config['imports'] ?? [];
92  if (isset($imports[$specifier])) {
93  if ($loadImportConfiguration) {
94  $this->‪loadDependency($package);
95  }
96  return $imports[$specifier];
97  }
98 
99  $specifierParts = explode('/', $specifier);
100  $specifierPartCount = count($specifierParts);
101  for ($i = 1; $i < $specifierPartCount; ++$i) {
102  $prefix = implode('/', array_slice($specifierParts, 0, $i)) . '/';
103  if (isset($imports[$prefix])) {
104  if ($loadImportConfiguration) {
105  $this->‪loadDependency($package);
106  }
107  return $imports[$prefix] . implode(array_slice($specifierParts, $i));
108  }
109  }
110  }
111 
112  return null;
113  }
114 
115  public function ‪render(
116  string $urlPrefix,
117  ?string $nonce,
118  bool $includePolyfill = true
119  ): string {
120  if (count($this->extensionsToLoad) === 0 || count($this->‪getImportMaps()) === 0) {
121  return '';
122  }
123 
124  $html = [];
125 
126  $importMap = $this->‪composeImportMap($urlPrefix);
127  $json = json_encode(
128  $importMap,
129  JSON_FORCE_OBJECT | JSON_UNESCAPED_SLASHES | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_THROW_ON_ERROR
130  );
131  $nonceAttr = $nonce !== null ? ' nonce="' . htmlspecialchars($nonce) . '"' : '';
132  $html[] = sprintf('<script type="importmap"%s>%s</script>', $nonceAttr, $json);
133 
134  if ($includePolyfill) {
135  $importmapPolyfill = $urlPrefix . ‪PathUtility::getPublicResourceWebPath(
136  'EXT:core/Resources/Public/JavaScript/Contrib/es-module-shims.js',
137  false
138  );
139 
140  $html[] = sprintf(
141  '<script src="%s"%s></script>',
142  htmlspecialchars($importmapPolyfill),
143  $nonceAttr
144  );
145  }
146 
147  return implode(PHP_EOL, $html) . PHP_EOL;
148  }
149 
150  public function ‪warmupCaches(): void
151  {
152  $this->‪computeImportMaps();
153  }
154 
155  protected function ‪getImportMaps(): array
156  {
157  return $this->importMaps ?? $this->‪getFromCache() ?? $this->‪computeImportMaps();
158  }
159 
160  protected function ‪getFromCache(): ?array
161  {
162  if ($this->cache === null) {
163  return null;
164  }
165  if (!$this->cache->has($this->cacheIdentifier)) {
166  return null;
167  }
168  $this->importMaps = $this->cache->get($this->cacheIdentifier);
169  return ‪$this->importMaps;
170  }
171 
172  protected function ‪computeImportMaps(): array
173  {
174  $extensionVersions = [];
175  ‪$importMaps = [];
176  foreach ($this->packages as $package) {
177  $configurationFile = $package->getPackagePath() . 'Configuration/JavaScriptModules.php';
178  if (!is_readable($configurationFile)) {
179  continue;
180  }
181  $extensionVersions[$package->getPackageKey()] = implode(':', [
182  $package->getPackageKey(),
183  $package->getPackageMetadata()->getVersion(),
184  ]);
185  $packageConfiguration = require($configurationFile);
186  ‪$importMaps[$package->getPackageKey()] = $packageConfiguration ?? [];
187  }
188 
189  $isDevelopment = ‪Environment::getContext()->isDevelopment();
190  if ($isDevelopment) {
191  $bust = (string)‪$GLOBALS['EXEC_TIME'];
192  } else {
193  $bust = ‪GeneralUtility::hmac(
194  ‪Environment::getProjectPath() . implode('|', $extensionVersions)
195  );
196  }
197 
198  foreach (‪$importMaps as $packageName => $config) {
199  ‪$importMaps[$packageName]['imports'] = $this->‪resolvePaths(
200  $config['imports'] ?? [],
201  $this->bustSuffix ? $bust : null
202  );
203  }
204 
205  $this->importMaps = ‪$importMaps;
206  if ($this->cache !== null) {
207  $this->cache->set($this->cacheIdentifier, ‪$importMaps);
208  }
209  return ‪$importMaps;
210  }
211 
212  protected function ‪resolveRecursiveImportMap(
213  string $prefix,
214  string $path,
215  array $exclude,
216  string $bust
217  ): array {
218  $path = GeneralUtility::getFileAbsFileName($path);
219  if (!$path || @!is_dir($path)) {
220  return [];
221  }
222  $exclude = array_map(
223  static fn (string $excludePath): string => GeneralUtility::getFileAbsFileName($excludePath),
224  $exclude
225  );
226 
227  $fileIterator = new \RegexIterator(
228  new \RecursiveIteratorIterator(
229  new \RecursiveDirectoryIterator($path)
230  ),
231  '#^' . preg_quote($path, '#') . '(.+\.js)$#',
232  \RegexIterator::GET_MATCH
233  );
234 
235  $map = [];
236  foreach ($fileIterator as $match) {
237  $fileName = $match[0];
238  $specifier = $prefix . ($match[1] ?? '');
239 
240  // @todo: Abstract into an iterator?
241  foreach ($exclude as $excludedPath) {
242  if (str_starts_with($fileName, $excludedPath)) {
243  continue 2;
244  }
245  }
246 
247  $webPath = ‪PathUtility::getAbsoluteWebPath($fileName, false) . '?bust=' . $bust;
248 
249  $map[$specifier] = $webPath;
250  }
251 
252  return $map;
253  }
254 
255  protected function ‪resolvePaths(
256  array $imports,
257  string $bust = null
258  ): array {
259  $cacheBustingSpecifiers = [];
260  foreach ($imports as $specifier => $address) {
261  if (str_ends_with($specifier, '/')) {
262  $path = is_array($address) ? ($address['path'] ?? '') : $address;
263  $exclude = is_array($address) ? ($address['exclude'] ?? []) : [];
264 
266  $cacheBusted = preg_match('#[^/]@#', $path) === 1;
267  if ($bust !== null && !$cacheBusted) {
268  // Resolve recursive importmap in order to add a bust suffix
269  // to each file.
270  $cacheBustingSpecifiers[] = $this->‪resolveRecursiveImportMap($specifier, $path, $exclude, $bust);
271  }
272  } else {
274  $cacheBusted = preg_match('#[^/]@#', $address) === 1;
275  if ($bust !== null && !$cacheBusted) {
276  ‪$url .= '?bust=' . $bust;
277  }
278  }
279  $imports[$specifier] = ‪$url;
280  }
281 
282  return $imports + array_merge(...$cacheBustingSpecifiers);
283  }
284 
285  protected function ‪loadDependency(string $packageName): void
286  {
287  if (isset($this->extensionsToLoad[$packageName])) {
288  return;
289  }
290 
291  $this->extensionsToLoad[$packageName] = true;
292  $dependencies = $this->‪getImportMaps()[$packageName]['dependencies'] ?? [];
293  foreach ($dependencies as $dependency) {
294  $this->‪loadDependency($dependency);
295  }
296  }
297 
298  protected function ‪composeImportMap(string $urlPrefix): array
299  {
300  ‪$importMaps = $this->‪getImportMaps();
301 
302  if (!isset($this->extensionsToLoad['*'])) {
303  ‪$importMaps = array_intersect_key(‪$importMaps, $this->extensionsToLoad);
304  }
305 
306  $importMap = [];
307  foreach (‪$importMaps as $singleImportMap) {
308  ArrayUtility::mergeRecursiveWithOverrule($importMap, $singleImportMap);
309  }
310  unset($importMap['dependencies']);
311  unset($importMap['tags']);
312 
313  foreach ($importMap['imports'] ?? [] as $specifier => ‪$url) {
314  $importMap['imports'][$specifier] = $urlPrefix . ‪$url;
315  }
316 
317  return $importMap;
318  }
319 
321  string $specifier,
322  bool $loadImportConfiguration = true
323  ): ?string {
324  if ($this->eventDispatcher === null) {
325  return null;
326  }
327 
328  return $this->eventDispatcher->dispatch(
329  new ‪ResolveJavaScriptImportEvent($specifier, $loadImportConfiguration, $this)
330  )->resolution;
331  }
332 
336  public function ‪updateState(array $state): void
337  {
338  $this->extensionsToLoad = $state['extensionsToLoad'] ?? [];
339  }
340 
344  public function ‪getState(): array
345  {
346  return [
347  'extensionsToLoad' => ‪$this->extensionsToLoad,
348  ];
349  }
350 }
‪TYPO3\CMS\Core\Page\ImportMap\__construct
‪__construct(protected readonly array $packages, protected readonly ?FrontendInterface $cache=null, protected readonly string $cacheIdentifier='', protected readonly ?EventDispatcherInterface $eventDispatcher=null, protected readonly bool $bustSuffix=true)
Definition: ImportMap.php:41
‪TYPO3\CMS\Core\Page\ImportMap\$extensionsToLoad
‪array $extensionsToLoad
Definition: ImportMap.php:34
‪TYPO3\CMS\Core\Utility\PathUtility
Definition: PathUtility.php:27
‪TYPO3\CMS\Core\Page\ImportMap
Definition: ImportMap.php:33
‪TYPO3\CMS\Core\Page
Definition: AssetCollector.php:18
‪TYPO3\CMS\Core\Page\ImportMap\composeImportMap
‪composeImportMap(string $urlPrefix)
Definition: ImportMap.php:298
‪TYPO3\CMS\Core\Page\ImportMap\includeTaggedImports
‪includeTaggedImports(string $tag)
Definition: ImportMap.php:58
‪TYPO3\CMS\Core\Package\PackageInterface
Definition: PackageInterface.php:22
‪TYPO3\CMS\Core\Utility\PathUtility\getAbsoluteWebPath
‪static string getAbsoluteWebPath(string $targetPath, bool $prefixWithSitePath=true)
Definition: PathUtility.php:52
‪TYPO3\CMS\Core\Page\ImportMap\resolvePaths
‪resolvePaths(array $imports, string $bust=null)
Definition: ImportMap.php:255
‪TYPO3\CMS\Core\Core\Environment\getProjectPath
‪static string getProjectPath()
Definition: Environment.php:160
‪TYPO3\CMS\Core\Page\ImportMap\warmupCaches
‪warmupCaches()
Definition: ImportMap.php:150
‪TYPO3\CMS\Core\Utility\PathUtility\getPublicResourceWebPath
‪static getPublicResourceWebPath(string $resourcePath, bool $prefixWithSitePath=true)
Definition: PathUtility.php:97
‪TYPO3\CMS\Core\Page\ImportMap\resolveImport
‪resolveImport(string $specifier, bool $loadImportConfiguration=true)
Definition: ImportMap.php:81
‪TYPO3\CMS\Core\Page\ImportMap\getFromCache
‪getFromCache()
Definition: ImportMap.php:160
‪TYPO3\CMS\Core\Page\ImportMap\render
‪render(string $urlPrefix, ?string $nonce, bool $includePolyfill=true)
Definition: ImportMap.php:115
‪TYPO3\CMS\Core\Utility\GeneralUtility\hmac
‪static string hmac($input, $additionalSecret='')
Definition: GeneralUtility.php:584
‪TYPO3\CMS\Core\Page\ImportMap\computeImportMaps
‪computeImportMaps()
Definition: ImportMap.php:172
‪TYPO3\CMS\Core\Page\ImportMap\getState
‪getState()
Definition: ImportMap.php:344
‪TYPO3\CMS\Core\Page\ImportMap\resolveRecursiveImportMap
‪resolveRecursiveImportMap(string $prefix, string $path, array $exclude, string $bust)
Definition: ImportMap.php:212
‪TYPO3\CMS\Core\Page\ImportMap\includeImportsFor
‪includeImportsFor(string $specifier)
Definition: ImportMap.php:72
‪TYPO3\CMS\Core\Cache\Frontend\FrontendInterface
Definition: FrontendInterface.php:22
‪TYPO3\CMS\Core\Page\ImportMap\updateState
‪updateState(array $state)
Definition: ImportMap.php:336
‪TYPO3\CMS\Webhooks\Message\$url
‪identifier readonly UriInterface $url
Definition: LoginErrorOccurredMessage.php:36
‪TYPO3\CMS\Core\Page\ImportMap\$importMaps
‪array $importMaps
Definition: ImportMap.php:36
‪TYPO3\CMS\Core\Page\ImportMap\dispatchResolveJavaScriptImportEvent
‪dispatchResolveJavaScriptImportEvent(string $specifier, bool $loadImportConfiguration=true)
Definition: ImportMap.php:320
‪TYPO3\CMS\Core\Utility\ArrayUtility
Definition: ArrayUtility.php:26
‪$GLOBALS
‪$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['adminpanel']['modules']
Definition: ext_localconf.php:25
‪TYPO3\CMS\Core\Core\Environment
Definition: Environment.php:41
‪TYPO3\CMS\Core\Page\ImportMap\loadDependency
‪loadDependency(string $packageName)
Definition: ImportMap.php:285
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:51
‪TYPO3\CMS\Core\Page\ImportMap\getImportMaps
‪getImportMaps()
Definition: ImportMap.php:155
‪TYPO3\CMS\Core\Core\Environment\getContext
‪static getContext()
Definition: Environment.php:128
‪TYPO3\CMS\Core\Page\Event\ResolveJavaScriptImportEvent
Definition: ResolveJavaScriptImportEvent.php:24
‪TYPO3\CMS\Core\Page\ImportMap\includeAllImports
‪includeAllImports()
Definition: ImportMap.php:53