From 803eb39c905dafd98f8f4da0c7fbfd3a645c2c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81dric=20Andrietti?= Date: Mon, 27 Apr 2026 11:58:40 +0200 Subject: [PATCH 1/3] Add WebpackStaticImagesPlugin to handle static image processing - Introduced WebpackStaticImagesPlugin to copy and convert static images to WebP format. - Configured plugin in plugins.js with input and output directories, quality settings, and console output options. --- config/plugins.js | 7 ++ config/webpack-static-images-plugin.js | 125 +++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 config/webpack-static-images-plugin.js diff --git a/config/plugins.js b/config/plugins.js index 17e125dc..fc8647a5 100644 --- a/config/plugins.js +++ b/config/plugins.js @@ -14,6 +14,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 WebpackStaticImagesPlugin = require('./webpack-static-images-plugin') module.exports = { get: function (mode) { @@ -85,6 +86,12 @@ module.exports = { defaultImageFormat: 'jpg', // Generated image format (jpg, png, webp, avif) silence: true, // Suppress console output }), + new WebpackStaticImagesPlugin({ + inputDir: 'src/img/static', + outputDir: 'dist/images', + quality: 80, + silence: false, // Suppress console output + }), ] if (mode === 'production') { diff --git a/config/webpack-static-images-plugin.js b/config/webpack-static-images-plugin.js new file mode 100644 index 00000000..f955013e --- /dev/null +++ b/config/webpack-static-images-plugin.js @@ -0,0 +1,125 @@ +const fs = require('fs') +const path = require('path') + +// Try to require sharp, fallback gracefully if not available +let sharp +try { + sharp = require('sharp') +} catch (error) { + console.warn('âš ī¸ Sharp not available. WebP conversion will be disabled.') +} + +/** + * Webpack plugin to copy and automatically convert static images in a folder to WebP. + */ +class WebpackStaticImagesPlugin { + /** + * Creates an instance of WebpackStaticImagesPlugin. + * + * @param {Object} [options={}] - Configuration options + * @param {string} [options.inputDir='src/img/static'] - Input directory + * @param {string} [options.outputDir='dist/images'] - Output directory + * @param {number} [options.quality=80] - WebP compression quality + * @param {boolean} [options.silence=false] - Disable console output + */ + constructor(options = {}) { + this.options = { + inputDir: 'src/img/static', + outputDir: 'dist/images', + quality: 80, + silence: false, + ...options, + } + } + + /** + * Logs a message to the console if silence option is not enabled. + */ + log(level, ...args) { + if (!this.options.silence) { + console[level](...args) + } + } + + /** + * Entry point for Webpack. + */ + apply(compiler) { + if (!sharp) { + return + } // If Sharp is not installed, silently cancel. + + // Use afterEmit to ensure that Webpack has created the dist folder. + compiler.hooks.afterEmit.tapPromise('WebpackStaticImagesPlugin', async (compilation) => { + const { context } = compiler + const inputPath = path.resolve(context, this.options.inputDir) + const outputPath = path.resolve(context, this.options.outputDir) + + // Check if the source directory exists. + if (!fs.existsSync(inputPath)) { + this.log('warn', `âš ī¸ Source directory not found: ${inputPath}`) + return + } + + // Create the output directory if it doesn't exist. + if (!fs.existsSync(outputPath)) { + fs.mkdirSync(outputPath, { recursive: true }) + } + + try { + this.log('log', '🔄 Starting static images processing...') + await this.processImages(inputPath, outputPath) + this.log('log', '🎉 Static images processing completed!') + } catch (error) { + this.log('error', '❌ Error during static images processing:', error) + } + }) + } + + /** + * Processes the images in the directory. + */ + async processImages(inputPath, outputPath) { + const files = fs.readdirSync(inputPath) + const promises = [] + let count = 0 + + for (const file of files) { + // Only process JPG and PNG files. + if (file.match(/\.(png|jpe?g)$/i)) { + const filePath = path.join(inputPath, file) + const fileName = path.parse(file).name + + // Create the output paths. + const outputOriginal = path.join(outputPath, file) + const outputWebp = path.join(outputPath, `${fileName}.webp`) + + // Prepare the Sharp instances. + // (Sharp returns Promises, it is crucial to wait for them.) + const copyPromise = sharp(filePath).toFile(outputOriginal) + const webpPromise = sharp(filePath).webp({ quality: this.options.quality }).toFile(outputWebp) + + // Group the two actions for this file. + const filePromise = Promise.all([copyPromise, webpPromise]) + .then(() => { + this.log('log', ` ✅ Converted: ${file} (+ .webp version)`) + }) + .catch((err) => { + this.log('error', ` ❌ Error on ${file}:`, err.message) + }) + + promises.push(filePromise) + count++ + } + } + + // Wait for all images to be generated before returning to Webpack. + if (count > 0) { + await Promise.all(promises) + } else { + this.log('log', ' â„šī¸ No images to process in the directory.') + } + } +} + +module.exports = WebpackStaticImagesPlugin From 17492e8701d716243101398ea6f716a420023afd Mon Sep 17 00:00:00 2001 From: mricoul Date: Mon, 27 Apr 2026 14:14:47 +0200 Subject: [PATCH 2/3] feat(webpack): optimize static image processing in watch mode Improve build performance by skipping image processing when no files in the input directory have changed. The plugin now registers the input directory as a context dependency and checks modified files during subsequent builds to avoid redundant processing. --- config/webpack-static-images-plugin.js | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/config/webpack-static-images-plugin.js b/config/webpack-static-images-plugin.js index f955013e..b6795075 100644 --- a/config/webpack-static-images-plugin.js +++ b/config/webpack-static-images-plugin.js @@ -30,6 +30,7 @@ class WebpackStaticImagesPlugin { silence: false, ...options, } + this.hasBeenBuiltOnce = false } /** @@ -49,6 +50,13 @@ class WebpackStaticImagesPlugin { return } // If Sharp is not installed, silently cancel. + compiler.hooks.compilation.tap('WebpackStaticImagesPlugin', (compilation) => { + const inputPath = path.resolve(compiler.context, this.options.inputDir) + if (fs.existsSync(inputPath)) { + compilation.contextDependencies.add(inputPath) + } + }) + // Use afterEmit to ensure that Webpack has created the dist folder. compiler.hooks.afterEmit.tapPromise('WebpackStaticImagesPlugin', async (compilation) => { const { context } = compiler @@ -61,6 +69,24 @@ class WebpackStaticImagesPlugin { return } + // Skip re-processing in watch when nothing under inputDir changed (see WebpackImageSizesPlugin). + let hasChanges = false + if (this.hasBeenBuiltOnce && compilation.modifiedFiles) { + for (const filePath of compilation.modifiedFiles) { + if (this.isFileUnderDir(filePath, inputPath)) { + hasChanges = true + break + } + } + } + + if (this.hasBeenBuiltOnce && !hasChanges) { + this.log('log', `✅ No changes detected in ${this.options.inputDir}`) + return + } + + this.hasBeenBuiltOnce = true + // Create the output directory if it doesn't exist. if (!fs.existsSync(outputPath)) { fs.mkdirSync(outputPath, { recursive: true }) @@ -76,6 +102,19 @@ class WebpackStaticImagesPlugin { }) } + /** + * Returns true if `filePath` is `inputDir` or a file inside it (cross-platform). + */ + isFileUnderDir(filePath, inputDirResolved) { + const resolvedFile = path.resolve(filePath) + const resolvedDir = path.resolve(inputDirResolved) + if (resolvedFile === resolvedDir) { + return true + } + const relative = path.relative(resolvedDir, resolvedFile) + return !relative.startsWith('..') && !path.isAbsolute(relative) + } + /** * Processes the images in the directory. */ From 8ad12c8babc5e8bd97c08b46bfeeceed07697242 Mon Sep 17 00:00:00 2001 From: mricoul Date: Mon, 27 Apr 2026 14:15:03 +0200 Subject: [PATCH 3/3] feat(webpack): use byte-for-byte copy for original images in static plugin Switch from Sharp to fs.copyFile for original assets to ensure they are preserved exactly as-is, maintaining source quality and metadata (EXIF/ICC). Sharp is now used exclusively for generating the WebP derivatives. --- config/webpack-static-images-plugin.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/config/webpack-static-images-plugin.js b/config/webpack-static-images-plugin.js index b6795075..e38dfe2f 100644 --- a/config/webpack-static-images-plugin.js +++ b/config/webpack-static-images-plugin.js @@ -10,7 +10,7 @@ try { } /** - * Webpack plugin to copy and automatically convert static images in a folder to WebP. + * Webpack plugin to copy static images byte-for-byte and generate WebP derivatives with Sharp. */ class WebpackStaticImagesPlugin { /** @@ -19,7 +19,7 @@ class WebpackStaticImagesPlugin { * @param {Object} [options={}] - Configuration options * @param {string} [options.inputDir='src/img/static'] - Input directory * @param {string} [options.outputDir='dist/images'] - Output directory - * @param {number} [options.quality=80] - WebP compression quality + * @param {number} [options.quality=80] - WebP output quality (originals are not re-encoded) * @param {boolean} [options.silence=false] - Disable console output */ constructor(options = {}) { @@ -133,15 +133,13 @@ class WebpackStaticImagesPlugin { const outputOriginal = path.join(outputPath, file) const outputWebp = path.join(outputPath, `${fileName}.webp`) - // Prepare the Sharp instances. - // (Sharp returns Promises, it is crucial to wait for them.) - const copyPromise = sharp(filePath).toFile(outputOriginal) + // Byte copy preserves source quality, EXIF/ICC, etc. WebP is generated separately. + const copyPromise = fs.promises.copyFile(filePath, outputOriginal) const webpPromise = sharp(filePath).webp({ quality: this.options.quality }).toFile(outputWebp) - // Group the two actions for this file. const filePromise = Promise.all([copyPromise, webpPromise]) .then(() => { - this.log('log', ` ✅ Converted: ${file} (+ .webp version)`) + this.log('log', ` ✅ ${file} (original copied, .webp generated)`) }) .catch((err) => { this.log('error', ` ❌ Error on ${file}:`, err.message)