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