metalsmith-optimize-images 0.10.2 → 0.12.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 +147 -62
- package/lib/index.cjs +355 -84
- package/lib/index.cjs.map +1 -1
- package/lib/index.js +355 -84
- package/lib/index.js.map +1 -1
- package/package.json +11 -13
package/lib/index.cjs
CHANGED
|
@@ -139,7 +139,14 @@ function buildConfig(options = {}) {
|
|
|
139
139
|
// Background image processing settings
|
|
140
140
|
processUnusedImages: true,
|
|
141
141
|
// Process images not found in HTML for background use
|
|
142
|
-
imagePattern: '**/*.{jpg,jpeg,png,gif,webp,avif}'
|
|
142
|
+
imagePattern: '**/*.{jpg,jpeg,png,gif,webp,avif}',
|
|
143
|
+
// Pattern to find images for background processing
|
|
144
|
+
|
|
145
|
+
// Persistent cache directory for generated variants (relative to metalsmith.directory())
|
|
146
|
+
// false = disabled, true = default path ('lib/<outputDir>'), string = custom path
|
|
147
|
+
// When set, variants are written to this directory and loaded from it on subsequent builds.
|
|
148
|
+
// Commit this directory to git so CI/Netlify never needs to run Sharp.
|
|
149
|
+
cache: false
|
|
143
150
|
};
|
|
144
151
|
|
|
145
152
|
// Special handling for formatOptions to ensure deep merging
|
|
@@ -226,9 +233,10 @@ function generateVariantPath(originalPath, width, format, hash, config) {
|
|
|
226
233
|
* @param {string} originalPath - Original image path
|
|
227
234
|
* @param {Function} debugFn - Debug function for logging
|
|
228
235
|
* @param {Object} config - Plugin configuration
|
|
236
|
+
* @param {string} [cacheDir] - Absolute path to the persistent cache directory (e.g., lib/assets/images/responsive)
|
|
229
237
|
* @return {Promise<Array<Object>>} - Array of generated variants
|
|
230
238
|
*/
|
|
231
|
-
async function processImageToVariants(buffer, originalPath, debugFn, config) {
|
|
239
|
+
async function processImageToVariants(buffer, originalPath, debugFn, config, cacheDir) {
|
|
232
240
|
const image = sharp__default["default"](buffer);
|
|
233
241
|
const metadata = await image.metadata();
|
|
234
242
|
const variants = [];
|
|
@@ -242,6 +250,16 @@ async function processImageToVariants(buffer, originalPath, debugFn, config) {
|
|
|
242
250
|
return [];
|
|
243
251
|
}
|
|
244
252
|
|
|
253
|
+
// Check if all variants already exist in the persistent cache directory.
|
|
254
|
+
// The content hash in each filename ensures correctness — if the source image
|
|
255
|
+
// changes, the hash changes, filenames differ, and the cache misses naturally.
|
|
256
|
+
if (cacheDir) {
|
|
257
|
+
const cached = await loadCachedVariants(originalPath, hash, targetWidths, config, cacheDir, metadata, debugFn);
|
|
258
|
+
if (cached) {
|
|
259
|
+
return cached;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
245
263
|
// Process all widths in parallel for better performance
|
|
246
264
|
const widthPromises = targetWidths.map(async width => {
|
|
247
265
|
// Create a Sharp instance for this width - clone to avoid conflicts
|
|
@@ -310,6 +328,76 @@ async function processImageToVariants(buffer, originalPath, debugFn, config) {
|
|
|
310
328
|
// Wait for all widths to complete and flatten the results
|
|
311
329
|
const widthResults = await Promise.all(widthPromises);
|
|
312
330
|
variants.push(...widthResults.flat());
|
|
331
|
+
|
|
332
|
+
// Persist newly generated variants to the cache directory so subsequent
|
|
333
|
+
// builds (local or CI) can skip Sharp entirely for this image.
|
|
334
|
+
if (cacheDir && variants.length > 0) {
|
|
335
|
+
for (const variant of variants) {
|
|
336
|
+
const cachePath = path__default["default"].join(cacheDir, path__default["default"].basename(variant.path));
|
|
337
|
+
fs__default["default"].writeFileSync(cachePath, variant.buffer);
|
|
338
|
+
}
|
|
339
|
+
debugFn(`Wrote ${variants.length} variants to cache for ${originalPath}`);
|
|
340
|
+
}
|
|
341
|
+
return variants;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Loads previously generated variants from the persistent cache directory.
|
|
346
|
+
* Variant files live directly in cacheDir (flat structure, no subdirectories).
|
|
347
|
+
* Returns the loaded variants array, or null on any cache miss.
|
|
348
|
+
* @param {string} originalPath - Original image path
|
|
349
|
+
* @param {string} hash - Content hash of the source image
|
|
350
|
+
* @param {number[]} targetWidths - Array of widths to check
|
|
351
|
+
* @param {Object} config - Plugin configuration
|
|
352
|
+
* @param {string} cacheDir - Absolute path to the cache directory (e.g., lib/assets/images/responsive)
|
|
353
|
+
* @param {Object} sourceMetadata - Sharp metadata of the source image
|
|
354
|
+
* @param {Function} debugFn - Debug function
|
|
355
|
+
* @return {Promise<Array<Object>|null>} - Loaded variants or null on cache miss
|
|
356
|
+
*/
|
|
357
|
+
async function loadCachedVariants(originalPath, hash, targetWidths, config, cacheDir, sourceMetadata, debugFn) {
|
|
358
|
+
const expected = [];
|
|
359
|
+
for (const width of targetWidths) {
|
|
360
|
+
for (const format of config.formats) {
|
|
361
|
+
if (format === 'original' && sourceMetadata.format.toLowerCase() === 'webp') {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const variantPath = generateVariantPath(originalPath, width, format, hash, config);
|
|
365
|
+
const fullPath = path__default["default"].join(cacheDir, path__default["default"].basename(variantPath));
|
|
366
|
+
expected.push({
|
|
367
|
+
variantPath,
|
|
368
|
+
fullPath,
|
|
369
|
+
width,
|
|
370
|
+
format
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Quick existence check — bail on first miss
|
|
376
|
+
for (const ev of expected) {
|
|
377
|
+
if (!fs__default["default"].existsSync(ev.fullPath)) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// All variants found on disk, load them
|
|
383
|
+
debugFn(`Loading ${expected.length} cached variants for ${originalPath}`);
|
|
384
|
+
|
|
385
|
+
// Compute height from the source aspect ratio instead of calling sharp().metadata()
|
|
386
|
+
// on every cached file — avoids spinning up Sharp entirely on cache hits.
|
|
387
|
+
const aspectRatio = sourceMetadata.height / sourceMetadata.width;
|
|
388
|
+
const variants = expected.map(ev => {
|
|
389
|
+
const buffer = fs__default["default"].readFileSync(ev.fullPath);
|
|
390
|
+
const resolvedFormat = ev.format === 'original' ? sourceMetadata.format.toLowerCase() : ev.format;
|
|
391
|
+
return {
|
|
392
|
+
path: ev.variantPath,
|
|
393
|
+
buffer,
|
|
394
|
+
width: ev.width,
|
|
395
|
+
format: resolvedFormat,
|
|
396
|
+
originalFormat: sourceMetadata.format.toLowerCase(),
|
|
397
|
+
size: buffer.length,
|
|
398
|
+
height: Math.round(ev.width * aspectRatio)
|
|
399
|
+
};
|
|
400
|
+
});
|
|
313
401
|
return variants;
|
|
314
402
|
}
|
|
315
403
|
|
|
@@ -324,6 +412,8 @@ async function processImageToVariants(buffer, originalPath, debugFn, config) {
|
|
|
324
412
|
* @param {Function} context.debug - Debug function
|
|
325
413
|
* @param {Object} context.config - Plugin configuration
|
|
326
414
|
* @param {Function} context.replacePictureElement - Function to replace img with picture
|
|
415
|
+
* @param {string|null} context.cacheDir - Resolved absolute path to persistent cache, or null
|
|
416
|
+
* @param {string|null} context.sourcePrefix - Prefix to map build paths to source asset paths on disk, or null
|
|
327
417
|
* @return {Promise<void>} - Promise that resolves when the image is processed
|
|
328
418
|
*/
|
|
329
419
|
async function processImage({
|
|
@@ -334,7 +424,9 @@ async function processImage({
|
|
|
334
424
|
processedImages,
|
|
335
425
|
debug,
|
|
336
426
|
config,
|
|
337
|
-
replacePictureElement
|
|
427
|
+
replacePictureElement,
|
|
428
|
+
cacheDir,
|
|
429
|
+
sourcePrefix
|
|
338
430
|
}) {
|
|
339
431
|
const $img = $(img);
|
|
340
432
|
const src = $img.attr('src');
|
|
@@ -353,30 +445,53 @@ async function processImage({
|
|
|
353
445
|
// Remove leading slash if present (HTML paths vs Metalsmith file keys)
|
|
354
446
|
const normalizedSrc = src.startsWith('/') ? src.slice(1) : src;
|
|
355
447
|
|
|
356
|
-
// Image not in Metalsmith files object
|
|
357
|
-
// This handles cases where images were copied by other plugins (like assets)
|
|
448
|
+
// Image not in Metalsmith files object — try alternative locations on disk
|
|
358
449
|
if (!files[normalizedSrc]) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
debug(`Image not found in build: ${normalizedSrc}`);
|
|
376
|
-
return;
|
|
450
|
+
let loaded = false;
|
|
451
|
+
|
|
452
|
+
// When cache is configured and the plugin runs before the static-files copy,
|
|
453
|
+
// source images live on disk at sourcePrefix + normalizedSrc
|
|
454
|
+
if (sourcePrefix && !loaded) {
|
|
455
|
+
try {
|
|
456
|
+
const sourcePath = path__default["default"].resolve(metalsmith.directory(), sourcePrefix, normalizedSrc);
|
|
457
|
+
if (fs__default["default"].existsSync(sourcePath)) {
|
|
458
|
+
files[normalizedSrc] = {
|
|
459
|
+
contents: fs__default["default"].readFileSync(sourcePath),
|
|
460
|
+
mtime: fs__default["default"].statSync(sourcePath).mtimeMs
|
|
461
|
+
};
|
|
462
|
+
loaded = true;
|
|
463
|
+
}
|
|
464
|
+
} catch (err) {
|
|
465
|
+
debug(`Error loading source image from ${sourcePrefix}: ${err.message}`);
|
|
377
466
|
}
|
|
378
|
-
}
|
|
379
|
-
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Fallback: try the build directory (handles post-static-copy scenario)
|
|
470
|
+
if (!loaded) {
|
|
471
|
+
try {
|
|
472
|
+
const destination = metalsmith.destination();
|
|
473
|
+
const imagePath = path__default["default"].join(destination, normalizedSrc);
|
|
474
|
+
|
|
475
|
+
// Security: Ensure resolved path stays within destination directory
|
|
476
|
+
const resolvedPath = path__default["default"].resolve(imagePath);
|
|
477
|
+
const resolvedDestination = path__default["default"].resolve(destination);
|
|
478
|
+
if (!resolvedPath.startsWith(resolvedDestination + path__default["default"].sep)) {
|
|
479
|
+
debug(`Skipping path traversal attempt: ${normalizedSrc}`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (fs__default["default"].existsSync(imagePath)) {
|
|
483
|
+
files[normalizedSrc] = {
|
|
484
|
+
contents: fs__default["default"].readFileSync(imagePath),
|
|
485
|
+
mtime: fs__default["default"].statSync(imagePath).mtimeMs
|
|
486
|
+
};
|
|
487
|
+
loaded = true;
|
|
488
|
+
}
|
|
489
|
+
} catch (err) {
|
|
490
|
+
debug(`Error loading image from build directory: ${err.message}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (!loaded) {
|
|
494
|
+
debug(`Image not found: ${normalizedSrc}`);
|
|
380
495
|
return;
|
|
381
496
|
}
|
|
382
497
|
}
|
|
@@ -396,15 +511,18 @@ async function processImage({
|
|
|
396
511
|
debug(`Processing image: ${normalizedSrc}`);
|
|
397
512
|
try {
|
|
398
513
|
// Process image to generate all variants (different sizes and formats)
|
|
399
|
-
const variants = await processImageToVariants(files[normalizedSrc].contents, normalizedSrc, debug, config);
|
|
514
|
+
const variants = await processImageToVariants(files[normalizedSrc].contents, normalizedSrc, debug, config, cacheDir);
|
|
400
515
|
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
variants
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
516
|
+
// When cache is configured, variant files are written to cacheDir by
|
|
517
|
+
// processImageToVariants and the static-files plugin copies them to the build.
|
|
518
|
+
// When cache is NOT configured, add variants to the files object directly.
|
|
519
|
+
if (!cacheDir) {
|
|
520
|
+
variants.forEach(variant => {
|
|
521
|
+
files[variant.path] = {
|
|
522
|
+
contents: variant.buffer
|
|
523
|
+
};
|
|
524
|
+
});
|
|
525
|
+
}
|
|
408
526
|
|
|
409
527
|
// Cache variants for this image to avoid reprocessing
|
|
410
528
|
processedImages.set(cacheKey, variants);
|
|
@@ -891,9 +1009,11 @@ function replacePictureElement($, $img, variants, config) {
|
|
|
891
1009
|
* @param {Map} processedImages - Cache of processed images
|
|
892
1010
|
* @param {Function} debug - Debug function
|
|
893
1011
|
* @param {Object} config - Plugin configuration
|
|
1012
|
+
* @param {string|null} cacheDir - Resolved absolute path to persistent cache, or null
|
|
1013
|
+
* @param {string|null} sourcePrefix - Prefix to map build paths to source asset paths on disk, or null
|
|
894
1014
|
* @return {Promise<void>} - Promise that resolves when the HTML file is processed
|
|
895
1015
|
*/
|
|
896
|
-
async function processHtmlFile(htmlFile, fileData, files, metalsmith, processedImages, debug, config) {
|
|
1016
|
+
async function processHtmlFile(htmlFile, fileData, files, metalsmith, processedImages, debug, config, cacheDir, sourcePrefix) {
|
|
897
1017
|
debug(`Processing HTML file: ${htmlFile}`);
|
|
898
1018
|
|
|
899
1019
|
// Validate file.contents before processing
|
|
@@ -930,7 +1050,9 @@ async function processHtmlFile(htmlFile, fileData, files, metalsmith, processedI
|
|
|
930
1050
|
metalsmith,
|
|
931
1051
|
processedImages,
|
|
932
1052
|
debug,
|
|
933
|
-
config
|
|
1053
|
+
config,
|
|
1054
|
+
cacheDir,
|
|
1055
|
+
sourcePrefix
|
|
934
1056
|
}) : processImage({
|
|
935
1057
|
$,
|
|
936
1058
|
img,
|
|
@@ -939,7 +1061,9 @@ async function processHtmlFile(htmlFile, fileData, files, metalsmith, processedI
|
|
|
939
1061
|
processedImages,
|
|
940
1062
|
debug,
|
|
941
1063
|
config,
|
|
942
|
-
replacePictureElement
|
|
1064
|
+
replacePictureElement,
|
|
1065
|
+
cacheDir,
|
|
1066
|
+
sourcePrefix
|
|
943
1067
|
})));
|
|
944
1068
|
}));
|
|
945
1069
|
|
|
@@ -995,7 +1119,9 @@ async function processProgressiveImage({
|
|
|
995
1119
|
metalsmith,
|
|
996
1120
|
processedImages,
|
|
997
1121
|
debug,
|
|
998
|
-
config
|
|
1122
|
+
config,
|
|
1123
|
+
cacheDir,
|
|
1124
|
+
sourcePrefix
|
|
999
1125
|
}) {
|
|
1000
1126
|
const $img = $(img);
|
|
1001
1127
|
const src = $img.attr('src');
|
|
@@ -1014,29 +1140,53 @@ async function processProgressiveImage({
|
|
|
1014
1140
|
// Normalize src path to match Metalsmith files object keys
|
|
1015
1141
|
const normalizedSrc = src.startsWith('/') ? src.slice(1) : src;
|
|
1016
1142
|
|
|
1017
|
-
// Image not in files
|
|
1143
|
+
// Image not in Metalsmith files object — try alternative locations on disk
|
|
1018
1144
|
if (!files[normalizedSrc]) {
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
debug(`Image not found in build: ${normalizedSrc}`);
|
|
1036
|
-
return;
|
|
1145
|
+
let loaded = false;
|
|
1146
|
+
|
|
1147
|
+
// When cache is configured and the plugin runs before the static-files copy,
|
|
1148
|
+
// source images live on disk at sourcePrefix + normalizedSrc
|
|
1149
|
+
if (sourcePrefix && !loaded) {
|
|
1150
|
+
try {
|
|
1151
|
+
const sourcePath = path__default["default"].resolve(metalsmith.directory(), sourcePrefix, normalizedSrc);
|
|
1152
|
+
if (fs__default["default"].existsSync(sourcePath)) {
|
|
1153
|
+
files[normalizedSrc] = {
|
|
1154
|
+
contents: fs__default["default"].readFileSync(sourcePath),
|
|
1155
|
+
mtime: fs__default["default"].statSync(sourcePath).mtimeMs
|
|
1156
|
+
};
|
|
1157
|
+
loaded = true;
|
|
1158
|
+
}
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
debug(`Error loading source image from ${sourcePrefix}: ${err.message}`);
|
|
1037
1161
|
}
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Fallback: try the build directory (handles post-static-copy scenario)
|
|
1165
|
+
if (!loaded) {
|
|
1166
|
+
try {
|
|
1167
|
+
const destination = metalsmith.destination();
|
|
1168
|
+
const imagePath = path__default["default"].join(destination, normalizedSrc);
|
|
1169
|
+
|
|
1170
|
+
// Security: Ensure resolved path stays within destination directory
|
|
1171
|
+
const resolvedPath = path__default["default"].resolve(imagePath);
|
|
1172
|
+
const resolvedDestination = path__default["default"].resolve(destination);
|
|
1173
|
+
if (!resolvedPath.startsWith(resolvedDestination + path__default["default"].sep)) {
|
|
1174
|
+
debug(`Skipping path traversal attempt: ${normalizedSrc}`);
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
if (fs__default["default"].existsSync(imagePath)) {
|
|
1178
|
+
files[normalizedSrc] = {
|
|
1179
|
+
contents: fs__default["default"].readFileSync(imagePath),
|
|
1180
|
+
mtime: fs__default["default"].statSync(imagePath).mtimeMs
|
|
1181
|
+
};
|
|
1182
|
+
loaded = true;
|
|
1183
|
+
}
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
debug(`Error loading image from build directory: ${err.message}`);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
if (!loaded) {
|
|
1189
|
+
debug(`Image not found: ${normalizedSrc}`);
|
|
1040
1190
|
return;
|
|
1041
1191
|
}
|
|
1042
1192
|
}
|
|
@@ -1059,19 +1209,23 @@ async function processProgressiveImage({
|
|
|
1059
1209
|
debug(`Processing progressive image: ${normalizedSrc}`);
|
|
1060
1210
|
try {
|
|
1061
1211
|
// Process image to generate all variants (sizes and formats)
|
|
1062
|
-
const variants = await processImageToVariants(files[normalizedSrc].contents, normalizedSrc, debug, config);
|
|
1212
|
+
const variants = await processImageToVariants(files[normalizedSrc].contents, normalizedSrc, debug, config, cacheDir);
|
|
1063
1213
|
|
|
1064
1214
|
// Generate low-quality placeholder image for smooth loading transitions
|
|
1065
1215
|
const placeholderData = await generatePlaceholder(normalizedSrc, files[normalizedSrc].contents, config.placeholder, metalsmith);
|
|
1066
1216
|
|
|
1067
|
-
//
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1217
|
+
// When cache is configured, variant files are written to cacheDir by
|
|
1218
|
+
// processImageToVariants and the static-files plugin copies them to the build.
|
|
1219
|
+
// When cache is NOT configured, add variants to the files object directly.
|
|
1220
|
+
if (!cacheDir) {
|
|
1221
|
+
variants.forEach(variant => {
|
|
1222
|
+
files[variant.path] = {
|
|
1223
|
+
contents: variant.buffer
|
|
1224
|
+
};
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1073
1227
|
|
|
1074
|
-
// Save placeholder to files
|
|
1228
|
+
// Save placeholder to files (always needed for progressive loading)
|
|
1075
1229
|
files[placeholderData.path] = {
|
|
1076
1230
|
contents: placeholderData.contents
|
|
1077
1231
|
};
|
|
@@ -1090,12 +1244,14 @@ async function processProgressiveImage({
|
|
|
1090
1244
|
|
|
1091
1245
|
// Fallback to standard processing if progressive loading fails
|
|
1092
1246
|
try {
|
|
1093
|
-
const variants = await processImageToVariants(files[normalizedSrc].contents, normalizedSrc, debug, config);
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1247
|
+
const variants = await processImageToVariants(files[normalizedSrc].contents, normalizedSrc, debug, config, cacheDir);
|
|
1248
|
+
if (!cacheDir) {
|
|
1249
|
+
variants.forEach(variant => {
|
|
1250
|
+
files[variant.path] = {
|
|
1251
|
+
contents: variant.buffer
|
|
1252
|
+
};
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1099
1255
|
const $picture = createStandardPicture($, $img, variants, config);
|
|
1100
1256
|
$img.replaceWith($picture);
|
|
1101
1257
|
} catch (fallbackErr) {
|
|
@@ -1158,6 +1314,27 @@ function optimizeImagesPlugin(options = {}) {
|
|
|
1158
1314
|
// Set up debug function for logging (uses 'DEBUG=metalsmith-optimize-images*' env var)
|
|
1159
1315
|
const debug = metalsmith.debug('metalsmith-optimize-images');
|
|
1160
1316
|
|
|
1317
|
+
// Resolve persistent cache directory from config.
|
|
1318
|
+
// When set (e.g., 'lib/assets/images/responsive'), variants are read/written there
|
|
1319
|
+
// and the static-files plugin copies them to the build.
|
|
1320
|
+
let cacheDir = null;
|
|
1321
|
+
let sourcePrefix = null;
|
|
1322
|
+
if (config.cache) {
|
|
1323
|
+
// Normalise: cache: true → default path 'lib/<outputDir>'
|
|
1324
|
+
const cachePath = typeof config.cache === 'string' ? config.cache : path__default["default"].join('lib', config.outputDir);
|
|
1325
|
+
cacheDir = path__default["default"].resolve(metalsmith.directory(), cachePath);
|
|
1326
|
+
mkdirp__namespace.mkdirpSync(cacheDir);
|
|
1327
|
+
|
|
1328
|
+
// Derive the source-asset prefix so the plugin can find images on disk
|
|
1329
|
+
// when it runs before the static-files copy.
|
|
1330
|
+
// e.g., cachePath='lib/assets/images/responsive', outputDir='assets/images/responsive'
|
|
1331
|
+
// → sourcePrefix = 'lib/' (the part of cachePath that precedes outputDir)
|
|
1332
|
+
if (cachePath.endsWith(config.outputDir)) {
|
|
1333
|
+
sourcePrefix = cachePath.slice(0, cachePath.length - config.outputDir.length);
|
|
1334
|
+
}
|
|
1335
|
+
debug(`Persistent cache: ${cacheDir}`);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1161
1338
|
// Ensure the output directory exists where processed images will be saved
|
|
1162
1339
|
mkdirp__namespace.mkdirpSync(outputPath);
|
|
1163
1340
|
|
|
@@ -1197,7 +1374,7 @@ function optimizeImagesPlugin(options = {}) {
|
|
|
1197
1374
|
// Process files within each chunk in parallel
|
|
1198
1375
|
await Promise.all(chunk.map(async htmlFile => {
|
|
1199
1376
|
// This function parses HTML, finds images, processes them, and updates the HTML
|
|
1200
|
-
await processHtmlFile(htmlFile, files[htmlFile], files, metalsmith, processedImages, debug, config);
|
|
1377
|
+
await processHtmlFile(htmlFile, files[htmlFile], files, metalsmith, processedImages, debug, config, cacheDir, sourcePrefix);
|
|
1201
1378
|
}));
|
|
1202
1379
|
}));
|
|
1203
1380
|
|
|
@@ -1205,7 +1382,7 @@ function optimizeImagesPlugin(options = {}) {
|
|
|
1205
1382
|
// This finds images that weren't processed during HTML scanning and creates variants
|
|
1206
1383
|
// for use in CSS background-image with image-set()
|
|
1207
1384
|
if (config.processUnusedImages) {
|
|
1208
|
-
await processUnusedImages(files, metalsmith, processedImages, debug, config);
|
|
1385
|
+
await processUnusedImages(files, metalsmith, processedImages, debug, config, cacheDir);
|
|
1209
1386
|
}
|
|
1210
1387
|
|
|
1211
1388
|
// Optional: Generate a JSON metadata file with information about all processed images
|
|
@@ -1234,7 +1411,7 @@ function optimizeImagesPlugin(options = {}) {
|
|
|
1234
1411
|
* @param {Object} config - Plugin configuration
|
|
1235
1412
|
* @return {Promise<void>} - Promise that resolves when processing is complete
|
|
1236
1413
|
*/
|
|
1237
|
-
async function processUnusedImages(files, metalsmith, processedImages, debug, config) {
|
|
1414
|
+
async function processUnusedImages(files, metalsmith, processedImages, debug, config, cacheDir) {
|
|
1238
1415
|
debug('Processing unused images for background image support');
|
|
1239
1416
|
|
|
1240
1417
|
// Get all image paths that were already processed during HTML scanning
|
|
@@ -1260,14 +1437,18 @@ async function processUnusedImages(files, metalsmith, processedImages, debug, co
|
|
|
1260
1437
|
debug(`Processing background image: ${imageObj.path} (source: ${imageObj.source})`);
|
|
1261
1438
|
|
|
1262
1439
|
// Generate background variants with original size and half size
|
|
1263
|
-
const variants = await processBackgroundImageVariants(imageObj.buffer, imageObj.path, debug, config);
|
|
1264
|
-
|
|
1265
|
-
//
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1440
|
+
const variants = await processBackgroundImageVariants(imageObj.buffer, imageObj.path, debug, config, cacheDir);
|
|
1441
|
+
|
|
1442
|
+
// When cache is configured, variant files are written to cacheDir by
|
|
1443
|
+
// processBackgroundImageVariants and the static-files plugin copies them.
|
|
1444
|
+
// Otherwise, add them to the files object directly.
|
|
1445
|
+
if (!cacheDir) {
|
|
1446
|
+
variants.forEach(variant => {
|
|
1447
|
+
files[variant.path] = {
|
|
1448
|
+
contents: variant.buffer
|
|
1449
|
+
};
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1271
1452
|
|
|
1272
1453
|
// Cache the variants (using current timestamp as mtime for unused images)
|
|
1273
1454
|
const cacheKey = `${imageObj.path}:${Date.now()}`;
|
|
@@ -1450,12 +1631,24 @@ async function findUnprocessedImages(files, metalsmith, config, processedImagePa
|
|
|
1450
1631
|
* @param {string} originalPath - Original image path
|
|
1451
1632
|
* @param {Function} debugFn - Debug function for logging
|
|
1452
1633
|
* @param {Object} config - Plugin configuration
|
|
1634
|
+
* @param {string} [cacheDir] - Absolute path to the persistent cache directory, or null
|
|
1453
1635
|
* @return {Promise<Array<Object>>} - Array of generated variants
|
|
1454
1636
|
*/
|
|
1455
|
-
async function processBackgroundImageVariants(buffer, originalPath, debugFn, config) {
|
|
1637
|
+
async function processBackgroundImageVariants(buffer, originalPath, debugFn, config, cacheDir) {
|
|
1456
1638
|
const image = sharp__default["default"](buffer);
|
|
1457
1639
|
const metadata = await image.metadata();
|
|
1458
1640
|
const variants = [];
|
|
1641
|
+
|
|
1642
|
+
// Check if background variants already exist on disk from a previous build.
|
|
1643
|
+
// Background filenames are deterministic (no content hash), so existence alone
|
|
1644
|
+
// tells us the work was already done. If a source image changes content without
|
|
1645
|
+
// changing filename, delete the responsive directory to force regeneration.
|
|
1646
|
+
if (cacheDir) {
|
|
1647
|
+
const cached = await loadCachedBgVariants(originalPath, metadata, config, cacheDir, debugFn);
|
|
1648
|
+
if (cached) {
|
|
1649
|
+
return cached;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1459
1652
|
debugFn(`Processing background image ${originalPath}: ${metadata.width}x${metadata.height}`);
|
|
1460
1653
|
|
|
1461
1654
|
// Create 1x (original size) and 2x (half size) variants
|
|
@@ -1535,10 +1728,88 @@ async function processBackgroundImageVariants(buffer, originalPath, debugFn, con
|
|
|
1535
1728
|
sizeResults.forEach(formatVariants => {
|
|
1536
1729
|
variants.push(...formatVariants);
|
|
1537
1730
|
});
|
|
1731
|
+
|
|
1732
|
+
// Persist newly generated variants to the cache directory so subsequent
|
|
1733
|
+
// builds can skip Sharp entirely for these background images.
|
|
1734
|
+
if (cacheDir && variants.length > 0) {
|
|
1735
|
+
for (const variant of variants) {
|
|
1736
|
+
const cachePath = path__default["default"].join(cacheDir, path__default["default"].basename(variant.path));
|
|
1737
|
+
fs__default["default"].writeFileSync(cachePath, variant.buffer);
|
|
1738
|
+
}
|
|
1739
|
+
debugFn(`Wrote ${variants.length} background variants to cache for ${originalPath}`);
|
|
1740
|
+
}
|
|
1538
1741
|
debugFn(`Generated ${variants.length} background variants for ${originalPath}`);
|
|
1539
1742
|
return variants;
|
|
1540
1743
|
}
|
|
1541
1744
|
|
|
1745
|
+
/**
|
|
1746
|
+
* Loads previously generated background variants from the persistent cache directory.
|
|
1747
|
+
* Checks that every expected variant file (size × format) exists on disk.
|
|
1748
|
+
* Returns the loaded variants array, or null on any cache miss.
|
|
1749
|
+
* @param {string} originalPath - Original image path
|
|
1750
|
+
* @param {Object} sourceMetadata - Sharp metadata of the source image
|
|
1751
|
+
* @param {Object} config - Plugin configuration
|
|
1752
|
+
* @param {string} cacheDir - Absolute path to the persistent cache directory
|
|
1753
|
+
* @param {Function} debugFn - Debug function
|
|
1754
|
+
* @return {Promise<Array<Object>|null>} - Loaded variants or null on cache miss
|
|
1755
|
+
*/
|
|
1756
|
+
async function loadCachedBgVariants(originalPath, sourceMetadata, config, cacheDir, debugFn) {
|
|
1757
|
+
const sizes = [{
|
|
1758
|
+
width: sourceMetadata.width,
|
|
1759
|
+
density: '1x'
|
|
1760
|
+
}, {
|
|
1761
|
+
width: Math.round(sourceMetadata.width / 2),
|
|
1762
|
+
density: '2x'
|
|
1763
|
+
}];
|
|
1764
|
+
const expected = [];
|
|
1765
|
+
for (const size of sizes) {
|
|
1766
|
+
for (const format of config.formats) {
|
|
1767
|
+
if (format === 'original' && sourceMetadata.format.toLowerCase() === 'webp') {
|
|
1768
|
+
continue;
|
|
1769
|
+
}
|
|
1770
|
+
let outputFormat = format;
|
|
1771
|
+
if (format === 'original') {
|
|
1772
|
+
outputFormat = sourceMetadata.format.toLowerCase();
|
|
1773
|
+
}
|
|
1774
|
+
const variantPath = generateBackgroundVariantPath(originalPath, size.width, outputFormat, config);
|
|
1775
|
+
const fullPath = path__default["default"].join(cacheDir, path__default["default"].basename(variantPath));
|
|
1776
|
+
expected.push({
|
|
1777
|
+
variantPath,
|
|
1778
|
+
fullPath,
|
|
1779
|
+
width: size.width,
|
|
1780
|
+
format: outputFormat,
|
|
1781
|
+
density: size.density
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// Quick existence check — bail on first miss
|
|
1787
|
+
for (const ev of expected) {
|
|
1788
|
+
if (!fs__default["default"].existsSync(ev.fullPath)) {
|
|
1789
|
+
return null;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// All variants found on disk, load them
|
|
1794
|
+
debugFn(`Loading ${expected.length} cached background variants for ${originalPath}`);
|
|
1795
|
+
|
|
1796
|
+
// Compute height from the source aspect ratio instead of calling sharp().metadata()
|
|
1797
|
+
// on every cached file — avoids spinning up Sharp entirely on cache hits.
|
|
1798
|
+
const aspectRatio = sourceMetadata.height / sourceMetadata.width;
|
|
1799
|
+
const variants = expected.map(ev => {
|
|
1800
|
+
const buffer = fs__default["default"].readFileSync(ev.fullPath);
|
|
1801
|
+
return {
|
|
1802
|
+
path: ev.variantPath,
|
|
1803
|
+
buffer,
|
|
1804
|
+
width: ev.width,
|
|
1805
|
+
height: Math.round(ev.width * aspectRatio),
|
|
1806
|
+
format: ev.format,
|
|
1807
|
+
density: ev.density
|
|
1808
|
+
};
|
|
1809
|
+
});
|
|
1810
|
+
return variants;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1542
1813
|
/**
|
|
1543
1814
|
* Generate background image variant path without hash for easier CSS usage
|
|
1544
1815
|
* Creates predictable filenames that can be written in CSS without knowing the hash
|