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
6 changes: 6 additions & 0 deletions config/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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/**'],
}),
Expand Down
1 change: 0 additions & 1 deletion config/svgo.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ module.exports = {
name: 'removeViewBox',
active: false,
},
// Plugins that are not in the "preset-default" and that you want to activate
'removeTitle',
'convertStyleToAttrs',
'prefixIds',
Expand Down
110 changes: 110 additions & 0 deletions config/webpack-icon-files-plugin.js
Original file line number Diff line number Diff line change
@@ -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
12 changes: 7 additions & 5 deletions inc/Helpers/Svg.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,30 @@
* @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 {
Comment thread
cedric07 marked this conversation as resolved.
/**
* @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 ) : '';
}

/**
* @usage BEA\Theme\Framework\Helpers\Svg\the_icon( 'like' );
*
* @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 ) : '';
}
149 changes: 135 additions & 14 deletions inc/Services/Svg.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
cedric07 marked this conversation as resolved.
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(
'<svg class="%s" aria-hidden="true" focusable="false"><use href="%s#%s"></use></svg>',
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 );
Expand All @@ -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( '<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 );
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(
'/<svg\b([^>]*)>/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' . $attrs . '>';
},
$svg_markup,
1
);

return is_string( $updated ) ? $updated : $svg_markup;
}

/**
Expand Down
Loading