diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3916694..f74683c4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ for a given releases. Unreleased, upcoming changes will be updated here periodic # 2.x +## [2.13.0](https://github.com/liip/LiipImagineBundle/tree/2.13.0) + +- Support JsonManifestVersionStrategy that was added in Symfony 6. + ## [2.12.3](https://github.com/liip/LiipImagineBundle/tree/2.12.3) - Add alias for `Imagine\Image\ImagineInterface` to help autowiring. diff --git a/composer.json b/composer.json index 51ac1dd28..71b5908ca 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ "phpstan/phpstan-symfony": "^1.0", "psr/cache": "^3.0", "psr/log": "^1.0|^2.0|^3.0", + "symfony/asset": "^6.4|^7.0", "symfony/browser-kit": "^6.4|^7.0", "symfony/cache": "^6.4|^7.0", "symfony/console": "^6.4|^7.0", @@ -54,6 +55,7 @@ }, "suggest": { "ext-exif": "required to read EXIF metadata from images", + "ext-json": "required to read JSON manifest versioning", "ext-gd": "required to use gd driver", "ext-gmagick": "required to use gmagick driver", "ext-imagick": "required to use imagick driver", @@ -64,6 +66,7 @@ "league/flysystem": "required to use FlySystem data loader or cache resolver", "monolog/monolog": "A psr/log compatible logger is required to enable logging", "rokka/imagine-vips": "required to use 'vips' driver", + "symfony/asset": "If you want to use asset versioning", "symfony/messenger": "If you like to process images in background" }, "config": { diff --git a/doc/asset-versioning.rst b/doc/asset-versioning.rst index 997ce44fc..18dbc4033 100644 --- a/doc/asset-versioning.rst +++ b/doc/asset-versioning.rst @@ -17,6 +17,11 @@ setting for ``framework.assets.version``. It strips the version from the file name and appends it to the resulting image URL so that the file is found and cache busting is used. +Since LiipImagineBundle version 2.13, we integrate with the configuration +setting for ``framework.assets.json_manifest_path``. The manifest file is used +to lookup the location of the actual file, and append the versioning string to +the resulting image URL so that cache busting is used. + Cache Busting ~~~~~~~~~~~~~ @@ -25,6 +30,12 @@ versioning to bust the cache of your images. This can help for example after you changed the settings of a filter set. If you use ``framework.assets.version``, change the asset version in that case. +If you use ``framework.assets.json_manifest_path``, then rebuild the manifest +in your asset pipeline. Note that your versioning string might be calculated +using a content hash. Changing a filter setting in these cases will *not* bust +the previous cache. Either rename your filter in that case or use a different +versioning strategy within your asset pipeline that ensures a new hash for each +build. If you do not use Symfony asset versioning, set the ``liip_imagine.twig.assets_version`` parameter. Note that you still need to clear/refresh the cached images to have them updated, the asset version is only diff --git a/src/DependencyInjection/Compiler/AssetsVersionCompilerPass.php b/src/DependencyInjection/Compiler/AssetsVersionCompilerPass.php index 8ded1d029..f0b5407e8 100644 --- a/src/DependencyInjection/Compiler/AssetsVersionCompilerPass.php +++ b/src/DependencyInjection/Compiler/AssetsVersionCompilerPass.php @@ -11,6 +11,7 @@ namespace Liip\ImagineBundle\DependencyInjection\Compiler; +use Symfony\Component\Asset\VersionStrategy\JsonManifestVersionStrategy; use Symfony\Component\Asset\VersionStrategy\StaticVersionStrategy; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -18,14 +19,16 @@ * Inject the Symfony framework assets version parameter to the * LiipImagineBundle twig extension if possible. * - * We extract the version parameter from the StaticVersionStrategy service - * definition. If anything is not as expected, we log a warning and do nothing. + * We extract either: + * - the version parameter from the StaticVersionStrategy service + * - the json manifest from the JsonManifestVersionStrategy service + * If anything is not as expected, we log a warning and do nothing. * * The expectation is for the user to configure the assets version in liip * imagine for custom setups. * - * Anything other than StaticVersionStrategy needs to be implemented by the - * user in CacheResolveEvent event listeners. + * Anything other than StaticVersionStrategy or JsonManifestVersionStrategy needs + * to be implemented by the user in CacheResolveEvent event listeners. */ class AssetsVersionCompilerPass extends AbstractCompilerPass { @@ -46,7 +49,9 @@ public function process(ContainerBuilder $container): void } $versionStrategyDefinition = $container->findDefinition('assets._version__default'); - if (!is_a($versionStrategyDefinition->getClass(), StaticVersionStrategy::class, true)) { + if (!is_a($versionStrategyDefinition->getClass(), StaticVersionStrategy::class, true) + && !is_a($versionStrategyDefinition->getClass(), JsonManifestVersionStrategy::class, true) + ) { $this->log($container, 'Symfony assets versioning strategy "'.$versionStrategyDefinition->getClass().'" not automatically supported. Configure liip_imagine.twig.assets_version if you have problems with assets versioning'); return; @@ -61,5 +66,23 @@ public function process(ContainerBuilder $container): void } $runtimeDefinition->setArgument(1, $version); + + if (is_a($versionStrategyDefinition->getClass(), JsonManifestVersionStrategy::class, true)) { + $jsonManifestString = file_get_contents($version); + + if (!\is_string($jsonManifestString)) { + $this->log($container, 'Can not handle assets versioning with "'.$versionStrategyDefinition->getClass().'". The manifest file at "'.$version.' " could not be read'); + + return; + } + $jsonManifest = json_decode($jsonManifestString, true); + if (!\is_array($jsonManifest)) { + $this->log($container, 'Can not handle assets versioning with "'.$versionStrategyDefinition->getClass().'". The manifest file at "'.$version.' " does not contain valid JSON'); + + return; + } + $runtimeDefinition->setArgument(1, null); + $runtimeDefinition->setArgument(2, $jsonManifest); + } } } diff --git a/src/Resources/config/imagine_twig_mode_lazy.xml b/src/Resources/config/imagine_twig_mode_lazy.xml index 9e4a9f1bb..ac9ec6f19 100644 --- a/src/Resources/config/imagine_twig_mode_lazy.xml +++ b/src/Resources/config/imagine_twig_mode_lazy.xml @@ -14,6 +14,7 @@ null + null diff --git a/src/Templating/LazyFilterRuntime.php b/src/Templating/LazyFilterRuntime.php index aec3a1a09..f7b17e659 100644 --- a/src/Templating/LazyFilterRuntime.php +++ b/src/Templating/LazyFilterRuntime.php @@ -24,10 +24,25 @@ final class LazyFilterRuntime implements RuntimeExtensionInterface */ private ?string $assetVersion; - public function __construct(CacheManager $cache, ?string $assetVersion = null) + /** + * @var array|null + */ + private $jsonManifest; + + /** + * @var array|null + */ + private $jsonManifestLookup; + + /** + * @param array|null $jsonManifest + */ + public function __construct(CacheManager $cache, ?string $assetVersion = null, ?array $jsonManifest = null) { $this->cache = $cache; $this->assetVersion = $assetVersion; + $this->jsonManifest = $jsonManifest; + $this->jsonManifestLookup = $jsonManifest ? array_flip($jsonManifest) : null; } /** @@ -36,9 +51,9 @@ public function __construct(CacheManager $cache, ?string $assetVersion = null) public function filter(string $path, string $filter, array $config = [], ?string $resolver = null, int $referenceType = UrlGeneratorInterface::ABSOLUTE_URL): string { $path = $this->cleanPath($path); - $path = $this->cache->getBrowserPath($path, $filter, $config, $resolver, $referenceType); + $resolvedPath = $this->cache->getBrowserPath($path, $filter, $config, $resolver, $referenceType); - return $this->appendAssetVersion($path); + return $this->appendAssetVersion($resolvedPath, $path); } /** @@ -52,33 +67,99 @@ public function filterCache(string $path, string $filter, array $config = [], ?s if (\count($config)) { $path = $this->cache->getRuntimePath($path, $config); } - $path = $this->cache->resolve($path, $filter, $resolver); + $resolvedPath = $this->cache->resolve($path, $filter, $resolver); - return $this->appendAssetVersion($path); + return $this->appendAssetVersion($resolvedPath, $path); } private function cleanPath(string $path): string { - if (!$this->assetVersion) { + if (!$this->assetVersion && !$this->jsonManifest) { return $path; } - $start = mb_strrpos($path, $this->assetVersion); - if (mb_strlen($path) - mb_strlen($this->assetVersion) === $start) { - return rtrim(mb_substr($path, 0, $start), '?'); + if ($this->assetVersion) { + $start = mb_strrpos($path, $this->assetVersion); + if (mb_strlen($path) - mb_strlen($this->assetVersion) === $start) { + return rtrim(mb_substr($path, 0, $start), '?'); + } + } + + if ($this->jsonManifest) { + if (\array_key_exists($path, $this->jsonManifestLookup)) { + return $this->jsonManifestLookup[$path]; + } } return $path; } - private function appendAssetVersion(string $path): string + private function appendAssetVersion(string $resolvedPath, string $path): string { - if (!$this->assetVersion) { - return $path; + if (!$this->assetVersion && !$this->jsonManifest) { + return $resolvedPath; + } + + if ($this->assetVersion) { + $separator = false !== mb_strpos($resolvedPath, '?') ? '&' : '?'; + + return $resolvedPath.$separator.$this->assetVersion; + } + + if (\array_key_exists($path, $this->jsonManifest)) { + $prefixedSlash = 0 !== mb_strpos($path, '/') && 0 === mb_strpos($this->jsonManifest[$path], '/'); + $versionedPath = $prefixedSlash ? mb_substr($this->jsonManifest[$path], 1) : $this->jsonManifest[$path]; + + $originalExt = pathinfo($path, PATHINFO_EXTENSION); + $resolvedExt = pathinfo($resolvedPath, PATHINFO_EXTENSION); + + if ($originalExt !== $resolvedExt) { + $path = str_replace('.'.$originalExt, '.'.$resolvedExt, $path); + $versionedPath = str_replace('.'.$originalExt, '.'.$resolvedExt, $versionedPath); + } + + $versioning = $this->captureVersion(pathinfo($path, PATHINFO_BASENAME), pathinfo($versionedPath, PATHINFO_BASENAME)); + $resolvedFilename = pathinfo($resolvedPath, PATHINFO_BASENAME); + $resolvedDir = pathinfo($resolvedPath, PATHINFO_DIRNAME); + $resolvedPath = $resolvedDir.'/'.$this->insertVersion($resolvedFilename, $versioning['version'], $versioning['position']); + } + + return $resolvedPath; + } + + /** + * Capture the versioning string from the versioned filename + * + * @return array{version: string, position: int} + */ + private function captureVersion(string $originalFilename, string $versionedFilename): array + { + $originalLength = mb_strlen($originalFilename); + $versionedLength = mb_strlen($versionedFilename); + + for ($i = 0; $i < $originalLength && $i < $versionedLength; ++$i) { + if ($originalFilename[$i] !== $versionedFilename[$i]) { + break; + } + } + + $version = mb_substr($versionedFilename, $i, $versionedLength - $originalLength); + + return ['version' => $version, 'position' => $i]; + } + + /** + * Insert the version string into our resolved filename + */ + private function insertVersion(string $resolvedFilename, string $version, int $position): string + { + if ($position < 0 || $position > mb_strlen($resolvedFilename)) { + return $resolvedFilename; } - $separator = false !== mb_strpos($path, '?') ? '&' : '?'; + $firstPart = mb_substr($resolvedFilename, 0, $position); + $secondPart = mb_substr($resolvedFilename, $position); - return $path.$separator.$this->assetVersion; + return $firstPart.$version.$secondPart; } } diff --git a/tests/Templating/LazyFilterRuntimeTest.php b/tests/Templating/LazyFilterRuntimeTest.php index 4f3a7747a..5bc8da17f 100644 --- a/tests/Templating/LazyFilterRuntimeTest.php +++ b/tests/Templating/LazyFilterRuntimeTest.php @@ -27,6 +27,12 @@ class LazyFilterRuntimeTest extends AbstractTest { private const FILTER = 'thumbnail'; private const VERSION = 'v2'; + private const JSON_MANIFEST = [ + 'image/cats.png' => '/image/cats.png?v=bc321bd12a', + 'image/dogs.png' => '/image/dogs.ac38d2a1bc.png', + '/image/cows.png' => '/image/cows.png?v=a5de32a2c4', + '/image/sheep.png' => '/image/sheep.7ca26b36af.png', + ]; private LazyFilterRuntime $runtime; /** @@ -101,6 +107,70 @@ public function testDifferentVersion(): void $this->assertSame($cachePath.'?'.self::VERSION, $actualPath); } + /** + * @dataProvider provideJsonManifest + */ + public function testJsonManifestVersionHandling(string $sourcePath, string $versionedPath): void + { + $this->runtime = new LazyFilterRuntime($this->manager, null, self::JSON_MANIFEST); + + $cachePath = 'image/cache/'.self::FILTER.'/'.('/' === (mb_substr($sourcePath, 0, 1)) ? mb_substr($sourcePath, 1) : $sourcePath); + $expectedPath = 'image/cache/'.self::FILTER.'/'.('/' === (mb_substr($versionedPath, 0, 1)) ? mb_substr($versionedPath, 1) : $versionedPath); + + $this->manager + ->expects($this->once()) + ->method('getBrowserPath') + ->with($sourcePath, self::FILTER) + ->willReturn($cachePath); + + $actualPath = $this->runtime->filter($versionedPath, self::FILTER); + + $this->assertSame($expectedPath, $actualPath); + } + + /** + * @dataProvider provideJsonManifestSwapExt + */ + public function testJsonManifestVersionHandlingWithExtensionSwapping(string $sourcePath, string $versionedPath, $originalExt, $newExt): void + { + $this->runtime = new LazyFilterRuntime($this->manager, null, self::JSON_MANIFEST); + + $cachePath = 'image/cache/'.self::FILTER.'/'.('/' === (mb_substr($sourcePath, 0, 1)) ? mb_substr($sourcePath, 1) : $sourcePath); + $cachePath = str_replace('.'.$originalExt, '.'.$newExt, $cachePath); + $expectedPath = 'image/cache/'.self::FILTER.'/'.('/' === (mb_substr($versionedPath, 0, 1)) ? mb_substr($versionedPath, 1) : $versionedPath); + $expectedPath = str_replace('.'.$originalExt, '.'.$newExt, $expectedPath); + + $this->manager + ->expects($this->once()) + ->method('getBrowserPath') + ->with($sourcePath, self::FILTER) + ->willReturn($cachePath); + + $actualPath = $this->runtime->filter($versionedPath, self::FILTER); + + $this->assertSame($expectedPath, $actualPath); + } + + public function provideJsonManifest(): array + { + return [ + 'query parameter, no slash' => [array_keys(self::JSON_MANIFEST)[0], array_values(self::JSON_MANIFEST)[0]], + 'in filename, no slash' => [array_keys(self::JSON_MANIFEST)[1], array_values(self::JSON_MANIFEST)[1]], + 'query parameter, slash' => [array_keys(self::JSON_MANIFEST)[2], array_values(self::JSON_MANIFEST)[2]], + 'in filename, slash' => [array_keys(self::JSON_MANIFEST)[3], array_values(self::JSON_MANIFEST)[3]], + ]; + } + + public function provideJsonManifestSwapExt(): array + { + return [ + 'query parameter, no slash' => [array_keys(self::JSON_MANIFEST)[0], array_values(self::JSON_MANIFEST)[0], 'png', 'webp'], + 'in filename, no slash' => [array_keys(self::JSON_MANIFEST)[1], array_values(self::JSON_MANIFEST)[1], 'png', 'webp'], + 'query parameter, slash' => [array_keys(self::JSON_MANIFEST)[2], array_values(self::JSON_MANIFEST)[2], 'png', 'webp'], + 'in filename, slash' => [array_keys(self::JSON_MANIFEST)[3], array_values(self::JSON_MANIFEST)[3], 'png', 'webp'], + ]; + } + public function testInvokeFilterCacheMethod(): void { $expectedInputPath = 'thePathToTheImage';