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( + '/]*)>/i', + static function ( array $matches ) use ( $class_attr ): string { + $attrs = $matches[1]; + + if ( '' !== $class_attr ) { + if ( preg_match( '/\bclass=(["\'])([^"\']*)\1/i', $attrs, $class_match ) ) { + $new_class = trim( $class_match[2] . ' ' . $class_attr ); + $attrs = preg_replace( + '/\bclass=(["\'])([^"\']*)\1/i', + 'class="' . esc_attr( $new_class ) . '"', + $attrs, + 1 + ); + } else { + $attrs .= sprintf( ' class="%s"', esc_attr( $class_attr ) ); + } + } + + if ( ! preg_match( '/\baria-hidden=/i', $attrs ) ) { + $attrs .= ' aria-hidden="true"'; + } + + if ( ! preg_match( '/\bfocusable=/i', $attrs ) ) { + $attrs .= ' focusable="false"'; + } + + return ''; + }, + $svg_markup, + 1 + ); + + return is_string( $updated ) ? $updated : $svg_markup; } /** diff --git a/package-lock.json b/package-lock.json index 6edc34d7..9800378d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "stylelint-scss": "^6.14.0", "stylelint-webpack-plugin": "^5.1.0", "svg-sprite-loader": "^6.0.11", + "svgo": "^4.0.2", "svgo-loader": "^3.0.3", "terser-webpack-plugin": "^5.3.14", "webpack": "^5.109.2", diff --git a/package.json b/package.json index 3f1255d9..478c1d1a 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "stylelint-scss": "^6.14.0", "stylelint-webpack-plugin": "^5.1.0", "svg-sprite-loader": "^6.0.11", + "svgo": "^4.0.2", "svgo-loader": "^3.0.3", "terser-webpack-plugin": "^5.3.14", "webpack": "^5.109.2",