TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:09 +02:00
commit af8cc155b5
6818 changed files with 642608 additions and 0 deletions
+421
View File
@@ -0,0 +1,421 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Page;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Package\PackageInterface;
use TYPO3\CMS\Core\Page\Event\ResolveJavaScriptImportEvent;
use TYPO3\CMS\Core\Page\Event\ResolveVirtualJavaScriptImportEvent;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\HashValue;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Mutation;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationMode;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyRegistry;
use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolvePublicResourceException;
use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolveSystemResourceException;
use TYPO3\CMS\Core\SystemResource\Publishing\UriGenerationOptions;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* @internal
*/
class ImportMap
{
protected array $extensionsToLoad = [];
private ?array $importMaps = null;
/**
* @param list<PackageInterface> $packages
*/
public function __construct(
protected readonly HashService $hashService,
protected readonly array $packages,
protected readonly ?PolicyRegistry $policyRegistry = null,
protected readonly ?FrontendInterface $cache = null,
protected readonly string $cacheIdentifier = '',
protected readonly ?EventDispatcherInterface $eventDispatcher = null,
protected readonly bool $bustSuffix = true
) {}
/**
* HEADS UP: Do only use in authenticated mode as this discloses as installed extensions
*/
public function includeAllImports(): void
{
$this->extensionsToLoad['*'] = true;
}
public function includeTaggedImports(string $tag): void
{
if (isset($this->extensionsToLoad['*'])) {
return;
}
foreach ($this->getImportMaps() as $package => $config) {
$tags = $config['tags'] ?? [];
if (in_array($tag, $tags, true)) {
$this->loadDependency($package);
}
}
}
public function includeImportsFor(string $specifier): void
{
if (!isset($this->extensionsToLoad['*'])) {
$this->resolveImport($specifier, true);
} else {
$this->dispatchResolveJavaScriptImportEvent($specifier, true);
}
}
/**
* @return ?non-empty-string
*/
public function resolveImport(
string $specifier,
bool $loadImportConfiguration = true,
string $uriPrefix = '/'
): ?string {
$resolution = $this->dispatchResolveJavaScriptImportEvent($specifier, $loadImportConfiguration);
if ($resolution !== null) {
return $resolution;
}
foreach (array_reverse($this->getImportMaps()) as $package => $config) {
$imports = $config['imports'] ?? [];
if (isset($imports[$specifier])) {
if ($loadImportConfiguration) {
$this->loadDependency($package);
}
return $this->getResourceUri($imports[$specifier], $uriPrefix);
}
$specifierParts = explode('/', $specifier);
$specifierPartCount = count($specifierParts);
for ($i = 1; $i < $specifierPartCount; ++$i) {
$prefix = implode('/', array_slice($specifierParts, 0, $i)) . '/';
if (isset($imports[$prefix])) {
if ($loadImportConfiguration) {
$this->loadDependency($package);
}
return $this->getResourceUri($imports[$prefix] . implode('/', array_slice($specifierParts, $i)), $uriPrefix);
}
}
}
return null;
}
public function render(
string $uriPrefix,
string|ConsumableNonce|null $nonce
): string {
if (count($this->extensionsToLoad) === 0 || count($this->getImportMaps()) === 0) {
return '';
}
$html = [];
$importMap = $this->composeImportMap($uriPrefix);
$json = json_encode(
$importMap,
JSON_FORCE_OBJECT | JSON_UNESCAPED_SLASHES | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_THROW_ON_ERROR
);
$attributes = [
'type' => 'importmap',
];
if ($nonce !== null) {
$attributes['nonce'] = $nonce instanceof ConsumableNonce ? $nonce->consumeInline(Directive::ScriptSrcElem) : $nonce;
} else {
$this->policyRegistry?->appendMutationCollection(
new MutationCollection(
new Mutation(MutationMode::Extend, Directive::ScriptSrcElem, HashValue::hash($json))
)
);
}
$html[] = sprintf(
'<script %s>%s</script>',
GeneralUtility::implodeAttributes($attributes, true),
$json
);
return implode(PHP_EOL, $html) . PHP_EOL;
}
public function warmupCaches(): void
{
$this->computeImportMaps();
}
protected function getImportMaps(): array
{
return $this->importMaps ?? $this->getFromCache() ?? $this->computeImportMaps();
}
protected function getFromCache(): ?array
{
if ($this->cache === null) {
return null;
}
if (!$this->cache->has($this->cacheIdentifier)) {
return null;
}
$importMaps = $this->cache->get($this->cacheIdentifier);
if ($importMaps === false) {
// Cache entry has been removed in the meantime
return null;
}
if (!is_array($importMaps)) {
// An invalid result is to be ignored (cache will be recreated)
return null;
}
$this->importMaps = $importMaps;
return $importMaps;
}
protected function computeImportMaps(): array
{
$extensionVersions = [];
$importMaps = [];
foreach ($this->packages as $package) {
$configurationFile = $package->getPackagePath() . 'Configuration/JavaScriptModules.php';
if (!is_readable($configurationFile)) {
continue;
}
$extensionVersions[$package->getPackageKey()] = implode(':', [
$package->getPackageKey(),
$package->getPackageMetadata()->getVersion(),
]);
$packageConfiguration = require($configurationFile);
$importMaps[$package->getPackageKey()] = $packageConfiguration ?? [];
}
$isDevelopment = Environment::getContext()->isDevelopment();
if ($isDevelopment) {
$bust = (string)$GLOBALS['EXEC_TIME'];
} else {
$bust = $this->hashService->hmac(
Environment::getProjectPath() . implode('|', $extensionVersions),
self::class
);
}
foreach ($importMaps as $packageName => $config) {
$importMaps[$packageName]['imports'] = $this->resolvePaths(
$config['imports'] ?? [],
$this->bustSuffix ? $bust : null
);
}
$this->importMaps = $importMaps;
if ($this->cache !== null) {
$this->cache->set($this->cacheIdentifier, $importMaps);
}
return $importMaps;
}
protected function resolveRecursiveImportMap(
string $prefix,
string $pathResourceIdentifier,
array $exclude,
string $bust
): array {
// @todo resolve with resource directory object once available
$absolutePath = GeneralUtility::getFileAbsFileName($pathResourceIdentifier);
if (!$absolutePath || @!is_dir($absolutePath)) {
return [];
}
$exclude = array_map(
static fn(string $excludePath): string => GeneralUtility::getFileAbsFileName($excludePath),
$exclude
);
$fileIterator = new \RegexIterator(
new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($absolutePath)
),
'#^' . preg_quote($absolutePath, '#') . '(.+\.js)$#',
\RegexIterator::GET_MATCH
);
$map = [];
foreach ($fileIterator as $match) {
$fileName = $match[0];
$specifier = $prefix . ($match[1] ?? '');
$resourceIdentifier = $pathResourceIdentifier . ($match[1] ?? '');
// @todo: Abstract into an iterator?
foreach ($exclude as $excludedPath) {
if (str_starts_with($fileName, $excludedPath)) {
continue 2;
}
}
$map[$specifier] = $resourceIdentifier . '?bust=' . $bust;
}
return $map;
}
protected function resolvePaths(
array $imports,
?string $bust = null
): array {
$cacheBustingSpecifiers = [];
foreach ($imports as $specifier => $address) {
if (is_string($address) && str_starts_with($address, 'VIRTUAL:')) {
$imports[$specifier] = $address;
continue;
}
if (str_ends_with($specifier, '/')) {
$resourceIdentifier = is_array($address) ? ($address['path'] ?? '') : $address;
$exclude = is_array($address) ? ($address['exclude'] ?? []) : [];
if ($bust !== null) {
// Resolve recursive importmap in order to add a bust suffix
// to each file.
$cacheBustingSpecifiers[] = $this->resolveRecursiveImportMap($specifier, $resourceIdentifier, $exclude, $bust);
}
} else {
$resourceIdentifier = $address;
if ($bust !== null) {
$resourceIdentifier .= '?bust=' . $bust;
}
}
$imports[$specifier] = $resourceIdentifier;
}
return $imports + array_merge(...$cacheBustingSpecifiers);
}
protected function loadDependency(string $packageName): void
{
if (isset($this->extensionsToLoad[$packageName])) {
return;
}
$this->extensionsToLoad[$packageName] = true;
$dependencies = $this->getImportMaps()[$packageName]['dependencies'] ?? [];
foreach ($dependencies as $dependency) {
$this->loadDependency($dependency);
}
}
protected function composeImportMap(string $uriPrefix): array
{
$importMaps = $this->getImportMaps();
if (!isset($this->extensionsToLoad['*'])) {
$importMaps = array_intersect_key($importMaps, $this->extensionsToLoad);
}
$importMap = [];
foreach ($importMaps as $singleImportMap) {
ArrayUtility::mergeRecursiveWithOverrule($importMap, $singleImportMap);
}
unset($importMap['dependencies']);
unset($importMap['tags']);
foreach ($importMap['imports'] ?? [] as $specifier => $resourceIdentifier) {
if (str_starts_with($resourceIdentifier, 'VIRTUAL:')) {
$virtualName = substr($resourceIdentifier, 8);
$resolved = $this->dispatchResolveVirtualJavaScriptImportEvent($virtualName);
if ($resolved === null) {
unset($importMap['imports'][$specifier]);
continue;
}
} else {
$resolved = $this->getResourceUri($resourceIdentifier, $uriPrefix);
}
$importMap['imports'][$specifier] = $resolved;
}
return $importMap;
}
/**
* @throws CanNotResolvePublicResourceException
* @throws CanNotResolveSystemResourceException
*/
protected function getResourceUri(string $resourceIdentifier, $uriPrefix): string
{
return (string)PathUtility::getSystemResourceUri(
$resourceIdentifier,
null,
new UriGenerationOptions(
uriPrefix: $uriPrefix,
cacheBusting: false,
)
);
}
/**
* @return ?non-empty-string
*/
protected function dispatchResolveJavaScriptImportEvent(
string $specifier,
bool $loadImportConfiguration = true
): ?string {
if ($this->eventDispatcher === null) {
return null;
}
return $this->eventDispatcher->dispatch(
new ResolveJavaScriptImportEvent($specifier, $loadImportConfiguration, $this)
)->resolution;
}
/**
* @return ?non-empty-string
*/
protected function dispatchResolveVirtualJavaScriptImportEvent(
string $specifier,
): ?string {
if ($this->eventDispatcher === null) {
return null;
}
return $this->eventDispatcher->dispatch(
new ResolveVirtualJavaScriptImportEvent($specifier, $this)
)->resolution;
}
/**
* @internal
*/
public function updateState(array $state): void
{
$this->extensionsToLoad = $state['extensionsToLoad'] ?? [];
}
/**
* @internal
*/
public function getState(): array
{
return [
'extensionsToLoad' => $this->extensionsToLoad,
];
}
}