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/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}' // Pattern to find images for background processing
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 - try to load it from the build directory
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
- try {
360
- const destination = metalsmith.destination();
361
- const imagePath = path__default["default"].join(destination, normalizedSrc);
362
- if (fs__default["default"].existsSync(imagePath)) {
363
- // Load the image contents from the build directory
364
- const imageBuffer = fs__default["default"].readFileSync(imagePath);
365
-
366
- // Get modification time for cache busting - this helps with incremental builds
367
- const mtime = fs__default["default"].statSync(imagePath).mtimeMs;
368
-
369
- // Add it to Metalsmith files so the plugin can process it
370
- files[normalizedSrc] = {
371
- contents: imageBuffer,
372
- mtime
373
- };
374
- } else {
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
- } catch (err) {
379
- debug(`Error processing image from build directory: ${err.message}`);
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
- // Save all generated variants to Metalsmith files object
402
- // This makes them available in the final build output
403
- variants.forEach(variant => {
404
- files[variant.path] = {
405
- contents: variant.buffer
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, try to load it from the build directory (same logic as processImage)
1143
+ // Image not in Metalsmith files object try alternative locations on disk
1018
1144
  if (!files[normalizedSrc]) {
1019
- try {
1020
- const destination = metalsmith.destination();
1021
- const imagePath = path__default["default"].join(destination, normalizedSrc);
1022
- if (fs__default["default"].existsSync(imagePath)) {
1023
- // Load the image contents from the build directory
1024
- const imageBuffer = fs__default["default"].readFileSync(imagePath);
1025
-
1026
- // Get modification time for cache busting
1027
- const mtime = fs__default["default"].statSync(imagePath).mtimeMs;
1028
-
1029
- // Add it to files so the plugin can process it
1030
- files[normalizedSrc] = {
1031
- contents: imageBuffer,
1032
- mtime
1033
- };
1034
- } else {
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
- } catch (err) {
1039
- debug(`Error processing image from build directory: ${err.message}`);
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
- // Save all variants to Metalsmith files
1068
- variants.forEach(variant => {
1069
- files[variant.path] = {
1070
- contents: variant.buffer
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
- variants.forEach(variant => {
1095
- files[variant.path] = {
1096
- contents: variant.buffer
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
- // Save all generated variants to Metalsmith files object
1266
- variants.forEach(variant => {
1267
- files[variant.path] = {
1268
- contents: variant.buffer
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