‪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;
30 
35 {
36  protected array ‪$extensionsToLoad = [];
37 
38  private ?array ‪$importMaps = null;
39 
43  public function ‪__construct(
44  protected readonly ‪HashService $hashService,
45  protected readonly array $packages,
46  protected readonly ?‪FrontendInterface $cache = null,
47  protected readonly string $cacheIdentifier = '',
48  protected readonly ?EventDispatcherInterface $eventDispatcher = null,
49  protected readonly bool $bustSuffix = true
50  ) {}
51 
55  public function ‪includeAllImports(): void
56  {
57  $this->extensionsToLoad['*'] = true;
58  }
59 
60  public function ‪includeTaggedImports(string $tag): void
61  {
62  if (isset($this->extensionsToLoad['*'])) {
63  return;
64  }
65 
66  foreach ($this->‪getImportMaps() as $package => $config) {
67  $tags = $config['tags'] ?? [];
68  if (in_array($tag, $tags, true)) {
69  $this->‪loadDependency($package);
70  }
71  }
72  }
73 
74  public function ‪includeImportsFor(string $specifier): void
75  {
76  if (!isset($this->extensionsToLoad['*'])) {
77  $this->‪resolveImport($specifier, true);
78  } else {
79  $this->‪dispatchResolveJavaScriptImportEvent($specifier, true);
80  }
81  }
82 
83  public function ‪resolveImport(
84  string $specifier,
85  bool $loadImportConfiguration = true
86  ): ?string {
87  $resolution = $this->‪dispatchResolveJavaScriptImportEvent($specifier, $loadImportConfiguration);
88  if ($resolution !== null) {
89  return $resolution;
90  }
91 
92  foreach (array_reverse($this->‪getImportMaps()) as $package => $config) {
93  $imports = $config['imports'] ?? [];
94  if (isset($imports[$specifier])) {
95  if ($loadImportConfiguration) {
96  $this->‪loadDependency($package);
97  }
98  return $imports[$specifier];
99  }
100 
101  $specifierParts = explode('/', $specifier);
102  $specifierPartCount = count($specifierParts);
103  for ($i = 1; $i < $specifierPartCount; ++$i) {
104  $prefix = implode('/', array_slice($specifierParts, 0, $i)) . '/';
105  if (isset($imports[$prefix])) {
106  if ($loadImportConfiguration) {
107  $this->‪loadDependency($package);
108  }
109  return $imports[$prefix] . implode(array_slice($specifierParts, $i));
110  }
111  }
112  }
113 
114  return null;
115  }
116 
117  public function ‪render(
118  string $urlPrefix,
119  null|string|‪ConsumableNonce $nonce
120  ): string {
121  if (count($this->extensionsToLoad) === 0 || count($this->‪getImportMaps()) === 0) {
122  return '';
123  }
124 
125  $html = [];
126 
127  $importMap = $this->‪composeImportMap($urlPrefix);
128  $json = json_encode(
129  $importMap,
130  JSON_FORCE_OBJECT | JSON_UNESCAPED_SLASHES | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_THROW_ON_ERROR
131  );
132  $nonceAttr = $nonce !== null ? ' nonce="' . htmlspecialchars((string)$nonce) . '"' : '';
133  $html[] = sprintf('<script type="importmap"%s>%s</script>', $nonceAttr, $json);
134 
135  return implode(PHP_EOL, $html) . PHP_EOL;
136  }
137 
138  public function ‪warmupCaches(): void
139  {
140  $this->‪computeImportMaps();
141  }
142 
143  protected function ‪getImportMaps(): array
144  {
145  return $this->importMaps ?? $this->‪getFromCache() ?? $this->‪computeImportMaps();
146  }
147 
148  protected function ‪getFromCache(): ?array
149  {
150  if ($this->cache === null) {
151  return null;
152  }
153  if (!$this->cache->has($this->cacheIdentifier)) {
154  return null;
155  }
156  $this->importMaps = $this->cache->get($this->cacheIdentifier);
157  return ‪$this->importMaps;
158  }
159 
160  protected function ‪computeImportMaps(): array
161  {
162  $extensionVersions = [];
163  ‪$importMaps = [];
164  foreach ($this->packages as $package) {
165  $configurationFile = $package->getPackagePath() . 'Configuration/JavaScriptModules.php';
166  if (!is_readable($configurationFile)) {
167  continue;
168  }
169  $extensionVersions[$package->getPackageKey()] = implode(':', [
170  $package->getPackageKey(),
171  $package->getPackageMetadata()->getVersion(),
172  ]);
173  $packageConfiguration = require($configurationFile);
174  ‪$importMaps[$package->getPackageKey()] = $packageConfiguration ?? [];
175  }
176 
177  $isDevelopment = ‪Environment::getContext()->isDevelopment();
178  if ($isDevelopment) {
179  $bust = (string)‪$GLOBALS['EXEC_TIME'];
180  } else {
181  $bust = $this->hashService->hmac(
182  ‪Environment::getProjectPath() . implode('|', $extensionVersions),
183  self::class
184  );
185  }
186 
187  foreach (‪$importMaps as $packageName => $config) {
188  ‪$importMaps[$packageName]['imports'] = $this->‪resolvePaths(
189  $config['imports'] ?? [],
190  $this->bustSuffix ? $bust : null
191  );
192  }
193 
194  $this->importMaps = ‪$importMaps;
195  if ($this->cache !== null) {
196  $this->cache->set($this->cacheIdentifier, ‪$importMaps);
197  }
198  return ‪$importMaps;
199  }
200 
201  protected function ‪resolveRecursiveImportMap(
202  string $prefix,
203  string $path,
204  array $exclude,
205  string $bust
206  ): array {
207  $path = GeneralUtility::getFileAbsFileName($path);
208  if (!$path || @!is_dir($path)) {
209  return [];
210  }
211  $exclude = array_map(
212  static fn(string $excludePath): string => GeneralUtility::getFileAbsFileName($excludePath),
213  $exclude
214  );
215 
216  $fileIterator = new \RegexIterator(
217  new \RecursiveIteratorIterator(
218  new \RecursiveDirectoryIterator($path)
219  ),
220  '#^' . preg_quote($path, '#') . '(.+\.js)$#',
221  \RegexIterator::GET_MATCH
222  );
223 
224  $map = [];
225  foreach ($fileIterator as $match) {
226  $fileName = $match[0];
227  $specifier = $prefix . ($match[1] ?? '');
228 
229  // @todo: Abstract into an iterator?
230  foreach ($exclude as $excludedPath) {
231  if (str_starts_with($fileName, $excludedPath)) {
232  continue 2;
233  }
234  }
235 
236  $webPath = ‪PathUtility::getAbsoluteWebPath($fileName, false) . '?bust=' . $bust;
237 
238  $map[$specifier] = $webPath;
239  }
240 
241  return $map;
242  }
243 
244  protected function ‪resolvePaths(
245  array $imports,
246  string $bust = null
247  ): array {
248  $cacheBustingSpecifiers = [];
249  foreach ($imports as $specifier => $address) {
250  if (str_ends_with($specifier, '/')) {
251  $path = is_array($address) ? ($address['path'] ?? '') : $address;
252  $exclude = is_array($address) ? ($address['exclude'] ?? []) : [];
253 
255  $cacheBusted = preg_match('#[^/]@#', $path) === 1;
256  if ($bust !== null && !$cacheBusted) {
257  // Resolve recursive importmap in order to add a bust suffix
258  // to each file.
259  $cacheBustingSpecifiers[] = $this->‪resolveRecursiveImportMap($specifier, $path, $exclude, $bust);
260  }
261  } else {
263  $cacheBusted = preg_match('#[^/]@#', $address) === 1;
264  if ($bust !== null && !$cacheBusted) {
265  ‪$url .= '?bust=' . $bust;
266  }
267  }
268  $imports[$specifier] = ‪$url;
269  }
270 
271  return $imports + array_merge(...$cacheBustingSpecifiers);
272  }
273 
274  protected function ‪loadDependency(string $packageName): void
275  {
276  if (isset($this->extensionsToLoad[$packageName])) {
277  return;
278  }
279 
280  $this->extensionsToLoad[$packageName] = true;
281  $dependencies = $this->‪getImportMaps()[$packageName]['dependencies'] ?? [];
282  foreach ($dependencies as $dependency) {
283  $this->‪loadDependency($dependency);
284  }
285  }
286 
287  protected function ‪composeImportMap(string $urlPrefix): array
288  {
289  ‪$importMaps = $this->‪getImportMaps();
290 
291  if (!isset($this->extensionsToLoad['*'])) {
292  ‪$importMaps = array_intersect_key(‪$importMaps, $this->extensionsToLoad);
293  }
294 
295  $importMap = [];
296  foreach (‪$importMaps as $singleImportMap) {
297  ArrayUtility::mergeRecursiveWithOverrule($importMap, $singleImportMap);
298  }
299  unset($importMap['dependencies']);
300  unset($importMap['tags']);
301 
302  foreach ($importMap['imports'] ?? [] as $specifier => ‪$url) {
303  $importMap['imports'][$specifier] = $urlPrefix . ‪$url;
304  }
305 
306  return $importMap;
307  }
308 
310  string $specifier,
311  bool $loadImportConfiguration = true
312  ): ?string {
313  if ($this->eventDispatcher === null) {
314  return null;
315  }
316 
317  return $this->eventDispatcher->dispatch(
318  new ‪ResolveJavaScriptImportEvent($specifier, $loadImportConfiguration, $this)
319  )->resolution;
320  }
321 
325  public function ‪updateState(array $state): void
326  {
327  $this->extensionsToLoad = $state['extensionsToLoad'] ?? [];
328  }
329 
333  public function ‪getState(): array
334  {
335  return [
336  'extensionsToLoad' => ‪$this->extensionsToLoad,
337  ];
338  }
339 }
‪TYPO3\CMS\Core\Page\ImportMap\$extensionsToLoad
‪array $extensionsToLoad
Definition: ImportMap.php:36
‪TYPO3\CMS\Core\Utility\PathUtility
Definition: PathUtility.php:27
‪TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce
Definition: ConsumableNonce.php:24
‪TYPO3\CMS\Core\Page\ImportMap\render
‪render(string $urlPrefix, null|string|ConsumableNonce $nonce)
Definition: ImportMap.php:117
‪TYPO3\CMS\Core\Page\ImportMap
Definition: ImportMap.php:35
‪TYPO3\CMS\Core\Page
Definition: AssetCollector.php:18
‪TYPO3\CMS\Core\Page\ImportMap\composeImportMap
‪composeImportMap(string $urlPrefix)
Definition: ImportMap.php:287
‪TYPO3\CMS\Core\Page\ImportMap\includeTaggedImports
‪includeTaggedImports(string $tag)
Definition: ImportMap.php:60
‪TYPO3\CMS\Core\Package\PackageInterface
Definition: PackageInterface.php:24
‪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:244
‪TYPO3\CMS\Core\Core\Environment\getProjectPath
‪static string getProjectPath()
Definition: Environment.php:160
‪TYPO3\CMS\Core\Page\ImportMap\warmupCaches
‪warmupCaches()
Definition: ImportMap.php:138
‪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:83
‪TYPO3\CMS\Core\Page\ImportMap\__construct
‪__construct(protected readonly HashService $hashService, 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:43
‪TYPO3\CMS\Core\Page\ImportMap\getFromCache
‪getFromCache()
Definition: ImportMap.php:148
‪TYPO3\CMS\Core\Page\ImportMap\computeImportMaps
‪computeImportMaps()
Definition: ImportMap.php:160
‪TYPO3\CMS\Core\Page\ImportMap\getState
‪getState()
Definition: ImportMap.php:333
‪TYPO3\CMS\Core\Page\ImportMap\resolveRecursiveImportMap
‪resolveRecursiveImportMap(string $prefix, string $path, array $exclude, string $bust)
Definition: ImportMap.php:201
‪TYPO3\CMS\Core\Page\ImportMap\includeImportsFor
‪includeImportsFor(string $specifier)
Definition: ImportMap.php:74
‪TYPO3\CMS\Core\Cache\Frontend\FrontendInterface
Definition: FrontendInterface.php:22
‪TYPO3\CMS\Core\Page\ImportMap\updateState
‪updateState(array $state)
Definition: ImportMap.php:325
‪TYPO3\CMS\Webhooks\Message\$url
‪identifier readonly UriInterface $url
Definition: LoginErrorOccurredMessage.php:36
‪TYPO3\CMS\Core\Page\ImportMap\$importMaps
‪array $importMaps
Definition: ImportMap.php:38
‪TYPO3\CMS\Core\Page\ImportMap\dispatchResolveJavaScriptImportEvent
‪dispatchResolveJavaScriptImportEvent(string $specifier, bool $loadImportConfiguration=true)
Definition: ImportMap.php:309
‪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:274
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:52
‪TYPO3\CMS\Core\Page\ImportMap\getImportMaps
‪getImportMaps()
Definition: ImportMap.php:143
‪TYPO3\CMS\Core\Crypto\HashService
Definition: HashService.php:27
‪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:55