Skip to content
4 changes: 4 additions & 0 deletions config/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ 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({
returnFormat: 'php',
}),
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: ['**/*', '!images', '!images/**'],
}),
Expand Down
100 changes: 100 additions & 0 deletions config/webpack-sprite-hash-plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')

const ALLOWED_RETURN_FORMATS = ['json', 'php']

/**
* Webpack plugin to generate content hashes for SVG sprite files.
* Creates a sprite-hashes.json (or sprite-hashes.asset.php) file in the dist folder.
*
* @param {Object} options Plugin options.
* @param {string} [options.returnFormat='json'] Output format: 'json' or 'php'.
* When 'php', generates a .asset.php file (outputFilename .json → .asset.php).
* @param {string} [options.outputPath='dist'] Output directory.
* @param {string} [options.spritePath='dist/icons'] Sprite SVG directory.
* @param {string} [options.outputFilename='sprite-hashes.json'] Output file name.
* @param {number} [options.hashLength=8] Hash length in characters.
*/
class SpriteHashPlugin {
constructor(options = {}) {
const returnFormat = options.returnFormat || 'json'
if (!ALLOWED_RETURN_FORMATS.includes(returnFormat)) {
throw new Error(`SpriteHashPlugin: returnFormat must be one of ${ALLOWED_RETURN_FORMATS.join(', ')}`)
}

this.options = {
outputPath: options.outputPath || 'dist',
spritePath: options.spritePath || 'dist/icons',
outputFilename: options.outputFilename || 'sprite-hashes.' + returnFormat,
hashLength: options.hashLength || 8,
returnFormat,
}
}

/**
* 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 = key.replace(/'/g, "\\'")
const escapedValue = String(value).replace(/'/g, "\\'")
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 outputFilename =
this.options.returnFormat === 'php'
? this.options.outputFilename.replace(/\.json$/i, '.asset.php')
: this.options.outputFilename
const outputFile = path.resolve(compiler.options.context, this.options.outputPath, 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
})

if (this.options.returnFormat === 'php') {
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'))
} else {
fs.writeFileSync(outputFile, JSON.stringify(hashes, null, 2))
}
console.log(`SpriteHashPlugin: Generated ${outputFilename} with ${Object.keys(hashes).length} sprites`)

callback()
})
}
}

module.exports = SpriteHashPlugin
53 changes: 47 additions & 6 deletions inc/Services/Svg.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,14 @@
$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 @@
'focusable' => [],
'class' => [],
'style' => [],
'width' => [],
'height' => [],
];

$tags['path'] = [
Expand All @@ -104,4 +108,41 @@

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 ) {
$php_file = get_theme_file_path( '/dist/sprite-hashes.php' );
$json_file = get_theme_file_path( '/dist/sprite-hashes.json' );

if ( is_readable( $php_file ) ) {
$sprite_hashes = require $php_file;
$sprite_hashes = \is_array( $sprite_hashes ) ? $sprite_hashes : [];
} elseif ( is_readable( $json_file ) ) {
$json_content = file_get_contents( $json_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
try {
$sprite_hashes = json_decode( $json_content, true, 512, JSON_THROW_ON_ERROR );
$sprite_hashes = \is_array( $sprite_hashes ) ? $sprite_hashes : [];
} catch ( \JsonException $e ) {
$sprite_hashes = [];
}
} else {
$sprite_hashes = [];

return null;
}

$sprite_hashes = $sprite_hash;

Check failure on line 143 in inc/Services/Svg.php

View workflow job for this annotation

GitHub Actions / PHP 8.3 Test

UndefinedVariable

inc/Services/Svg.php:143:21: UndefinedVariable: Cannot find referenced variable $sprite_hash (see https://psalm.dev/024)

Check failure on line 143 in inc/Services/Svg.php

View workflow job for this annotation

GitHub Actions / PHP 8.3 Test

UndefinedVariable

inc/Services/Svg.php:143:21: UndefinedVariable: Cannot find referenced variable $sprite_hash (see https://psalm.dev/024)
}

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

Check failure on line 146 in inc/Services/Svg.php

View workflow job for this annotation

GitHub Actions / PHP 8.3 Test

UndefinedVariable

inc/Services/Svg.php:146:10: UndefinedVariable: Cannot find referenced variable $sprite_hash (see https://psalm.dev/024)

Check failure on line 146 in inc/Services/Svg.php

View workflow job for this annotation

GitHub Actions / PHP 8.3 Test

UndefinedVariable

inc/Services/Svg.php:146:10: UndefinedVariable: Cannot find referenced variable $sprite_hash (see https://psalm.dev/024)
}
}
Loading