diff --git a/config/plugins.js b/config/plugins.js
index 05123095..8b41c5d0 100644
--- a/config/plugins.js
+++ b/config/plugins.js
@@ -12,6 +12,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')
+const WebpackIconFilesPlugin = require('./webpack-icon-files-plugin')
module.exports = {
get: function (mode) {
@@ -20,6 +21,11 @@ module.exports = {
watch: mode !== 'production',
}),
new SpriteHashPlugin(),
+ new WebpackIconFilesPlugin({
+ sourcePath: 'src/img/icons',
+ outputPath: 'dist/images',
+ silence: true,
+ }),
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: ['**/*', '!images', '!images/**'],
}),
diff --git a/config/svgo.config.js b/config/svgo.config.js
index 7e994e7a..ea0d351c 100644
--- a/config/svgo.config.js
+++ b/config/svgo.config.js
@@ -2,14 +2,7 @@ module.exports = {
plugins: [
{
name: 'preset-default',
- params: {
- overrides: {
- // Disable a plugin included by default that you don't want (false)
- removeViewBox: false,
- },
- },
},
- // Plugins that are not in the "preset-default" and that you want to activate
'removeTitle',
'convertStyleToAttrs',
'prefixIds',
diff --git a/config/webpack-icon-files-plugin.js b/config/webpack-icon-files-plugin.js
new file mode 100644
index 00000000..bb0a392a
--- /dev/null
+++ b/config/webpack-icon-files-plugin.js
@@ -0,0 +1,110 @@
+const fs = require('fs')
+const path = require('path')
+const { optimize } = require('svgo')
+const svgoConfig = require('./svgo.config')
+
+/**
+ * Webpack plugin that writes optimized standalone SVG files from icon sources.
+ * Sprite sheets under dist/icons are unchanged; this mirrors src/img/icons/** into dist/images/**.
+ *
+ * @class WebpackIconFilesPlugin
+ */
+class WebpackIconFilesPlugin {
+ /**
+ * @param {Object} [options={}] Plugin options.
+ * @param {string} [options.sourcePath='src/img/icons'] Source icons directory.
+ * @param {string} [options.outputPath='dist/images'] Output directory (subfolders preserved).
+ * @param {boolean} [options.silence=false] Suppress console output.
+ */
+ constructor(options = {}) {
+ this.options = {
+ sourcePath: 'src/img/icons',
+ outputPath: 'dist/images',
+ silence: false,
+ ...options,
+ }
+ }
+
+ /**
+ * @param {string} level Log level.
+ * @param {...any} args Log arguments.
+ */
+ log(level, ...args) {
+ if (!this.options.silence) {
+ console[level](...args)
+ }
+ }
+
+ /**
+ * @param {string} directory Directory to scan.
+ * @return {string[]} Absolute paths to SVG files.
+ */
+ collectSvgFiles(directory) {
+ const files = []
+
+ if (!fs.existsSync(directory)) {
+ return files
+ }
+
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+ const fullPath = path.join(directory, entry.name)
+
+ if (entry.isDirectory()) {
+ files.push(...this.collectSvgFiles(fullPath))
+ continue
+ }
+
+ if (entry.name.endsWith('.svg')) {
+ files.push(fullPath)
+ }
+ }
+
+ return files
+ }
+
+ /**
+ * @param {import('webpack').Compiler} compiler Webpack compiler.
+ */
+ apply(compiler) {
+ compiler.hooks.afterEmit.tapAsync('WebpackIconFilesPlugin', (compilation, callback) => {
+ const context = compiler.options.context
+ const sourceDir = path.resolve(context, this.options.sourcePath)
+ const outputDir = path.resolve(context, this.options.outputPath)
+ const svgFiles = this.collectSvgFiles(sourceDir)
+
+ if (svgFiles.length === 0) {
+ callback()
+ return
+ }
+
+ let written = 0
+
+ for (const sourceFile of svgFiles) {
+ const relativePath = path.relative(sourceDir, sourceFile)
+ const destFile = path.join(outputDir, relativePath)
+
+ fs.mkdirSync(path.dirname(destFile), { recursive: true })
+
+ const input = fs.readFileSync(sourceFile, 'utf8')
+ const result = optimize(input, {
+ path: sourceFile,
+ ...svgoConfig,
+ })
+
+ if (result.error) {
+ console.warn(`WebpackIconFilesPlugin: SVGO failed for ${relativePath}: ${result.error}`)
+ continue
+ }
+
+ fs.writeFileSync(destFile, result.data)
+ written++
+ }
+
+ this.log('log', `WebpackIconFilesPlugin: Wrote ${written} optimized SVG(s) to ${this.options.outputPath}`)
+
+ callback()
+ })
+ }
+}
+
+module.exports = WebpackIconFilesPlugin
diff --git a/inc/Helpers/Svg.php b/inc/Helpers/Svg.php
index e9ca986c..38817d45 100644
--- a/inc/Helpers/Svg.php
+++ b/inc/Helpers/Svg.php
@@ -7,16 +7,17 @@
* @usage BEA\Theme\Framework\Helpers\Svg\get_the_icon( 'like' );
*
* @param string $icon_class
- * @param array $additionnal_classes
+ * @param array $additionnal_classes
+ * @param bool $is_sprite
*
* @return string
*/
-function get_the_icon( string $icon_class, $additionnal_classes = [] ): string {
+function get_the_icon( string $icon_class, $additionnal_classes = [], bool $is_sprite = true ): string {
/**
* @var Svg $svg
*/
$svg = \BEA\Theme\Framework\Framework::get_container()->get_service( 'svg' );
- return false !== $svg ? $svg->get_the_icon( $icon_class, $additionnal_classes ) : '';
+ return false !== $svg ? $svg->get_the_icon( $icon_class, $additionnal_classes, $is_sprite ) : '';
}
/**
@@ -24,11 +25,12 @@ function get_the_icon( string $icon_class, $additionnal_classes = [] ): string {
*
* @param string $icon_class
* @param array $additionnal_classes
+ * @param bool $is_sprite
*/
-function the_icon( string $icon_class, $additionnal_classes = [] ): void {
+function the_icon( string $icon_class, $additionnal_classes = [], bool $is_sprite = true ): void {
/**
* @var Svg $svg
*/
$svg = \BEA\Theme\Framework\Framework::get_container()->get_service( 'svg' );
- false !== $svg ? $svg->the_icon( $icon_class, $additionnal_classes ) : '';
+ false !== $svg ? $svg->the_icon( $icon_class, $additionnal_classes, $is_sprite ) : '';
}
diff --git a/inc/Services/Svg.php b/inc/Services/Svg.php
index a8a3e17e..93a2591e 100644
--- a/inc/Services/Svg.php
+++ b/inc/Services/Svg.php
@@ -33,16 +33,55 @@ public function get_service_name(): string {
}
/**
- * @param string $icon_class
- * @param array $additionnal_classes
+ * @param string $icon_class Icon identifier (e.g. menu, social/facebook, social.svg#icon-facebook).
+ * @param array $additionnal_classes Extra CSS classes.
+ * @param bool $is_sprite When true, output a sprite reference; when false, inline the SVG file from dist/images.
*
* @return string
*/
- public function get_the_icon( string $icon_class, array $additionnal_classes = [] ): string {
+ public function get_the_icon( string $icon_class, array $additionnal_classes = [], bool $is_sprite = true ): string {
if ( empty( $icon_class ) ) {
return '';
}
+ $parts = $this->parse_icon_identifier( $icon_class );
+ $classes = array_map(
+ 'sanitize_html_class',
+ array_merge( [ 'icon', $parts['icon_slug'] ], $additionnal_classes )
+ );
+
+ if ( ! $is_sprite ) {
+ return $this->get_inline_icon_markup( $parts['sprite_name'], $parts['file_base'], $classes );
+ }
+
+ $icon_url = \get_theme_file_uri( sprintf( '/dist/icons/%s.svg', $parts['sprite_name'] ) );
+ $hash_sprite = $this->get_sprite_hash( $parts['sprite_name'] );
+
+ return sprintf(
+ '',
+ implode( ' ', $classes ),
+ add_query_arg( [ 'v' => $hash_sprite ], $icon_url ),
+ $parts['icon_slug']
+ );
+ }
+
+ /**
+ * @param string $icon_class Icon identifier.
+ * @param array $additionnal_classes Extra CSS classes.
+ * @param bool $is_sprite When true, output a sprite reference; when false, inline the SVG file.
+ */
+ public function the_icon( string $icon_class, array $additionnal_classes = [], bool $is_sprite = true ): void {
+ echo $this->get_the_icon( $icon_class, $additionnal_classes, $is_sprite ); //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ }
+
+ /**
+ * Normalizes an icon identifier into sprite folder, symbol id and file basename.
+ *
+ * @param string $icon_class Raw icon identifier.
+ *
+ * @return array{sprite_name: string, icon_slug: string, file_base: string}
+ */
+ private function parse_icon_identifier( string $icon_class ): array {
// acf-svg-icon already return sprite-name.svg#icon-name, ex: social.svg#icon-facebook
// format the string to obtain sprite-name/icon-name
$icon_class = str_replace( '.svg#icon-', '/', $icon_class );
@@ -55,22 +94,104 @@ 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 );
- $icon_url = \get_theme_file_uri( sprintf( '/dist/icons/%s.svg', $sprite_name ) );
- $hash_sprite = $this->get_sprite_hash( $sprite_name );
+ $sprite_name = preg_replace( '/[^a-z0-9-]/', '', strtolower( (string) $sprite_name ) );
+ if ( '' === $sprite_name ) {
+ $sprite_name = 'sprite';
+ }
+
+ $icon_slug = str_starts_with( $icon_class, 'icon-' ) ? $icon_class : sprintf( 'icon-%s', $icon_class );
+ $file_base = preg_replace( '/^icon-/', '', $icon_slug );
+ $file_base = preg_replace( '/[^a-z0-9-]/', '', strtolower( (string) $file_base ) );
+
+ return [
+ 'sprite_name' => $sprite_name,
+ 'icon_slug' => $icon_slug,
+ 'file_base' => $file_base,
+ ];
+ }
+
+ /**
+ * Loads an optimized SVG from dist/images and merges wrapper classes onto the root element.
+ *
+ * @param string $sprite_name Icon subdirectory (e.g. sprite, social).
+ * @param string $file_base SVG filename without extension.
+ * @param array $classes CSS classes for the root SVG element.
+ *
+ * @return string
+ */
+ private function get_inline_icon_markup( string $sprite_name, string $file_base, array $classes ): string {
+ if ( '' === $file_base ) {
+ return '';
+ }
+
+ $relative_path = sprintf( 'dist/images/%s/%s.svg', $sprite_name, $file_base );
+ $file_path = \get_theme_file_path( $relative_path );
+
+ if ( ! is_readable( $file_path ) ) {
+ return '';
+ }
+
+ $real_file = realpath( $file_path );
+ $real_base = realpath( \get_theme_file_path( 'dist/images' ) );
+
+ if ( false === $real_file || false === $real_base || ! str_starts_with( $real_file, $real_base ) ) {
+ return '';
+ }
+
+ $svg_markup = file_get_contents( $real_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
- return sprintf( '', implode( ' ', $classes ), add_query_arg( [ 'v' => $hash_sprite ], $icon_url ), $icon_slug );
+ if ( false === $svg_markup || '' === $svg_markup ) {
+ return '';
+ }
+
+ return $this->inject_svg_classes( $svg_markup, $classes );
}
/**
- * @param string $icon_class
- * @param array $additionnal_classes
+ * Merges classes and a11y attributes on the first root SVG element.
+ *
+ * @param string $svg_markup SVG file contents.
+ * @param array $classes CSS classes to apply.
+ *
+ * @return string
*/
- public function the_icon( string $icon_class, array $additionnal_classes = [] ): void {
- echo $this->get_the_icon( $icon_class, $additionnal_classes ); //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ private function inject_svg_classes( string $svg_markup, array $classes ): string {
+ $class_attr = implode( ' ', array_filter( $classes ) );
+
+ $updated = preg_replace_callback(
+ '/