metalsmith-optimize-images 0.10.3 → 1.0.0

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