Skip to content
2 changes: 2 additions & 0 deletions config/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPl

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

module.exports = {
get: function (mode) {
const plugins = [
new WebpackThemeJsonPlugin({
watch: mode !== 'production',
}),
new SpriteHashPlugin(),
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: ['**/*', '!images', '!images/**'],
}),
Expand Down
94 changes: 94 additions & 0 deletions config/webpack-sprite-hash-plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')

/**
* Webpack plugin to generate content hashes for SVG sprite files.
* Creates a sprite-hashes.php file in the dist folder.
*
* @param {Object} options Plugin options.
* @param {string} [options.outputPath='dist'] Output directory.
* @param {string} [options.spritePath='dist/icons'] Sprite SVG directory.
* @param {string} [options.outputFilename='sprite-hashes.php'] Output file name.
* @param {number} [options.hashLength=8] Hash length in characters.
*/
class SpriteHashPlugin {
constructor(options = {}) {
this.options = {
outputPath: options.outputPath || 'dist',
spritePath: options.spritePath || 'dist/icons',
outputFilename: options.outputFilename || 'sprite-hashes.asset.php',
hashLength: options.hashLength || 8,
}
}

/**
* Escapes a string for safe use inside a PHP single-quoted string literal.
*
* @param {string} str Input string.
* @return {string} Escaped string.
*/
_escapePhpSingleQuoted(str) {
return String(str).replace(/\\/g, '\\\\').replace(/'/g, "\\'")
}

/**
* Formats a plain object as a PHP associative array string.
*
* @param {Record<string, string>} obj Key-value pairs.
* @return {string} PHP array literal.
*/
formatPhpArray(obj) {
const entries = Object.entries(obj).map(([key, value]) => {
const escapedKey = this._escapePhpSingleQuoted(key)
const escapedValue = this._escapePhpSingleQuoted(value)
return `\t'${escapedKey}' => '${escapedValue}'`
})
return `array(\n${entries.join(',\n')}\n)`
}

apply(compiler) {
compiler.hooks.afterEmit.tapAsync('SpriteHashPlugin', (compilation, callback) => {
const spriteDir = path.resolve(compiler.options.context, this.options.spritePath)
const outputFile = path.resolve(compiler.options.context, this.options.outputPath, this.options.outputFilename)

if (!fs.existsSync(spriteDir)) {
console.warn(`SpriteHashPlugin: Sprite directory not found: ${spriteDir}`)
callback()
return
}

const hashes = {}
const files = fs.readdirSync(spriteDir).filter((file) => file.endsWith('.svg'))

files.forEach((file) => {
const filePath = path.join(spriteDir, file)
const content = fs.readFileSync(filePath)
const hash = crypto.createHash('md5').update(content).digest('hex').substring(0, this.options.hashLength)

// Store with relative path as key
const relativePath = `icons/${file}`
hashes[relativePath] = hash
})

const phpLines = [
'<?php',
'/**',
' * Sprite file hashes. Generated by SpriteHashPlugin.',
' *',
' * @return array<string, string> Path => hash.',
' */',
'return ' + this.formatPhpArray(hashes) + ';',
'',
]
fs.writeFileSync(outputFile, phpLines.join('\n'))
console.log(
`SpriteHashPlugin: Generated ${this.options.outputFilename} with ${Object.keys(hashes).length} sprites`
)

callback()
})
}
}

module.exports = SpriteHashPlugin
49 changes: 43 additions & 6 deletions inc/Services/Svg.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,14 @@ public function get_the_icon( string $icon_class, array $additionnal_classes = [
$icon_class = substr( $icon_class, $slash_pos + 1 );
}

$icon_slug = strpos( $icon_class, 'icon-' ) === 0 ? $icon_class : sprintf( 'icon-%s', $icon_class );
$classes = [ 'icon', $icon_slug ];
$classes = array_merge( $classes, $additionnal_classes );
$classes = array_map( 'sanitize_html_class', $classes );

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
$icon_slug = strpos( $icon_class, 'icon-' ) === 0 ? $icon_class : sprintf( 'icon-%s', $icon_class );
$classes = [ 'icon', $icon_slug ];
$classes = array_merge( $classes, $additionnal_classes );
$classes = array_map( 'sanitize_html_class', $classes );
$icon_url = \get_theme_file_uri( sprintf( '/dist/icons/%s.svg', $sprite_name ) );
$hash_sprite = $this->get_sprite_hash( $sprite_name );

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 );
}

/**
Expand Down Expand Up @@ -89,6 +91,8 @@ public function allow_svg_tag( $tags ) {
'focusable' => [],
'class' => [],
'style' => [],
'width' => [],
'height' => [],
];

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

return $tags;
}

/**
* Get the hash of the sprite
*
* @param string $sprite_name
*
* @return string | null
*/
public function get_sprite_hash( string $sprite_name ): ?string {
static $sprite_hashes = null;

if ( null === $sprite_hashes ) {
$sprite_hash_file = get_theme_file_path( '/dist/sprite-hashes.asset.php' );

if ( ! is_readable( $sprite_hash_file ) ) {
$sprite_hashes = [];

return null;
}

$sprite_hash = require $sprite_hash_file;

if ( ! is_array( $sprite_hash ) ) {
$sprite_hashes = [];

return null;
}

$sprite_hashes = $sprite_hash;
}

return $sprite_hashes[ sprintf( 'icons/%s.svg', $sprite_name ) ] ?? null;
}
}