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
+276
View File
@@ -0,0 +1,276 @@
<?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 TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\ArrayUtility;
/**
* The Asset Collector is responsible for keeping track of
* - everything within <script> tags: javascript files and inline javascript code
* - inline CSS and CSS files
*
* The goal of the asset collector is to:
* - utilize a single "runtime-based" store for adding assets of certain kinds that are added to the output
* - allow to deal with assets from non-cacheable plugins and cacheable content in the Frontend
* - reduce the "power" and flexibility (I'd say it's a burden) of the "god class" PageRenderer.
* - reduce the burden of storing everything in PageRenderer
*
* As a side effect this allows to:
* - Add a single CSS snippet or CSS file per content block, but assure that the CSS is only added once to the output.
*
* Note on the implementation:
* - We use a Singleton to make use of the AssetCollector throughout Frontend process (similar to PageRenderer).
* - Although this is not optimal, I don't see any other way to do so in the current code.
*/
class AssetCollector implements SingletonInterface
{
protected array $javaScripts = [];
protected array $inlineJavaScripts = [];
protected array $javaScriptModules = [];
protected array $styleSheets = [];
protected array $inlineStyleSheets = [];
protected array $media = [];
/**
* @param string $source URI to JavaScript file (allows EXT: syntax)
* @param array $attributes additional HTML <script> tag attributes
* @param array $options ['priority' => true] means rendering before other tags
*/
public function addJavaScript(string $identifier, string $source, array $attributes = [], array $options = []): self
{
$existingAttributes = $this->javaScripts[$identifier]['attributes'] ?? [];
ArrayUtility::mergeRecursiveWithOverrule($existingAttributes, $attributes);
$existingOptions = $this->javaScripts[$identifier]['options'] ?? [];
ArrayUtility::mergeRecursiveWithOverrule($existingOptions, $options);
$this->javaScripts[$identifier] = [
'source' => $source,
'attributes' => $existingAttributes,
'options' => $existingOptions,
];
return $this;
}
/**
* @param string $identifier Bare module identifier like @my/package/Filename.js
*/
public function addJavaScriptModule(string $identifier): self
{
$this->javaScriptModules[$identifier] = true;
return $this;
}
/**
* @param string $source JavaScript code
* @param array $attributes additional HTML <script> tag attributes
* @param array $options ['priority' => true] means rendering before other tags
*/
public function addInlineJavaScript(string $identifier, string $source, array $attributes = [], array $options = []): self
{
$existingAttributes = $this->inlineJavaScripts[$identifier]['attributes'] ?? [];
ArrayUtility::mergeRecursiveWithOverrule($existingAttributes, $attributes);
$existingOptions = $this->inlineJavaScripts[$identifier]['options'] ?? [];
ArrayUtility::mergeRecursiveWithOverrule($existingOptions, $options);
$this->inlineJavaScripts[$identifier] = [
'source' => $source,
'attributes' => $existingAttributes,
'options' => $existingOptions,
];
return $this;
}
/**
* @param string $source URI to stylesheet file (allows EXT: syntax)
* @param array $attributes additional HTML <link> tag attributes
* @param array $options ['priority' => true] means rendering before other tags
*/
public function addStyleSheet(string $identifier, string $source, array $attributes = [], array $options = []): self
{
$existingAttributes = $this->styleSheets[$identifier]['attributes'] ?? [];
ArrayUtility::mergeRecursiveWithOverrule($existingAttributes, $attributes);
$existingOptions = $this->styleSheets[$identifier]['options'] ?? [];
ArrayUtility::mergeRecursiveWithOverrule($existingOptions, $options);
$this->styleSheets[$identifier] = [
'source' => $source,
'attributes' => $existingAttributes,
'options' => $existingOptions,
];
return $this;
}
/**
* @param string $source stylesheet code
* @param array $attributes additional HTML <link> tag attributes
* @param array $options ['priority' => true] means rendering before other tags
*/
public function addInlineStyleSheet(string $identifier, string $source, array $attributes = [], array $options = []): self
{
$existingAttributes = $this->inlineStyleSheets[$identifier]['attributes'] ?? [];
ArrayUtility::mergeRecursiveWithOverrule($existingAttributes, $attributes);
$existingOptions = $this->inlineStyleSheets[$identifier]['options'] ?? [];
ArrayUtility::mergeRecursiveWithOverrule($existingOptions, $options);
$this->inlineStyleSheets[$identifier] = [
'source' => $source,
'attributes' => $existingAttributes,
'options' => $existingOptions,
];
return $this;
}
/**
* @param array $additionalInformation One dimensional hash map (array with non-numerical keys) with scalar values
*/
public function addMedia(string $fileName, array $additionalInformation): self
{
$existingAdditionalInformation = $this->media[$fileName] ?? [];
ArrayUtility::mergeRecursiveWithOverrule($existingAdditionalInformation, $this->ensureAllValuesAreSerializable($additionalInformation));
$this->media[$fileName] = $existingAdditionalInformation;
return $this;
}
private function ensureAllValuesAreSerializable(array $additionalInformation): array
{
// Currently just filtering all non-scalar values
return array_filter($additionalInformation, is_scalar(...));
}
public function removeJavaScript(string $identifier): self
{
unset($this->javaScripts[$identifier]);
return $this;
}
public function removeInlineJavaScript(string $identifier): self
{
unset($this->inlineJavaScripts[$identifier]);
return $this;
}
public function removeStyleSheet(string $identifier): self
{
unset($this->styleSheets[$identifier]);
return $this;
}
public function removeInlineStyleSheet(string $identifier): self
{
unset($this->inlineStyleSheets[$identifier]);
return $this;
}
public function removeMedia(string $identifier): self
{
unset($this->media[$identifier]);
return $this;
}
public function getMedia(): array
{
return $this->media;
}
public function getJavaScripts(?bool $priority = null): array
{
return $this->filterAssetsPriority($this->javaScripts, $priority);
}
public function getInlineJavaScripts(?bool $priority = null): array
{
return $this->filterAssetsPriority($this->inlineJavaScripts, $priority);
}
public function getJavaScriptModules(): array
{
return array_keys($this->javaScriptModules);
}
public function getStyleSheets(?bool $priority = null): array
{
return $this->filterAssetsPriority($this->styleSheets, $priority);
}
public function getInlineStyleSheets(?bool $priority = null): array
{
return $this->filterAssetsPriority($this->inlineStyleSheets, $priority);
}
public function hasJavaScript(string $identifier): bool
{
return isset($this->javaScripts[$identifier]);
}
public function hasInlineJavaScript(string $identifier): bool
{
return isset($this->inlineJavaScripts[$identifier]);
}
public function hasStyleSheet(string $identifier): bool
{
return isset($this->styleSheets[$identifier]);
}
public function hasInlineStyleSheet(string $identifier): bool
{
return isset($this->inlineStyleSheets[$identifier]);
}
public function hasMedia(string $fileName): bool
{
return isset($this->media[$fileName]);
}
/**
* @param array $assets The array to filter
* @param bool|null $priority null: no filter; else filters for the given priority
*/
protected function filterAssetsPriority(array $assets, ?bool $priority): array
{
if ($priority === null) {
return $assets;
}
$currentPriorityAssets = [];
foreach ($assets as $identifier => $asset) {
if ($priority === ($asset['options']['priority'] ?? false)) {
$currentPriorityAssets[$identifier] = $asset;
}
}
return $currentPriorityAssets;
}
/**
* @internal
*/
public function updateState(array $newState): void
{
foreach ($newState as $var => $value) {
$this->{$var} = $value;
}
}
/**
* @internal
*/
public function getState(): array
{
$state = [];
foreach (get_object_vars($this) as $var => $value) {
$state[$var] = $value;
}
return $state;
}
}
+162
View File
@@ -0,0 +1,162 @@
<?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 Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Page\Event\BeforeJavaScriptsRenderingEvent;
use TYPO3\CMS\Core\Page\Event\BeforeStylesheetsRenderingEvent;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\DirectiveHashCollection;
use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourcePublisherInterface;
use TYPO3\CMS\Core\SystemResource\SystemResourceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* @internal The AssetRenderer is used for the asset rendering and is not public API
*/
#[Autoconfigure(public: true)]
readonly class AssetRenderer
{
public function __construct(
protected AssetCollector $assetCollector,
protected EventDispatcherInterface $eventDispatcher,
protected SystemResourcePublisherInterface $resourcePublisher,
protected SystemResourceFactory $systemResourceFactory,
protected ResourceHashCollection $resourceHashCollection,
protected DirectiveHashCollection $directiveHashCollection,
) {}
public function renderInlineJavaScript($priority = false, ?ConsumableNonce $nonce = null): string
{
$this->eventDispatcher->dispatch(
new BeforeJavaScriptsRenderingEvent($this->assetCollector, true, $priority)
);
$template = '<script%attributes%>%source%</script>';
$assets = $this->assetCollector->getInlineJavaScripts($priority);
return $this->render($assets, $template, true, Directive::ScriptSrcElem, $nonce);
}
public function renderJavaScript($priority = false, ?ConsumableNonce $nonce = null): string
{
$this->eventDispatcher->dispatch(
new BeforeJavaScriptsRenderingEvent($this->assetCollector, false, $priority)
);
$template = '<script%attributes%></script>';
$assets = $this->assetCollector->getJavaScripts($priority);
foreach ($assets as &$assetData) {
// Collect CSP hash from original source path before URL transformation
if (!empty($assetData['options']['csp'])) {
$integrity = $assetData['attributes']['integrity'] ?? '';
if ($integrity !== '') {
$this->directiveHashCollection->addGenericHashValue(Directive::ScriptSrcElem, $integrity);
} else {
$this->directiveHashCollection->addResourceHash(Directive::ScriptSrcElem, $assetData['source']);
}
}
$assetData['source'] = $this->getAbsoluteWebPath($assetData['source']);
$assetData['attributes']['src'] = $assetData['source'];
}
return $this->render($assets, $template, false, Directive::ScriptSrcElem, $nonce);
}
public function renderInlineStyleSheets($priority = false, ?ConsumableNonce $nonce = null): string
{
$this->eventDispatcher->dispatch(
new BeforeStylesheetsRenderingEvent($this->assetCollector, true, $priority)
);
$template = '<style%attributes%>%source%</style>';
$assets = $this->assetCollector->getInlineStyleSheets($priority);
return $this->render($assets, $template, true, Directive::StyleSrcElem, $nonce);
}
public function renderStyleSheets(bool $priority = false, string $endingSlash = '', ?ConsumableNonce $nonce = null): string
{
$this->eventDispatcher->dispatch(
new BeforeStylesheetsRenderingEvent($this->assetCollector, false, $priority)
);
$template = '<link%attributes% ' . $endingSlash . '>';
$assets = $this->assetCollector->getStyleSheets($priority);
foreach ($assets as &$assetData) {
$originalSource = $assetData['source'];
// Collect CSP hash from original source path before URL transformation
if (!empty($assetData['options']['csp'])) {
$integrity = $assetData['attributes']['integrity'] ?? '';
if ($integrity !== '') {
$this->directiveHashCollection->addGenericHashValue(Directive::StyleSrcElem, $integrity);
} else {
$this->directiveHashCollection->addResourceHash(Directive::StyleSrcElem, $assetData['source']);
}
}
$assetData['source'] = $this->getAbsoluteWebPath($assetData['source']);
$assetData['attributes']['href'] = $assetData['source'];
$assetData['attributes']['rel'] = $assetData['attributes']['rel'] ?? 'stylesheet';
if (($assetData['attributes']['integrity'] ?? '') === ResourceHashCollection::AUTO) {
$hash = $this->resourceHashCollection->fetchResourceHash($originalSource)?->export() ?? '';
if ($hash !== '') {
$assetData['attributes']['integrity'] = $hash;
if (empty($assetData['attributes']['crossorigin']) && PathUtility::hasProtocolAndScheme($originalSource)) {
$assetData['attributes']['crossorigin'] = 'anonymous';
}
} else {
unset($assetData['attributes']['integrity']);
}
}
}
return $this->render($assets, $template, false, Directive::StyleSrcElem, $nonce);
}
protected function render(
array $assets,
string $template,
bool $isInline,
Directive $directive,
?ConsumableNonce $nonce = null
): string {
$results = [];
foreach ($assets as $assetData) {
$attributes = $assetData['attributes'];
$useCsp = !empty($assetData['options']['csp']);
if ($isInline && $useCsp) {
$this->directiveHashCollection->addInlineHash($directive, $assetData['source']);
}
if ($nonce !== null && $useCsp) {
$attributes['nonce'] = $isInline ? $nonce->consumeInline($directive) : $nonce->consumeStatic($directive);
}
$attributesString = count($attributes) ? ' ' . GeneralUtility::implodeAttributes($attributes, true) : '';
$results[] = str_replace(
['%attributes%', '%source%'],
[$attributesString, $assetData['source']],
$template
);
}
return implode(LF, $results);
}
private function getAbsoluteWebPath(string $file): string
{
$resource = $this->systemResourceFactory->createPublicResource($file);
return (string)$this->resourcePublisher->generateUri($resource, null);
}
}
+98
View File
@@ -0,0 +1,98 @@
<?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;
/**
* Contains information about a specific column in a page layout,
* including the content elements to be rendered (depending on the context)
*
* @internal This is not part of TYPO3 Core API.
*/
final class ContentArea implements \IteratorAggregate, \Countable
{
public function __construct(
private readonly string $identifier,
private readonly string $name,
private readonly int $colPos,
private readonly ContentSlideMode $slideMode,
private readonly array $allowedContentTypes,
private readonly array $disallowedContentTypes,
private readonly array $configuration,
private array $records,
) {}
public function getIdentifier(): string
{
return $this->identifier;
}
public function getName(): string
{
return $this->name;
}
public function getColPos(): int
{
return $this->colPos;
}
public function getSlideMode(): ContentSlideMode
{
return $this->slideMode;
}
public function getAllowedContentTypes(): array
{
return $this->allowedContentTypes;
}
public function getDisallowedContentTypes(): array
{
return $this->disallowedContentTypes;
}
public function getConfiguration(): array
{
return $this->configuration;
}
public function getRecords(): array
{
return $this->records;
}
/**
* @internal Only to be used for AfterContentHasBeenFetchedEvent
*/
public function withRecords(array $records): self
{
$self = clone $this;
$self->records = $records;
return $self;
}
public function getIterator(): \Traversable
{
return new \ArrayIterator($this->records);
}
public function count(): int
{
return count($this->records);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?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\Http\Message\ServerRequestInterface;
/**
* Used to initialize a content area once it is accessed
*
* @internal
*/
final readonly class ContentAreaClosure
{
public function __construct(
private \Closure $instantiator
) {}
public function instantiate(ServerRequestInterface $request): ContentArea
{
return ($this->instantiator)($request);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?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\Container\ContainerInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Collection of content areas
*
* @internal
*
* @implements \IteratorAggregate<string, ContentArea>
*/
final readonly class ContentAreaCollection implements ContainerInterface, \IteratorAggregate
{
public function __construct(
/** @var ContentAreaClosure[]|ContentArea[] $contentAreas */
private array $contentAreas,
private ?ServerRequestInterface $request = null,
) {}
public function withRequest(ServerRequestInterface $request): self
{
return new self($this->contentAreas, $request);
}
public function get(string $id): ContentArea
{
if (!$this->has($id)) {
throw new ContentAreaNotFoundException('No content area found for identifier: ' . $id, 1726479567);
}
$area = $this->contentAreas[$id];
if ($area instanceof ContentAreaClosure) {
if ($this->request === null) {
throw new \LogicException('Cannot instantiate ContentAreaClosure without a request. Call withRequest() first.', 1776158770);
}
return $area->instantiate($this->request);
}
return $area;
}
public function has(string $id): bool
{
return array_key_exists($id, $this->contentAreas);
}
/**
* @internal Only for AfterContentHasBeenFetchedEvent
*/
public function getGroupedRecords(ServerRequestInterface $request): array
{
$areas = [];
foreach ($this->contentAreas as $area) {
$area = $area instanceof ContentAreaClosure ? $area->instantiate($request) : $area;
$areas[$area->getIdentifier()] = [
'name' => $area->getName(),
'colPos' => $area->getColPos(),
'identifier' => $area->getIdentifier(),
'allowedContentTypes' => $area->getAllowedContentTypes(),
'records' => $area->getRecords(),
'area' => $area,
];
}
return $areas;
}
/**
* @internal Only for AfterContentHasBeenFetchedEvent
*/
public function withUpdatedRecords(array $groupedRecords): self
{
$areas = [];
foreach ($groupedRecords as $identifier => $data) {
$areas[$identifier] = $data['area']->withRecords($data['records']);
}
return new self($areas, $this->request);
}
/**
* @return \Traversable<string, ContentArea>
*/
public function getIterator(): \Traversable
{
$result = [];
foreach (array_keys($this->contentAreas) as $key) {
$result[$key] = $this->get($key);
}
return new \ArrayIterator($result);
}
}
@@ -0,0 +1,26 @@
<?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\Container\NotFoundExceptionInterface;
use TYPO3\CMS\Core\Exception;
/**
* @internal
*/
class ContentAreaNotFoundException extends Exception implements NotFoundExceptionInterface {}
+41
View File
@@ -0,0 +1,41 @@
<?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;
enum ContentSlideMode
{
case None;
case Slide;
case Collect;
case CollectReverse;
public static function tryFrom(?string $slideMode): ContentSlideMode
{
return match ($slideMode) {
'slide' => self::Slide,
'collect' => self::Collect,
'collectReverse' => self::CollectReverse,
default => self::None,
};
}
public function getValue(): string
{
return lcfirst($this->name);
}
}
@@ -0,0 +1,58 @@
<?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\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Trait used to add default JavaScript in frontend rendering context
* while considering TypoScript setting `config.removeDefaultJS` as well.
*
* @internal only to be used in EXT:frontend and TYPO3 Core, not part of TYPO3 Core API.
*/
trait DefaultJavaScriptAssetTrait
{
protected function addDefaultFrontendJavaScript(ServerRequestInterface $request): void
{
// `config.removeDefaultJS = 1` - remove default JavaScript, no action required
if ($this->shallRemoveDefaultFrontendJavaScript($request)) {
return;
}
$filePath = 'EXT:frontend/Resources/Public/JavaScript/default_frontend.js';
$collector = GeneralUtility::makeInstance(AssetCollector::class);
// `config.removeDefaultJS = external` - persist JavaScript to `typo3temp/assets/`
if ($this->shallExportDefaultFrontendJavaScript($request)) {
$source = file_get_contents(GeneralUtility::getFileAbsFileName($filePath));
$filePath = GeneralUtility::writeJavaScriptContentToTemporaryFile((string)$source);
}
$collector->addJavaScript('frontend-default', $filePath, ['async' => 'async']);
}
protected function shallRemoveDefaultFrontendJavaScript(ServerRequestInterface $request): bool
{
$frontendTypoScriptConfigArray = $request->getAttribute('frontend.typoscript')?->getConfigArray();
return ($frontendTypoScriptConfigArray['removeDefaultJS'] ?? 'external') === '1';
}
protected function shallExportDefaultFrontendJavaScript(ServerRequestInterface $request): bool
{
$frontendTypoScriptConfigArray = $request->getAttribute('frontend.typoscript')?->getConfigArray();
return ($frontendTypoScriptConfigArray['removeDefaultJS'] ?? 'external') === 'external';
}
}
@@ -0,0 +1,42 @@
<?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\Event;
use TYPO3\CMS\Core\Page\AssetCollector;
abstract class AbstractBeforeAssetRenderingEvent
{
protected AssetCollector $assetCollector;
protected bool $inline;
protected bool $priority;
public function getAssetCollector(): AssetCollector
{
return $this->assetCollector;
}
public function isInline(): bool
{
return $this->inline;
}
public function isPriority(): bool
{
return $this->priority;
}
}
@@ -0,0 +1,33 @@
<?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\Event;
use TYPO3\CMS\Core\Page\AssetCollector;
/**
* This event is fired once before \TYPO3\CMS\Core\Page\AssetRenderer::render[Inline]JavaScript renders the output.
*/
final class BeforeJavaScriptsRenderingEvent extends AbstractBeforeAssetRenderingEvent
{
public function __construct(AssetCollector $assetCollector, bool $isInline, bool $priority)
{
$this->assetCollector = $assetCollector;
$this->inline = $isInline;
$this->priority = $priority;
}
}
@@ -0,0 +1,33 @@
<?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\Event;
use TYPO3\CMS\Core\Page\AssetCollector;
/**
* This event is fired once before \TYPO3\CMS\Core\Page\AssetRenderer::render[Inline]Stylesheets renders the output.
*/
final class BeforeStylesheetsRenderingEvent extends AbstractBeforeAssetRenderingEvent
{
public function __construct(AssetCollector $assetCollector, bool $isInline, bool $priority)
{
$this->assetCollector = $assetCollector;
$this->inline = $isInline;
$this->priority = $priority;
}
}
@@ -0,0 +1,40 @@
<?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\Event;
use Psr\EventDispatcher\StoppableEventInterface;
use TYPO3\CMS\Core\Page\ImportMap;
final class ResolveJavaScriptImportEvent implements StoppableEventInterface
{
/**
* @var ?non-empty-string
*/
public ?string $resolution = null;
public function __construct(
public readonly string $specifier,
public readonly bool $loadImportConfiguration,
public readonly ImportMap $importMap,
) {}
public function isPropagationStopped(): bool
{
return $this->resolution !== null;
}
}
@@ -0,0 +1,42 @@
<?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\Event;
use Psr\EventDispatcher\StoppableEventInterface;
use TYPO3\CMS\Core\Page\ImportMap;
/**
* @internal
*/
final class ResolveVirtualJavaScriptImportEvent implements StoppableEventInterface
{
/**
* @var ?non-empty-string
*/
public ?string $resolution = null;
public function __construct(
public readonly string $virtualName,
public readonly ImportMap $importMap,
) {}
public function isPropagationStopped(): bool
{
return $this->resolution !== null;
}
}
+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,
];
}
}
+39
View File
@@ -0,0 +1,39 @@
<?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 TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Cache\Event\CacheWarmupEvent;
/**
* @internal
*/
final readonly class ImportMapCacheWarmer
{
public function __construct(
private ImportMapFactory $importMapFactory
) {}
#[AsEventListener]
public function warmupCaches(CacheWarmupEvent $event): void
{
if ($event->hasGroup('system')) {
$this->importMapFactory->create(true)->warmupCaches();
}
}
}
+58
View File
@@ -0,0 +1,58 @@
<?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 Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Package\PackageManager;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyRegistry;
use TYPO3\CMS\Core\SingletonInterface;
#[Autoconfigure(public: true)]
readonly class ImportMapFactory implements SingletonInterface
{
public function __construct(
private HashService $hashService,
private PackageManager $packageManager,
private PolicyRegistry $policyRegistry,
#[Autowire(service: 'cache.assets')]
private FrontendInterface $assetsCache,
private EventDispatcherInterface $eventDispatcher,
#[Autowire(expression: 'service("package-dependent-cache-identifier").withPrefix("ImportMap").toString()')]
private string $cacheIdentifier,
) {}
public function create(bool $bustSuffix = true): ImportMap
{
$activePackages = array_values(
$this->packageManager->getActivePackages()
);
return new ImportMap(
$this->hashService,
$activePackages,
$this->policyRegistry,
$this->assetsCache,
$this->cacheIdentifier,
$this->eventDispatcher,
$bustSuffix
);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?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;
final class JavaScriptItems implements \JsonSerializable
{
/**
* @var list<array>
*/
private array $globalAssignments = [];
/**
* @var list<JavaScriptModuleInstruction>
*/
private array $javaScriptModuleInstructions = [];
public function jsonSerialize(): array
{
return $this->toArray();
}
public function addGlobalAssignment(array $payload): void
{
if (empty($payload)) {
return;
}
$this->globalAssignments[] = $payload;
}
public function addJavaScriptModuleInstruction(JavaScriptModuleInstruction $instruction): void
{
$this->javaScriptModuleInstructions[] = $instruction;
}
/**
* @return list<array{type: string, payload: mixed}>
* @internal
*/
public function toArray(): array
{
if ($this->isEmpty()) {
return [];
}
$items = [];
foreach ($this->globalAssignments as $item) {
$items[] = [
'type' => 'globalAssignment',
'payload' => $item,
];
}
foreach ($this->javaScriptModuleInstructions as $item) {
$items[] = [
'type' => 'javaScriptModuleInstruction',
'payload' => $item,
];
}
return $items;
}
public function isEmpty(): bool
{
return $this->globalAssignments === []
&& empty($this->javaScriptModuleInstructions);
}
/**
* @internal
*/
public function updateState(array $state): void
{
$this->globalAssignments = $state['globalAssignments'] ?? [];
$this->javaScriptModuleInstructions = [];
foreach ($state['javaScriptModuleInstructions'] ?? [] as $instruction) {
$this->javaScriptModuleInstructions[] = JavaScriptModuleInstruction::fromState($instruction);
}
}
/**
* @internal
*/
public function getState(): array
{
return [
'globalAssignments' => $this->globalAssignments,
'javaScriptModuleInstructions' => array_map(
static fn(JavaScriptModuleInstruction $instruction): array => $instruction->getState(),
$this->javaScriptModuleInstructions
),
];
}
/**
* @return list<array>
*/
public function getGlobalAssignments(): array
{
return $this->globalAssignments;
}
/**
* @return list<JavaScriptModuleInstruction>
*/
public function getJavaScriptModuleInstructions(): array
{
return $this->javaScriptModuleInstructions;
}
}
@@ -0,0 +1,174 @@
<?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 TYPO3\CMS\Core\Utility\GeneralUtility;
class JavaScriptModuleInstruction implements \JsonSerializable
{
/**
* Indicates an ES6/11 module shall be loaded (paths mapped by an importmap)
*/
public const FLAG_LOAD_IMPORTMAP = 2;
/**
* Indicates all actions shall be applied globally to `top.window`.
*/
public const FLAG_USE_TOP_WINDOW = 16;
public const ITEM_ASSIGN = 'assign';
public const ITEM_INVOKE = 'invoke';
public const ITEM_INSTANCE = 'instance';
protected string $name;
protected ?string $exportName;
protected int $flags;
protected array $items = [];
/**
* @param string $name Module name mapped by an importmap or absolute specifier
* @param string|null $exportName (optional) name used internally to export the module
*/
public static function create(string $name, ?string $exportName = null): self
{
$target = GeneralUtility::makeInstance(static::class, $name, self::FLAG_LOAD_IMPORTMAP);
$target->exportName = $exportName;
return $target;
}
/**
* @return self
* @internal
*/
public static function fromState(array $state)
{
$target = GeneralUtility::makeInstance(static::class, $state['name'], $state['flags'] ?? 0);
$target->exportName = $state['exportName'] ?? null;
$target->items = $state['items'];
return $target;
}
/**
* @param string $name Module name
*/
public function __construct(string $name, int $flags)
{
$this->name = $name;
$this->flags = $flags;
}
/**
* @internal
*/
public function getState(): array
{
return [
'name' => $this->name,
'exportName' => $this->exportName,
'flags' => $this->flags,
'items' => $this->items,
];
}
public function jsonSerialize(): array
{
return $this->getState();
}
public function getName(): string
{
return $this->name;
}
public function getExportName(): ?string
{
return $this->exportName;
}
public function getFlags(): int
{
return $this->flags;
}
public function getItems(): array
{
return $this->items;
}
/**
* @return $this
*/
public function addFlags(int ...$flags): self
{
foreach ($flags as $flag) {
$this->flags |= $flag;
}
return $this;
}
/**
* @param array $assignments key-value assignments
* @return static
*/
public function assign(array $assignments): self
{
$this->items[] = [
'type' => static::ITEM_ASSIGN,
'assignments' => $assignments,
];
return $this;
}
/**
* @param string|null $method method of JavaScript module to be invoked
* @param mixed ...$args corresponding method arguments
* @return static
*/
public function invoke(?string $method = null, ...$args): self
{
$this->items[] = [
'type' => static::ITEM_INVOKE,
'method' => $method,
'args' => $args,
];
return $this;
}
/**
* @param mixed ...$args new instance arguments
* @return static
*/
public function instance(...$args): self
{
$this->items[] = [
'type' => static::ITEM_INSTANCE,
'args' => $args,
];
return $this;
}
public function shallLoadImportMap(): bool
{
return ($this->flags & self::FLAG_LOAD_IMPORTMAP) === self::FLAG_LOAD_IMPORTMAP;
}
public function shallUseTopWindow(): bool
{
return ($this->flags & self::FLAG_USE_TOP_WINDOW) === self::FLAG_USE_TOP_WINDOW;
}
}
+290
View File
@@ -0,0 +1,290 @@
<?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 TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive;
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;
class JavaScriptRenderer
{
protected string $handlerResource;
protected JavaScriptItems $items;
protected ImportMap $importMap;
protected int $javaScriptModuleInstructionFlags = 0;
protected int $instructionsWithItems = 0;
/**
* @internal Only to be used by PageRenderer
*/
public static function create(?string $resourceIdentifier = null): self
{
$resourceIdentifier ??= 'EXT:core/Resources/Public/JavaScript/java-script-item-handler.js';
return GeneralUtility::makeInstance(static::class, $resourceIdentifier);
}
/**
* @internal
*/
public function __construct(string $handlerResource)
{
$this->handlerResource = $handlerResource;
$this->items = new JavaScriptItems();
$this->importMap = GeneralUtility::makeInstance(ImportMapFactory::class)->create();
}
public function addGlobalAssignment(array $payload): void
{
$this->items->addGlobalAssignment($payload);
}
public function addJavaScriptModuleInstruction(JavaScriptModuleInstruction $instruction): void
{
if ($instruction->shallLoadImportMap()) {
$this->importMap->includeImportsFor($instruction->getName());
}
$this->javaScriptModuleInstructionFlags |= $instruction->getFlags();
if ($instruction->getItems() !== []) {
$this->instructionsWithItems++;
}
$this->items->addJavaScriptModuleInstruction($instruction);
}
public function hasImportMap(): bool
{
return ($this->javaScriptModuleInstructionFlags & JavaScriptModuleInstruction::FLAG_LOAD_IMPORTMAP) === JavaScriptModuleInstruction::FLAG_LOAD_IMPORTMAP;
}
/**
* HEADS UP: Do only use in authenticated mode as this discloses as installed extensions
*/
public function includeAllImports(): void
{
$this->importMap->includeAllImports();
}
public function includeTaggedImports(string $tag): void
{
$this->importMap->includeTaggedImports($tag);
}
/**
* @return list<array{type: string, payload: mixed}>
* @internal
*/
public function toArray(): array
{
if ($this->isEmpty()) {
return [];
}
return $this->items->toArray();
}
/**
* @throws \InvalidArgumentException when a JavaScript module could not be resolved (no src URL in import map)
*/
public function render(string|ConsumableNonce|null $nonce, string $uriPrefix): string
{
if ($this->isEmpty()) {
return '';
}
$scriptTags = [];
$modules = [];
$dynamicInstructions = [];
foreach ($this->items->getJavascriptModuleInstructions() as $instruction) {
$moduleName = $instruction->getName();
$url = $this->importMap->resolveImport($moduleName, true, $uriPrefix);
if ($url === null) {
throw new \InvalidArgumentException(
sprintf(
'JavaScript module "%s" could not be resolved. (Missing entry in Configuration/JavaScriptModules.php?).',
$moduleName
),
1728220800
);
}
if (
$instruction->getItems() !== []
|| ($instruction->getFlags() & JavaScriptModuleInstruction::FLAG_USE_TOP_WINDOW) !== 0
) {
$dynamicInstructions[] = [
'type' => 'javaScriptModuleInstruction',
'payload' => $instruction,
];
} else {
$modules[$moduleName] = $url;
}
}
$globalAssignments = $this->mergeGlobalAssignments($this->items->getGlobalAssignments());
if ($globalAssignments !== []) {
$scriptTags[] = $this->createScriptElement(
['nonce' => $nonce instanceof ConsumableNonce ? $nonce->consumeInline(Directive::ScriptSrcElem) : (string)$nonce],
sprintf('Object.assign(globalThis, %s)', $this->jsonEncode($globalAssignments))
);
}
$scriptTags = [
...$scriptTags,
...array_map(
fn(string $url): string => $this->createScriptElement([
'type' => 'module',
'async' => 'async',
'src' => $url,
]),
$modules
),
];
if ($dynamicInstructions !== []) {
$scriptTags[] = $this->createItemHandlerElement($dynamicInstructions, true, $nonce, $uriPrefix);
}
return implode(PHP_EOL, $scriptTags);
}
public function renderImportMap(string $uriPrefix, string|ConsumableNonce|null $nonce = null): string
{
if (!$this->isEmpty() && ($this->instructionsWithItems > 0 || $this->items->getGlobalAssignments() !== [])) {
$this->importMap->includeImportsFor('@typo3/core/java-script-item-handler.js');
}
return $this->importMap->render($uriPrefix, $nonce);
}
protected function isEmpty(): bool
{
return $this->items->isEmpty();
}
protected function createItemHandlerElement(array $payload, bool $async, string|ConsumableNonce|null $nonce, string $uriPrefix): string
{
// actual JSON payload is stored as comment in `script.textContent`
// and consumed by java-script-item-handler.js
return $this->createScriptElement(
[
'src' => PathUtility::getSystemResourceUri(
$this->handlerResource,
null,
new UriGenerationOptions(
uriPrefix: $uriPrefix,
cacheBusting: false,
)
),
'nonce' => (string)$nonce,
'async' => $async ? 'async' : '',
],
'/* ' . $this->jsonEncode($payload) . ' */'
);
}
protected function createScriptElement(array $attributes, string $textContent = ''): string
{
if (empty($attributes)) {
return '';
}
$attributesPart = GeneralUtility::implodeAttributes($attributes, true);
return sprintf('<script%s%s>%s</script>', $attributesPart ? ' ' : '', $attributesPart, $textContent);
}
protected function jsonEncode($value): string
{
return (string)json_encode($value, JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG);
}
protected function mergeGlobalAssignments(array $assignments): array
{
$globalAssignments = [];
foreach ($assignments as $assignment) {
// Merge `window` one level up, as we target `globalThis` which is window.
// Needed because we assign to globalThis and must not overwrite window entirely,
// but only merge to it and because we want to forbid nested assignments like
// `window.parent.foo` below.
if (isset($assignment['window'])) {
$assignment = [
...$assignment,
...$assignment['window'],
];
unset($assignment['window']);
}
$globalAssignments = array_merge_recursive($globalAssignments, $assignment);
}
// deny indirect global assignments (not for security reasons, but for reducing
// the chance of hard-to-debug side-effects)
unset($globalAssignments['window']);
unset($globalAssignments['parent']);
unset($globalAssignments['globalThis']);
unset($globalAssignments['document']);
// filter potential prototype pollution side-effects
return ArrayUtility::filterRecursive(
$globalAssignments,
static fn(string $key): bool => match ($key) {
'__proto__', 'prototype', 'constructor' => false,
default => true,
},
ARRAY_FILTER_USE_KEY
);
}
/**
* @internal
*/
public function updateState(array $state): void
{
foreach ($state as $var => $value) {
switch ($var) {
case 'items':
$this->items->updateState($value);
break;
case 'importMap':
$this->importMap->updateState($value);
break;
default:
$this->{$var} = $value;
break;
}
}
}
/**
* @internal
*/
public function getState(): array
{
$state = [];
foreach (get_object_vars($this) as $var => $value) {
switch ($var) {
case 'items':
$state[$var] = $this->items->getState();
break;
case 'importMap':
$state[$var] = $this->importMap->getState();
break;
default:
$state[$var] = $value;
break;
}
}
return $state;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?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;
/**
* Contains information about the layout of a page,
* mainly which content areas (colPos=0, colPos=1, ...) are used and filled.
*
* @internal This is not part of TYPO3 Core API.
*/
class PageLayout
{
public function __construct(
protected string $identifier,
protected string $title,
protected ContentAreaCollection $contentAreas,
) {}
public function getIdentifier(): string
{
return $this->identifier;
}
public function getTitle(): string
{
return $this->title;
}
public function getContentAreas(): ContentAreaCollection
{
return $this->contentAreas;
}
}
+114
View File
@@ -0,0 +1,114 @@
<?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 Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\View\BackendLayout\DataProviderCollection;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\TypoScript\PageTsConfigFactory;
/**
* Finds the proper layout for a page, using the database fields "backend_layout"
* and "backend_layout_next_level".
*
* The most crucial part is that "backend_layout" is only applied for the CURRENT level,
* whereas backend_layout_next_level.
*
* Used in TypoScript as "getData:pagelayout".
*
* Currently, there is a hard dependency on EXT:backend however, all DataProvider logic should be migrated
* towards EXT:core.
*
* @internal This is not part of TYPO3 Core API.
*/
#[Autoconfigure(public: true)]
readonly class PageLayoutResolver
{
public function __construct(
protected DataProviderCollection $dataProviderCollection,
protected SiteFinder $siteFinder,
protected PageTsConfigFactory $pageTsConfigFactory,
protected EventDispatcherInterface $eventDispatcher,
) {}
public function getLayoutForPage(array $pageRecord, array $rootLine): ?PageLayout
{
$pageId = (int)$pageRecord['uid'];
$selectedPageLayout = $this->getLayoutIdentifierForPage($pageRecord, $rootLine);
$layout = $this->dataProviderCollection->getBackendLayout($selectedPageLayout, $pageId);
if ($layout === null) {
return null;
}
$contentAreas = $this->eventDispatcher->dispatch(new ResolveContentAreasEvent($layout))->getContentAreas();
return new PageLayout($layout->getIdentifier(), $layout->getTitle(), new ContentAreaCollection($contentAreas));
}
/**
* Check if the current page has a value in the DB field "backend_layout"
* if empty, check the root line for "backend_layout_next_level"
* Same as TypoScript:
* field = backend_layout
* ifEmpty.data = levelfield:-2, backend_layout_next_level, slide
* ifEmpty.ifEmpty = default
*/
public function getLayoutIdentifierForPage(array $page, array $rootLine): string
{
$selectedLayout = $page['backend_layout'] ?? '';
// If it is set to "none" - don't use any
if ($selectedLayout === '-1') {
return 'none';
}
if ($selectedLayout === '' || $selectedLayout === '0') {
// If it not set check the root-line for a layout on next level and use this
// Remove first element, which is the current page
// See also \TYPO3\CMS\Backend\View\BackendLayoutView::getSelectedCombinedIdentifier()
array_shift($rootLine);
foreach ($rootLine as $rootLinePage) {
$selectedLayout = (string)($rootLinePage['backend_layout_next_level'] ?? '');
// If layout for "next level" is set to "none" - don't use any and stop searching
if ($selectedLayout === '-1') {
$selectedLayout = 'none';
break;
}
if ($selectedLayout !== '' && $selectedLayout !== '0') {
// Stop searching if a layout for "next level" is set
break;
}
}
}
if ($selectedLayout === '0' || $selectedLayout === '') {
$selectedLayout = 'default';
}
return $selectedLayout;
}
public function getLayoutIdentifierForPageWithoutPrefix(array $page, array $rootLine): string
{
$selectedLayout = $this->getLayoutIdentifierForPage($page, $rootLine);
if (str_contains($selectedLayout, '__')) {
return explode('__', $selectedLayout, 2)[1] ?? '';
}
return $selectedLayout;
}
}
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
<?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 TYPO3\CMS\Backend\View\BackendLayout\BackendLayout;
/**
* @internal
*/
final class ResolveContentAreasEvent
{
/** @var ContentAreaClosure[]|ContentArea[] */
private array $contentAreas = [];
public function __construct(private readonly BackendLayout $layout) {}
public function getBackendLayout(): BackendLayout
{
return $this->layout;
}
public function setContentAreas(array $contentAreas): void
{
$this->contentAreas = $contentAreas;
}
public function getContentAreas(): array
{
return $this->contentAreas;
}
}
+92
View File
@@ -0,0 +1,92 @@
<?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\Http\Message\UriInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Http\Uri;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\HashProxy;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\HashType;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\HashValue;
use TYPO3\CMS\Core\SystemResource\SystemResourceFactory;
use TYPO3\CMS\Core\SystemResource\Type\StaticResourceInterface;
use TYPO3\CMS\Core\SystemResource\Type\SystemResourceInterface;
use TYPO3\CMS\Core\SystemResource\Type\UriResource;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* @internal
*/
final readonly class ResourceHashCollection
{
public const string AUTO = 'auto';
public function __construct(
private LoggerInterface $logger,
private SystemResourceFactory $systemResourceFactory,
#[Autowire(service: 'cache.assets')]
private FrontendInterface $assetsCache,
) {}
public function fetchResourceHash(string|UriInterface|StaticResourceInterface $value, HashType $type = HashType::sha256): ?HashValue
{
if (is_string($value)) {
$value = $this->resolveResourceValue($value);
}
if (empty($value)) {
return null;
}
try {
if ($value instanceof UriInterface || $value instanceof UriResource) {
return HashValue::parse(
HashProxy::urls((string)$value)->withType($type)->compile($this->assetsCache)
);
}
if ($value instanceof SystemResourceInterface) {
return HashValue::parse(
HashProxy::resource((string)$value)->withType($type)->compile($this->assetsCache)
);
}
return null;
} catch (\Throwable $t) {
$this->logger->error('Could not add resource hash: {exceptionMessage}', [
'value' => $value,
'exceptionMessage' => $t->getMessage(),
'exceptionCode' => $t->getCode(),
]);
return null;
}
}
public function resolveResourceValue(string $value): UriInterface|StaticResourceInterface|null
{
if (PathUtility::hasProtocolAndScheme($value)) {
try {
return new Uri($value);
} catch (\Exception) {
return null;
}
}
if (PathUtility::isExtensionPath($value)) {
return $this->systemResourceFactory->createResource($value);
}
return null;
}
}