├── LICENSE ├── README.md ├── composer.json ├── phpstan.neon └── src ├── Asset └── HashingVersionStrategy.php ├── CacheWarmer ├── AssetFinder.php └── HashCacheWarmer.php ├── DependencyInjection ├── Configuration.php └── IncenteevHashedAssetExtension.php ├── Hashing ├── AssetHasherInterface.php ├── CachedHasher.php └── FileHasher.php ├── IncenteevHashedAssetBundle.php └── Resources └── config ├── cache.xml └── services.xml /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Incenteev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | HashedAssetBundle 2 | ================= 3 | 4 | The HashedAssetBundle provides an asset version strategy which uses a hash 5 | of the file content as asset version. This allows bumping the asset version 6 | separately for each asset (automatically). 7 | 8 | [![CI](https://github.com/Incenteev/hashed-asset-bundle/actions/workflows/ci.yml/badge.svg)](https://github.com/Incenteev/hashed-asset-bundle/actions/workflows/ci.yml) [![Total Downloads](https://poser.pugx.org/incenteev/hashed-asset-bundle/downloads.svg)](https://packagist.org/packages/incenteev/hashed-asset-bundle) [![Latest Stable Version](https://poser.pugx.org/incenteev/hashed-asset-bundle/v/stable.svg)](https://packagist.org/packages/incenteev/hashed-asset-bundle) 9 | 10 | ## Installation 11 | 12 | Use [Composer](https://getcomposer.org) to install the bundle: 13 | 14 | ```bash 15 | $ composer require incenteev/hashed-asset-bundle 16 | ``` 17 | 18 | ## Usage 19 | 20 | Register the bundle in the kernel: 21 | 22 | ```php 23 | // app/AppKernel.php 24 | 25 | // ... 26 | 27 | class AppKernel extends Kernel { 28 | public function registerBundles() 29 | { 30 | $bundles = array( 31 | // ... 32 | new Incenteev\HashedAssetBundle\IncenteevHashedAssetBundle(), 33 | ); 34 | } 35 | } 36 | ``` 37 | 38 | Then configure FrameworkBundle to use the new version strategy: 39 | 40 | ```yaml 41 | framework: 42 | assets: 43 | version_strategy: incenteev_hashed_asset.strategy 44 | ``` 45 | 46 | ## Advanced configuration 47 | 48 | The default configuration should fit common needs, but the bundle exposes 49 | a few configuration settings in case you need them: 50 | 51 | ```yaml 52 | incenteev_hashed_asset: 53 | # Absolute path to the folder in which assets can be found 54 | # Note: in case you apply a base_path in your asset package, it is not 55 | # yet applied to the string received by the bundle 56 | web_root: '%kernel.project_dir%/web' 57 | # Format used to apply the version. This is equivalent to the 58 | # `framework > assets > version_format` of the static version strategy 59 | # of FrameworkBundle. 60 | version_format: '%%s?%%s' 61 | ``` 62 | 63 | ## License 64 | 65 | This bundle is under the [MIT license](LICENSE). 66 | 67 | ## Alternative projects 68 | 69 | If you want to apply cache busting by renaming files in your asset pipeline 70 | (for instance with the webpack-encore versioning feature), have a look at the 71 | `json_manifest` strategy available in Symfony itself. 72 | 73 | ## Reporting an issue or a feature request 74 | 75 | Issues and feature requests are tracked in the [Github issue tracker](https://github.com/Incenteev/hashed-asset-bundle/issues). 76 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "incenteev/hashed-asset-bundle", 3 | "description": "Apply an asset version based on a hash of the asset for symfony/asset", 4 | "keywords": ["symfony", "symfony-bundle", "bundle", "assets", "cache-busting"], 5 | "type": "symfony-bundle", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Christophe Coevoet", 10 | "email": "stof@notk.org" 11 | } 12 | ], 13 | "require": { 14 | "php": ">=7.4", 15 | "symfony/asset": "^4.4.13 || ^5.3 || ^6.0 || ^7.0", 16 | "symfony/cache": "^4.4.13 || ^5.3 || ^6.0 || ^7.0", 17 | "symfony/config": "^4.4 || ^5.3 || ^6.0 || ^7.0", 18 | "symfony/dependency-injection": "^4.4.13 || ^5.3 || ^6.0 || ^7.0", 19 | "symfony/finder": "^4.4.13 || ^5.3 || ^6.0 || ^7.0", 20 | "symfony/framework-bundle": "^4.4.13 || ^5.3 || ^6.0 || ^7.0", 21 | "symfony/http-kernel": "^4.4.13 || ^5.3 || ^6.0 || ^7.0" 22 | }, 23 | "require-dev": { 24 | "jangregor/phpstan-prophecy": "^1.0", 25 | "phpspec/prophecy-phpunit": "^2.0", 26 | "phpstan/phpstan": "^1.5", 27 | "phpstan/phpstan-deprecation-rules": "^1.0", 28 | "phpstan/phpstan-phpunit": "^1.0", 29 | "phpstan/phpstan-symfony": "^1.1", 30 | "phpunit/phpunit": "^9.5", 31 | "symfony/phpunit-bridge": "^5.3 || ^6.0 || ^7.0" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Incenteev\\HashedAssetBundle\\": "src" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Incenteev\\HashedAssetBundle\\Tests\\": "tests" 41 | } 42 | }, 43 | "config": { 44 | "sort-packages": true 45 | }, 46 | "extra": { 47 | "branch-alias": { 48 | "dev-master": "1.x-dev" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | paths: 4 | - src/ 5 | - tests/ 6 | ignoreErrors: 7 | - '#^Method Incenteev\\HashedAssetBundle\\Tests\\[^:]++\:\:test\w++\(\) has no return type specified\.$#' 8 | - '#^Method Incenteev\\HashedAssetBundle\\Tests\\[^:]++\:\:get\w++\(\) has no return type specified\.$#' 9 | 10 | includes: 11 | - vendor/phpstan/phpstan-deprecation-rules/rules.neon 12 | - vendor/phpstan/phpstan-phpunit/extension.neon 13 | - vendor/phpstan/phpstan-phpunit/rules.neon 14 | - vendor/phpstan/phpstan-symfony/extension.neon 15 | - vendor/phpstan/phpstan-symfony/rules.neon 16 | - vendor/jangregor/phpstan-prophecy/extension.neon 17 | -------------------------------------------------------------------------------- /src/Asset/HashingVersionStrategy.php: -------------------------------------------------------------------------------- 1 | format = $format ?: '%s?%s'; 16 | $this->hasher = $hasher; 17 | } 18 | 19 | /** 20 | * @param string $path 21 | */ 22 | public function getVersion($path): string 23 | { 24 | return $this->hasher->computeHash($path); 25 | } 26 | 27 | /** 28 | * @param string $path 29 | */ 30 | public function applyVersion($path): string 31 | { 32 | $versionized = sprintf($this->format, ltrim($path, '/'), $this->hasher->computeHash($path)); 33 | 34 | if ($path && '/' === $path[0]) { 35 | return '/'.$versionized; 36 | } 37 | 38 | return $versionized; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/CacheWarmer/AssetFinder.php: -------------------------------------------------------------------------------- 1 | webRoot = $webRoot; 18 | } 19 | 20 | /** 21 | * @return \Traversable 22 | */ 23 | public function getAssetPaths(): \Traversable 24 | { 25 | $finder = (new Finder())->files() 26 | ->in($this->webRoot); 27 | 28 | /** @var SplFileInfo $file */ 29 | foreach ($finder as $file) { 30 | yield $file->getRelativePathname(); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/CacheWarmer/HashCacheWarmer.php: -------------------------------------------------------------------------------- 1 | assetFinder = $assetFinder; 24 | $this->cacheFile = $cacheFile; 25 | $this->hasher = $hasher; 26 | 27 | if (!$fallbackPool instanceof AdapterInterface) { 28 | $fallbackPool = new ProxyAdapter($fallbackPool); 29 | } 30 | 31 | $this->fallbackPool = $fallbackPool; 32 | } 33 | 34 | /** 35 | * @param string $cacheDir 36 | * 37 | * @return string[] 38 | */ 39 | public function warmUp($cacheDir, string $buildDir = null): array 40 | { 41 | $phpArrayPool = new PhpArrayAdapter($this->cacheFile, $this->fallbackPool); 42 | $arrayPool = new ArrayAdapter(0, false); 43 | 44 | $hasher = new CachedHasher($this->hasher, $arrayPool); 45 | 46 | foreach ($this->assetFinder->getAssetPaths() as $path) { 47 | $hasher->computeHash($path); 48 | } 49 | 50 | $values = $arrayPool->getValues(); 51 | $phpArrayPool->warmUp($values); 52 | 53 | foreach ($values as $k => $v) { 54 | $item = $this->fallbackPool->getItem($k); 55 | $this->fallbackPool->saveDeferred($item->set($v)); 56 | } 57 | $this->fallbackPool->commit(); 58 | 59 | return []; 60 | } 61 | 62 | public function isOptional(): bool 63 | { 64 | return true; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 18 | 19 | $rootNode->children() 20 | ->scalarNode('web_root')->defaultValue(\class_exists(Recipe::class) ? '%kernel.project_dir%/public' : '%kernel.project_dir%/web')->end() 21 | ->scalarNode('version_format')->defaultValue('%%s?%%s')->end() 22 | ; 23 | 24 | return $treeBuilder; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/DependencyInjection/IncenteevHashedAssetExtension.php: -------------------------------------------------------------------------------- 1 | load('services.xml'); 19 | 20 | $container->getDefinition('incenteev_hashed_asset.file_hasher') 21 | ->replaceArgument(0, $config['web_root']); 22 | 23 | $container->getDefinition('incenteev_hashed_asset.strategy') 24 | ->replaceArgument(1, $config['version_format']); 25 | 26 | if (!$container->getParameter('kernel.debug')) { 27 | $loader->load('cache.xml'); 28 | 29 | $container->getDefinition('incenteev_hashed_asset.asset_finder') 30 | ->replaceArgument(0, $config['web_root']); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Hashing/AssetHasherInterface.php: -------------------------------------------------------------------------------- 1 | hasher = $hasher; 15 | $this->cache = $cache; 16 | } 17 | 18 | public function computeHash(string $path): string 19 | { 20 | // The hashing implementation does not care about leading slashes in the path, so share cache keys for them 21 | $item = $this->cache->getItem(base64_encode(ltrim($path, '/'))); 22 | 23 | if ($item->isHit()) { 24 | $cachedHash = $item->get(); 25 | 26 | if (\is_string($cachedHash)) { 27 | return $cachedHash; 28 | } 29 | } 30 | 31 | $hash = $this->hasher->computeHash($path); 32 | 33 | $item->set($hash); 34 | $this->cache->save($item); 35 | 36 | return $hash; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Hashing/FileHasher.php: -------------------------------------------------------------------------------- 1 | webRoot = $webRoot; 12 | } 13 | 14 | public function computeHash(string $path): string 15 | { 16 | $fullPath = $this->webRoot.'/'.ltrim($path, '/'); 17 | 18 | if (!is_file($fullPath)) { 19 | return ''; 20 | } 21 | 22 | $hash = sha1_file($fullPath); 23 | 24 | if ($hash === false) { 25 | return ''; 26 | } 27 | 28 | return substr($hash, 0, 7); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/IncenteevHashedAssetBundle.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | %kernel.cache_dir%/incenteev_asset_hashes.php 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | %incenteev_hashed_asset.cache.file% 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | %incenteev_hashed_asset.cache.file% 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/Resources/config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | --------------------------------------------------------------------------------