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