metalsmith-optimize-images 0.1.1 → 0.9.3

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/lib/index.cjs CHANGED
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
2
 
3
3
  var path = require('path');
4
+ var fs = require('fs');
4
5
  var mkdirp = require('mkdirp');
5
- var cheerio = require('cheerio');
6
6
  var sharp = require('sharp');
7
- var fs = require('fs');
7
+ var cheerio = require('cheerio');
8
8
  var crypto = require('crypto');
9
9
 
10
10
  function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
@@ -28,14 +28,15 @@ function _interopNamespace(e) {
28
28
  }
29
29
 
30
30
  var path__default = /*#__PURE__*/_interopDefaultLegacy(path);
31
+ var fs__default = /*#__PURE__*/_interopDefaultLegacy(fs);
31
32
  var mkdirp__namespace = /*#__PURE__*/_interopNamespace(mkdirp);
32
- var cheerio__namespace = /*#__PURE__*/_interopNamespace(cheerio);
33
33
  var sharp__default = /*#__PURE__*/_interopDefaultLegacy(sharp);
34
- var fs__default = /*#__PURE__*/_interopDefaultLegacy(fs);
34
+ var cheerio__namespace = /*#__PURE__*/_interopNamespace(cheerio);
35
35
  var crypto__default = /*#__PURE__*/_interopDefaultLegacy(crypto);
36
36
 
37
37
  /**
38
38
  * Configuration utility for the plugin
39
+ * Handles merging user options with sensible defaults
39
40
  */
40
41
 
41
42
  /**
@@ -44,19 +45,33 @@ var crypto__default = /*#__PURE__*/_interopDefaultLegacy(crypto);
44
45
  * @param {Object} source - Source object
45
46
  * @return {Object} - Merged result
46
47
  */
47
- function deepMerge(target, source) {
48
- const result = {
49
- ...target
50
- };
51
- for (const key in source) {
52
- if (source[key] instanceof Object && key in target && target[key] instanceof Object) {
53
- result[key] = deepMerge(target[key], source[key]);
48
+ /*
49
+ function deepMerge( target, source ) {
50
+ const result = { ...target };
51
+
52
+ for ( const key in source ) {
53
+ if ( source[key] instanceof Object && key in target && target[key] instanceof Object ) {
54
+ result[key] = deepMerge( target[key], source[key] );
54
55
  } else {
55
56
  result[key] = source[key];
56
57
  }
57
58
  }
59
+
58
60
  return result;
59
61
  }
62
+ */
63
+
64
+ // Modern functional approach to deep merge - handles nested objects properly
65
+ // This is needed for formatOptions and placeholder options which are nested objects
66
+ const deepMerge = (target, source) => Object.keys(source).reduce((acc, key) => {
67
+ var _source$key;
68
+ return {
69
+ ...acc,
70
+ [key]: ((_source$key = source[key]) == null ? void 0 : _source$key.constructor) === Object ? deepMerge(target[key] || {}, source[key]) : source[key]
71
+ };
72
+ }, {
73
+ ...target
74
+ });
60
75
 
61
76
  /**
62
77
  * Builds configuration with sensible defaults
@@ -110,64 +125,99 @@ function buildConfig(options = {}) {
110
125
  // Maximum number of images to process in parallel
111
126
  concurrency: 5,
112
127
  // Whether to generate a metadata JSON file
113
- generateMetadata: false
128
+ generateMetadata: false,
129
+ // Progressive loading options
130
+ isProgressive: false,
131
+ // TODO: Debug timeout issue in tests
132
+
133
+ // Placeholder image settings for progressive loading
134
+ placeholder: {
135
+ width: 50,
136
+ quality: 30,
137
+ blur: 10
138
+ },
139
+ // Background image processing settings
140
+ processUnusedImages: true,
141
+ // Process images not found in HTML for background use
142
+ imagePattern: '**/*.{jpg,jpeg,png,gif,webp,avif}' // Pattern to find images for background processing
114
143
  };
115
144
 
116
145
  // Special handling for formatOptions to ensure deep merging
117
- if (options.formatOptions) {
146
+ // This allows users to override specific format settings without losing defaults
147
+ // e.g., { formatOptions: { jpeg: { quality: 90 } } } only changes JPEG quality
148
+ if (options && options.formatOptions) {
118
149
  options = {
119
150
  ...options,
120
151
  formatOptions: deepMerge(defaults.formatOptions, options.formatOptions)
121
152
  };
122
153
  }
123
154
 
155
+ // Special handling for placeholder options to ensure deep merging
156
+ // Allows partial placeholder config like { placeholder: { width: 100 } }
157
+ if (options && options.placeholder) {
158
+ options = {
159
+ ...options,
160
+ placeholder: deepMerge(defaults.placeholder, options.placeholder)
161
+ };
162
+ }
163
+
124
164
  // Merge the defaults with user options
125
165
  return {
126
166
  ...defaults,
127
- ...options
167
+ ...(options || {})
128
168
  };
129
169
  }
130
170
 
131
171
  /**
132
172
  * Utility for generating content hashes
173
+ * Used for cache-busting - ensures filenames change when image content changes
133
174
  */
134
175
 
135
176
  /**
136
177
  * Generates a short hash based on image content
178
+ * Creates an 8-character hash for cache-busting in filenames
137
179
  * @param {Buffer} buffer - The image buffer
138
- * @return {string} - A short hash string
180
+ * @return {string} - A short hash string (8 characters)
139
181
  */
140
182
  function generateHash(buffer) {
141
- return crypto__default["default"].createHash('md5').update(buffer).digest('hex').slice(0, 8);
183
+ // SHA-256 for cache-busting - using secure algorithm to satisfy security scanners
184
+ // Only use first 8 characters to keep filenames manageable
185
+ return crypto__default["default"].createHash('sha256').update(buffer).digest('hex').slice(0, 8);
142
186
  }
143
187
 
144
188
  /**
145
189
  * Path utilities for image variants
190
+ * Handles the filename pattern system with token replacement
146
191
  */
147
192
 
148
193
  /**
149
194
  * Generate variant filename using pattern
195
+ * Applies token replacement to create output filenames
196
+ * Tokens: [filename], [width], [format], [hash]
150
197
  * @param {string} originalPath - Original image path
151
198
  * @param {number} width - Target width
152
- * @param {string} format - Target format
199
+ * @param {string} format - Target format ('original' means keep source format)
153
200
  * @param {string} hash - Content hash for cache busting
154
201
  * @param {object} config - Plugin config options
155
- * @return {string} - Generated path
202
+ * @return {string} - Generated path relative to Metalsmith destination
156
203
  */
157
204
  function generateVariantPath(originalPath, width, format, hash, config) {
158
205
  const parsedPath = path__default["default"].parse(originalPath);
159
206
  const originalFormat = parsedPath.ext.slice(1).toLowerCase();
160
207
 
161
- // If format is 'original', use the source format
208
+ // If format is 'original', use the source format (e.g., 'jpeg' for image.jpg)
162
209
  const outputFormat = format === 'original' ? originalFormat : format;
163
210
 
164
- // Apply pattern replacements
211
+ // Apply pattern replacements using the tokens system
212
+ // Default pattern: '[filename]-[width]w-[hash].[format]'
213
+ // Results in: 'image-320w-abc12345.webp'
165
214
  const outputName = config.outputPattern.replace('[filename]', parsedPath.name).replace('[width]', width).replace('[format]', outputFormat).replace('[hash]', hash || '');
166
215
  return path__default["default"].join(config.outputDir, outputName);
167
216
  }
168
217
 
169
218
  /**
170
219
  * Image processing utilities for creating responsive image variants
220
+ * Handles the core Sharp.js operations for resizing and format conversion
171
221
  */
172
222
 
173
223
  /**
@@ -184,45 +234,58 @@ async function processImageToVariants(buffer, originalPath, debugFn, config) {
184
234
  const variants = [];
185
235
  const hash = generateHash(buffer);
186
236
 
187
- // Determine which widths to generate
237
+ // Determine which widths to generate based on skipLarger setting
238
+ // If skipLarger is true (default), don't generate sizes larger than original
188
239
  const targetWidths = config.skipLarger ? config.widths.filter(w => w <= metadata.width) : config.widths;
189
240
  if (targetWidths.length === 0) {
190
241
  debugFn(`Skipping ${originalPath} - no valid target widths`);
191
242
  return [];
192
243
  }
193
244
 
194
- // Process all widths in parallel
245
+ // Process all widths in parallel for better performance
195
246
  const widthPromises = targetWidths.map(async width => {
196
- // Resize image (reused for all formats at this width)
247
+ // Create a Sharp instance for this width - clone to avoid conflicts
197
248
  const resized = image.clone().resize({
198
249
  width,
199
- withoutEnlargement: config.skipLarger
250
+ withoutEnlargement: config.skipLarger // Prevents upscaling small images
200
251
  });
201
252
 
202
- // Get dimensions for this width (for dimension attributes)
253
+ // Get actual dimensions after resize (may be smaller than requested width)
203
254
  const resizedMeta = await resized.metadata();
204
255
 
205
256
  // Process each format in parallel for this width
206
257
  const formatPromises = config.formats.map(async format => {
207
258
  try {
208
- // Skip formats that make no sense (like webp -> original)
259
+ // Skip problematic format combinations (e.g., webp -> original doesn't make sense)
209
260
  if (format === 'original' && metadata.format.toLowerCase() === 'webp') {
210
261
  return null;
211
262
  }
212
263
  let formatted;
213
264
  const outputPath = generateVariantPath(originalPath, width, format, hash, config);
214
265
 
215
- // Apply format-specific processing
266
+ // Apply format-specific processing with quality/compression settings
216
267
  if (format === 'original') {
268
+ // For 'original' format, use the source image format
217
269
  const originalFormat = metadata.format.toLowerCase();
218
270
  const formatOptions = config.formatOptions[originalFormat] || {};
219
271
  formatted = resized.clone().toFormat(originalFormat, formatOptions);
220
272
  } else {
273
+ // For specific formats (avif, webp, etc.), apply format-specific options
221
274
  const formatOptions = config.formatOptions[format] || {};
222
- formatted = resized.clone()[format](formatOptions);
275
+ if (format === 'avif') {
276
+ formatted = resized.clone().avif(formatOptions);
277
+ } else if (format === 'webp') {
278
+ formatted = resized.clone().webp(formatOptions);
279
+ } else if (format === 'jpeg') {
280
+ formatted = resized.clone().jpeg(formatOptions);
281
+ } else if (format === 'png') {
282
+ formatted = resized.clone().png(formatOptions);
283
+ } else {
284
+ formatted = resized.clone()[format](formatOptions);
285
+ }
223
286
  }
224
287
 
225
- // Generate the image buffer
288
+ // Generate the actual image buffer - this is where compression happens
226
289
  const formatBuffer = await formatted.toBuffer();
227
290
  return {
228
291
  path: outputPath,
@@ -281,10 +344,11 @@ async function processImage({
281
344
  }
282
345
 
283
346
  // Normalize src path to match Metalsmith files object keys
284
- // Remove leading slash if present
347
+ // Remove leading slash if present (HTML paths vs Metalsmith file keys)
285
348
  const normalizedSrc = src.startsWith('/') ? src.slice(1) : src;
286
349
 
287
- // Image not in files, try to load it from the build directory
350
+ // Image not in Metalsmith files object - try to load it from the build directory
351
+ // This handles cases where images were copied by other plugins (like assets)
288
352
  if (!files[normalizedSrc]) {
289
353
  try {
290
354
  const destination = metalsmith.destination();
@@ -293,10 +357,10 @@ async function processImage({
293
357
  // Load the image contents from the build directory
294
358
  const imageBuffer = fs__default["default"].readFileSync(imagePath);
295
359
 
296
- // Get modification time for cache busting
360
+ // Get modification time for cache busting - this helps with incremental builds
297
361
  const mtime = fs__default["default"].statSync(imagePath).mtimeMs;
298
362
 
299
- // Add it to files so the plugin can process it
363
+ // Add it to Metalsmith files so the plugin can process it
300
364
  files[normalizedSrc] = {
301
365
  contents: imageBuffer,
302
366
  mtime
@@ -312,10 +376,11 @@ async function processImage({
312
376
  }
313
377
 
314
378
  // Create a cache key that includes the file path and modification time
379
+ // This prevents reprocessing the same image multiple times in a single build
315
380
  const fileMtime = files[normalizedSrc].mtime || Date.now();
316
381
  const cacheKey = `${normalizedSrc}:${fileMtime}`;
317
382
 
318
- // Check if we've already processed this image
383
+ // Check if we've already processed this exact image (same file + mtime)
319
384
  if (processedImages.has(cacheKey)) {
320
385
  debug(`Using cached variants for ${normalizedSrc}`);
321
386
  const variants = processedImages.get(cacheKey);
@@ -324,28 +389,394 @@ async function processImage({
324
389
  }
325
390
  debug(`Processing image: ${normalizedSrc}`);
326
391
  try {
327
- // Process image to generate variants
392
+ // Process image to generate all variants (different sizes and formats)
328
393
  const variants = await processImageToVariants(files[normalizedSrc].contents, normalizedSrc, debug, config);
329
394
 
330
- // Save variants to Metalsmith files
395
+ // Save all generated variants to Metalsmith files object
396
+ // This makes them available in the final build output
331
397
  variants.forEach(variant => {
332
398
  files[variant.path] = {
333
399
  contents: variant.buffer
334
400
  };
335
401
  });
336
402
 
337
- // Cache variants for this image
403
+ // Cache variants for this image to avoid reprocessing
338
404
  processedImages.set(cacheKey, variants);
339
405
 
340
- // Replace img with picture element
406
+ // Replace the original <img> tag with a responsive <picture> element
341
407
  replacePictureElement($, $img, variants, config);
342
408
  } catch (err) {
343
409
  debug(`Error processing image: ${err.message}`);
344
410
  }
345
411
  }
346
412
 
413
+ /**
414
+ * Progressive image loading processor
415
+ * Handles placeholder generation and smooth loading transitions
416
+ */
417
+
418
+ /**
419
+ * Generate placeholder image for progressive loading
420
+ * Creates a small, blurred, low-quality version for instant display
421
+ * @param {string} imagePath - Original image path
422
+ * @param {Buffer} imageBuffer - Original image buffer
423
+ * @param {Object} placeholderConfig - Placeholder configuration (width, quality, blur)
424
+ * @param {Object} metalsmith - Metalsmith instance
425
+ * @return {Promise<Object>} Placeholder data with path and contents
426
+ */
427
+ async function generatePlaceholder(imagePath, imageBuffer, placeholderConfig, metalsmith) {
428
+ const {
429
+ width,
430
+ quality,
431
+ blur
432
+ } = placeholderConfig;
433
+ try {
434
+ // Get original image dimensions for aspect ratio calculation
435
+ const image = sharp__default["default"](imageBuffer);
436
+ const metadata = await image.metadata();
437
+
438
+ // Process image: resize to small width, blur heavily, compress heavily
439
+ const processed = await image.resize(width) // Default: 50px wide
440
+ .blur(blur) // Default: 10px blur
441
+ .jpeg({
442
+ quality
443
+ }) // Default: 30% quality
444
+ .toBuffer();
445
+ const fileName = `${path__default["default"].basename(imagePath, path__default["default"].extname(imagePath))}-placeholder.jpg`;
446
+ const outputPath = path__default["default"].join('assets/images/responsive', fileName);
447
+ return {
448
+ path: outputPath,
449
+ contents: processed,
450
+ fileName,
451
+ originalWidth: metadata.width,
452
+ originalHeight: metadata.height
453
+ };
454
+ } catch (error) {
455
+ metalsmith.debug('metalsmith-optimize-images')(`Error generating placeholder for ${imagePath}: ${error.message}`);
456
+ throw error;
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Create progressive wrapper HTML structure
462
+ * Creates a container with both placeholder and high-res images for smooth transitions
463
+ * @param {Object} $ - Cheerio instance
464
+ * @param {Object} $img - Original img element
465
+ * @param {Array} variants - Generated image variants
466
+ * @param {Object} placeholderData - Placeholder image data
467
+ * @param {Object} config - Plugin configuration
468
+ * @return {Object} Cheerio element for progressive wrapper
469
+ */
470
+ function createProgressiveWrapper($, $img, variants, placeholderData, _config) {
471
+ // Get original attributes
472
+ const alt = $img.attr('alt') || '';
473
+ const className = $img.attr('class') || '';
474
+
475
+ // Group variants by format - use only original format for progressive mode
476
+ // Progressive mode focuses on smooth loading rather than format optimization
477
+ const variantsByFormat = {};
478
+ variants.forEach(v => {
479
+ if (!variantsByFormat[v.format]) {
480
+ variantsByFormat[v.format] = [];
481
+ }
482
+ variantsByFormat[v.format].push(v);
483
+ });
484
+
485
+ // Get original format variants (skip AVIF/WebP for progressive mode)
486
+ // JavaScript will handle format detection dynamically
487
+ const originalFormat = Object.keys(variantsByFormat).find(f => f !== 'avif' && f !== 'webp');
488
+ const originalVariants = originalFormat ? variantsByFormat[originalFormat] : [];
489
+ if (originalVariants.length === 0) {
490
+ return $img.clone(); // Fallback if no variants
491
+ }
492
+
493
+ // Calculate aspect ratio using original image dimensions to prevent layout shift
494
+ // Fallback to variant dimensions if placeholderData doesn't have original dimensions
495
+ let aspectRatio;
496
+ if (placeholderData.originalWidth && placeholderData.originalHeight) {
497
+ aspectRatio = `${placeholderData.originalWidth}/${placeholderData.originalHeight}`;
498
+ } else {
499
+ // Fallback: use the largest variant for most accurate aspect ratio
500
+ const largestVariant = [...originalVariants].sort((a, b) => b.width - a.width)[0];
501
+ aspectRatio = `${largestVariant.width}/${largestVariant.height}`;
502
+ }
503
+
504
+ // Find middle-sized variant for high-res image (good balance of quality/size)
505
+ const highResVariant = originalVariants[Math.floor(originalVariants.length / 2)];
506
+
507
+ // Create wrapper div with modern CSS aspect-ratio
508
+ const $wrapper = $('<div>').addClass('responsive-wrapper js-progressive-image-wrapper').attr('style', `aspect-ratio: ${aspectRatio}`);
509
+
510
+ // Add class from original image if present
511
+ if (className) {
512
+ $wrapper.addClass(className);
513
+ }
514
+
515
+ // Create low-res image (placeholder) - shown immediately
516
+ const $lowRes = $('<img>').addClass('low-res').attr('src', `/${placeholderData.path}`).attr('alt', alt);
517
+
518
+ // Create high-res image (empty with data source) - loaded by JavaScript
519
+ const $highRes = $('<img>').addClass('high-res').attr('src', '').attr('alt', alt).attr('data-source', `/${highResVariant.path}`);
520
+
521
+ // Assemble the progressive wrapper
522
+ $lowRes.appendTo($wrapper);
523
+ $highRes.appendTo($wrapper);
524
+ return $wrapper;
525
+ }
526
+
527
+ /**
528
+ * Create standard picture element HTML
529
+ * Fallback function for when progressive loading fails
530
+ * @param {Object} $ - Cheerio instance
531
+ * @param {Object} $img - Original img element
532
+ * @param {Array} variants - Generated image variants
533
+ * @param {Object} config - Plugin configuration
534
+ * @return {Object} Cheerio element for picture
535
+ */
536
+ function createStandardPicture($, $img, variants, config) {
537
+ // Get original attributes
538
+ const src = $img.attr('src');
539
+ const alt = $img.attr('alt') || '';
540
+ const className = $img.attr('class') || '';
541
+ const sizesAttr = $img.attr('sizes') || config.sizes;
542
+
543
+ // Group variants by format
544
+ const variantsByFormat = {};
545
+ variants.forEach(v => {
546
+ if (!variantsByFormat[v.format]) {
547
+ variantsByFormat[v.format] = [];
548
+ }
549
+ variantsByFormat[v.format].push(v);
550
+ });
551
+
552
+ // Create picture element with all formats (standard mode)
553
+ const $picture = $('<picture>');
554
+
555
+ // Add format-specific source elements in preference order
556
+ ['avif', 'webp'].forEach(format => {
557
+ const formatVariants = variantsByFormat[format];
558
+ if (!formatVariants || formatVariants.length === 0) {
559
+ return;
560
+ }
561
+
562
+ // Sort variants by width
563
+ formatVariants.sort((a, b) => a.width - b.width);
564
+
565
+ // Create srcset string
566
+ const srcset = formatVariants.map(v => `/${v.path} ${v.width}w`).join(', ');
567
+
568
+ // Create source element
569
+ $('<source>').attr('type', `image/${format}`).attr('srcset', srcset).attr('sizes', sizesAttr).appendTo($picture);
570
+ });
571
+
572
+ // Add original format as img element
573
+ const originalFormat = Object.keys(variantsByFormat).find(f => f !== 'avif' && f !== 'webp');
574
+ if (originalFormat && variantsByFormat[originalFormat]) {
575
+ var _formatVariants$Math$;
576
+ const formatVariants = variantsByFormat[originalFormat];
577
+ formatVariants.sort((a, b) => a.width - b.width);
578
+ const srcset = formatVariants.map(v => `/${v.path} ${v.width}w`).join(', ');
579
+ const defaultSrc = (_formatVariants$Math$ = formatVariants[Math.floor(formatVariants.length / 2)]) == null ? void 0 : _formatVariants$Math$.path;
580
+
581
+ // Create new img element
582
+ const $newImg = $('<img>').attr('src', defaultSrc ? `/${defaultSrc}` : src).attr('srcset', srcset).attr('sizes', sizesAttr).attr('alt', alt).attr('loading', 'lazy');
583
+
584
+ // Add class if present
585
+ if (className) {
586
+ $newImg.attr('class', className);
587
+ }
588
+
589
+ // Add width/height attributes if configured and available
590
+ if (config.dimensionAttributes && variants.length > 0) {
591
+ const largestVariant = [...variants].sort((a, b) => b.width - a.width)[0];
592
+ $newImg.attr('width', largestVariant.width);
593
+ $newImg.attr('height', largestVariant.height);
594
+ }
595
+ $newImg.appendTo($picture);
596
+ }
597
+ return $picture;
598
+ }
599
+
600
+ /**
601
+ * Progressive image loader JavaScript
602
+ * Handles intersection observer, format detection, and smooth loading transitions
603
+ */
604
+ const progressiveImageLoader = `
605
+ (function() {
606
+ 'use strict';
607
+
608
+ // Cache for detected format support
609
+ let bestFormat = null;
610
+
611
+ // Main function called when images enter the viewport
612
+ const loadImage = function(entries, observer) {
613
+ for (let entry of entries) {
614
+ if (entry.isIntersecting) {
615
+ const thisWrapper = entry.target;
616
+
617
+ // Find the high res image in the wrapper
618
+ const thisImage = thisWrapper.querySelector('.high-res');
619
+ const thisImageSource = thisImage.dataset.source;
620
+
621
+ if (!thisImageSource) {
622
+ console.warn('No data-source found for high-res image');
623
+ return;
624
+ }
625
+
626
+ // Apply format based on detected support
627
+ let finalImageSource = thisImageSource;
628
+
629
+ if (bestFormat === 'avif') {
630
+ finalImageSource = thisImageSource.replace(/\.(jpg|jpeg|png)$/i, '.avif');
631
+ } else if (bestFormat === 'webp') {
632
+ finalImageSource = thisImageSource.replace(/\.(jpg|jpeg|png)$/i, '.webp');
633
+ }
634
+ // If 'original' or null, use original (no change needed)
635
+
636
+ thisImage.src = finalImageSource;
637
+
638
+ // Take this image off the observe list to prevent duplicate loading
639
+ observer.unobserve(thisWrapper);
640
+
641
+ // Once the hi-res image has been loaded, add done class to trigger CSS transition
642
+ thisImage.onload = function() {
643
+ thisWrapper.classList.add('done');
644
+ };
645
+
646
+ // Handle loading errors gracefully
647
+ thisImage.onerror = function() {
648
+ thisWrapper.classList.add('error');
649
+ };
650
+ }
651
+ }
652
+ };
653
+
654
+ const init = async function() {
655
+ // Detect best supported format first
656
+ bestFormat = await detectBestFormat();
657
+
658
+ // Check for Intersection Observer support (not available in older browsers)
659
+ if (!('IntersectionObserver' in window)) {
660
+ // Fallback: load all images immediately for older browsers
661
+ document.querySelectorAll('.js-progressive-image-wrapper').forEach(function(wrapper) {
662
+ const img = wrapper.querySelector('.high-res');
663
+ if (img && img.dataset.source) {
664
+ let finalImageSource = img.dataset.source;
665
+
666
+ // Apply detected format for fallback
667
+ if (bestFormat === 'avif') {
668
+ finalImageSource = img.dataset.source.replace(/\.(jpg|jpeg|png)$/i, '.avif');
669
+ } else if (bestFormat === 'webp') {
670
+ finalImageSource = img.dataset.source.replace(/\.(jpg|jpeg|png)$/i, '.webp');
671
+ }
672
+
673
+ img.src = finalImageSource;
674
+ wrapper.classList.add('done');
675
+ }
676
+ });
677
+ return;
678
+ }
679
+
680
+ // Create intersection observer with 50px margin (loads images slightly before they're visible)
681
+ const observer = new IntersectionObserver(loadImage, {
682
+ rootMargin: '50px'
683
+ });
684
+
685
+ // Loop over all image wrappers and add to intersection observer
686
+ const allImageWrappers = document.querySelectorAll('.js-progressive-image-wrapper');
687
+ for (let imageWrapper of allImageWrappers) {
688
+ observer.observe(imageWrapper);
689
+ }
690
+ };
691
+
692
+ // Format detection using createImageBitmap - more reliable than canvas encoding
693
+ async function detectBestFormat() {
694
+ const fallbackFormat = 'original';
695
+
696
+ if (!window.createImageBitmap) return fallbackFormat;
697
+
698
+ const avifData = 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUEAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAABYAAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgSAAAAAAABNjb2xybmNseAACAAIABoAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB5tZGF0EgAKBzgADlAgIGkyCR/wAABAAACvcA==';
699
+ const webpData = 'data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoCAAEAAQAcJaQAA3AA/v3AgAA=';
700
+
701
+ try {
702
+ const avifBlob = await fetch(avifData).then(r => r.blob());
703
+ await createImageBitmap(avifBlob);
704
+ return 'avif';
705
+ } catch {
706
+ try {
707
+ const webpBlob = await fetch(webpData).then(r => r.blob());
708
+ await createImageBitmap(webpBlob);
709
+ return 'webp';
710
+ } catch {
711
+ return fallbackFormat;
712
+ }
713
+ }
714
+ }
715
+
716
+ // Initialize when DOM is ready
717
+ if (document.readyState === 'loading') {
718
+ document.addEventListener('DOMContentLoaded', init);
719
+ } else {
720
+ init();
721
+ }
722
+
723
+ })();
724
+ `;
725
+
726
+ /**
727
+ * Progressive image CSS styles
728
+ * Handles aspect ratio, positioning, and smooth transitions between placeholder and high-res images
729
+ */
730
+ const progressiveImageCSS = `
731
+ .responsive-wrapper {
732
+ position: relative;
733
+ overflow: hidden;
734
+ background-color: #f0f0f0;
735
+ }
736
+
737
+ .responsive-wrapper .low-res {
738
+ position: absolute;
739
+ top: 0;
740
+ left: 0;
741
+ width: 100%;
742
+ height: 100%;
743
+ object-fit: cover;
744
+ transition: opacity 0.4s ease;
745
+ }
746
+
747
+ .responsive-wrapper .high-res {
748
+ position: absolute;
749
+ top: 0;
750
+ left: 0;
751
+ width: 100%;
752
+ height: 100%;
753
+ object-fit: cover;
754
+ opacity: 0;
755
+ transition: opacity 0.4s ease;
756
+ }
757
+
758
+ .responsive-wrapper.done .high-res {
759
+ opacity: 1;
760
+ }
761
+
762
+ .responsive-wrapper.done .low-res {
763
+ opacity: 0;
764
+ }
765
+
766
+ .responsive-wrapper.error .low-res {
767
+ filter: none;
768
+ }
769
+
770
+ /* Ensure images are responsive */
771
+ .responsive-wrapper img {
772
+ max-width: 100%;
773
+ height: auto;
774
+ }
775
+ `;
776
+
347
777
  /**
348
778
  * HTML processing utilities for replacing img tags with responsive picture elements
779
+ * Handles both standard and progressive loading modes
349
780
  */
350
781
 
351
782
  /**
@@ -366,7 +797,7 @@ function replacePictureElement($, $img, variants, config) {
366
797
  const className = $img.attr('class') || '';
367
798
  const sizesAttr = $img.attr('sizes') || config.sizes;
368
799
 
369
- // Group variants by format
800
+ // Group variants by format for creating <source> elements
370
801
  const variantsByFormat = {};
371
802
  variants.forEach(v => {
372
803
  if (!variantsByFormat[v.format]) {
@@ -375,12 +806,13 @@ function replacePictureElement($, $img, variants, config) {
375
806
  variantsByFormat[v.format].push(v);
376
807
  });
377
808
 
378
- // Create picture element
809
+ // Create picture element that will contain all formats
379
810
  const $picture = $('<picture>');
380
811
 
381
- // Add format-specific source elements in preference order
812
+ // Add format-specific source elements in preference order (avif, webp, then original)
813
+ // Browser will use the first format it supports
382
814
  config.formats.forEach(format => {
383
- // Skip 'original' placeholder
815
+ // Skip 'original' placeholder - it's handled separately
384
816
  if (format === 'original') {
385
817
  return;
386
818
  }
@@ -389,17 +821,17 @@ function replacePictureElement($, $img, variants, config) {
389
821
  return;
390
822
  }
391
823
 
392
- // Sort variants by width
824
+ // Sort variants by width for proper srcset ordering
393
825
  formatVariants.sort((a, b) => a.width - b.width);
394
826
 
395
- // Create srcset string
827
+ // Create srcset string: "path 320w, path 640w, path 960w"
396
828
  const srcset = formatVariants.map(v => `/${v.path} ${v.width}w`).join(', ');
397
829
 
398
- // Create source element
830
+ // Create source element with format type and srcset
399
831
  $('<source>').attr('type', `image/${format}`).attr('srcset', srcset).attr('sizes', sizesAttr).appendTo($picture);
400
832
  });
401
833
 
402
- // Add original format as last source
834
+ // Add original format as last source (fallback for browsers that don't support modern formats)
403
835
  const originalFormat = Object.keys(variantsByFormat).find(f => f !== 'avif' && f !== 'webp');
404
836
  if (originalFormat && variantsByFormat[originalFormat]) {
405
837
  const formatVariants = variantsByFormat[originalFormat];
@@ -408,29 +840,29 @@ function replacePictureElement($, $img, variants, config) {
408
840
  $('<source>').attr('type', `image/${originalFormat}`).attr('srcset', srcset).attr('sizes', sizesAttr).appendTo($picture);
409
841
  }
410
842
 
411
- // Create new img element
412
- const $newImg = $('<img>').attr('src', src) // Keep original as fallback
843
+ // Create new img element that serves as the final fallback
844
+ const $newImg = $('<img>').attr('src', src) // Keep original as fallback for very old browsers
413
845
  .attr('alt', alt);
414
846
 
415
- // Add class if present
847
+ // Preserve original class attribute if present
416
848
  if (className) {
417
849
  $newImg.attr('class', className);
418
850
  }
419
851
 
420
- // Add lazy loading if configured
852
+ // Add native lazy loading if configured (improves performance)
421
853
  if (config.lazy) {
422
854
  $newImg.attr('loading', 'lazy');
423
855
  }
424
856
 
425
- // Add width/height attributes if configured and available
857
+ // Add width/height attributes to prevent layout shift (CLS)
426
858
  if (config.dimensionAttributes && variants.length > 0) {
427
- // Use the largest variant as reference
859
+ // Use the largest variant as reference for dimensions
428
860
  const largestVariant = [...variants].sort((a, b) => b.width - a.width)[0];
429
861
  $newImg.attr('width', largestVariant.width);
430
862
  $newImg.attr('height', largestVariant.height);
431
863
  }
432
864
 
433
- // Copy any other attributes from original img
865
+ // Copy any other attributes from original img (except ones we handle specially)
434
866
  for (const attrib in $img[0].attribs) {
435
867
  if (!['src', 'alt', 'class', 'width', 'height', 'sizes'].includes(attrib)) {
436
868
  $newImg.attr(attrib, $img.attr(attrib));
@@ -462,7 +894,7 @@ async function processHtmlFile(htmlFile, fileData, files, metalsmith, processedI
462
894
  // Parse HTML
463
895
  const $ = cheerio__namespace.load(content);
464
896
 
465
- // Find all images matching our selector
897
+ // Find all images matching our selector (default: img:not([data-no-responsive]))
466
898
  const images = $(config.imgSelector);
467
899
  if (images.length === 0) {
468
900
  debug(`No images found in ${htmlFile}`);
@@ -470,16 +902,24 @@ async function processHtmlFile(htmlFile, fileData, files, metalsmith, processedI
470
902
  }
471
903
  debug(`Found ${images.length} images in ${htmlFile}`);
472
904
 
473
- // Process images in parallel with a concurrency limit
905
+ // Process images in parallel with a concurrency limit to prevent overwhelming the system
474
906
  const imageChunks = [];
475
907
  for (let i = 0; i < images.length; i += config.concurrency) {
476
908
  imageChunks.push(Array.from(images).slice(i, i + config.concurrency));
477
909
  }
478
910
 
479
- // Process all chunks in parallel
911
+ // Process all chunks in parallel - each chunk processes its images in parallel
480
912
  await Promise.all(imageChunks.map(async imageChunk => {
481
913
  // Process images within each chunk in parallel
482
- await Promise.all(imageChunk.map(img => processImage({
914
+ await Promise.all(imageChunk.map(img => config.isProgressive ? processProgressiveImage({
915
+ $,
916
+ img,
917
+ files,
918
+ metalsmith,
919
+ processedImages,
920
+ debug,
921
+ config
922
+ }) : processImage({
483
923
  $,
484
924
  img,
485
925
  files,
@@ -491,20 +931,31 @@ async function processHtmlFile(htmlFile, fileData, files, metalsmith, processedI
491
931
  })));
492
932
  }));
493
933
 
494
- // Update file contents with modified HTML
934
+ // Inject progressive loading CSS and JavaScript if needed
935
+ if (config.isProgressive) {
936
+ injectProgressiveAssets($);
937
+ }
938
+
939
+ // Update file contents with modified HTML (converts back to Buffer)
495
940
  fileData.contents = Buffer.from($.html());
496
941
  }
497
942
 
498
943
  /**
499
944
  * Generate metadata file if configured
945
+ * Creates a JSON manifest with information about all processed images
946
+ * Useful for debugging or integration with other tools
500
947
  * @param {Map} processedImages - Cache of processed images
501
948
  * @param {Object} files - Metalsmith files object
502
949
  * @param {Object} config - Plugin configuration
503
950
  */
504
951
  function generateMetadata(processedImages, files, config) {
505
952
  const metadataObj = {};
506
- processedImages.forEach((variants, key) => {
953
+ processedImages.forEach((value, key) => {
954
+ // Extract the original path from the cache key (path:mtime)
507
955
  const [path] = key.split(':');
956
+
957
+ // Handle both array format (from background processing) and object format (from HTML processing)
958
+ const variants = Array.isArray(value) ? value : value.variants;
508
959
  metadataObj[path] = variants.map(v => ({
509
960
  path: v.path,
510
961
  width: v.width,
@@ -519,9 +970,148 @@ function generateMetadata(processedImages, files, config) {
519
970
  };
520
971
  }
521
972
 
973
+ /**
974
+ * Process a single image with progressive loading
975
+ * Creates low-quality placeholders and high-resolution images with smooth transitions
976
+ * @param {Object} context - Processing context
977
+ * @return {Promise<void>} - Promise that resolves when the image is processed
978
+ */
979
+ async function processProgressiveImage({
980
+ $,
981
+ img,
982
+ files,
983
+ metalsmith,
984
+ processedImages,
985
+ debug,
986
+ config
987
+ }) {
988
+ const $img = $(img);
989
+ const src = $img.attr('src');
990
+ debug(`Starting progressive processing for: ${src}`);
991
+ if (!src || src.startsWith('http') || src.startsWith('data:')) {
992
+ debug(`Skipping external or data URL: ${src}`);
993
+ return;
994
+ }
995
+
996
+ // Normalize src path to match Metalsmith files object keys
997
+ const normalizedSrc = src.startsWith('/') ? src.slice(1) : src;
998
+
999
+ // Image not in files, try to load it from the build directory (same logic as processImage)
1000
+ if (!files[normalizedSrc]) {
1001
+ try {
1002
+ const destination = metalsmith.destination();
1003
+ const imagePath = path__default["default"].join(destination, normalizedSrc);
1004
+ if (fs__default["default"].existsSync(imagePath)) {
1005
+ // Load the image contents from the build directory
1006
+ const imageBuffer = fs__default["default"].readFileSync(imagePath);
1007
+
1008
+ // Get modification time for cache busting
1009
+ const mtime = fs__default["default"].statSync(imagePath).mtimeMs;
1010
+
1011
+ // Add it to files so the plugin can process it
1012
+ files[normalizedSrc] = {
1013
+ contents: imageBuffer,
1014
+ mtime
1015
+ };
1016
+ } else {
1017
+ debug(`Image not found in build: ${normalizedSrc}`);
1018
+ return;
1019
+ }
1020
+ } catch (err) {
1021
+ debug(`Error processing image from build directory: ${err.message}`);
1022
+ return;
1023
+ }
1024
+ }
1025
+
1026
+ // Create a cache key
1027
+ const fileMtime = files[normalizedSrc].mtime || Date.now();
1028
+ const cacheKey = `${normalizedSrc}:${fileMtime}`;
1029
+
1030
+ // Check if we've already processed this image
1031
+ if (processedImages.has(cacheKey)) {
1032
+ debug(`Using cached variants for ${normalizedSrc}`);
1033
+ const {
1034
+ variants,
1035
+ placeholderData
1036
+ } = processedImages.get(cacheKey);
1037
+ const $wrapper = createProgressiveWrapper($, $img, variants, placeholderData);
1038
+ $img.replaceWith($wrapper);
1039
+ return;
1040
+ }
1041
+ debug(`Processing progressive image: ${normalizedSrc}`);
1042
+ try {
1043
+ // Process image to generate all variants (sizes and formats)
1044
+ const variants = await processImageToVariants(files[normalizedSrc].contents, normalizedSrc, debug, config);
1045
+
1046
+ // Generate low-quality placeholder image for smooth loading transitions
1047
+ const placeholderData = await generatePlaceholder(normalizedSrc, files[normalizedSrc].contents, config.placeholder, metalsmith);
1048
+
1049
+ // Save all variants to Metalsmith files
1050
+ variants.forEach(variant => {
1051
+ files[variant.path] = {
1052
+ contents: variant.buffer
1053
+ };
1054
+ });
1055
+
1056
+ // Save placeholder to files
1057
+ files[placeholderData.path] = {
1058
+ contents: placeholderData.contents
1059
+ };
1060
+
1061
+ // Cache variants and placeholder for this image
1062
+ processedImages.set(cacheKey, {
1063
+ variants,
1064
+ placeholderData
1065
+ });
1066
+
1067
+ // Create progressive wrapper with placeholder and high-res image
1068
+ const $wrapper = createProgressiveWrapper($, $img, variants, placeholderData, config);
1069
+ $img.replaceWith($wrapper);
1070
+ } catch (err) {
1071
+ debug(`Error processing progressive image: ${err.message}`);
1072
+
1073
+ // Fallback to standard processing if progressive loading fails
1074
+ try {
1075
+ const variants = await processImageToVariants(files[normalizedSrc].contents, normalizedSrc, debug, config);
1076
+ variants.forEach(variant => {
1077
+ files[variant.path] = {
1078
+ contents: variant.buffer
1079
+ };
1080
+ });
1081
+ const $picture = createStandardPicture($, $img, variants, config);
1082
+ $img.replaceWith($picture);
1083
+ } catch (fallbackErr) {
1084
+ debug(`Fallback processing also failed: ${fallbackErr.message}`);
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ /**
1090
+ * Inject progressive loading CSS and JavaScript assets
1091
+ * Only injects if progressive images are actually present on the page
1092
+ * @param {Object} $ - Cheerio instance
1093
+ */
1094
+ function injectProgressiveAssets($) {
1095
+ // Check if progressive images exist on this page
1096
+ const hasProgressiveImages = $('.js-progressive-image-wrapper').length > 0;
1097
+ if (!hasProgressiveImages) {
1098
+ return;
1099
+ }
1100
+
1101
+ // Inject CSS styles for progressive loading (only once per page)
1102
+ if (!$('#progressive-image-styles').length) {
1103
+ $('head').append(`<style id="progressive-image-styles">${progressiveImageCSS}</style>`);
1104
+ }
1105
+
1106
+ // Inject JavaScript for intersection observer and loading logic (only once per page)
1107
+ if (!$('#progressive-image-loader').length) {
1108
+ $('body').append(`<script id="progressive-image-loader">${progressiveImageLoader}</script>`);
1109
+ }
1110
+ }
1111
+
522
1112
  /**
523
1113
  * Metalsmith plugin for generating responsive images with optimal formats
524
- * @module metalsmith-responsive-images
1114
+ * @module metalsmith-optimize-images
525
1115
  */
526
1116
 
527
1117
  /**
@@ -546,9 +1136,17 @@ function generateMetadata(processedImages, files, config) {
546
1136
  * @param {string} [options.sizes] - Default sizes attribute
547
1137
  * @param {number} [options.concurrency] - Maximum number of images to process in parallel
548
1138
  * @param {boolean} [options.generateMetadata] - Whether to generate a metadata JSON file
1139
+ * @param {boolean} [options.isProgressive] - Whether to use progressive image loading (default: true)
1140
+ * @param {Object} [options.placeholder] - Placeholder image settings for progressive loading
1141
+ * @param {number} [options.placeholder.width] - Placeholder image width (default: 50)
1142
+ * @param {number} [options.placeholder.quality] - Placeholder image quality (default: 30)
1143
+ * @param {number} [options.placeholder.blur] - Placeholder image blur amount (default: 10)
1144
+ * @param {boolean} [options.processUnusedImages] - Whether to process unused images for background use (default: true)
1145
+ * @param {string} [options.imagePattern] - Glob pattern to find images for background processing (default: `**\/*.{jpg,jpeg,png,gif,webp,avif}`)
1146
+ * @param {string} [options.imageFolder] - Folder to scan for background images, relative to source (default: 'lib/assets/images')
549
1147
  * @return {Function} - Metalsmith plugin function
550
1148
  */
551
- function responsiveImagesPlugin(options = {}) {
1149
+ function optimizeImagesPlugin(options = {}) {
552
1150
  // Build configuration with defaults and user options
553
1151
  const config = buildConfig(options);
554
1152
 
@@ -559,42 +1157,66 @@ function responsiveImagesPlugin(options = {}) {
559
1157
  * @param {Function} done - Callback function
560
1158
  * @return {void}
561
1159
  */
562
- return async function responsiveImages(files, metalsmith, done) {
1160
+ return async function optimizeImages(files, metalsmith, done) {
563
1161
  try {
564
1162
  const destination = metalsmith.destination();
565
1163
  const outputPath = path__default["default"].join(destination, config.outputDir);
566
1164
 
567
- // Set up debug function
568
- const debug = metalsmith.debug('metalsmith-responsive-images');
1165
+ // Set up debug function for logging (uses 'DEBUG=metalsmith-optimize-images*' env var)
1166
+ const debug = metalsmith.debug('metalsmith-optimize-images');
569
1167
 
570
- // Create output directory
1168
+ // Ensure the output directory exists where processed images will be saved
571
1169
  mkdirp__namespace.mkdirpSync(outputPath);
572
1170
 
573
- // Find all HTML files
574
- const htmlFiles = Object.keys(files).filter(file => metalsmith.match(config.htmlPattern, file));
1171
+ // Find all HTML files that match the pattern (default: **/*.html)
1172
+ // Also ensure they actually end with .html to avoid processing CSS/JS files
1173
+ const htmlFiles = Object.keys(files).filter(file => {
1174
+ // Must match the HTML pattern
1175
+ if (!metalsmith.match(config.htmlPattern, file)) {
1176
+ return false;
1177
+ }
1178
+
1179
+ // Must actually be an HTML file
1180
+ if (!file.endsWith('.html')) {
1181
+ return false;
1182
+ }
1183
+ return true;
1184
+ });
575
1185
  if (htmlFiles.length === 0) {
576
1186
  debug('No HTML files found');
577
1187
  return done();
578
1188
  }
579
1189
 
580
- // Track all generated images to avoid duplicate processing
1190
+ // Cache to avoid re-processing identical images across different HTML files
1191
+ // Key: "filepath:mtime", Value: array of processed image variants
581
1192
  const processedImages = new Map();
582
1193
 
583
- // Process HTML files in parallel with a concurrency limit
1194
+ // Chunk HTML files to respect concurrency limit (default: 5)
1195
+ // This prevents overwhelming the system with too many parallel operations
584
1196
  const chunks = [];
585
1197
  for (let i = 0; i < htmlFiles.length; i += config.concurrency) {
586
1198
  chunks.push(htmlFiles.slice(i, i + config.concurrency));
587
1199
  }
588
1200
 
589
- // Process all chunks in parallel
1201
+ // Process all chunks in parallel - each chunk processes its files in parallel
1202
+ // This creates a two-level parallelism: chunk-level and file-level within chunks
590
1203
  await Promise.all(chunks.map(async chunk => {
591
1204
  // Process files within each chunk in parallel
592
1205
  await Promise.all(chunk.map(async htmlFile => {
1206
+ // This function parses HTML, finds images, processes them, and updates the HTML
593
1207
  await processHtmlFile(htmlFile, files[htmlFile], files, metalsmith, processedImages, debug, config);
594
1208
  }));
595
1209
  }));
596
1210
 
597
- // Generate metadata file if requested
1211
+ // Process unused images for background image support
1212
+ // This finds images that weren't processed during HTML scanning and creates variants
1213
+ // for use in CSS background-image with image-set()
1214
+ if (config.processUnusedImages) {
1215
+ await processUnusedImages(files, metalsmith, processedImages, debug, config);
1216
+ }
1217
+
1218
+ // Optional: Generate a JSON metadata file with information about all processed images
1219
+ // Useful for debugging or integration with other tools
598
1220
  if (config.generateMetadata) {
599
1221
  generateMetadata(processedImages, files, config);
600
1222
  }
@@ -608,5 +1230,345 @@ function responsiveImagesPlugin(options = {}) {
608
1230
  };
609
1231
  }
610
1232
 
611
- module.exports = responsiveImagesPlugin;
1233
+ /**
1234
+ * Process unused images for background image support
1235
+ * Finds images that weren't processed during HTML scanning and creates 1x/2x variants
1236
+ * for use in CSS background-image with image-set()
1237
+ * @param {Object} files - Metalsmith files object
1238
+ * @param {Object} metalsmith - Metalsmith instance
1239
+ * @param {Map} processedImages - Cache of already processed images
1240
+ * @param {Function} debug - Debug function
1241
+ * @param {Object} config - Plugin configuration
1242
+ * @return {Promise<void>} - Promise that resolves when processing is complete
1243
+ */
1244
+ async function processUnusedImages(files, metalsmith, processedImages, debug, config) {
1245
+ debug('Processing unused images for background image support');
1246
+
1247
+ // Get all image paths that were already processed during HTML scanning
1248
+ const processedImagePaths = new Set();
1249
+ processedImages.forEach((_variants, cacheKey) => {
1250
+ const [imagePath] = cacheKey.split(':');
1251
+ processedImagePaths.add(imagePath);
1252
+ });
1253
+ debug(`Processed image paths from HTML: ${Array.from(processedImagePaths).join(', ')}`);
1254
+
1255
+ // Find images that weren't processed during HTML scanning using hybrid approach
1256
+ const allBackgroundImages = await findUnprocessedImages(files, metalsmith, config, processedImagePaths, debug);
1257
+ debug(`Background images found to process: ${allBackgroundImages.map(img => img.path).join(', ')}`);
1258
+ if (allBackgroundImages.length === 0) {
1259
+ debug('No unused images found to process');
1260
+ return;
1261
+ }
1262
+ debug(`Found ${allBackgroundImages.length} unused images to process for background use`);
1263
+
1264
+ // Process background images in parallel for better performance
1265
+ await Promise.all(allBackgroundImages.map(async imageObj => {
1266
+ try {
1267
+ debug(`Processing background image: ${imageObj.path} (source: ${imageObj.source})`);
1268
+
1269
+ // Generate background variants with original size and half size
1270
+ const variants = await processBackgroundImageVariants(imageObj.buffer, imageObj.path, debug, config);
1271
+
1272
+ // Save all generated variants to Metalsmith files object
1273
+ variants.forEach(variant => {
1274
+ files[variant.path] = {
1275
+ contents: variant.buffer
1276
+ };
1277
+ });
1278
+
1279
+ // Cache the variants (using current timestamp as mtime for unused images)
1280
+ const cacheKey = `${imageObj.path}:${Date.now()}`;
1281
+ processedImages.set(cacheKey, variants);
1282
+ debug(`Generated ${variants.length} background variants for ${imageObj.path}`);
1283
+ } catch (err) {
1284
+ debug(`Error processing background image ${imageObj.path}: ${err.message}`);
1285
+ }
1286
+ }));
1287
+ debug('Background image processing complete');
1288
+ }
1289
+
1290
+ /**
1291
+ * Find images that weren't processed during HTML scanning
1292
+ * Uses a hybrid approach: scans filesystem first, then falls back to Metalsmith files object
1293
+ * @param {Object} files - Metalsmith files object
1294
+ * @param {Object} metalsmith - Metalsmith instance
1295
+ * @param {Object} config - Plugin configuration
1296
+ * @param {Set} processedImagePaths - Set of already processed image paths
1297
+ * @param {Function} debug - Debug function
1298
+ * @return {Promise<Array>} - Array of unprocessed image objects with {path, buffer}
1299
+ */
1300
+ async function findUnprocessedImages(files, metalsmith, config, processedImagePaths, debug) {
1301
+ const unprocessedImages = [];
1302
+ const sourceImagesDir = path__default["default"].join(metalsmith.source(), 'lib/assets/images');
1303
+ debug(`Looking for unprocessed images using hybrid approach`);
1304
+
1305
+ // Method 1: Scan filesystem (for real testbed scenario)
1306
+ try {
1307
+ debug(`Attempting to scan source directory: ${sourceImagesDir}`);
1308
+ debug(`Source directory exists: ${fs__default["default"].existsSync(sourceImagesDir)}`);
1309
+ debug(`Metalsmith source: ${metalsmith.source()}`);
1310
+ debug(`Metalsmith destination: ${metalsmith.destination()}`);
1311
+ if (fs__default["default"].existsSync(sourceImagesDir)) {
1312
+ debug(`Scanning source directory: ${sourceImagesDir}`);
1313
+ const scanDirectory = (dir, relativePath = '') => {
1314
+ const items = fs__default["default"].readdirSync(dir);
1315
+ debug(`Found ${items.length} items in ${dir}`);
1316
+ for (const item of items) {
1317
+ if (item === '.DS_Store') {
1318
+ continue;
1319
+ }
1320
+ const fullPath = path__default["default"].join(dir, item);
1321
+ const itemRelativePath = path__default["default"].join(relativePath, item);
1322
+ if (fs__default["default"].statSync(fullPath).isDirectory()) {
1323
+ debug(`Scanning subdirectory: ${item}`);
1324
+ scanDirectory(fullPath, itemRelativePath);
1325
+ } else {
1326
+ const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'];
1327
+ if (imageExtensions.some(ext => item.toLowerCase().endsWith(ext))) {
1328
+ // Skip if this is in the responsive output directory
1329
+ if (itemRelativePath.startsWith('responsive/') || itemRelativePath.includes('/responsive/') || fullPath.includes(config.outputDir)) {
1330
+ debug(`Skipping responsive variant: ${itemRelativePath}`);
1331
+ continue;
1332
+ }
1333
+ const buildPath = path__default["default"].join('assets/images', itemRelativePath);
1334
+ const normalizedBuildPath = buildPath.replace(/\\/g, '/');
1335
+ debug(`Found filesystem image: ${item} -> ${normalizedBuildPath}`);
1336
+ debug(`Already processed? ${processedImagePaths.has(normalizedBuildPath)}`);
1337
+ if (!processedImagePaths.has(normalizedBuildPath)) {
1338
+ debug(`Found unprocessed filesystem image: ${itemRelativePath}`);
1339
+ const imageBuffer = fs__default["default"].readFileSync(fullPath);
1340
+ unprocessedImages.push({
1341
+ path: itemRelativePath,
1342
+ buffer: imageBuffer,
1343
+ source: 'filesystem'
1344
+ });
1345
+ }
1346
+ }
1347
+ }
1348
+ }
1349
+ };
1350
+ scanDirectory(sourceImagesDir);
1351
+ } else {
1352
+ debug(`Source directory does not exist, trying alternative paths...`);
1353
+
1354
+ // Try alternative paths
1355
+ const altPaths = [path__default["default"].join(metalsmith.source(), 'assets/images'), path__default["default"].join(metalsmith.source(), 'images'), path__default["default"].join(metalsmith.destination(), 'assets/images'), path__default["default"].join(process.cwd(), 'lib/assets/images'), path__default["default"].join(process.cwd(), 'src/assets/images')];
1356
+ for (const altPath of altPaths) {
1357
+ debug(`Trying alternative path: ${altPath} - exists: ${fs__default["default"].existsSync(altPath)}`);
1358
+ if (fs__default["default"].existsSync(altPath)) {
1359
+ debug(`Found images at alternative path: ${altPath}`);
1360
+
1361
+ // Scan the found alternative path
1362
+ const scanAlternativeDirectory = (dir, relativePath = '') => {
1363
+ const items = fs__default["default"].readdirSync(dir);
1364
+ debug(`Found ${items.length} items in alternative path ${dir}`);
1365
+ for (const item of items) {
1366
+ if (item === '.DS_Store') {
1367
+ continue;
1368
+ }
1369
+ const fullPath = path__default["default"].join(dir, item);
1370
+ const itemRelativePath = path__default["default"].join(relativePath, item);
1371
+ if (fs__default["default"].statSync(fullPath).isDirectory()) {
1372
+ debug(`Scanning alternative subdirectory: ${item}`);
1373
+ scanAlternativeDirectory(fullPath, itemRelativePath);
1374
+ } else {
1375
+ const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'];
1376
+ if (imageExtensions.some(ext => item.toLowerCase().endsWith(ext))) {
1377
+ // Skip if this is in the responsive output directory
1378
+ if (itemRelativePath.startsWith('responsive/') || itemRelativePath.includes('/responsive/') || fullPath.includes(config.outputDir)) {
1379
+ debug(`Skipping responsive variant in alt scan: ${itemRelativePath}`);
1380
+ continue;
1381
+ }
1382
+
1383
+ // For build directory, the path structure is already correct
1384
+ const buildPath = altPath.includes('build') ? path__default["default"].join('assets/images', itemRelativePath) : path__default["default"].join('assets/images', itemRelativePath);
1385
+ const normalizedBuildPath = buildPath.replace(/\\/g, '/');
1386
+ debug(`Found alternative filesystem image: ${item} -> ${normalizedBuildPath}`);
1387
+ debug(`Already processed? ${processedImagePaths.has(normalizedBuildPath)}`);
1388
+ if (!processedImagePaths.has(normalizedBuildPath)) {
1389
+ debug(`Found unprocessed alternative filesystem image: ${itemRelativePath}`);
1390
+ const imageBuffer = fs__default["default"].readFileSync(fullPath);
1391
+ unprocessedImages.push({
1392
+ path: itemRelativePath,
1393
+ buffer: imageBuffer,
1394
+ source: 'filesystem-alt'
1395
+ });
1396
+ }
1397
+ }
1398
+ }
1399
+ }
1400
+ };
1401
+ scanAlternativeDirectory(altPath);
1402
+ break; // Stop after finding and scanning the first valid path
1403
+ }
1404
+ }
1405
+ }
1406
+ } catch (err) {
1407
+ debug(`Error scanning filesystem: ${err.message}`);
1408
+ }
1409
+
1410
+ // Method 2: Scan Metalsmith files object (for test scenarios and edge cases)
1411
+ debug(`Scanning Metalsmith files object`);
1412
+ const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'];
1413
+ Object.keys(files).forEach(filePath => {
1414
+ // Skip if not an image
1415
+ if (!imageExtensions.some(ext => filePath.toLowerCase().endsWith(ext))) {
1416
+ return;
1417
+ }
1418
+
1419
+ // Skip if it's already a responsive variant (comprehensive checks)
1420
+ if (filePath.startsWith(`${config.outputDir}/`) || filePath.includes('/responsive/') || filePath.includes('responsive-images-manifest.json') || filePath.match(/-\d+w(-[a-f0-9]+)?\.(avif|webp|jpg|jpeg|png)$/i)) {
1421
+ debug(`Skipping responsive variant in files object: ${filePath}`);
1422
+ return;
1423
+ }
1424
+
1425
+ // Skip if already processed during HTML scanning
1426
+ if (processedImagePaths.has(filePath)) {
1427
+ debug(`Skipping already processed files object image: ${filePath}`);
1428
+ return;
1429
+ }
1430
+
1431
+ // Check if we already found this image from filesystem scan
1432
+ const isAlreadyFound = unprocessedImages.some(img => {
1433
+ // For files object images starting with 'images/', check if filesystem found the same file
1434
+ if (filePath.startsWith('images/')) {
1435
+ const relativePath = filePath.replace('images/', '');
1436
+ return img.path === relativePath;
1437
+ }
1438
+ return false;
1439
+ });
1440
+ if (!isAlreadyFound) {
1441
+ debug(`Found unprocessed files object image: ${filePath}`);
1442
+ unprocessedImages.push({
1443
+ path: filePath,
1444
+ buffer: files[filePath].contents,
1445
+ source: 'files'
1446
+ });
1447
+ }
1448
+ });
1449
+ debug(`Found ${unprocessedImages.length} unprocessed images total`);
1450
+ return unprocessedImages;
1451
+ }
1452
+
1453
+ /**
1454
+ * Process a background image to create 1x (original) and 2x (half-size) variants
1455
+ * for use with CSS image-set() for retina displays
1456
+ * @param {Buffer} buffer - Original image buffer
1457
+ * @param {string} originalPath - Original image path
1458
+ * @param {Function} debugFn - Debug function for logging
1459
+ * @param {Object} config - Plugin configuration
1460
+ * @return {Promise<Array<Object>>} - Array of generated variants
1461
+ */
1462
+ async function processBackgroundImageVariants(buffer, originalPath, debugFn, config) {
1463
+ const image = sharp__default["default"](buffer);
1464
+ const metadata = await image.metadata();
1465
+ const variants = [];
1466
+ debugFn(`Processing background image ${originalPath}: ${metadata.width}x${metadata.height}`);
1467
+
1468
+ // Create 1x (original size) and 2x (half size) variants
1469
+ const sizes = [{
1470
+ width: metadata.width,
1471
+ density: '1x'
1472
+ }, {
1473
+ width: Math.round(metadata.width / 2),
1474
+ density: '2x'
1475
+ }];
1476
+
1477
+ // Process both sizes in parallel
1478
+ const sizePromises = sizes.map(async size => {
1479
+ // Create a Sharp instance for this size
1480
+ const resized = image.clone().resize({
1481
+ width: size.width,
1482
+ withoutEnlargement: true // Don't upscale images
1483
+ });
1484
+
1485
+ // Get actual dimensions after resize
1486
+ const resizedMeta = await resized.metadata();
1487
+
1488
+ // Process each format in parallel for this size
1489
+ const formatPromises = config.formats.map(async format => {
1490
+ try {
1491
+ // Skip problematic format combinations
1492
+ if (format === 'original' && metadata.format.toLowerCase() === 'webp') {
1493
+ return null;
1494
+ }
1495
+
1496
+ // Determine output format and Sharp method
1497
+ let outputFormat = format;
1498
+ let sharpMethod = format;
1499
+ if (format === 'original') {
1500
+ outputFormat = metadata.format.toLowerCase();
1501
+ sharpMethod = outputFormat === 'jpeg' ? 'jpeg' : outputFormat;
1502
+ }
1503
+
1504
+ // Apply format-specific processing
1505
+ let processedImage = resized.clone();
1506
+ const formatOptions = config.formatOptions[format === 'original' ? outputFormat : format] || {};
1507
+ if (sharpMethod === 'avif') {
1508
+ processedImage = processedImage.avif(formatOptions);
1509
+ } else if (sharpMethod === 'webp') {
1510
+ processedImage = processedImage.webp(formatOptions);
1511
+ } else if (sharpMethod === 'jpeg') {
1512
+ processedImage = processedImage.jpeg(formatOptions);
1513
+ } else if (sharpMethod === 'png') {
1514
+ processedImage = processedImage.png(formatOptions);
1515
+ }
1516
+
1517
+ // Generate output buffer
1518
+ const outputBuffer = await processedImage.toBuffer();
1519
+
1520
+ // Generate variant path without hash for easier CSS usage
1521
+ const variantPath = generateBackgroundVariantPath(originalPath, size.width, outputFormat, config);
1522
+ debugFn(`Generated background variant: ${variantPath} (${size.density})`);
1523
+ return {
1524
+ path: variantPath,
1525
+ buffer: outputBuffer,
1526
+ width: resizedMeta.width,
1527
+ height: resizedMeta.height,
1528
+ format: outputFormat,
1529
+ density: size.density
1530
+ };
1531
+ } catch (err) {
1532
+ debugFn(`Error processing ${format} format for ${originalPath}: ${err.message}`);
1533
+ return null;
1534
+ }
1535
+ });
1536
+ const formatResults = await Promise.all(formatPromises);
1537
+ return formatResults.filter(result => result !== null);
1538
+ });
1539
+ const sizeResults = await Promise.all(sizePromises);
1540
+
1541
+ // Flatten the results
1542
+ sizeResults.forEach(formatVariants => {
1543
+ variants.push(...formatVariants);
1544
+ });
1545
+ debugFn(`Generated ${variants.length} background variants for ${originalPath}`);
1546
+ return variants;
1547
+ }
1548
+
1549
+ /**
1550
+ * Generate background image variant path without hash for easier CSS usage
1551
+ * Creates predictable filenames that can be written in CSS without knowing the hash
1552
+ * @param {string} originalPath - Original image path
1553
+ * @param {number} width - Target width
1554
+ * @param {string} format - Target format
1555
+ * @param {Object} config - Plugin configuration
1556
+ * @return {string} - Generated path without hash
1557
+ */
1558
+ function generateBackgroundVariantPath(originalPath, width, format, config) {
1559
+ const parsedPath = path__default["default"].parse(originalPath);
1560
+ const originalFormat = parsedPath.ext.slice(1).toLowerCase();
1561
+
1562
+ // If format is 'original', use the source format
1563
+ const outputFormat = format === 'original' ? originalFormat : format;
1564
+
1565
+ // Create background pattern without hash: '[filename]-[width]w.[format]'
1566
+ // Results in: 'header1-1000w.webp' instead of 'header1-1000w-abc12345.webp'
1567
+ const outputName = config.outputPattern.replace('[filename]', parsedPath.name).replace('[width]', width).replace('[format]', outputFormat).replace('-[hash]', '') // Remove hash placeholder and preceding dash
1568
+ .replace('[hash]', ''); // Remove any remaining hash placeholder
1569
+
1570
+ return path__default["default"].join(config.outputDir, outputName);
1571
+ }
1572
+
1573
+ module.exports = optimizeImagesPlugin;
612
1574
  //# sourceMappingURL=index.cjs.map