Repository navigation
Build icons as plain SVG #519
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ) : ''; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ); | ||
|
|
@@ -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; | ||
| } | ||
|
|
||
| /** | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
|
|
||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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