metalsmith-optimize-images 0.12.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -9
- package/package.json +20 -37
- package/src/index.js +686 -0
- package/src/processors/htmlProcessor.js +446 -0
- package/src/processors/imageProcessor.js +330 -0
- package/src/processors/progressiveProcessor.js +379 -0
- package/src/utils/config.js +117 -0
- package/src/utils/hash.js +17 -0
- package/src/utils/paths.js +35 -0
- package/lib/index.cjs +0 -1843
- package/lib/index.cjs.map +0 -1
- package/lib/index.js +0 -1814
- package/lib/index.js.map +0 -1
package/src/index.js
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metalsmith plugin for generating responsive images with optimal formats
|
|
3
|
+
* @module metalsmith-optimize-images
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} Options
|
|
8
|
+
* @property {number[]} [widths=[320, 640, 960, 1280, 1920]] - Array of image widths to generate
|
|
9
|
+
* @property {string[]} [formats=['avif', 'webp', 'original']] - Array of image formats to generate (in order of preference)
|
|
10
|
+
* @property {Object} [formatOptions] - Format-specific compression settings
|
|
11
|
+
* @property {Object} [formatOptions.avif] - AVIF compression options
|
|
12
|
+
* @property {Object} [formatOptions.webp] - WebP compression options
|
|
13
|
+
* @property {Object} [formatOptions.jpeg] - JPEG compression options
|
|
14
|
+
* @property {Object} [formatOptions.png] - PNG compression options
|
|
15
|
+
* @property {string} [htmlPattern='**\/*.html'] - Glob pattern to match HTML files
|
|
16
|
+
* @property {string} [imgSelector='img:not([data-no-responsive])'] - CSS selector for images to process
|
|
17
|
+
* @property {string} [outputDir='assets/images/responsive'] - Output directory for processed images
|
|
18
|
+
* @property {string} [outputPattern='[filename]-[width]w-[hash].[format]'] - Output naming pattern
|
|
19
|
+
* @property {boolean} [skipLarger=true] - Whether to skip generating sizes larger than original
|
|
20
|
+
* @property {boolean} [lazy=true] - Whether to add loading="lazy" to images
|
|
21
|
+
* @property {boolean} [dimensionAttributes=true] - Whether to add width/height attributes
|
|
22
|
+
* @property {string} [sizes] - Default sizes attribute
|
|
23
|
+
* @property {number} [concurrency=5] - Maximum number of images to process in parallel
|
|
24
|
+
* @property {boolean} [generateMetadata=false] - Whether to generate a metadata JSON file
|
|
25
|
+
* @property {boolean} [isProgressive=false] - Whether to use progressive image loading
|
|
26
|
+
* @property {Object} [placeholder] - Placeholder image settings for progressive loading
|
|
27
|
+
* @property {number} [placeholder.width=50] - Placeholder image width
|
|
28
|
+
* @property {number} [placeholder.quality=30] - Placeholder image quality
|
|
29
|
+
* @property {number} [placeholder.blur=10] - Placeholder image blur amount
|
|
30
|
+
* @property {boolean} [processUnusedImages=true] - Whether to process unused images for background use
|
|
31
|
+
* @property {string} [imagePattern='**\/*.{jpg,jpeg,png,gif,webp,avif}'] - Glob pattern to find images for background processing
|
|
32
|
+
* @property {string} [imageFolder='lib/assets/images'] - Folder to scan for background images, relative to source
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import * as mkdirp from 'mkdirp';
|
|
38
|
+
import sharp from 'sharp';
|
|
39
|
+
import { buildConfig } from './utils/config.js';
|
|
40
|
+
import { processHtmlFile, generateMetadata } from './processors/htmlProcessor.js';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Assert that a user-supplied path option stays within a base directory.
|
|
44
|
+
* Guards against `outputDir`/`cache` escaping the build via `..` segments
|
|
45
|
+
* or an absolute path pointing elsewhere.
|
|
46
|
+
* @param {string} base - Directory the target must resolve inside
|
|
47
|
+
* @param {string} target - User-supplied path to validate
|
|
48
|
+
* @param {string} label - Option name, used in the error message
|
|
49
|
+
* @returns {string} The resolved, validated absolute target path
|
|
50
|
+
*/
|
|
51
|
+
function assertWithin(base, target, label) {
|
|
52
|
+
const resolvedBase = path.resolve(base);
|
|
53
|
+
const resolvedTarget = path.resolve(resolvedBase, target);
|
|
54
|
+
const rel = path.relative(resolvedBase, resolvedTarget);
|
|
55
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
56
|
+
throw new Error(`Invalid ${label}: "${target}" resolves outside the build directory`);
|
|
57
|
+
}
|
|
58
|
+
return resolvedTarget;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Creates a responsive images plugin for Metalsmith
|
|
63
|
+
* Generates multiple sizes and formats of images and replaces img tags with picture elements
|
|
64
|
+
*
|
|
65
|
+
* @param {Options} [options={}] - Configuration options for the plugin
|
|
66
|
+
* @returns {import('metalsmith').Plugin} - Metalsmith plugin function
|
|
67
|
+
*/
|
|
68
|
+
function optimizeImagesPlugin(options = {}) {
|
|
69
|
+
// Build configuration with defaults and user options
|
|
70
|
+
const config = buildConfig(options);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The Metalsmith plugin function
|
|
74
|
+
* @param {Object} files - Metalsmith files object
|
|
75
|
+
* @param {Object} metalsmith - Metalsmith instance
|
|
76
|
+
* @param {Function} done - Callback function
|
|
77
|
+
* @return {void}
|
|
78
|
+
*/
|
|
79
|
+
return async function optimizeImages(files, metalsmith, done) {
|
|
80
|
+
try {
|
|
81
|
+
const destination = metalsmith.destination();
|
|
82
|
+
const outputPath = assertWithin(destination, config.outputDir, 'outputDir');
|
|
83
|
+
|
|
84
|
+
// Set up debug function for logging (uses 'DEBUG=metalsmith-optimize-images*' env var)
|
|
85
|
+
const debug = metalsmith.debug('metalsmith-optimize-images');
|
|
86
|
+
|
|
87
|
+
// Resolve persistent cache directory from config.
|
|
88
|
+
// When set (e.g., 'lib/assets/images/responsive'), variants are read/written there
|
|
89
|
+
// and the static-files plugin copies them to the build.
|
|
90
|
+
let cacheDir = null;
|
|
91
|
+
let sourcePrefix = null;
|
|
92
|
+
|
|
93
|
+
if (config.cache) {
|
|
94
|
+
// Normalise: cache: true → default path 'lib/<outputDir>'
|
|
95
|
+
const cachePath = typeof config.cache === 'string' ? config.cache : path.join('lib', config.outputDir);
|
|
96
|
+
|
|
97
|
+
cacheDir = assertWithin(metalsmith.directory(), cachePath, 'cache');
|
|
98
|
+
mkdirp.mkdirpSync(cacheDir);
|
|
99
|
+
|
|
100
|
+
// Derive the source-asset prefix so the plugin can find images on disk
|
|
101
|
+
// when it runs before the static-files copy.
|
|
102
|
+
// e.g., cachePath='lib/assets/images/responsive', outputDir='assets/images/responsive'
|
|
103
|
+
// → sourcePrefix = 'lib/' (the part of cachePath that precedes outputDir)
|
|
104
|
+
if (cachePath.endsWith(config.outputDir)) {
|
|
105
|
+
sourcePrefix = cachePath.slice(0, cachePath.length - config.outputDir.length);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
debug(`Persistent cache: ${cacheDir}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Ensure the output directory exists where processed images will be saved
|
|
112
|
+
mkdirp.mkdirpSync(outputPath);
|
|
113
|
+
|
|
114
|
+
// Find all HTML files that match the pattern (default: **/*.html)
|
|
115
|
+
// Also ensure they actually end with .html to avoid processing CSS/JS files
|
|
116
|
+
const htmlFiles = Object.keys(files).filter((file) => {
|
|
117
|
+
// Must match the HTML pattern
|
|
118
|
+
if (!metalsmith.match(config.htmlPattern, file)) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Must actually be an HTML file
|
|
123
|
+
if (!file.endsWith('.html')) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return true;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (htmlFiles.length === 0) {
|
|
131
|
+
debug('No HTML files found');
|
|
132
|
+
return done();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Cache to avoid re-processing identical images across different HTML files
|
|
136
|
+
// Key: "filepath:mtime", Value: array of processed image variants
|
|
137
|
+
const processedImages = new Map();
|
|
138
|
+
|
|
139
|
+
// Chunk HTML files to respect concurrency limit (default: 5)
|
|
140
|
+
// This prevents overwhelming the system with too many parallel operations
|
|
141
|
+
const chunks = [];
|
|
142
|
+
for (let i = 0; i < htmlFiles.length; i += config.concurrency) {
|
|
143
|
+
chunks.push(htmlFiles.slice(i, i + config.concurrency));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Process all chunks in parallel - each chunk processes its files in parallel
|
|
147
|
+
// This creates a two-level parallelism: chunk-level and file-level within chunks
|
|
148
|
+
await Promise.all(
|
|
149
|
+
chunks.map(async (chunk) => {
|
|
150
|
+
// Process files within each chunk in parallel
|
|
151
|
+
await Promise.all(
|
|
152
|
+
chunk.map(async (htmlFile) => {
|
|
153
|
+
// This function parses HTML, finds images, processes them, and updates the HTML
|
|
154
|
+
await processHtmlFile(
|
|
155
|
+
htmlFile,
|
|
156
|
+
files[htmlFile],
|
|
157
|
+
files,
|
|
158
|
+
metalsmith,
|
|
159
|
+
processedImages,
|
|
160
|
+
debug,
|
|
161
|
+
config,
|
|
162
|
+
cacheDir,
|
|
163
|
+
sourcePrefix
|
|
164
|
+
);
|
|
165
|
+
})
|
|
166
|
+
);
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Process unused images for background image support
|
|
171
|
+
// This finds images that weren't processed during HTML scanning and creates variants
|
|
172
|
+
// for use in CSS background-image with image-set()
|
|
173
|
+
if (config.processUnusedImages) {
|
|
174
|
+
await processUnusedImages(files, metalsmith, processedImages, debug, config, cacheDir);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Optional: Generate a JSON metadata file with information about all processed images
|
|
178
|
+
// Useful for debugging or integration with other tools
|
|
179
|
+
if (config.generateMetadata) {
|
|
180
|
+
generateMetadata(processedImages, files, config);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
debug('Responsive images processing complete');
|
|
184
|
+
done();
|
|
185
|
+
} catch (err) {
|
|
186
|
+
// Use console.error for errors to ensure they're visible even if debug mode is not enabled
|
|
187
|
+
console.error(`Error in responsive images plugin: ${err.message}`);
|
|
188
|
+
done(err);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Process unused images for background image support
|
|
195
|
+
* Finds images that weren't processed during HTML scanning and creates 1x/2x variants
|
|
196
|
+
* for use in CSS background-image with image-set()
|
|
197
|
+
* @param {Object} files - Metalsmith files object
|
|
198
|
+
* @param {Object} metalsmith - Metalsmith instance
|
|
199
|
+
* @param {Map} processedImages - Cache of already processed images
|
|
200
|
+
* @param {Function} debug - Debug function
|
|
201
|
+
* @param {Object} config - Plugin configuration
|
|
202
|
+
* @return {Promise<void>} - Promise that resolves when processing is complete
|
|
203
|
+
*/
|
|
204
|
+
async function processUnusedImages(files, metalsmith, processedImages, debug, config, cacheDir) {
|
|
205
|
+
debug('Processing unused images for background image support');
|
|
206
|
+
|
|
207
|
+
// Get all image paths that were already processed during HTML scanning
|
|
208
|
+
const processedImagePaths = new Set();
|
|
209
|
+
processedImages.forEach((_variants, cacheKey) => {
|
|
210
|
+
const [imagePath] = cacheKey.split(':');
|
|
211
|
+
processedImagePaths.add(imagePath);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
debug(`Processed image paths from HTML: ${Array.from(processedImagePaths).join(', ')}`);
|
|
215
|
+
|
|
216
|
+
// Find images that weren't processed during HTML scanning using hybrid approach
|
|
217
|
+
const allBackgroundImages = await findUnprocessedImages(files, metalsmith, config, processedImagePaths, debug);
|
|
218
|
+
debug(`Background images found to process: ${allBackgroundImages.map((img) => img.path).join(', ')}`);
|
|
219
|
+
|
|
220
|
+
if (allBackgroundImages.length === 0) {
|
|
221
|
+
debug('No unused images found to process');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
debug(`Found ${allBackgroundImages.length} unused images to process for background use`);
|
|
226
|
+
|
|
227
|
+
// Process background images in parallel for better performance
|
|
228
|
+
await Promise.all(
|
|
229
|
+
allBackgroundImages.map(async (imageObj) => {
|
|
230
|
+
try {
|
|
231
|
+
debug(`Processing background image: ${imageObj.path} (source: ${imageObj.source})`);
|
|
232
|
+
|
|
233
|
+
// Generate background variants with original size and half size
|
|
234
|
+
const variants = await processBackgroundImageVariants(imageObj.buffer, imageObj.path, debug, config, cacheDir);
|
|
235
|
+
|
|
236
|
+
// When cache is configured, variant files are written to cacheDir by
|
|
237
|
+
// processBackgroundImageVariants and the static-files plugin copies them.
|
|
238
|
+
// Otherwise, add them to the files object directly.
|
|
239
|
+
if (!cacheDir) {
|
|
240
|
+
variants.forEach((variant) => {
|
|
241
|
+
files[variant.path] = {
|
|
242
|
+
contents: variant.buffer
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Cache the variants (using current timestamp as mtime for unused images)
|
|
248
|
+
const cacheKey = `${imageObj.path}:${Date.now()}`;
|
|
249
|
+
processedImages.set(cacheKey, variants);
|
|
250
|
+
|
|
251
|
+
debug(`Generated ${variants.length} background variants for ${imageObj.path}`);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
debug(`Error processing background image ${imageObj.path}: ${err.message}`);
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
debug('Background image processing complete');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Find images that weren't processed during HTML scanning
|
|
263
|
+
* Uses a hybrid approach: scans filesystem first, then falls back to Metalsmith files object
|
|
264
|
+
* @param {Object} files - Metalsmith files object
|
|
265
|
+
* @param {Object} metalsmith - Metalsmith instance
|
|
266
|
+
* @param {Object} config - Plugin configuration
|
|
267
|
+
* @param {Set} processedImagePaths - Set of already processed image paths
|
|
268
|
+
* @param {Function} debug - Debug function
|
|
269
|
+
* @return {Promise<Array>} - Array of unprocessed image objects with {path, buffer}
|
|
270
|
+
*/
|
|
271
|
+
async function findUnprocessedImages(files, metalsmith, config, processedImagePaths, debug) {
|
|
272
|
+
const unprocessedImages = [];
|
|
273
|
+
const sourceImagesDir = path.join(metalsmith.source(), 'lib/assets/images');
|
|
274
|
+
|
|
275
|
+
debug(`Looking for unprocessed images using hybrid approach`);
|
|
276
|
+
|
|
277
|
+
// Method 1: Scan filesystem (for real testbed scenario)
|
|
278
|
+
try {
|
|
279
|
+
debug(`Attempting to scan source directory: ${sourceImagesDir}`);
|
|
280
|
+
debug(`Source directory exists: ${fs.existsSync(sourceImagesDir)}`);
|
|
281
|
+
debug(`Metalsmith source: ${metalsmith.source()}`);
|
|
282
|
+
debug(`Metalsmith destination: ${metalsmith.destination()}`);
|
|
283
|
+
|
|
284
|
+
if (fs.existsSync(sourceImagesDir)) {
|
|
285
|
+
debug(`Scanning source directory: ${sourceImagesDir}`);
|
|
286
|
+
|
|
287
|
+
const scanDirectory = (dir, relativePath = '') => {
|
|
288
|
+
const items = fs.readdirSync(dir);
|
|
289
|
+
debug(`Found ${items.length} items in ${dir}`);
|
|
290
|
+
|
|
291
|
+
for (const item of items) {
|
|
292
|
+
if (item === '.DS_Store') {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const fullPath = path.join(dir, item);
|
|
297
|
+
const itemRelativePath = path.join(relativePath, item);
|
|
298
|
+
|
|
299
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
300
|
+
debug(`Scanning subdirectory: ${item}`);
|
|
301
|
+
scanDirectory(fullPath, itemRelativePath);
|
|
302
|
+
} else {
|
|
303
|
+
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'];
|
|
304
|
+
if (imageExtensions.some((ext) => item.toLowerCase().endsWith(ext))) {
|
|
305
|
+
// Skip if this is in the responsive output directory
|
|
306
|
+
if (
|
|
307
|
+
itemRelativePath.startsWith('responsive/') ||
|
|
308
|
+
itemRelativePath.includes('/responsive/') ||
|
|
309
|
+
fullPath.includes(config.outputDir)
|
|
310
|
+
) {
|
|
311
|
+
debug(`Skipping responsive variant: ${itemRelativePath}`);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const buildPath = path.join('assets/images', itemRelativePath);
|
|
316
|
+
const normalizedBuildPath = buildPath.replace(/\\/g, '/');
|
|
317
|
+
|
|
318
|
+
debug(`Found filesystem image: ${item} -> ${normalizedBuildPath}`);
|
|
319
|
+
debug(`Already processed? ${processedImagePaths.has(normalizedBuildPath)}`);
|
|
320
|
+
|
|
321
|
+
if (!processedImagePaths.has(normalizedBuildPath)) {
|
|
322
|
+
debug(`Found unprocessed filesystem image: ${itemRelativePath}`);
|
|
323
|
+
const imageBuffer = fs.readFileSync(fullPath);
|
|
324
|
+
unprocessedImages.push({
|
|
325
|
+
path: itemRelativePath,
|
|
326
|
+
buffer: imageBuffer,
|
|
327
|
+
source: 'filesystem'
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
scanDirectory(sourceImagesDir);
|
|
336
|
+
} else {
|
|
337
|
+
debug(`Source directory does not exist, trying alternative paths...`);
|
|
338
|
+
|
|
339
|
+
// Try alternative paths
|
|
340
|
+
const altPaths = [
|
|
341
|
+
path.join(metalsmith.source(), 'assets/images'),
|
|
342
|
+
path.join(metalsmith.source(), 'images'),
|
|
343
|
+
path.join(metalsmith.destination(), 'assets/images'),
|
|
344
|
+
path.join(process.cwd(), 'lib/assets/images'),
|
|
345
|
+
path.join(process.cwd(), 'src/assets/images')
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
for (const altPath of altPaths) {
|
|
349
|
+
debug(`Trying alternative path: ${altPath} - exists: ${fs.existsSync(altPath)}`);
|
|
350
|
+
if (fs.existsSync(altPath)) {
|
|
351
|
+
debug(`Found images at alternative path: ${altPath}`);
|
|
352
|
+
|
|
353
|
+
// Scan the found alternative path
|
|
354
|
+
const scanAlternativeDirectory = (dir, relativePath = '') => {
|
|
355
|
+
const items = fs.readdirSync(dir);
|
|
356
|
+
debug(`Found ${items.length} items in alternative path ${dir}`);
|
|
357
|
+
|
|
358
|
+
for (const item of items) {
|
|
359
|
+
if (item === '.DS_Store') {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const fullPath = path.join(dir, item);
|
|
364
|
+
const itemRelativePath = path.join(relativePath, item);
|
|
365
|
+
|
|
366
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
367
|
+
debug(`Scanning alternative subdirectory: ${item}`);
|
|
368
|
+
scanAlternativeDirectory(fullPath, itemRelativePath);
|
|
369
|
+
} else {
|
|
370
|
+
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'];
|
|
371
|
+
if (imageExtensions.some((ext) => item.toLowerCase().endsWith(ext))) {
|
|
372
|
+
// Skip if this is in the responsive output directory
|
|
373
|
+
if (
|
|
374
|
+
itemRelativePath.startsWith('responsive/') ||
|
|
375
|
+
itemRelativePath.includes('/responsive/') ||
|
|
376
|
+
fullPath.includes(config.outputDir)
|
|
377
|
+
) {
|
|
378
|
+
debug(`Skipping responsive variant in alt scan: ${itemRelativePath}`);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// For build directory, the path structure is already correct
|
|
383
|
+
const buildPath = altPath.includes('build')
|
|
384
|
+
? path.join('assets/images', itemRelativePath)
|
|
385
|
+
: path.join('assets/images', itemRelativePath);
|
|
386
|
+
const normalizedBuildPath = buildPath.replace(/\\/g, '/');
|
|
387
|
+
|
|
388
|
+
debug(`Found alternative filesystem image: ${item} -> ${normalizedBuildPath}`);
|
|
389
|
+
debug(`Already processed? ${processedImagePaths.has(normalizedBuildPath)}`);
|
|
390
|
+
|
|
391
|
+
if (!processedImagePaths.has(normalizedBuildPath)) {
|
|
392
|
+
debug(`Found unprocessed alternative filesystem image: ${itemRelativePath}`);
|
|
393
|
+
const imageBuffer = fs.readFileSync(fullPath);
|
|
394
|
+
unprocessedImages.push({
|
|
395
|
+
path: itemRelativePath,
|
|
396
|
+
buffer: imageBuffer,
|
|
397
|
+
source: 'filesystem-alt'
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
scanAlternativeDirectory(altPath);
|
|
406
|
+
break; // Stop after finding and scanning the first valid path
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
} catch (err) {
|
|
411
|
+
debug(`Error scanning filesystem: ${err.message}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Method 2: Scan Metalsmith files object (for test scenarios and edge cases)
|
|
415
|
+
debug(`Scanning Metalsmith files object`);
|
|
416
|
+
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'];
|
|
417
|
+
|
|
418
|
+
Object.keys(files).forEach((filePath) => {
|
|
419
|
+
// Skip if not an image
|
|
420
|
+
if (!imageExtensions.some((ext) => filePath.toLowerCase().endsWith(ext))) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Skip if it's already a responsive variant (comprehensive checks)
|
|
425
|
+
if (
|
|
426
|
+
filePath.startsWith(`${config.outputDir}/`) ||
|
|
427
|
+
filePath.includes('/responsive/') ||
|
|
428
|
+
filePath.includes('responsive-images-manifest.json') ||
|
|
429
|
+
filePath.match(/-\d+w(-[a-f0-9]+)?\.(avif|webp|jpg|jpeg|png)$/i)
|
|
430
|
+
) {
|
|
431
|
+
debug(`Skipping responsive variant in files object: ${filePath}`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Skip if already processed during HTML scanning
|
|
436
|
+
if (processedImagePaths.has(filePath)) {
|
|
437
|
+
debug(`Skipping already processed files object image: ${filePath}`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Check if we already found this image from filesystem scan
|
|
442
|
+
const isAlreadyFound = unprocessedImages.some((img) => {
|
|
443
|
+
// For files object images starting with 'images/', check if filesystem found the same file
|
|
444
|
+
if (filePath.startsWith('images/')) {
|
|
445
|
+
const relativePath = filePath.replace('images/', '');
|
|
446
|
+
return img.path === relativePath;
|
|
447
|
+
}
|
|
448
|
+
return false;
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
if (!isAlreadyFound) {
|
|
452
|
+
debug(`Found unprocessed files object image: ${filePath}`);
|
|
453
|
+
unprocessedImages.push({
|
|
454
|
+
path: filePath,
|
|
455
|
+
buffer: files[filePath].contents,
|
|
456
|
+
source: 'files'
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
debug(`Found ${unprocessedImages.length} unprocessed images total`);
|
|
462
|
+
return unprocessedImages;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Process a background image to create 1x (original) and 2x (half-size) variants
|
|
467
|
+
* for use with CSS image-set() for retina displays
|
|
468
|
+
* @param {Buffer} buffer - Original image buffer
|
|
469
|
+
* @param {string} originalPath - Original image path
|
|
470
|
+
* @param {Function} debugFn - Debug function for logging
|
|
471
|
+
* @param {Object} config - Plugin configuration
|
|
472
|
+
* @param {string} [cacheDir] - Absolute path to the persistent cache directory, or null
|
|
473
|
+
* @return {Promise<Array<Object>>} - Array of generated variants
|
|
474
|
+
*/
|
|
475
|
+
async function processBackgroundImageVariants(buffer, originalPath, debugFn, config, cacheDir) {
|
|
476
|
+
const image = sharp(buffer);
|
|
477
|
+
const metadata = await image.metadata();
|
|
478
|
+
const variants = [];
|
|
479
|
+
|
|
480
|
+
// Check if background variants already exist on disk from a previous build.
|
|
481
|
+
// Background filenames are deterministic (no content hash), so existence alone
|
|
482
|
+
// tells us the work was already done. If a source image changes content without
|
|
483
|
+
// changing filename, delete the responsive directory to force regeneration.
|
|
484
|
+
if (cacheDir) {
|
|
485
|
+
const cached = await loadCachedBgVariants(originalPath, metadata, config, cacheDir, debugFn);
|
|
486
|
+
if (cached) {
|
|
487
|
+
return cached;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
debugFn(`Processing background image ${originalPath}: ${metadata.width}x${metadata.height}`);
|
|
492
|
+
|
|
493
|
+
// Create 1x (original size) and 2x (half size) variants
|
|
494
|
+
const sizes = [
|
|
495
|
+
{ width: metadata.width, density: '1x' },
|
|
496
|
+
{ width: Math.round(metadata.width / 2), density: '2x' }
|
|
497
|
+
];
|
|
498
|
+
|
|
499
|
+
// Process both sizes in parallel
|
|
500
|
+
const sizePromises = sizes.map(async (size) => {
|
|
501
|
+
// Create a Sharp instance for this size
|
|
502
|
+
const resized = image.clone().resize({
|
|
503
|
+
width: size.width,
|
|
504
|
+
withoutEnlargement: true // Don't upscale images
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Get actual dimensions after resize
|
|
508
|
+
const resizedMeta = await resized.metadata();
|
|
509
|
+
|
|
510
|
+
// Process each format in parallel for this size
|
|
511
|
+
const formatPromises = config.formats.map(async (format) => {
|
|
512
|
+
try {
|
|
513
|
+
// Skip problematic format combinations
|
|
514
|
+
if (format === 'original' && metadata.format.toLowerCase() === 'webp') {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Determine output format and Sharp method
|
|
519
|
+
let outputFormat = format;
|
|
520
|
+
let sharpMethod = format;
|
|
521
|
+
|
|
522
|
+
if (format === 'original') {
|
|
523
|
+
outputFormat = metadata.format.toLowerCase();
|
|
524
|
+
sharpMethod = outputFormat === 'jpeg' ? 'jpeg' : outputFormat;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Apply format-specific processing
|
|
528
|
+
let processedImage = resized.clone();
|
|
529
|
+
const formatOptions = config.formatOptions[format === 'original' ? outputFormat : format] || {};
|
|
530
|
+
|
|
531
|
+
if (sharpMethod === 'avif') {
|
|
532
|
+
processedImage = processedImage.avif(formatOptions);
|
|
533
|
+
} else if (sharpMethod === 'webp') {
|
|
534
|
+
processedImage = processedImage.webp(formatOptions);
|
|
535
|
+
} else if (sharpMethod === 'jpeg') {
|
|
536
|
+
processedImage = processedImage.jpeg(formatOptions);
|
|
537
|
+
} else if (sharpMethod === 'png') {
|
|
538
|
+
processedImage = processedImage.png(formatOptions);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Generate output buffer
|
|
542
|
+
const outputBuffer = await processedImage.toBuffer();
|
|
543
|
+
|
|
544
|
+
// Generate variant path without hash for easier CSS usage
|
|
545
|
+
const variantPath = generateBackgroundVariantPath(originalPath, size.width, outputFormat, config);
|
|
546
|
+
|
|
547
|
+
debugFn(`Generated background variant: ${variantPath} (${size.density})`);
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
path: variantPath,
|
|
551
|
+
buffer: outputBuffer,
|
|
552
|
+
width: resizedMeta.width,
|
|
553
|
+
height: resizedMeta.height,
|
|
554
|
+
format: outputFormat,
|
|
555
|
+
density: size.density
|
|
556
|
+
};
|
|
557
|
+
} catch (err) {
|
|
558
|
+
debugFn(`Error processing ${format} format for ${originalPath}: ${err.message}`);
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const formatResults = await Promise.all(formatPromises);
|
|
564
|
+
return formatResults.filter((result) => result !== null);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const sizeResults = await Promise.all(sizePromises);
|
|
568
|
+
|
|
569
|
+
// Flatten the results
|
|
570
|
+
sizeResults.forEach((formatVariants) => {
|
|
571
|
+
variants.push(...formatVariants);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// Persist newly generated variants to the cache directory so subsequent
|
|
575
|
+
// builds can skip Sharp entirely for these background images.
|
|
576
|
+
if (cacheDir && variants.length > 0) {
|
|
577
|
+
for (const variant of variants) {
|
|
578
|
+
const cachePath = path.join(cacheDir, path.basename(variant.path));
|
|
579
|
+
fs.writeFileSync(cachePath, variant.buffer);
|
|
580
|
+
}
|
|
581
|
+
debugFn(`Wrote ${variants.length} background variants to cache for ${originalPath}`);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
debugFn(`Generated ${variants.length} background variants for ${originalPath}`);
|
|
585
|
+
return variants;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Loads previously generated background variants from the persistent cache directory.
|
|
590
|
+
* Checks that every expected variant file (size × format) exists on disk.
|
|
591
|
+
* Returns the loaded variants array, or null on any cache miss.
|
|
592
|
+
* @param {string} originalPath - Original image path
|
|
593
|
+
* @param {Object} sourceMetadata - Sharp metadata of the source image
|
|
594
|
+
* @param {Object} config - Plugin configuration
|
|
595
|
+
* @param {string} cacheDir - Absolute path to the persistent cache directory
|
|
596
|
+
* @param {Function} debugFn - Debug function
|
|
597
|
+
* @return {Promise<Array<Object>|null>} - Loaded variants or null on cache miss
|
|
598
|
+
*/
|
|
599
|
+
async function loadCachedBgVariants(originalPath, sourceMetadata, config, cacheDir, debugFn) {
|
|
600
|
+
const sizes = [
|
|
601
|
+
{ width: sourceMetadata.width, density: '1x' },
|
|
602
|
+
{ width: Math.round(sourceMetadata.width / 2), density: '2x' }
|
|
603
|
+
];
|
|
604
|
+
|
|
605
|
+
const expected = [];
|
|
606
|
+
|
|
607
|
+
for (const size of sizes) {
|
|
608
|
+
for (const format of config.formats) {
|
|
609
|
+
if (format === 'original' && sourceMetadata.format.toLowerCase() === 'webp') {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
let outputFormat = format;
|
|
614
|
+
if (format === 'original') {
|
|
615
|
+
outputFormat = sourceMetadata.format.toLowerCase();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const variantPath = generateBackgroundVariantPath(originalPath, size.width, outputFormat, config);
|
|
619
|
+
const fullPath = path.join(cacheDir, path.basename(variantPath));
|
|
620
|
+
expected.push({ variantPath, fullPath, width: size.width, format: outputFormat, density: size.density });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Quick existence check — bail on first miss
|
|
625
|
+
for (const ev of expected) {
|
|
626
|
+
if (!fs.existsSync(ev.fullPath)) {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// All variants found on disk, load them
|
|
632
|
+
debugFn(`Loading ${expected.length} cached background variants for ${originalPath}`);
|
|
633
|
+
|
|
634
|
+
// Compute height from the source aspect ratio instead of calling sharp().metadata()
|
|
635
|
+
// on every cached file — avoids spinning up Sharp entirely on cache hits.
|
|
636
|
+
const aspectRatio = sourceMetadata.height / sourceMetadata.width;
|
|
637
|
+
|
|
638
|
+
const variants = expected.map((ev) => {
|
|
639
|
+
const buffer = fs.readFileSync(ev.fullPath);
|
|
640
|
+
return {
|
|
641
|
+
path: ev.variantPath,
|
|
642
|
+
buffer,
|
|
643
|
+
width: ev.width,
|
|
644
|
+
height: Math.round(ev.width * aspectRatio),
|
|
645
|
+
format: ev.format,
|
|
646
|
+
density: ev.density
|
|
647
|
+
};
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
return variants;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Generate background image variant path without hash for easier CSS usage
|
|
655
|
+
* Creates predictable filenames that can be written in CSS without knowing the hash
|
|
656
|
+
* @param {string} originalPath - Original image path
|
|
657
|
+
* @param {number} width - Target width
|
|
658
|
+
* @param {string} format - Target format
|
|
659
|
+
* @param {Object} config - Plugin configuration
|
|
660
|
+
* @return {string} - Generated path without hash
|
|
661
|
+
*/
|
|
662
|
+
function generateBackgroundVariantPath(originalPath, width, format, config) {
|
|
663
|
+
const parsedPath = path.parse(originalPath);
|
|
664
|
+
const originalFormat = parsedPath.ext.slice(1).toLowerCase();
|
|
665
|
+
|
|
666
|
+
// If format is 'original', use the source format
|
|
667
|
+
const outputFormat = format === 'original' ? originalFormat : format;
|
|
668
|
+
|
|
669
|
+
// Create background pattern without hash: '[filename]-[width]w.[format]'
|
|
670
|
+
// Results in: 'header1-1000w.webp' instead of 'header1-1000w-abc12345.webp'
|
|
671
|
+
const outputName = config.outputPattern
|
|
672
|
+
.replace('[filename]', parsedPath.name)
|
|
673
|
+
.replace('[width]', width)
|
|
674
|
+
.replace('[format]', outputFormat)
|
|
675
|
+
.replace('-[hash]', '') // Remove hash placeholder and preceding dash
|
|
676
|
+
.replace('[hash]', ''); // Remove any remaining hash placeholder
|
|
677
|
+
|
|
678
|
+
return path.join(config.outputDir, outputName);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Set function name for better debugging
|
|
682
|
+
Object.defineProperty(optimizeImagesPlugin, 'name', {
|
|
683
|
+
value: 'metalsmith-optimize-images'
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
export default optimizeImagesPlugin;
|