TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user