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';