metalsmith-optimize-images 0.10.3 → 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/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;