Skip to content

Commit 90876fc

Browse files
authored
Merge pull request #491 from BeAPI/feat/webp-plugin
Add WebpackStaticImagesPlugin to handle static image processing
2 parents d8070be + 8ad12c8 commit 90876fc

2 files changed

Lines changed: 169 additions & 0 deletions

File tree

config/plugins.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPl
1414
const WebpackImageSizesPlugin = require('./webpack-image-sizes-plugin')
1515
const WebpackThemeJsonPlugin = require('./webpack-theme-json-plugin')
1616
const SpriteHashPlugin = require('./webpack-sprite-hash-plugin')
17+
const WebpackStaticImagesPlugin = require('./webpack-static-images-plugin')
1718

1819
module.exports = {
1920
get: function (mode) {
@@ -85,6 +86,12 @@ module.exports = {
8586
defaultImageFormat: 'jpg', // Generated image format (jpg, png, webp, avif)
8687
silence: true, // Suppress console output
8788
}),
89+
new WebpackStaticImagesPlugin({
90+
inputDir: 'src/img/static',
91+
outputDir: 'dist/images',
92+
quality: 80,
93+
silence: false, // Suppress console output
94+
}),
8895
]
8996

9097
if (mode === 'production') {
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
const fs = require('fs')
2+
const path = require('path')
3+
4+
// Try to require sharp, fallback gracefully if not available
5+
let sharp
6+
try {
7+
sharp = require('sharp')
8+
} catch (error) {
9+
console.warn('⚠️ Sharp not available. WebP conversion will be disabled.')
10+
}
11+
12+
/**
13+
* Webpack plugin to copy static images byte-for-byte and generate WebP derivatives with Sharp.
14+
*/
15+
class WebpackStaticImagesPlugin {
16+
/**
17+
* Creates an instance of WebpackStaticImagesPlugin.
18+
*
19+
* @param {Object} [options={}] - Configuration options
20+
* @param {string} [options.inputDir='src/img/static'] - Input directory
21+
* @param {string} [options.outputDir='dist/images'] - Output directory
22+
* @param {number} [options.quality=80] - WebP output quality (originals are not re-encoded)
23+
* @param {boolean} [options.silence=false] - Disable console output
24+
*/
25+
constructor(options = {}) {
26+
this.options = {
27+
inputDir: 'src/img/static',
28+
outputDir: 'dist/images',
29+
quality: 80,
30+
silence: false,
31+
...options,
32+
}
33+
this.hasBeenBuiltOnce = false
34+
}
35+
36+
/**
37+
* Logs a message to the console if silence option is not enabled.
38+
*/
39+
log(level, ...args) {
40+
if (!this.options.silence) {
41+
console[level](...args)
42+
}
43+
}
44+
45+
/**
46+
* Entry point for Webpack.
47+
*/
48+
apply(compiler) {
49+
if (!sharp) {
50+
return
51+
} // If Sharp is not installed, silently cancel.
52+
53+
compiler.hooks.compilation.tap('WebpackStaticImagesPlugin', (compilation) => {
54+
const inputPath = path.resolve(compiler.context, this.options.inputDir)
55+
if (fs.existsSync(inputPath)) {
56+
compilation.contextDependencies.add(inputPath)
57+
}
58+
})
59+
60+
// Use afterEmit to ensure that Webpack has created the dist folder.
61+
compiler.hooks.afterEmit.tapPromise('WebpackStaticImagesPlugin', async (compilation) => {
62+
const { context } = compiler
63+
const inputPath = path.resolve(context, this.options.inputDir)
64+
const outputPath = path.resolve(context, this.options.outputDir)
65+
66+
// Check if the source directory exists.
67+
if (!fs.existsSync(inputPath)) {
68+
this.log('warn', `⚠️ Source directory not found: ${inputPath}`)
69+
return
70+
}
71+
72+
// Skip re-processing in watch when nothing under inputDir changed (see WebpackImageSizesPlugin).
73+
let hasChanges = false
74+
if (this.hasBeenBuiltOnce && compilation.modifiedFiles) {
75+
for (const filePath of compilation.modifiedFiles) {
76+
if (this.isFileUnderDir(filePath, inputPath)) {
77+
hasChanges = true
78+
break
79+
}
80+
}
81+
}
82+
83+
if (this.hasBeenBuiltOnce && !hasChanges) {
84+
this.log('log', `✅ No changes detected in ${this.options.inputDir}`)
85+
return
86+
}
87+
88+
this.hasBeenBuiltOnce = true
89+
90+
// Create the output directory if it doesn't exist.
91+
if (!fs.existsSync(outputPath)) {
92+
fs.mkdirSync(outputPath, { recursive: true })
93+
}
94+
95+
try {
96+
this.log('log', '🔄 Starting static images processing...')
97+
await this.processImages(inputPath, outputPath)
98+
this.log('log', '🎉 Static images processing completed!')
99+
} catch (error) {
100+
this.log('error', '❌ Error during static images processing:', error)
101+
}
102+
})
103+
}
104+
105+
/**
106+
* Returns true if `filePath` is `inputDir` or a file inside it (cross-platform).
107+
*/
108+
isFileUnderDir(filePath, inputDirResolved) {
109+
const resolvedFile = path.resolve(filePath)
110+
const resolvedDir = path.resolve(inputDirResolved)
111+
if (resolvedFile === resolvedDir) {
112+
return true
113+
}
114+
const relative = path.relative(resolvedDir, resolvedFile)
115+
return !relative.startsWith('..') && !path.isAbsolute(relative)
116+
}
117+
118+
/**
119+
* Processes the images in the directory.
120+
*/
121+
async processImages(inputPath, outputPath) {
122+
const files = fs.readdirSync(inputPath)
123+
const promises = []
124+
let count = 0
125+
126+
for (const file of files) {
127+
// Only process JPG and PNG files.
128+
if (file.match(/\.(png|jpe?g)$/i)) {
129+
const filePath = path.join(inputPath, file)
130+
const fileName = path.parse(file).name
131+
132+
// Create the output paths.
133+
const outputOriginal = path.join(outputPath, file)
134+
const outputWebp = path.join(outputPath, `${fileName}.webp`)
135+
136+
// Byte copy preserves source quality, EXIF/ICC, etc. WebP is generated separately.
137+
const copyPromise = fs.promises.copyFile(filePath, outputOriginal)
138+
const webpPromise = sharp(filePath).webp({ quality: this.options.quality }).toFile(outputWebp)
139+
140+
const filePromise = Promise.all([copyPromise, webpPromise])
141+
.then(() => {
142+
this.log('log', ` ✅ ${file} (original copied, .webp generated)`)
143+
})
144+
.catch((err) => {
145+
this.log('error', ` ❌ Error on ${file}:`, err.message)
146+
})
147+
148+
promises.push(filePromise)
149+
count++
150+
}
151+
}
152+
153+
// Wait for all images to be generated before returning to Webpack.
154+
if (count > 0) {
155+
await Promise.all(promises)
156+
} else {
157+
this.log('log', ' ℹ️ No images to process in the directory.')
158+
}
159+
}
160+
}
161+
162+
module.exports = WebpackStaticImagesPlugin

0 commit comments

Comments
 (0)