Skip to content

Commit ea5bc9a

Browse files
authored
Merge pull request #471 from BeAPI/feature/sprite-hash-manifest
feat(svg): adds sprite hash manifest for cache busting
2 parents ed363aa + ce82a75 commit ea5bc9a

3 files changed

Lines changed: 139 additions & 6 deletions

File tree

config/plugins.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPl
1111

1212
const WebpackImageSizesPlugin = require('./webpack-image-sizes-plugin')
1313
const WebpackThemeJsonPlugin = require('./webpack-theme-json-plugin')
14+
const SpriteHashPlugin = require('./webpack-sprite-hash-plugin')
1415

1516
module.exports = {
1617
get: function (mode) {
1718
const plugins = [
1819
new WebpackThemeJsonPlugin({
1920
watch: mode !== 'production',
2021
}),
22+
new SpriteHashPlugin(),
2123
new CleanWebpackPlugin({
2224
cleanOnceBeforeBuildPatterns: ['**/*', '!images', '!images/**'],
2325
}),
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
const fs = require('fs')
2+
const path = require('path')
3+
const crypto = require('crypto')
4+
5+
/**
6+
* Webpack plugin to generate content hashes for SVG sprite files.
7+
* Creates a sprite-hashes.php file in the dist folder.
8+
*
9+
* @param {Object} options Plugin options.
10+
* @param {string} [options.outputPath='dist'] Output directory.
11+
* @param {string} [options.spritePath='dist/icons'] Sprite SVG directory.
12+
* @param {string} [options.outputFilename='sprite-hashes.php'] Output file name.
13+
* @param {number} [options.hashLength=8] Hash length in characters.
14+
*/
15+
class SpriteHashPlugin {
16+
constructor(options = {}) {
17+
this.options = {
18+
outputPath: options.outputPath || 'dist',
19+
spritePath: options.spritePath || 'dist/icons',
20+
outputFilename: options.outputFilename || 'sprite-hashes.asset.php',
21+
hashLength: options.hashLength || 8,
22+
}
23+
}
24+
25+
/**
26+
* Escapes a string for safe use inside a PHP single-quoted string literal.
27+
*
28+
* @param {string} str Input string.
29+
* @return {string} Escaped string.
30+
*/
31+
_escapePhpSingleQuoted(str) {
32+
return String(str).replace(/\\/g, '\\\\').replace(/'/g, "\\'")
33+
}
34+
35+
/**
36+
* Formats a plain object as a PHP associative array string.
37+
*
38+
* @param {Record<string, string>} obj Key-value pairs.
39+
* @return {string} PHP array literal.
40+
*/
41+
formatPhpArray(obj) {
42+
const entries = Object.entries(obj).map(([key, value]) => {
43+
const escapedKey = this._escapePhpSingleQuoted(key)
44+
const escapedValue = this._escapePhpSingleQuoted(value)
45+
return `\t'${escapedKey}' => '${escapedValue}'`
46+
})
47+
return `array(\n${entries.join(',\n')}\n)`
48+
}
49+
50+
apply(compiler) {
51+
compiler.hooks.afterEmit.tapAsync('SpriteHashPlugin', (compilation, callback) => {
52+
const spriteDir = path.resolve(compiler.options.context, this.options.spritePath)
53+
const outputFile = path.resolve(compiler.options.context, this.options.outputPath, this.options.outputFilename)
54+
55+
if (!fs.existsSync(spriteDir)) {
56+
console.warn(`SpriteHashPlugin: Sprite directory not found: ${spriteDir}`)
57+
callback()
58+
return
59+
}
60+
61+
const hashes = {}
62+
const files = fs.readdirSync(spriteDir).filter((file) => file.endsWith('.svg'))
63+
64+
files.forEach((file) => {
65+
const filePath = path.join(spriteDir, file)
66+
const content = fs.readFileSync(filePath)
67+
const hash = crypto.createHash('md5').update(content).digest('hex').substring(0, this.options.hashLength)
68+
69+
// Store with relative path as key
70+
const relativePath = `icons/${file}`
71+
hashes[relativePath] = hash
72+
})
73+
74+
const phpLines = [
75+
'<?php',
76+
'/**',
77+
' * Sprite file hashes. Generated by SpriteHashPlugin.',
78+
' *',
79+
' * @return array<string, string> Path => hash.',
80+
' */',
81+
'return ' + this.formatPhpArray(hashes) + ';',
82+
'',
83+
]
84+
fs.writeFileSync(outputFile, phpLines.join('\n'))
85+
console.log(
86+
`SpriteHashPlugin: Generated ${this.options.outputFilename} with ${Object.keys(hashes).length} sprites`
87+
)
88+
89+
callback()
90+
})
91+
}
92+
}
93+
94+
module.exports = SpriteHashPlugin

inc/Services/Svg.php

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,14 @@ public function get_the_icon( string $icon_class, array $additionnal_classes = [
5555
$icon_class = substr( $icon_class, $slash_pos + 1 );
5656
}
5757

58-
$icon_slug = strpos( $icon_class, 'icon-' ) === 0 ? $icon_class : sprintf( 'icon-%s', $icon_class );
59-
$classes = [ 'icon', $icon_slug ];
60-
$classes = array_merge( $classes, $additionnal_classes );
61-
$classes = array_map( 'sanitize_html_class', $classes );
62-
63-
return sprintf( '<svg class="%s" aria-hidden="true" focusable="false"><use href="%s#%s"></use></svg>', implode( ' ', $classes ), \get_theme_file_uri( sprintf( '/dist/icons/%s.svg', $sprite_name ) ), $icon_slug ); //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
58+
$icon_slug = strpos( $icon_class, 'icon-' ) === 0 ? $icon_class : sprintf( 'icon-%s', $icon_class );
59+
$classes = [ 'icon', $icon_slug ];
60+
$classes = array_merge( $classes, $additionnal_classes );
61+
$classes = array_map( 'sanitize_html_class', $classes );
62+
$icon_url = \get_theme_file_uri( sprintf( '/dist/icons/%s.svg', $sprite_name ) );
63+
$hash_sprite = $this->get_sprite_hash( $sprite_name );
64+
65+
return sprintf( '<svg class="%s" aria-hidden="true" focusable="false"><use href="%s#%s"></use></svg>', implode( ' ', $classes ), add_query_arg( [ 'v' => $hash_sprite ], $icon_url ), $icon_slug );
6466
}
6567

6668
/**
@@ -89,6 +91,8 @@ public function allow_svg_tag( $tags ) {
8991
'focusable' => [],
9092
'class' => [],
9193
'style' => [],
94+
'width' => [],
95+
'height' => [],
9296
];
9397

9498
$tags['path'] = [
@@ -104,4 +108,37 @@ public function allow_svg_tag( $tags ) {
104108

105109
return $tags;
106110
}
111+
112+
/**
113+
* Get the hash of the sprite
114+
*
115+
* @param string $sprite_name
116+
*
117+
* @return string | null
118+
*/
119+
public function get_sprite_hash( string $sprite_name ): ?string {
120+
static $sprite_hashes = null;
121+
122+
if ( null === $sprite_hashes ) {
123+
$sprite_hash_file = get_theme_file_path( '/dist/sprite-hashes.asset.php' );
124+
125+
if ( ! is_readable( $sprite_hash_file ) ) {
126+
$sprite_hashes = [];
127+
128+
return null;
129+
}
130+
131+
$sprite_hash = require $sprite_hash_file;
132+
133+
if ( ! is_array( $sprite_hash ) ) {
134+
$sprite_hashes = [];
135+
136+
return null;
137+
}
138+
139+
$sprite_hashes = $sprite_hash;
140+
}
141+
142+
return $sprite_hashes[ sprintf( 'icons/%s.svg', $sprite_name ) ] ?? null;
143+
}
107144
}

0 commit comments

Comments
 (0)