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