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,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
|
+
}
|