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.
@@ -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
+ }