Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 81 additions & 2 deletions Classes/AssetCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,75 @@ public function getIconIdentifierFromFileName(string $xmlFile): string
return str_replace('.svg', '', basename($xmlFile));
}

/**
* Make the inline SVG sprite self-healing: ensure every icon referenced in
* the given markup (via <use href="#icon-…">) is collected for inline
* rendering.
*
* The sprite is injected into the response by a middleware reading the
* collected files from the "tx_assetcollector" cache, which lives next to –
* but separate from – the page cache. If that cache ever desyncs (e.g. it is
* cleared while the page cache survives, or it is stored on a different
* backend), a fully cached page is delivered without the ViewHelpers being
* re-rendered, the collector ends up empty and the sprite would be missing –
* every icon on the page disappears until the page cache is regenerated.
*
* By re-resolving the referenced icons from the given icon registry
* (identifier => SVG file) we guarantee the sprite is never delivered
* incomplete. The registry must be passed in because the TypoScript setup is
* not available in cached frontend scope – the caller is responsible for
* providing it (see InlineSvgInjector, which persists it in a dedicated
* cache). An empty registry makes this a no-op, so it can never make
* rendering worse than before.
*
* @param \Closure(): array<string, string> $iconRegistryProvider lazily resolves
* the icon identifier => SVG file map; only invoked when the page actually
* references an icon that is not collected, to avoid needless cache reads
*/
public function addReferencedIcons(string $markup, \Closure $iconRegistryProvider): void
{
if ($markup === '' || !preg_match_all('/(?:xlink:href|href)="#icon-([A-Za-z0-9_.-]+)"/', $markup, $matches)) {
return;
}
$referencedIdentifiers = array_unique($matches[1]);
$collectedIdentifiers = [];
foreach ($this->getUniqueXmlFiles() as $xmlFile) {
$collectedIdentifiers[$this->getIconIdentifierFromFileName($xmlFile)] = true;
}
$missingIdentifiers = array_diff($referencedIdentifiers, array_keys($collectedIdentifiers));
if ($missingIdentifiers === []) {
return;
}
$iconRegistry = $iconRegistryProvider();
foreach ($missingIdentifiers as $identifier) {
if (isset($iconRegistry[$identifier])) {
$this->addXmlFile($iconRegistry[$identifier]);
}
}
}

/**
* Map of icon identifier => SVG file path, built from the configured icon
* registry (plugin.tx_assetcollector.icons). Returns an empty array when the
* TypoScript setup is not available (e.g. in cached frontend scope).
*
* @return array<string, string>
*/
public function getIconRegistry(): array
{
if ($this->typoScriptConfiguration === null) {
$this->loadTypoScript();
}
$registry = [];
foreach ($this->typoScriptConfiguration as $file) {
$file = (string)$file;
if ($file !== '') {
$registry[$this->getIconIdentifierFromFileName($file)] = $file;
}
}
return $registry;
}

public function buildInlineCssTag(): string
{
$inlineCss = implode("\n", $this->getUniqueInlineCss());
Expand Down Expand Up @@ -255,9 +324,19 @@ protected function loadTypoScript(): void
if ($request === null) {
return;
}
/** @var FrontendTypoScript $typoScript */
/** @var FrontendTypoScript|null $typoScript */
$typoScript = $request->getAttribute('frontend.typoscript');
$setup = $typoScript->getSetupArray();
if ($typoScript === null) {
return;
}
// The full setup array is only available in uncached frontend scope. On a
// cached request getSetupArray() throws, so degrade gracefully to an empty
// configuration instead of breaking the request.
try {
$setup = $typoScript->getSetupArray();
} catch (\RuntimeException) {
return;
}
$this->typoScriptConfiguration = $setup['plugin.']['tx_assetcollector.']['icons.'] ?? [];
}

Expand Down
38 changes: 32 additions & 6 deletions Classes/Middleware/InlineSvgInjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ class InlineSvgInjector implements MiddlewareInterface
public function __construct(
#[Autowire(service: 'cache.tx_assetcollector')]
private readonly FrontendInterface $cache,
private readonly AssetCollector $assetCollector
private readonly AssetCollector $assetCollector,
#[Autowire(service: 'cache.tx_assetcollector_registry')]
private readonly FrontendInterface $registryCache
) {
}

Expand All @@ -41,11 +43,11 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
if ($response instanceof NullResponse) {
return $response;
}
$svgAsset = $this->getInlineSvgAsset($request);
$body = $response->getBody();
$body->rewind();
$contents = $body->getContents();
$svgAsset = $this->getInlineSvgAsset($request, $contents);
if ($svgAsset !== '') {
$body = $response->getBody();
$body->rewind();
$contents = $response->getBody()->getContents();
if (str_contains($contents, '</body>')) {
$content = str_ireplace(
'</body>',
Expand All @@ -62,7 +64,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
return $response;
}

protected function getInlineSvgAsset(ServerRequestInterface $request): string
protected function getInlineSvgAsset(ServerRequestInterface $request, string $responseBody): string
{
/** @var CacheDataCollector $cacheDataCollector */
$cacheDataCollector = $request->getAttribute('frontend.cache.collector');
Expand All @@ -74,6 +76,30 @@ protected function getInlineSvgAsset(ServerRequestInterface $request): string
if (!empty($cached['xmlFiles'] ?? null) && is_array($cached['xmlFiles'])) {
$this->assetCollector->mergeXmlFiles($cached['xmlFiles']);
}
// Self-heal: re-resolve any icon referenced in the page that is not (or
// no longer) collected, so a desynced "tx_assetcollector" cache can never
// result in a page being delivered with a missing/incomplete SVG sprite.
$cacheIdentifier = 'icons-' . ($request->getAttribute('site')?->getIdentifier() ?? 'default');
// In uncached scope the TypoScript-backed registry is available – (re)persist
// it so it can be used as a fallback for cached requests, where TypoScript
// (and therefore the registry) is no longer available.
$liveRegistry = $this->assetCollector->getIconRegistry();
if ($liveRegistry !== []) {
$this->registryCache->set($cacheIdentifier, $liveRegistry);
}
$this->assetCollector->addReferencedIcons(
$responseBody,
fn (): array => $liveRegistry !== [] ? $liveRegistry : $this->readPersistedRegistry($cacheIdentifier)
);
return $this->assetCollector->buildInlineXmlTag();
}

/**
* @return array<string, string>
*/
private function readPersistedRegistry(string $cacheIdentifier): array
{
$persisted = $this->registryCache->has($cacheIdentifier) ? $this->registryCache->get($cacheIdentifier) : null;
return is_array($persisted) ? $persisted : [];
}
}
5 changes: 5 additions & 0 deletions Configuration/Services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ services:
factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache']
arguments: ['tx_assetcollector']

cache.tx_assetcollector_registry:
class: TYPO3\CMS\Core\Cache\Frontend\FrontendInterface
factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache']
arguments: ['tx_assetcollector_registry']

71 changes: 71 additions & 0 deletions Tests/Functional/Frontend/SvgViewHelperSelfHealTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace B13\Assetcollector\Tests\Functional\Frontend;

/*
* This file is part of TYPO3 CMS-based extension "assetcollector" by b13.
*
* 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.
*/

use PHPUnit\Framework\Attributes\Test;
use TYPO3\CMS\Core\Cache\Backend\Typo3DatabaseBackend;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;

class SvgViewHelperSelfHealTest extends FunctionalTestCase
{
protected array $testExtensionsToLoad = ['typo3conf/ext/assetcollector'];
protected array $coreExtensionsToLoad = ['core', 'frontend'];
protected array $pathsToLinkInTestInstance = ['typo3conf/ext/assetcollector/Build/sites' => 'typo3conf/sites'];

protected array $configurationToUseInTestInstance = [
'SYS' => [
'caching' => [
'cacheConfigurations' => [
'pages' => [
'backend' => Typo3DatabaseBackend::class,
],
'tx_assetcollector' => [
'backend' => Typo3DatabaseBackend::class,
],
'tx_assetcollector_registry' => [
'backend' => Typo3DatabaseBackend::class,
],
],
],
],
];

/**
* Reproduces the situation where the page cache survives but the
* "tx_assetcollector" cache that holds the collected SVG files is gone (e.g.
* desynced because it sits on a different backend, or was cleared on its
* own). The cached page must still be delivered with the full inline SVG
* sprite instead of silently losing all its icons.
*/
#[Test]
public function inlineSvgSpriteIsRebuiltForCachedPageWhenAssetCacheIsLost(): void
{
$this->importCSVDataSet(__DIR__ . '/Fixtures/SvgViewHelper.csv');

// 1. uncached request: the sprite is rendered and the icon registry gets persisted
$bodyUncached = (string)$this->executeFrontendSubRequest(new InternalRequest('http://localhost/'))->getBody();
self::assertStringContainsString('<svg class="tx_assetcollector"', $bodyUncached);
self::assertStringContainsString('<symbol id="icon-Extension"', $bodyUncached);

// 2. the page cache survives, but the collected-asset cache is lost
GeneralUtility::makeInstance(CacheManager::class)->getCache('tx_assetcollector')->flush();

// 3. cached request: the sprite must be rebuilt from the persisted registry
$bodyCached = (string)$this->executeFrontendSubRequest(new InternalRequest('http://localhost/'))->getBody();
self::assertStringContainsString('<symbol id="icon-Extension"', $bodyCached);
self::assertSame($bodyUncached, $bodyCached);
}
}
45 changes: 45 additions & 0 deletions Tests/Unit/AssetCollectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,49 @@ public function buildInlineCssTagTest(): void
$cssTag = $assetCollector->buildInlineCssTag();
self::assertStringContainsString('<style class="tx_assetcollector">my-inline-css', $cssTag);
}

#[Test]
public function addReferencedIconsResolvesMissingIconFromRegistry(): void
{
$assetCollector = new AssetCollector();
$assetCollector->addReferencedIcons(
'<body><svg><use xlink:href="#icon-Extension"></use></svg></body>',
static fn (): array => ['Extension' => 'EXT:assetcollector/Resources/Public/Icons/Extension.svg']
);
self::assertSame(
['EXT:assetcollector/Resources/Public/Icons/Extension.svg'],
$assetCollector->getUniqueXmlFiles()
);
}

#[Test]
public function addReferencedIconsDoesNotInvokeRegistryWhenIconAlreadyCollected(): void
{
$assetCollector = new AssetCollector();
$assetCollector->addXmlFile('EXT:assetcollector/Resources/Public/Icons/Extension.svg');
$registryWasResolved = false;
$assetCollector->addReferencedIcons(
'<use xlink:href="#icon-Extension"></use>',
static function () use (&$registryWasResolved): array {
$registryWasResolved = true;
return [];
}
);
self::assertFalse($registryWasResolved, 'The registry provider must only be invoked when an icon is missing.');
self::assertSame(
['EXT:assetcollector/Resources/Public/Icons/Extension.svg'],
$assetCollector->getUniqueXmlFiles()
);
}

#[Test]
public function addReferencedIconsIgnoresIconsThatAreNotInTheRegistry(): void
{
$assetCollector = new AssetCollector();
$assetCollector->addReferencedIcons(
'<use xlink:href="#icon-unknown"></use>',
static fn (): array => ['Extension' => 'EXT:assetcollector/Resources/Public/Icons/Extension.svg']
);
self::assertSame([], $assetCollector->getUniqueXmlFiles());
}
}
9 changes: 9 additions & 0 deletions ext_localconf.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,12 @@
if (!is_array($GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['tx_assetcollector'] ?? null)) {
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['tx_assetcollector'] = ['groups' => ['pages']];
}

// Holds the icon registry (identifier => SVG file) so the inline SVG sprite can
// be rebuilt for cached pages even when the page-cache-bound "tx_assetcollector"
// cache is gone. Lives in the "system" group on purpose: it must survive a
// frontend ("pages") cache flush, otherwise it could vanish together with the
// very entry it is meant to recover from.
if (!is_array($GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['tx_assetcollector_registry'] ?? null)) {
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['tx_assetcollector_registry'] = ['groups' => ['system']];
}