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
7 changes: 0 additions & 7 deletions config/svgo.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
Comment on lines -5 to -10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C'est volontaire ?

Sur le svg généré, il n'y aura plus de viewbox. De mémoire ça posait des soucis de taille.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Je suis passé à SVGO 4 sur cette PR : https://github.com/BeAPI/beapi-frontend-framework/pull/518/changes

Et avait justement remanié le fichier pour éviter le warning, car la structure change. Il faut le mettre dans le tableau en dessous

},
// 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On part du principe qu'on utilise toujours des sprite par défaut ? pas l'inverse ?

/**
* @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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Même remarque

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
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#518 j'avais migé ici déjà, est-ce que ça vaut le coup de merger cette PR et rebase dans ta branche ?

"svgo-loader": "^3.0.3",
"terser-webpack-plugin": "^5.3.14",
"webpack": "^5.109.2",
Expand Down
Loading