diff --git a/config/plugins.js b/config/plugins.js index 8ed7d1c0..dd5858ee 100644 --- a/config/plugins.js +++ b/config/plugins.js @@ -11,6 +11,7 @@ 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) { @@ -18,6 +19,7 @@ module.exports = { new WebpackThemeJsonPlugin({ watch: mode !== 'production', }), + new SpriteHashPlugin(), new CleanWebpackPlugin({ cleanOnceBeforeBuildPatterns: ['**/*', '!images', '!images/**'], }), diff --git a/config/webpack-sprite-hash-plugin.js b/config/webpack-sprite-hash-plugin.js new file mode 100644 index 00000000..b33940a8 --- /dev/null +++ b/config/webpack-sprite-hash-plugin.js @@ -0,0 +1,84 @@ +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, + } + } + + /** + * Formats a plain object as a PHP associative array string. + * + * @param {Record} 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 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 = [ + ' 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 diff --git a/inc/Services/Svg.php b/inc/Services/Svg.php index b25df908..a8a3e17e 100644 --- a/inc/Services/Svg.php +++ b/inc/Services/Svg.php @@ -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( '', 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( '', implode( ' ', $classes ), add_query_arg( [ 'v' => $hash_sprite ], $icon_url ), $icon_slug ); } /** @@ -89,6 +91,8 @@ public function allow_svg_tag( $tags ) { 'focusable' => [], 'class' => [], 'style' => [], + 'width' => [], + 'height' => [], ]; $tags['path'] = [ @@ -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; + } }