Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
84 changes: 84 additions & 0 deletions config/webpack-sprite-hash-plugin.js
Original file line number Diff line number Diff line change
@@ -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<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, "\\'")

Check failure

Code scanning / CodeQL

Incomplete string escaping or encoding High

This does not escape backslash characters in the input.

Copilot Autofix

AI about 1 hour ago

In general, when building string literals with manual escaping, backslashes must be escaped before escaping other meta-characters like single quotes. For PHP single-quoted strings, every backslash (\) should become \\, and every single quote (') should become \'.

The best fix here is to replace the ad-hoc replace(/'/g, "\\'") logic with a small helper that correctly escapes both backslashes and single quotes for PHP single-quoted strings. This helper should be used for both keys and values in formatPhpArray. The order of replacements matters: first replace \ with \\, then replace ' with \', so that newly added backslashes from the quote-escaping step are not re-escaped.

Concretely, in config/webpack-sprite-hash-plugin.js:

  • Add a private helper method (e.g., _escapePhpSingleQuoted) to the SpriteHashPlugin class, above formatPhpArray.
  • Implement it as return String(str).replace(/\\/g, '\\\\').replace(/'/g, "\\'").
  • Update formatPhpArray to use this helper for both key and value instead of the current .replace(/'/g, "\\'") calls.

No new imports are needed; only standard JavaScript string methods and regexes are used.

Suggested changeset 1
config/webpack-sprite-hash-plugin.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/config/webpack-sprite-hash-plugin.js b/config/webpack-sprite-hash-plugin.js
--- a/config/webpack-sprite-hash-plugin.js
+++ b/config/webpack-sprite-hash-plugin.js
@@ -23,6 +23,16 @@
 	}
 
 	/**
+	 * 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.
@@ -30,8 +40,8 @@
 	 */
 	formatPhpArray(obj) {
 		const entries = Object.entries(obj).map(([key, value]) => {
-			const escapedKey = key.replace(/'/g, "\\'")
-			const escapedValue = String(value).replace(/'/g, "\\'")
+			const escapedKey = this._escapePhpSingleQuoted(key)
+			const escapedValue = this._escapePhpSingleQuoted(value)
 			return `\t'${escapedKey}' => '${escapedValue}'`
 		})
 		return `array(\n${entries.join(',\n')}\n)`
EOF
@@ -23,6 +23,16 @@
}

/**
* 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.
@@ -30,8 +40,8 @@
*/
formatPhpArray(obj) {
const entries = Object.entries(obj).map(([key, value]) => {
const escapedKey = key.replace(/'/g, "\\'")
const escapedValue = String(value).replace(/'/g, "\\'")
const escapedKey = this._escapePhpSingleQuoted(key)
const escapedValue = this._escapePhpSingleQuoted(value)
return `\t'${escapedKey}' => '${escapedValue}'`
})
return `array(\n${entries.join(',\n')}\n)`
Copilot is powered by AI and may make mistakes. Always verify output.
const escapedValue = String(value).replace(/'/g, "\\'")

Check failure

Code scanning / CodeQL

Incomplete string escaping or encoding High

This does not escape backslash characters in the input.

Copilot Autofix

AI about 1 hour ago

To correctly encode strings for a PHP single-quoted literal, both backslashes and single quotes must be escaped. In PHP, this is done by replacing \ with \\ and ' with \' (in that order). The current implementation only escapes single quotes with replace(/'/g, "\\'"), leaving backslashes unchanged, which can break the intended escaping if the original value contains backslashes.

The best fix is to update the formatPhpArray method so that it first escapes backslashes, then escapes single quotes, for both keys and values. We keep the current structure and behavior, only improving the escaping. Concretely, in config/webpack-sprite-hash-plugin.js, lines 33–35 inside formatPhpArray should be changed so that:

  • escapedKey is computed with .replace(/\\/g, '\\\\').replace(/'/g, "\\'")
  • escapedValue is computed similarly with .replace(/\\/g, '\\\\').replace(/'/g, "\\'")

No new imports or methods are necessary; this uses only built-in string operations. All other behavior (file paths, hash generation, output file format) remains unchanged.

Suggested changeset 1
config/webpack-sprite-hash-plugin.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/config/webpack-sprite-hash-plugin.js b/config/webpack-sprite-hash-plugin.js
--- a/config/webpack-sprite-hash-plugin.js
+++ b/config/webpack-sprite-hash-plugin.js
@@ -30,8 +30,8 @@
 	 */
 	formatPhpArray(obj) {
 		const entries = Object.entries(obj).map(([key, value]) => {
-			const escapedKey = key.replace(/'/g, "\\'")
-			const escapedValue = String(value).replace(/'/g, "\\'")
+			const escapedKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
+			const escapedValue = String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")
 			return `\t'${escapedKey}' => '${escapedValue}'`
 		})
 		return `array(\n${entries.join(',\n')}\n)`
EOF
@@ -30,8 +30,8 @@
*/
formatPhpArray(obj) {
const entries = Object.entries(obj).map(([key, value]) => {
const escapedKey = key.replace(/'/g, "\\'")
const escapedValue = String(value).replace(/'/g, "\\'")
const escapedKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
const escapedValue = String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")
return `\t'${escapedKey}' => '${escapedValue}'`
})
return `array(\n${entries.join(',\n')}\n)`
Copilot is powered by AI and may make mistakes. Always verify output.
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;
}
}