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,330 @@
1
+ /**
2
+ * Image processing utilities for creating responsive image variants
3
+ * Handles the core Sharp.js operations for resizing and format conversion
4
+ */
5
+ import sharp from 'sharp';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { generateHash } from '../utils/hash.js';
9
+ import { generateVariantPath } from '../utils/paths.js';
10
+
11
+ /**
12
+ * Process an image into multiple responsive variants and formats
13
+ * @param {Buffer} buffer - Original image buffer
14
+ * @param {string} originalPath - Original image path
15
+ * @param {Function} debugFn - Debug function for logging
16
+ * @param {Object} config - Plugin configuration
17
+ * @param {string} [cacheDir] - Absolute path to the persistent cache directory (e.g., lib/assets/images/responsive)
18
+ * @return {Promise<Array<Object>>} - Array of generated variants
19
+ */
20
+ export async function processImageToVariants(buffer, originalPath, debugFn, config, cacheDir) {
21
+ const image = sharp(buffer);
22
+ const metadata = await image.metadata();
23
+ const variants = [];
24
+ const hash = generateHash(buffer);
25
+
26
+ // Determine which widths to generate based on skipLarger setting
27
+ // If skipLarger is true (default), don't generate sizes larger than original
28
+ const targetWidths = config.skipLarger ? config.widths.filter((w) => w <= metadata.width) : config.widths;
29
+
30
+ if (targetWidths.length === 0) {
31
+ debugFn(`Skipping ${originalPath} - no valid target widths`);
32
+ return [];
33
+ }
34
+
35
+ // Check if all variants already exist in the persistent cache directory.
36
+ // The content hash in each filename ensures correctness — if the source image
37
+ // changes, the hash changes, filenames differ, and the cache misses naturally.
38
+ if (cacheDir) {
39
+ const cached = await loadCachedVariants(originalPath, hash, targetWidths, config, cacheDir, metadata, debugFn);
40
+ if (cached) {
41
+ return cached;
42
+ }
43
+ }
44
+
45
+ // Process all widths in parallel for better performance
46
+ const widthPromises = targetWidths.map(async (width) => {
47
+ // Create a Sharp instance for this width - clone to avoid conflicts
48
+ const resized = image.clone().resize({
49
+ width,
50
+ withoutEnlargement: config.skipLarger // Prevents upscaling small images
51
+ });
52
+
53
+ // Get actual dimensions after resize (may be smaller than requested width)
54
+ const resizedMeta = await resized.metadata();
55
+
56
+ // Process each format in parallel for this width
57
+ const formatPromises = config.formats.map(async (format) => {
58
+ try {
59
+ // Skip problematic format combinations (e.g., webp -> original doesn't make sense)
60
+ if (format === 'original' && metadata.format.toLowerCase() === 'webp') {
61
+ return null;
62
+ }
63
+
64
+ let formatted;
65
+ const outputPath = generateVariantPath(originalPath, width, format, hash, config);
66
+
67
+ // Apply format-specific processing with quality/compression settings
68
+ if (format === 'original') {
69
+ // For 'original' format, use the source image format
70
+ const originalFormat = metadata.format.toLowerCase();
71
+ const formatOptions = config.formatOptions[originalFormat] || {};
72
+ formatted = resized.clone().toFormat(originalFormat, formatOptions);
73
+ } else {
74
+ // For specific formats (avif, webp, etc.), apply format-specific options
75
+ const formatOptions = config.formatOptions[format] || {};
76
+ if (format === 'avif') {
77
+ formatted = resized.clone().avif(formatOptions);
78
+ } else if (format === 'webp') {
79
+ formatted = resized.clone().webp(formatOptions);
80
+ } else if (format === 'jpeg') {
81
+ formatted = resized.clone().jpeg(formatOptions);
82
+ } else if (format === 'png') {
83
+ formatted = resized.clone().png(formatOptions);
84
+ } else {
85
+ formatted = resized.clone()[format](formatOptions);
86
+ }
87
+ }
88
+
89
+ // Generate the actual image buffer - this is where compression happens
90
+ const formatBuffer = await formatted.toBuffer();
91
+
92
+ return {
93
+ path: outputPath,
94
+ buffer: formatBuffer,
95
+ width,
96
+ format: format === 'original' ? metadata.format.toLowerCase() : format,
97
+ originalFormat: metadata.format.toLowerCase(),
98
+ size: formatBuffer.length,
99
+ height: resizedMeta.height
100
+ };
101
+ } catch (err) {
102
+ debugFn(`Error generating ${format} variant for ${originalPath} at width ${width}: ${err.message}`);
103
+ return null;
104
+ }
105
+ });
106
+
107
+ // Wait for all formats at this width to complete
108
+ const formatResults = await Promise.all(formatPromises);
109
+ return formatResults.filter((v) => v !== null);
110
+ });
111
+
112
+ // Wait for all widths to complete and flatten the results
113
+ const widthResults = await Promise.all(widthPromises);
114
+ variants.push(...widthResults.flat());
115
+
116
+ // Persist newly generated variants to the cache directory so subsequent
117
+ // builds (local or CI) can skip Sharp entirely for this image.
118
+ if (cacheDir && variants.length > 0) {
119
+ for (const variant of variants) {
120
+ const cachePath = path.join(cacheDir, path.basename(variant.path));
121
+ fs.writeFileSync(cachePath, variant.buffer);
122
+ }
123
+ debugFn(`Wrote ${variants.length} variants to cache for ${originalPath}`);
124
+ }
125
+
126
+ return variants;
127
+ }
128
+
129
+ /**
130
+ * Loads previously generated variants from the persistent cache directory.
131
+ * Variant files live directly in cacheDir (flat structure, no subdirectories).
132
+ * Returns the loaded variants array, or null on any cache miss.
133
+ * @param {string} originalPath - Original image path
134
+ * @param {string} hash - Content hash of the source image
135
+ * @param {number[]} targetWidths - Array of widths to check
136
+ * @param {Object} config - Plugin configuration
137
+ * @param {string} cacheDir - Absolute path to the cache directory (e.g., lib/assets/images/responsive)
138
+ * @param {Object} sourceMetadata - Sharp metadata of the source image
139
+ * @param {Function} debugFn - Debug function
140
+ * @return {Promise<Array<Object>|null>} - Loaded variants or null on cache miss
141
+ */
142
+ async function loadCachedVariants(originalPath, hash, targetWidths, config, cacheDir, sourceMetadata, debugFn) {
143
+ const expected = [];
144
+
145
+ for (const width of targetWidths) {
146
+ for (const format of config.formats) {
147
+ if (format === 'original' && sourceMetadata.format.toLowerCase() === 'webp') {
148
+ continue;
149
+ }
150
+ const variantPath = generateVariantPath(originalPath, width, format, hash, config);
151
+ const fullPath = path.join(cacheDir, path.basename(variantPath));
152
+ expected.push({ variantPath, fullPath, width, format });
153
+ }
154
+ }
155
+
156
+ // Quick existence check — bail on first miss
157
+ for (const ev of expected) {
158
+ if (!fs.existsSync(ev.fullPath)) {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ // All variants found on disk, load them
164
+ debugFn(`Loading ${expected.length} cached variants for ${originalPath}`);
165
+
166
+ // Compute height from the source aspect ratio instead of calling sharp().metadata()
167
+ // on every cached file — avoids spinning up Sharp entirely on cache hits.
168
+ const aspectRatio = sourceMetadata.height / sourceMetadata.width;
169
+
170
+ const variants = expected.map((ev) => {
171
+ const buffer = fs.readFileSync(ev.fullPath);
172
+ const resolvedFormat = ev.format === 'original' ? sourceMetadata.format.toLowerCase() : ev.format;
173
+
174
+ return {
175
+ path: ev.variantPath,
176
+ buffer,
177
+ width: ev.width,
178
+ format: resolvedFormat,
179
+ originalFormat: sourceMetadata.format.toLowerCase(),
180
+ size: buffer.length,
181
+ height: Math.round(ev.width * aspectRatio)
182
+ };
183
+ });
184
+
185
+ return variants;
186
+ }
187
+
188
+ /**
189
+ * Process a single image
190
+ * @param {Object} context - Processing context
191
+ * @param {Object} context.$ - Cheerio instance
192
+ * @param {Object} context.img - Image DOM element
193
+ * @param {Object} context.files - Metalsmith files object
194
+ * @param {Object} context.metalsmith - Metalsmith instance
195
+ * @param {Map} context.processedImages - Cache of processed images
196
+ * @param {Function} context.debug - Debug function
197
+ * @param {Object} context.config - Plugin configuration
198
+ * @param {Function} context.replacePictureElement - Function to replace img with picture
199
+ * @param {string|null} context.cacheDir - Resolved absolute path to persistent cache, or null
200
+ * @param {string|null} context.sourcePrefix - Prefix to map build paths to source asset paths on disk, or null
201
+ * @return {Promise<void>} - Promise that resolves when the image is processed
202
+ */
203
+ export async function processImage({
204
+ $,
205
+ img,
206
+ files,
207
+ metalsmith,
208
+ processedImages,
209
+ debug,
210
+ config,
211
+ replacePictureElement,
212
+ cacheDir,
213
+ sourcePrefix
214
+ }) {
215
+ const $img = $(img);
216
+ const src = $img.attr('src');
217
+
218
+ if (!src || src.startsWith('http') || src.startsWith('data:')) {
219
+ debug(`Skipping external or data URL: ${src}`);
220
+ return;
221
+ }
222
+
223
+ // Skip SVG files - they are vector graphics that don't need responsive raster variants
224
+ if (src.toLowerCase().endsWith('.svg')) {
225
+ debug(`Skipping SVG file (vector graphics don't need responsive variants): ${src}`);
226
+ return;
227
+ }
228
+
229
+ // Normalize src path to match Metalsmith files object keys
230
+ // Remove leading slash if present (HTML paths vs Metalsmith file keys)
231
+ const normalizedSrc = src.startsWith('/') ? src.slice(1) : src;
232
+
233
+ // Image not in Metalsmith files object — try alternative locations on disk
234
+ if (!files[normalizedSrc]) {
235
+ let loaded = false;
236
+
237
+ // When cache is configured and the plugin runs before the static-files copy,
238
+ // source images live on disk at sourcePrefix + normalizedSrc
239
+ if (sourcePrefix && !loaded) {
240
+ try {
241
+ const sourcePath = path.resolve(metalsmith.directory(), sourcePrefix, normalizedSrc);
242
+ if (fs.existsSync(sourcePath)) {
243
+ files[normalizedSrc] = {
244
+ contents: fs.readFileSync(sourcePath),
245
+ mtime: fs.statSync(sourcePath).mtimeMs
246
+ };
247
+ loaded = true;
248
+ }
249
+ } catch (err) {
250
+ debug(`Error loading source image from ${sourcePrefix}: ${err.message}`);
251
+ }
252
+ }
253
+
254
+ // Fallback: try the build directory (handles post-static-copy scenario)
255
+ if (!loaded) {
256
+ try {
257
+ const destination = metalsmith.destination();
258
+ const imagePath = path.join(destination, normalizedSrc);
259
+
260
+ // Security: Ensure resolved path stays within destination directory
261
+ const resolvedPath = path.resolve(imagePath);
262
+ const resolvedDestination = path.resolve(destination);
263
+ if (!resolvedPath.startsWith(resolvedDestination + path.sep)) {
264
+ debug(`Skipping path traversal attempt: ${normalizedSrc}`);
265
+ return;
266
+ }
267
+
268
+ if (fs.existsSync(imagePath)) {
269
+ files[normalizedSrc] = {
270
+ contents: fs.readFileSync(imagePath),
271
+ mtime: fs.statSync(imagePath).mtimeMs
272
+ };
273
+ loaded = true;
274
+ }
275
+ } catch (err) {
276
+ debug(`Error loading image from build directory: ${err.message}`);
277
+ }
278
+ }
279
+
280
+ if (!loaded) {
281
+ debug(`Image not found: ${normalizedSrc}`);
282
+ return;
283
+ }
284
+ }
285
+
286
+ // Create a cache key that includes the file path and modification time
287
+ // This prevents reprocessing the same image multiple times in a single build
288
+ const fileMtime = files[normalizedSrc].mtime || Date.now();
289
+ const cacheKey = `${normalizedSrc}:${fileMtime}`;
290
+
291
+ // Check if we've already processed this exact image (same file + mtime)
292
+ if (processedImages.has(cacheKey)) {
293
+ debug(`Using cached variants for ${normalizedSrc}`);
294
+ const variants = processedImages.get(cacheKey);
295
+ replacePictureElement($, $img, variants, config);
296
+ return;
297
+ }
298
+
299
+ debug(`Processing image: ${normalizedSrc}`);
300
+
301
+ try {
302
+ // Process image to generate all variants (different sizes and formats)
303
+ const variants = await processImageToVariants(
304
+ files[normalizedSrc].contents,
305
+ normalizedSrc,
306
+ debug,
307
+ config,
308
+ cacheDir
309
+ );
310
+
311
+ // When cache is configured, variant files are written to cacheDir by
312
+ // processImageToVariants and the static-files plugin copies them to the build.
313
+ // When cache is NOT configured, add variants to the files object directly.
314
+ if (!cacheDir) {
315
+ variants.forEach((variant) => {
316
+ files[variant.path] = {
317
+ contents: variant.buffer
318
+ };
319
+ });
320
+ }
321
+
322
+ // Cache variants for this image to avoid reprocessing
323
+ processedImages.set(cacheKey, variants);
324
+
325
+ // Replace the original <img> tag with a responsive <picture> element
326
+ replacePictureElement($, $img, variants, config);
327
+ } catch (err) {
328
+ debug(`Error processing image: ${err.message}`);
329
+ }
330
+ }