metalsmith-optimize-images 0.10.3 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +150 -69
- package/package.json +21 -40
- package/src/index.js +686 -0
- package/src/processors/htmlProcessor.js +446 -0
- package/src/processors/imageProcessor.js +330 -0
- package/src/processors/progressiveProcessor.js +379 -0
- package/src/utils/config.js +117 -0
- package/src/utils/hash.js +17 -0
- package/src/utils/paths.js +35 -0
- package/lib/index.cjs +0 -1572
- package/lib/index.cjs.map +0 -1
- package/lib/index.js +0 -1543
- package/lib/index.js.map +0 -1
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML processing utilities for replacing img tags with responsive picture elements
|
|
3
|
+
* Handles both standard and progressive loading modes
|
|
4
|
+
*/
|
|
5
|
+
import * as cheerio from 'cheerio';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { processImage, processImageToVariants } from './imageProcessor.js';
|
|
9
|
+
import {
|
|
10
|
+
generatePlaceholder,
|
|
11
|
+
createProgressiveWrapper,
|
|
12
|
+
createStandardPicture,
|
|
13
|
+
progressiveImageCSS,
|
|
14
|
+
progressiveImageLoader
|
|
15
|
+
} from './progressiveProcessor.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Replace an img element with a responsive picture element
|
|
19
|
+
* @param {Object} $ - Cheerio instance
|
|
20
|
+
* @param {Object} $img - Cheerio image element
|
|
21
|
+
* @param {Array<Object>} variants - Generated image variants
|
|
22
|
+
* @param {Object} config - Plugin configuration
|
|
23
|
+
*/
|
|
24
|
+
export function replacePictureElement($, $img, variants, config) {
|
|
25
|
+
if (variants.length === 0) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Get original img attributes
|
|
30
|
+
const src = $img.attr('src');
|
|
31
|
+
const alt = $img.attr('alt') || '';
|
|
32
|
+
const className = $img.attr('class') || '';
|
|
33
|
+
const sizesAttr = $img.attr('sizes') || config.sizes;
|
|
34
|
+
|
|
35
|
+
// Group variants by format for creating <source> elements
|
|
36
|
+
const variantsByFormat = {};
|
|
37
|
+
variants.forEach((v) => {
|
|
38
|
+
if (!variantsByFormat[v.format]) {
|
|
39
|
+
variantsByFormat[v.format] = [];
|
|
40
|
+
}
|
|
41
|
+
variantsByFormat[v.format].push(v);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Create picture element that will contain all formats
|
|
45
|
+
const $picture = $('<picture>');
|
|
46
|
+
|
|
47
|
+
// Add format-specific source elements in preference order (avif, webp, then original)
|
|
48
|
+
// Browser will use the first format it supports
|
|
49
|
+
config.formats.forEach((format) => {
|
|
50
|
+
// Skip 'original' placeholder - it's handled separately
|
|
51
|
+
if (format === 'original') {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const formatVariants = variantsByFormat[format];
|
|
56
|
+
if (!formatVariants || formatVariants.length === 0) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Sort variants by width for proper srcset ordering
|
|
61
|
+
formatVariants.sort((a, b) => a.width - b.width);
|
|
62
|
+
|
|
63
|
+
// Create srcset string: "path 320w, path 640w, path 960w"
|
|
64
|
+
const srcset = formatVariants.map((v) => `/${v.path} ${v.width}w`).join(', ');
|
|
65
|
+
|
|
66
|
+
// Create source element with format type and srcset
|
|
67
|
+
$('<source>').attr('type', `image/${format}`).attr('srcset', srcset).attr('sizes', sizesAttr).appendTo($picture);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Add original format as last source (fallback for browsers that don't support modern formats)
|
|
71
|
+
const originalFormat = Object.keys(variantsByFormat).find((f) => f !== 'avif' && f !== 'webp');
|
|
72
|
+
|
|
73
|
+
if (originalFormat && variantsByFormat[originalFormat]) {
|
|
74
|
+
const formatVariants = variantsByFormat[originalFormat];
|
|
75
|
+
formatVariants.sort((a, b) => a.width - b.width);
|
|
76
|
+
|
|
77
|
+
const srcset = formatVariants.map((v) => `/${v.path} ${v.width}w`).join(', ');
|
|
78
|
+
|
|
79
|
+
$('<source>')
|
|
80
|
+
.attr('type', `image/${originalFormat}`)
|
|
81
|
+
.attr('srcset', srcset)
|
|
82
|
+
.attr('sizes', sizesAttr)
|
|
83
|
+
.appendTo($picture);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Create new img element that serves as the final fallback
|
|
87
|
+
const $newImg = $('<img>')
|
|
88
|
+
.attr('src', src) // Keep original as fallback for very old browsers
|
|
89
|
+
.attr('alt', alt);
|
|
90
|
+
|
|
91
|
+
// Preserve original class attribute if present
|
|
92
|
+
if (className) {
|
|
93
|
+
$newImg.attr('class', className);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Add native lazy loading if configured (improves performance)
|
|
97
|
+
if (config.lazy) {
|
|
98
|
+
$newImg.attr('loading', 'lazy');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Add width/height attributes to prevent layout shift (CLS)
|
|
102
|
+
if (config.dimensionAttributes && variants.length > 0) {
|
|
103
|
+
// Use the largest variant as reference for dimensions
|
|
104
|
+
const largestVariant = [...variants].sort((a, b) => b.width - a.width)[0];
|
|
105
|
+
$newImg.attr('width', largestVariant.width);
|
|
106
|
+
$newImg.attr('height', largestVariant.height);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Copy any other attributes from original img (except ones we handle specially)
|
|
110
|
+
for (const attrib in $img[0].attribs) {
|
|
111
|
+
if (!['src', 'alt', 'class', 'width', 'height', 'sizes'].includes(attrib)) {
|
|
112
|
+
$newImg.attr(attrib, $img.attr(attrib));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Add img to picture element
|
|
117
|
+
$newImg.appendTo($picture);
|
|
118
|
+
|
|
119
|
+
// Replace original img with picture element
|
|
120
|
+
$img.replaceWith($picture);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Process an HTML file to replace img tags with responsive picture elements
|
|
125
|
+
* @param {string} htmlFile - Path to HTML file
|
|
126
|
+
* @param {Object} fileData - File data object
|
|
127
|
+
* @param {Object} files - Metalsmith files object
|
|
128
|
+
* @param {Object} metalsmith - Metalsmith instance
|
|
129
|
+
* @param {Map} processedImages - Cache of processed images
|
|
130
|
+
* @param {Function} debug - Debug function
|
|
131
|
+
* @param {Object} config - Plugin configuration
|
|
132
|
+
* @param {string|null} cacheDir - Resolved absolute path to persistent cache, or null
|
|
133
|
+
* @param {string|null} sourcePrefix - Prefix to map build paths to source asset paths on disk, or null
|
|
134
|
+
* @return {Promise<void>} - Promise that resolves when the HTML file is processed
|
|
135
|
+
*/
|
|
136
|
+
export async function processHtmlFile(
|
|
137
|
+
htmlFile,
|
|
138
|
+
fileData,
|
|
139
|
+
files,
|
|
140
|
+
metalsmith,
|
|
141
|
+
processedImages,
|
|
142
|
+
debug,
|
|
143
|
+
config,
|
|
144
|
+
cacheDir,
|
|
145
|
+
sourcePrefix
|
|
146
|
+
) {
|
|
147
|
+
debug(`Processing HTML file: ${htmlFile}`);
|
|
148
|
+
|
|
149
|
+
// Validate file.contents before processing
|
|
150
|
+
if (!fileData.contents || !Buffer.isBuffer(fileData.contents)) {
|
|
151
|
+
debug(`Skipping ${htmlFile}: invalid or missing file contents`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const content = fileData.contents.toString();
|
|
156
|
+
|
|
157
|
+
// Parse HTML
|
|
158
|
+
const $ = cheerio.load(content);
|
|
159
|
+
|
|
160
|
+
// Find all images matching our selector (default: img:not([data-no-responsive]))
|
|
161
|
+
const images = $(config.imgSelector);
|
|
162
|
+
if (images.length === 0) {
|
|
163
|
+
debug(`No images found in ${htmlFile}`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
debug(`Found ${images.length} images in ${htmlFile}`);
|
|
168
|
+
|
|
169
|
+
// Process images in parallel with a concurrency limit to prevent overwhelming the system
|
|
170
|
+
const imageChunks = [];
|
|
171
|
+
for (let i = 0; i < images.length; i += config.concurrency) {
|
|
172
|
+
imageChunks.push(Array.from(images).slice(i, i + config.concurrency));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Process all chunks in parallel - each chunk processes its images in parallel
|
|
176
|
+
await Promise.all(
|
|
177
|
+
imageChunks.map(async (imageChunk) => {
|
|
178
|
+
// Process images within each chunk in parallel
|
|
179
|
+
await Promise.all(
|
|
180
|
+
imageChunk.map((img) =>
|
|
181
|
+
config.isProgressive
|
|
182
|
+
? processProgressiveImage({
|
|
183
|
+
$,
|
|
184
|
+
img,
|
|
185
|
+
files,
|
|
186
|
+
metalsmith,
|
|
187
|
+
processedImages,
|
|
188
|
+
debug,
|
|
189
|
+
config,
|
|
190
|
+
cacheDir,
|
|
191
|
+
sourcePrefix
|
|
192
|
+
})
|
|
193
|
+
: processImage({
|
|
194
|
+
$,
|
|
195
|
+
img,
|
|
196
|
+
files,
|
|
197
|
+
metalsmith,
|
|
198
|
+
processedImages,
|
|
199
|
+
debug,
|
|
200
|
+
config,
|
|
201
|
+
replacePictureElement,
|
|
202
|
+
cacheDir,
|
|
203
|
+
sourcePrefix
|
|
204
|
+
})
|
|
205
|
+
)
|
|
206
|
+
);
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Inject progressive loading CSS and JavaScript if needed
|
|
211
|
+
if (config.isProgressive) {
|
|
212
|
+
injectProgressiveAssets($);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Update file contents with modified HTML (converts back to Buffer)
|
|
216
|
+
fileData.contents = Buffer.from($.html());
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Generate metadata file if configured
|
|
221
|
+
* Creates a JSON manifest with information about all processed images
|
|
222
|
+
* Useful for debugging or integration with other tools
|
|
223
|
+
* @param {Map} processedImages - Cache of processed images
|
|
224
|
+
* @param {Object} files - Metalsmith files object
|
|
225
|
+
* @param {Object} config - Plugin configuration
|
|
226
|
+
*/
|
|
227
|
+
export function generateMetadata(processedImages, files, config) {
|
|
228
|
+
const metadataObj = {};
|
|
229
|
+
processedImages.forEach((value, key) => {
|
|
230
|
+
// Extract the original path from the cache key (path:mtime)
|
|
231
|
+
const [path] = key.split(':');
|
|
232
|
+
|
|
233
|
+
// Handle both array format (from background processing) and object format (from HTML processing)
|
|
234
|
+
const variants = Array.isArray(value) ? value : value.variants;
|
|
235
|
+
|
|
236
|
+
metadataObj[path] = variants.map((v) => ({
|
|
237
|
+
path: v.path,
|
|
238
|
+
width: v.width,
|
|
239
|
+
height: v.height,
|
|
240
|
+
format: v.format,
|
|
241
|
+
size: v.size
|
|
242
|
+
}));
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const metadataPath = path.join(config.outputDir, 'responsive-images-manifest.json');
|
|
246
|
+
files[metadataPath] = {
|
|
247
|
+
contents: Buffer.from(JSON.stringify(metadataObj, null, 2))
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Process a single image with progressive loading
|
|
253
|
+
* Creates low-quality placeholders and high-resolution images with smooth transitions
|
|
254
|
+
* @param {Object} context - Processing context
|
|
255
|
+
* @return {Promise<void>} - Promise that resolves when the image is processed
|
|
256
|
+
*/
|
|
257
|
+
async function processProgressiveImage({
|
|
258
|
+
$,
|
|
259
|
+
img,
|
|
260
|
+
files,
|
|
261
|
+
metalsmith,
|
|
262
|
+
processedImages,
|
|
263
|
+
debug,
|
|
264
|
+
config,
|
|
265
|
+
cacheDir,
|
|
266
|
+
sourcePrefix
|
|
267
|
+
}) {
|
|
268
|
+
const $img = $(img);
|
|
269
|
+
const src = $img.attr('src');
|
|
270
|
+
|
|
271
|
+
debug(`Starting progressive processing for: ${src}`);
|
|
272
|
+
|
|
273
|
+
if (!src || src.startsWith('http') || src.startsWith('data:')) {
|
|
274
|
+
debug(`Skipping external or data URL: ${src}`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Skip SVG files - they are vector graphics that don't need responsive raster variants
|
|
279
|
+
if (src.toLowerCase().endsWith('.svg')) {
|
|
280
|
+
debug(`Skipping SVG file (vector graphics don't need responsive variants): ${src}`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Normalize src path to match Metalsmith files object keys
|
|
285
|
+
const normalizedSrc = src.startsWith('/') ? src.slice(1) : src;
|
|
286
|
+
|
|
287
|
+
// Image not in Metalsmith files object — try alternative locations on disk
|
|
288
|
+
if (!files[normalizedSrc]) {
|
|
289
|
+
let loaded = false;
|
|
290
|
+
|
|
291
|
+
// When cache is configured and the plugin runs before the static-files copy,
|
|
292
|
+
// source images live on disk at sourcePrefix + normalizedSrc
|
|
293
|
+
if (sourcePrefix && !loaded) {
|
|
294
|
+
try {
|
|
295
|
+
const sourcePath = path.resolve(metalsmith.directory(), sourcePrefix, normalizedSrc);
|
|
296
|
+
if (fs.existsSync(sourcePath)) {
|
|
297
|
+
files[normalizedSrc] = {
|
|
298
|
+
contents: fs.readFileSync(sourcePath),
|
|
299
|
+
mtime: fs.statSync(sourcePath).mtimeMs
|
|
300
|
+
};
|
|
301
|
+
loaded = true;
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
debug(`Error loading source image from ${sourcePrefix}: ${err.message}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Fallback: try the build directory (handles post-static-copy scenario)
|
|
309
|
+
if (!loaded) {
|
|
310
|
+
try {
|
|
311
|
+
const destination = metalsmith.destination();
|
|
312
|
+
const imagePath = path.join(destination, normalizedSrc);
|
|
313
|
+
|
|
314
|
+
// Security: Ensure resolved path stays within destination directory
|
|
315
|
+
const resolvedPath = path.resolve(imagePath);
|
|
316
|
+
const resolvedDestination = path.resolve(destination);
|
|
317
|
+
if (!resolvedPath.startsWith(resolvedDestination + path.sep)) {
|
|
318
|
+
debug(`Skipping path traversal attempt: ${normalizedSrc}`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (fs.existsSync(imagePath)) {
|
|
323
|
+
files[normalizedSrc] = {
|
|
324
|
+
contents: fs.readFileSync(imagePath),
|
|
325
|
+
mtime: fs.statSync(imagePath).mtimeMs
|
|
326
|
+
};
|
|
327
|
+
loaded = true;
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
debug(`Error loading image from build directory: ${err.message}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!loaded) {
|
|
335
|
+
debug(`Image not found: ${normalizedSrc}`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Create a cache key
|
|
341
|
+
const fileMtime = files[normalizedSrc].mtime || Date.now();
|
|
342
|
+
const cacheKey = `${normalizedSrc}:${fileMtime}`;
|
|
343
|
+
|
|
344
|
+
// Check if we've already processed this image
|
|
345
|
+
if (processedImages.has(cacheKey)) {
|
|
346
|
+
debug(`Using cached variants for ${normalizedSrc}`);
|
|
347
|
+
const { variants, placeholderData } = processedImages.get(cacheKey);
|
|
348
|
+
const $wrapper = createProgressiveWrapper($, $img, variants, placeholderData, config);
|
|
349
|
+
$img.replaceWith($wrapper);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
debug(`Processing progressive image: ${normalizedSrc}`);
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
// Process image to generate all variants (sizes and formats)
|
|
357
|
+
const variants = await processImageToVariants(
|
|
358
|
+
files[normalizedSrc].contents,
|
|
359
|
+
normalizedSrc,
|
|
360
|
+
debug,
|
|
361
|
+
config,
|
|
362
|
+
cacheDir
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
// Generate low-quality placeholder image for smooth loading transitions
|
|
366
|
+
const placeholderData = await generatePlaceholder(
|
|
367
|
+
normalizedSrc,
|
|
368
|
+
files[normalizedSrc].contents,
|
|
369
|
+
config.placeholder,
|
|
370
|
+
metalsmith
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
// When cache is configured, variant files are written to cacheDir by
|
|
374
|
+
// processImageToVariants and the static-files plugin copies them to the build.
|
|
375
|
+
// When cache is NOT configured, add variants to the files object directly.
|
|
376
|
+
if (!cacheDir) {
|
|
377
|
+
variants.forEach((variant) => {
|
|
378
|
+
files[variant.path] = {
|
|
379
|
+
contents: variant.buffer
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Save placeholder to files (always needed for progressive loading)
|
|
385
|
+
files[placeholderData.path] = {
|
|
386
|
+
contents: placeholderData.contents
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// Cache variants and placeholder for this image
|
|
390
|
+
processedImages.set(cacheKey, { variants, placeholderData });
|
|
391
|
+
|
|
392
|
+
// Create progressive wrapper with placeholder and high-res image
|
|
393
|
+
const $wrapper = createProgressiveWrapper($, $img, variants, placeholderData, config);
|
|
394
|
+
$img.replaceWith($wrapper);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
debug(`Error processing progressive image: ${err.message}`);
|
|
397
|
+
|
|
398
|
+
// Fallback to standard processing if progressive loading fails
|
|
399
|
+
try {
|
|
400
|
+
const variants = await processImageToVariants(
|
|
401
|
+
files[normalizedSrc].contents,
|
|
402
|
+
normalizedSrc,
|
|
403
|
+
debug,
|
|
404
|
+
config,
|
|
405
|
+
cacheDir
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
if (!cacheDir) {
|
|
409
|
+
variants.forEach((variant) => {
|
|
410
|
+
files[variant.path] = {
|
|
411
|
+
contents: variant.buffer
|
|
412
|
+
};
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const $picture = createStandardPicture($, $img, variants, config);
|
|
417
|
+
$img.replaceWith($picture);
|
|
418
|
+
} catch (fallbackErr) {
|
|
419
|
+
debug(`Fallback processing also failed: ${fallbackErr.message}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Inject progressive loading CSS and JavaScript assets
|
|
426
|
+
* Only injects if progressive images are actually present on the page
|
|
427
|
+
* @param {Object} $ - Cheerio instance
|
|
428
|
+
*/
|
|
429
|
+
function injectProgressiveAssets($) {
|
|
430
|
+
// Check if progressive images exist on this page
|
|
431
|
+
const hasProgressiveImages = $('.js-progressive-image-wrapper').length > 0;
|
|
432
|
+
|
|
433
|
+
if (!hasProgressiveImages) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Inject CSS styles for progressive loading (only once per page)
|
|
438
|
+
if (!$('#progressive-image-styles').length) {
|
|
439
|
+
$('head').append(`<style id="progressive-image-styles">${progressiveImageCSS}</style>`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Inject JavaScript for intersection observer and loading logic (only once per page)
|
|
443
|
+
if (!$('#progressive-image-loader').length) {
|
|
444
|
+
$('body').append(`<script id="progressive-image-loader">${progressiveImageLoader}</script>`);
|
|
445
|
+
}
|
|
446
|
+
}
|