metalsmith-optimize-images 0.12.0 → 1.0.0

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