Commit 7e342e74 authored by Benni Mack's avatar Benni Mack Committed by Susanne Moog
Browse files

[FEATURE] Introduce AssetCollector

AssetCollector is a concept to allow custom CSS/JS code,
inline or external, to be added multiple times in e.g. a Fluid
template (via <f:script> or <f:css> ViewHelpers) and only be
added once.

It considers best practices by having a "priority" flag to be either
be moved in the <head> area (for CSS useful in above-the-fold concepts)
or at the bottom of the <body> tag contents.

AssetCollector helps to work with content elements as components,
reducing effectively the amount of CSS to be loaded and also incorporates
the HTTP/2 power where it is not relevant to have all files compressed
and concatenated in one file (although this could be added later-on).

AssetCollector is implemented as singleton and should slowly replace
the various existing options in TypoScript which seem to be confusing.

AssetCollector also collects information about "imagesOnPage"
effectively taking off pressure from PageRenderer and TSFE to
store common data in FE - as this is now handled in AssetCollector,
which can be used in cached and non-cached components.

Resolves: #90522
Releases: master
Change-Id: I6ce8141ad8891a7a8ee6d4f8a7377d93a894c3b8
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/63327


Tested-by: Daniel Goerz's avatarDaniel Goerz <daniel.goerz@posteo.de>
Tested-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Kevin Appelt's avatarKevin Appelt <kevin.appelt@icloud.com>
Tested-by: Daniel Gohlke's avatarDaniel Gohlke <daniel.gohlke@extco.de>
Tested-by: Susanne Moog's avatarSusanne Moog <look@susi.dev>
Reviewed-by: Daniel Goerz's avatarDaniel Goerz <daniel.goerz@posteo.de>
Reviewed-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
Reviewed-by: Kevin Appelt's avatarKevin Appelt <kevin.appelt@icloud.com>
Reviewed-by: Daniel Gohlke's avatarDaniel Gohlke <daniel.gohlke@extco.de>
Reviewed-by: Susanne Moog's avatarSusanne Moog <look@susi.dev>
parent 6db9a9dc
......@@ -22,6 +22,7 @@ use TYPO3\CMS\Adminpanel\ModuleApi\DataProviderInterface;
use TYPO3\CMS\Adminpanel\ModuleApi\ModuleData;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\UserAspect;
use TYPO3\CMS\Core\Page\AssetCollector;
use TYPO3\CMS\Core\TimeTracker\TimeTracker;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Fluid\View\StandaloneView;
......@@ -128,17 +129,16 @@ class GeneralInformation extends AbstractSubModule implements DataProviderInterf
$count = 0;
$totalImageSize = 0;
if (!empty($this->getTypoScriptFrontendController()->imagesOnPage)) {
foreach ($this->getTypoScriptFrontendController()->imagesOnPage as $file) {
$fileSize = @filesize($file);
$imagesOnPage['files'][] = [
'name' => $file,
'size' => $fileSize,
'sizeHuman' => GeneralUtility::formatSize($fileSize),
];
$totalImageSize += $fileSize;
$count++;
}
$imagesOnPage = GeneralUtility::makeInstance(AssetCollector::class)->getMedia();
foreach ($imagesOnPage as $file => $information) {
$fileSize = @filesize($file);
$imagesOnPage['files'][] = [
'name' => $file,
'size' => $fileSize,
'sizeHuman' => GeneralUtility::formatSize($fileSize),
];
$totalImageSize += $fileSize;
$count++;
}
$imagesOnPage['totalSize'] = GeneralUtility::formatSize($totalImageSize);
$imagesOnPage['total'] = $count;
......
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Page;
/*
* 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!
*/
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.
*
* https://developer.wordpress.org/reference/functions/wp_enqueue_style/
*/
class AssetCollector implements SingletonInterface
{
/**
* @var array
*/
protected $javaScripts = [];
/**
* @var array
*/
protected $inlineJavaScripts = [];
/**
* @var array
*/
protected $styleSheets = [];
/**
* @var array
*/
protected $inlineStyleSheets = [];
/**
* @var array
*/
protected $media = [];
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;
}
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;
}
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;
}
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;
}
public function addMedia(string $fileName, array $additionalInformation): self
{
$existingAdditionalInformation = $this->media[$fileName] ?? [];
ArrayUtility::mergeRecursiveWithOverrule($existingAdditionalInformation, $additionalInformation);
$this->media[$fileName] = $existingAdditionalInformation;
return $this;
}
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(): array
{
return $this->javaScripts;
}
public function getInlineJavaScripts(): array
{
return $this->inlineJavaScripts;
}
public function getStyleSheets(): array
{
return $this->styleSheets;
}
public function getInlineStyleSheets(): array
{
return $this->inlineStyleSheets;
}
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Page;
/*
* 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!
*/
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* Class AssetRenderer
* @internal The AssetRenderer is used for the asset rendering and is not public API
*/
class AssetRenderer
{
protected $assetCollector;
public function __construct(AssetCollector $assetCollector = null)
{
$this->assetCollector = $assetCollector ?? GeneralUtility::makeInstance(AssetCollector::class);
}
public function renderInlineJavaScript($priority = false): string
{
$template = '<script%attributes%>%source%</script>';
$assets = $this->assetCollector->getInlineJavaScripts();
foreach ($assets as &$assetData) {
$assetData['attributes']['type'] = $assetData['attributes']['type'] ?? 'text/javascript';
}
return $this->render($assets, $template, $priority);
}
public function renderJavaScript($priority = false): string
{
$template = '<script%attributes%></script>';
$assets = $this->assetCollector->getJavaScripts();
foreach ($assets as &$assetData) {
$assetData['attributes']['src'] = $this->getAbsoluteWebPath($assetData['source']);
$assetData['attributes']['type'] = $assetData['attributes']['type'] ?? 'text/javascript';
}
return $this->render($assets, $template, $priority);
}
public function renderInlineStyleSheets($priority = false): string
{
$template = '<style%attributes%>%source%</style>';
$assets = $this->assetCollector->getInlineStyleSheets();
return $this->render($assets, $template, $priority);
}
public function renderStyleSheets(bool $priority = false, string $endingSlash = ''): string
{
$template = '<link%attributes% ' . $endingSlash . '>';
$assets = $this->assetCollector->getStyleSheets();
foreach ($assets as &$assetData) {
$assetData['attributes']['href'] = $this->getAbsoluteWebPath($assetData['source']);
$assetData['attributes']['rel'] = $assetData['attributes']['rel'] ?? 'stylesheet';
$assetData['attributes']['type'] = $assetData['attributes']['type'] ?? 'text/css';
}
return $this->render($assets, $template, $priority);
}
protected function render(array $assets, string $template, bool $priority = false): string
{
$results = [];
foreach ($assets as $assetData) {
$attributes = $assetData['attributes'];
$attributesString = count($attributes) ? ' ' . GeneralUtility::implodeAttributes($attributes, true) : '';
$code = str_replace(['%attributes%', '%source%'], [$attributesString, $assetData['source']], $template);
$hasPriority = $assetData['options']['priority'] ?? false;
if ($hasPriority === $priority) {
$results[] = $code;
}
}
return implode(LF, $results);
}
private function getAbsoluteWebPath(string $file): string
{
return PathUtility::getAbsoluteWebPath(GeneralUtility::getFileAbsFileName($file));
}
}
......@@ -1787,6 +1787,17 @@ class PageRenderer implements SingletonInterface
$jsFooterInline = $jsInline . LF . $jsFooterInline;
$jsInline = '';
}
// Use AssetRenderer to inject all JavaScripts and CSS files
$assetRenderer = GeneralUtility::makeInstance(AssetRenderer::class);
$jsInline .= $assetRenderer->renderInlineJavaScript(true);
$jsFooterInline .= $assetRenderer->renderInlineJavaScript();
$jsFiles .= $assetRenderer->renderJavaScript(true);
$jsFooterFiles .= $assetRenderer->renderJavaScript();
$cssInline .= $assetRenderer->renderInlineStyleSheets(true);
$jsFooterFiles .= $assetRenderer->renderInlineStyleSheets();
$cssLibs .= $assetRenderer->renderStyleSheets(true, $this->endingSlash);
$cssFiles .= $assetRenderer->renderStyleSheets(false, $this->endingSlash);
$this->executePostRenderHook($jsLibs, $jsFiles, $jsFooterFiles, $cssLibs, $cssFiles, $jsInline, $cssInline, $jsFooterInline, $jsFooterLibs);
return [$jsLibs, $jsFiles, $jsFooterFiles, $cssLibs, $cssFiles, $jsInline, $cssInline, $jsFooterInline, $jsFooterLibs];
}
......
.. include:: ../../Includes.txt
======================================================
Deprecation: #90522 - TSFE properties regarding images
======================================================
See :issue:`90522`
Description
===========
The image related properties :php:`$imagesOnPage` and :php:`$lastImageInfo` of
:php:`TypoScriptFrontendController` have been marked as deprecated.
Impact
======
Calling these deprecated properties will trigger a deprecation notice.
Affected Installations
======================
All installations using these properties are affected.
Migration
=========
For :php:`$imagesOnPage` the AssetCollector may be used instead:
.. code-block:: php
$assetCollector = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(TYPO3\CMS\Core\Page\AssetCollector::class);
$imagesOnPage = $assetCollector->getMedia();
.. index:: Frontend, PHP-API, NotScanned, ext:core
.. include:: ../../Includes.txt
==========================================
Feature: #90522 - Introduce AssetCollector
==========================================
See :issue:`90522`
Description
===========
AssetCollector is a concept to allow custom CSS/JS code, inline or external, to be added multiple
times in e.g. a Fluid template (via <f:script> or <f:css> ViewHelpers) but only rendered once
in the output.
It supports best practices for optimizing page performance by having a "priority" flag to either
move the asset to the <head> section (useful for CSS in above-the-fold concepts) or to the
bottom of the <body> tag.
AssetCollector helps to work with content elements as components, effectively reducing the amount
of CSS to be loaded. It also incorporates the HTTP/2 power where it is not relevant to have all
files compressed and concatenated in one file (although this could be added later-on).
AssetCollector is implemented as singleton and should slowly replace the various existing options
in TypoScript.
AssetCollector also collects information about "imagesOnPage" effectively taking off pressure from
PageRenderer and TSFE to store common data in FE - as this is now handled in AssetCollector,
which can be used in cached and non-cached components.
The new API
-----------
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::addJavaScript(string $identifier, string $source, array $attributes, array $options = []): self`
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::addInlineJavaScript(string $identifier, string $source, array $attributes, array $options = []): self`
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::addStyleSheet(string $identifier, string $source, array $attributes, array $options = []): self`
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::addInlineStyleSheet(string $identifier, string $source, array $attributes, array $options = []): self`
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::addMedia(string $fileName, array $additionalInformation): self`
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::removeJavaScript(string $identifier): self`
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::removeInlineJavaScript(string $identifier): self`
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::removeStyleSheet(string $identifier): self`
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::removeInlineStyleSheet(string $identifier): self`
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::removeMedia(string $identifier): self`
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::getJavaScripts(): array`
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::getInlineJavaScripts(): array`
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::getStyleSheets(): array`
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::getInlineStyleSheets(): array`
- :php:`\TYPO3\CMS\Core\Page\AssetCollector::getMedia(): array`
New ViewHelpers
---------------
There are also two new ViewHelpers, the :html:`<f:asset.css>` and the - :html:`<f:asset.script>` ViewHelper which use the AssetCollector API.
.. code-block:: html
<f:asset.css identifier="identifier123" href="EXT:my_ext/Resources/Public/Css/foo.css" />
<f:asset.css identifier="identifier123">
.foo { color: black; }
</f:asset.css>
<f:asset.script identifier="identifier123" src="EXT:my_ext/Resources/Public/JavaScript/foo.js" />
<f:asset.script identifier="identifier123">
alert('hello world');
</f:asset.script>
Examples
--------
// Add a JavaScript file to the collector with script attribute data-foo="bar"
GeneralUtility::makeInstance(AssetCollector::class)
->addJavaScript('my_ext_foo', 'EXT:my_ext/Resources/Public/JavaScript/foo.js', ['data-foo' => 'bar']]);
// Add a JavaScript file to the collector with script attribute data-foo="bar" and priority
// which means rendering before other script tags
GeneralUtility::makeInstance(AssetCollector::class)
->addJavaScript('my_ext_foo', 'EXT:my_ext/Resources/Public/JavaScript/foo.js', ['data-foo' => 'bar'], ['priority' => true]]);
.. index:: Backend, Frontend, PHP-API, ext:core
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Tests\Unit\Page;
use TYPO3\CMS\Core\Page\AssetCollector;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
class AssetCollectorTest extends UnitTestCase
{
/**
* @var AssetCollector
*/
protected $assetCollector;
public function setUp(): void
{
parent::setUp();
$this->resetSingletonInstances = true;
$this->assetCollector = GeneralUtility::makeInstance(AssetCollector::class);
}
/**
* @param array $files
* @param array $expectedResult
* @dataProvider \TYPO3\CMS\Core\Tests\Unit\Page\AssetDataProvider::filesDataProvider
*/
public function testStyleSheets(array $files, array $expectedResult): void
{
foreach ($files as $file) {
[$identifier, $source, $attributes, $options] = $file;
$this->assetCollector->addStyleSheet($identifier, $source, $attributes, $options);
}
self::assertSame($expectedResult, $this->assetCollector->getStyleSheets());
self::assertSame([], $this->assetCollector->getInlineStyleSheets());
self::assertSame([], $this->assetCollector->getInlineJavaScripts());
self::assertSame([], $this->assetCollector->getJavaScripts());
self::assertSame([], $this->assetCollector->getMedia());
foreach ($files as $file) {
[$identifier] = $file;
$this->assetCollector->removeStyleSheet($identifier);
}
self::assertSame([], $this->assetCollector->getStyleSheets());
self::assertSame([], $this->assetCollector->getInlineStyleSheets());
self::assertSame([], $this->assetCollector->getInlineJavaScripts());
self::assertSame([], $this->assetCollector->getJavaScripts());
self::assertSame([], $this->assetCollector->getMedia());
}
/**
* @param array $files
* @param array $expectedResult
* @dataProvider \TYPO3\CMS\Core\Tests\Unit\Page\AssetDataProvider::filesDataProvider
*/
public function testJavaScript(array $files, array $expectedResult): void
{
foreach ($files as $file) {
[$identifier, $source, $attributes, $options] = $file;
$this->assetCollector->addJavaScript($identifier, $source, $attributes, $options);
}
self::assertSame($expectedResult, $this->assetCollector->getJavaScripts());
self::assertSame([], $this->assetCollector->getInlineStyleSheets());
self::assertSame([], $this->assetCollector->getInlineJavaScripts());
self::assertSame([], $this->assetCollector->getStyleSheets());
self::assertSame([], $this->assetCollector->getMedia());
foreach ($files as $file) {
[$identifier] = $file;
$this->assetCollector->removeJavaScript($identifier);
}
self::assertSame([], $this->assetCollector->getJavaScripts());
self::assertSame([], $this->assetCollector->getInlineStyleSheets());
self::assertSame([], $this->assetCollector->getInlineJavaScripts());
self::assertSame([], $this->assetCollector->getStyleSheets());
self::assertSame([], $this->assetCollector->getMedia());
}
/**
* @param array $sources
* @param array $expectedResult
* @dataProvider \TYPO3\CMS\Core\Tests\Unit\Page\AssetDataProvider::inlineDataProvider
*/
public function testInlineJavaScript(array $sources, array $expectedResult): void
{
foreach ($sources as $source) {
[$identifier, $source, $attributes, $options] = $source;
$this->assetCollector->addInlineJavaScript($identifier, $source, $attributes, $options);
}
self::assertSame($expectedResult, $this->assetCollector->getInlineJavaScripts());
self::assertSame([], $this->assetCollector->getInlineStyleSheets());
self::assertSame([], $this->assetCollector->getJavaScripts());
self::assertSame([], $this->assetCollector->getStyleSheets());
self::assertSame([], $this->assetCollector->getMedia());
foreach ($sources as $source) {
[$identifier] = $source;
$this->assetCollector->removeInlineJavaScript($identifier);
}
self::assertSame([], $this->assetCollector->getInlineJavaScripts());
self::assertSame([], $this->assetCollector->getInlineStyleSheets());
self::assertSame([], $this->assetCollector->getJavaScripts());
self::assertSame([], $this->assetCollector->getStyleSheets());
self::assertSame([], $this->assetCollector->getMedia());
}
/**
* @param array $sources
* @param array $expectedResult
* @dataProvider \TYPO3\CMS\Core\Tests\Unit\Page\AssetDataProvider::inlineDataProvider
*/
public function testInlineStyles(array $sources, array $expectedResult): void
{
foreach ($sources as $source) {
[$identifier, $source, $attributes, $options] = $source;
$this->assetCollector->addInlineStyleSheet($identifier, $source, $attributes, $options);
}
self::assertSame($expectedResult, $this->assetCollector->getInlineStyleSheets());
self::assertSame([], $this->assetCollector->getInlineJavaScripts());
self::assertSame([], $this->assetCollector->getJavaScripts());
self::assertSame([], $this->assetCollector->getStyleSheets());
self::assertSame([], $this->assetCollector->getMedia());
foreach ($sources as $source) {
[$identifier] = $source;
$this->assetCollector->removeInlineStyleSheet($identifier);
}
self::assertSame([], $this->assetCollector->getInlineStyleSheets());