metalsmith-optimize-images 0.9.0 → 0.9.3

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
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
2
 
3
3
  var path = require('path');
4
- var mkdirp = require('mkdirp');
5
- var cheerio = require('cheerio');
6
4
  var fs = require('fs');
5
+ var mkdirp = require('mkdirp');
7
6
  var sharp = require('sharp');
7
+ var cheerio = require('cheerio');
8
8
  var crypto = require('crypto');
9
9
 
10
10
  function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
@@ -28,10 +28,10 @@ function _interopNamespace(e) {
28
28
  }
29
29
 
30
30
  var path__default = /*#__PURE__*/_interopDefaultLegacy(path);
31
- var mkdirp__namespace = /*#__PURE__*/_interopNamespace(mkdirp);
32
- var cheerio__namespace = /*#__PURE__*/_interopNamespace(cheerio);
33
31
  var fs__default = /*#__PURE__*/_interopDefaultLegacy(fs);
32
+ var mkdirp__namespace = /*#__PURE__*/_interopNamespace(mkdirp);
34
33
  var sharp__default = /*#__PURE__*/_interopDefaultLegacy(sharp);
34
+ var cheerio__namespace = /*#__PURE__*/_interopNamespace(cheerio);
35
35
  var crypto__default = /*#__PURE__*/_interopDefaultLegacy(crypto);
36
36
 
37
37
  /**
@@ -135,7 +135,11 @@ function buildConfig(options = {}) {
135
135
  width: 50,
136
136
  quality: 30,
137
137
  blur: 10
138
- }
138
+ },
139
+ // Background image processing settings
140
+ processUnusedImages: true,
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
139
143
  };
140
144
 
141
145
  // Special handling for formatOptions to ensure deep merging
@@ -176,9 +180,9 @@ function buildConfig(options = {}) {
176
180
  * @return {string} - A short hash string (8 characters)
177
181
  */
178
182
  function generateHash(buffer) {
179
- // MD5 is sufficient for cache-busting (not cryptographic security)
183
+ // SHA-256 for cache-busting - using secure algorithm to satisfy security scanners
180
184
  // Only use first 8 characters to keep filenames manageable
181
- return crypto__default["default"].createHash('md5').update(buffer).digest('hex').slice(0, 8);
185
+ return crypto__default["default"].createHash('sha256').update(buffer).digest('hex').slice(0, 8);
182
186
  }
183
187
 
184
188
  /**
@@ -268,7 +272,17 @@ async function processImageToVariants(buffer, originalPath, debugFn, config) {
268
272
  } else {
269
273
  // For specific formats (avif, webp, etc.), apply format-specific options
270
274
  const formatOptions = config.formatOptions[format] || {};
271
- formatted = resized.clone()[format](formatOptions);
275
+ if (format === 'avif') {
276
+ formatted = resized.clone().avif(formatOptions);
277
+ } else if (format === 'webp') {
278
+ formatted = resized.clone().webp(formatOptions);
279
+ } else if (format === 'jpeg') {
280
+ formatted = resized.clone().jpeg(formatOptions);
281
+ } else if (format === 'png') {
282
+ formatted = resized.clone().png(formatOptions);
283
+ } else {
284
+ formatted = resized.clone()[format](formatOptions);
285
+ }
272
286
  }
273
287
 
274
288
  // Generate the actual image buffer - this is where compression happens
@@ -936,9 +950,12 @@ async function processHtmlFile(htmlFile, fileData, files, metalsmith, processedI
936
950
  */
937
951
  function generateMetadata(processedImages, files, config) {
938
952
  const metadataObj = {};
939
- processedImages.forEach((variants, key) => {
953
+ processedImages.forEach((value, key) => {
940
954
  // Extract the original path from the cache key (path:mtime)
941
955
  const [path] = key.split(':');
956
+
957
+ // Handle both array format (from background processing) and object format (from HTML processing)
958
+ const variants = Array.isArray(value) ? value : value.variants;
942
959
  metadataObj[path] = variants.map(v => ({
943
960
  path: v.path,
944
961
  width: v.width,
@@ -1124,6 +1141,9 @@ function injectProgressiveAssets($) {
1124
1141
  * @param {number} [options.placeholder.width] - Placeholder image width (default: 50)
1125
1142
  * @param {number} [options.placeholder.quality] - Placeholder image quality (default: 30)
1126
1143
  * @param {number} [options.placeholder.blur] - Placeholder image blur amount (default: 10)
1144
+ * @param {boolean} [options.processUnusedImages] - Whether to process unused images for background use (default: true)
1145
+ * @param {string} [options.imagePattern] - Glob pattern to find images for background processing (default: `**\/*.{jpg,jpeg,png,gif,webp,avif}`)
1146
+ * @param {string} [options.imageFolder] - Folder to scan for background images, relative to source (default: 'lib/assets/images')
1127
1147
  * @return {Function} - Metalsmith plugin function
1128
1148
  */
1129
1149
  function optimizeImagesPlugin(options = {}) {
@@ -1149,7 +1169,19 @@ function optimizeImagesPlugin(options = {}) {
1149
1169
  mkdirp__namespace.mkdirpSync(outputPath);
1150
1170
 
1151
1171
  // Find all HTML files that match the pattern (default: **/*.html)
1152
- const htmlFiles = Object.keys(files).filter(file => metalsmith.match(config.htmlPattern, file));
1172
+ // Also ensure they actually end with .html to avoid processing CSS/JS files
1173
+ const htmlFiles = Object.keys(files).filter(file => {
1174
+ // Must match the HTML pattern
1175
+ if (!metalsmith.match(config.htmlPattern, file)) {
1176
+ return false;
1177
+ }
1178
+
1179
+ // Must actually be an HTML file
1180
+ if (!file.endsWith('.html')) {
1181
+ return false;
1182
+ }
1183
+ return true;
1184
+ });
1153
1185
  if (htmlFiles.length === 0) {
1154
1186
  debug('No HTML files found');
1155
1187
  return done();
@@ -1176,6 +1208,13 @@ function optimizeImagesPlugin(options = {}) {
1176
1208
  }));
1177
1209
  }));
1178
1210
 
1211
+ // Process unused images for background image support
1212
+ // This finds images that weren't processed during HTML scanning and creates variants
1213
+ // for use in CSS background-image with image-set()
1214
+ if (config.processUnusedImages) {
1215
+ await processUnusedImages(files, metalsmith, processedImages, debug, config);
1216
+ }
1217
+
1179
1218
  // Optional: Generate a JSON metadata file with information about all processed images
1180
1219
  // Useful for debugging or integration with other tools
1181
1220
  if (config.generateMetadata) {
@@ -1191,5 +1230,345 @@ function optimizeImagesPlugin(options = {}) {
1191
1230
  };
1192
1231
  }
1193
1232
 
1233
+ /**
1234
+ * Process unused images for background image support
1235
+ * Finds images that weren't processed during HTML scanning and creates 1x/2x variants
1236
+ * for use in CSS background-image with image-set()
1237
+ * @param {Object} files - Metalsmith files object
1238
+ * @param {Object} metalsmith - Metalsmith instance
1239
+ * @param {Map} processedImages - Cache of already processed images
1240
+ * @param {Function} debug - Debug function
1241
+ * @param {Object} config - Plugin configuration
1242
+ * @return {Promise<void>} - Promise that resolves when processing is complete
1243
+ */
1244
+ async function processUnusedImages(files, metalsmith, processedImages, debug, config) {
1245
+ debug('Processing unused images for background image support');
1246
+
1247
+ // Get all image paths that were already processed during HTML scanning
1248
+ const processedImagePaths = new Set();
1249
+ processedImages.forEach((_variants, cacheKey) => {
1250
+ const [imagePath] = cacheKey.split(':');
1251
+ processedImagePaths.add(imagePath);
1252
+ });
1253
+ debug(`Processed image paths from HTML: ${Array.from(processedImagePaths).join(', ')}`);
1254
+
1255
+ // Find images that weren't processed during HTML scanning using hybrid approach
1256
+ const allBackgroundImages = await findUnprocessedImages(files, metalsmith, config, processedImagePaths, debug);
1257
+ debug(`Background images found to process: ${allBackgroundImages.map(img => img.path).join(', ')}`);
1258
+ if (allBackgroundImages.length === 0) {
1259
+ debug('No unused images found to process');
1260
+ return;
1261
+ }
1262
+ debug(`Found ${allBackgroundImages.length} unused images to process for background use`);
1263
+
1264
+ // Process background images in parallel for better performance
1265
+ await Promise.all(allBackgroundImages.map(async imageObj => {
1266
+ try {
1267
+ debug(`Processing background image: ${imageObj.path} (source: ${imageObj.source})`);
1268
+
1269
+ // Generate background variants with original size and half size
1270
+ const variants = await processBackgroundImageVariants(imageObj.buffer, imageObj.path, debug, config);
1271
+
1272
+ // Save all generated variants to Metalsmith files object
1273
+ variants.forEach(variant => {
1274
+ files[variant.path] = {
1275
+ contents: variant.buffer
1276
+ };
1277
+ });
1278
+
1279
+ // Cache the variants (using current timestamp as mtime for unused images)
1280
+ const cacheKey = `${imageObj.path}:${Date.now()}`;
1281
+ processedImages.set(cacheKey, variants);
1282
+ debug(`Generated ${variants.length} background variants for ${imageObj.path}`);
1283
+ } catch (err) {
1284
+ debug(`Error processing background image ${imageObj.path}: ${err.message}`);
1285
+ }
1286
+ }));
1287
+ debug('Background image processing complete');
1288
+ }
1289
+
1290
+ /**
1291
+ * Find images that weren't processed during HTML scanning
1292
+ * Uses a hybrid approach: scans filesystem first, then falls back to Metalsmith files object
1293
+ * @param {Object} files - Metalsmith files object
1294
+ * @param {Object} metalsmith - Metalsmith instance
1295
+ * @param {Object} config - Plugin configuration
1296
+ * @param {Set} processedImagePaths - Set of already processed image paths
1297
+ * @param {Function} debug - Debug function
1298
+ * @return {Promise<Array>} - Array of unprocessed image objects with {path, buffer}
1299
+ */
1300
+ async function findUnprocessedImages(files, metalsmith, config, processedImagePaths, debug) {
1301
+ const unprocessedImages = [];
1302
+ const sourceImagesDir = path__default["default"].join(metalsmith.source(), 'lib/assets/images');
1303
+ debug(`Looking for unprocessed images using hybrid approach`);
1304
+
1305
+ // Method 1: Scan filesystem (for real testbed scenario)
1306
+ try {
1307
+ debug(`Attempting to scan source directory: ${sourceImagesDir}`);
1308
+ debug(`Source directory exists: ${fs__default["default"].existsSync(sourceImagesDir)}`);
1309
+ debug(`Metalsmith source: ${metalsmith.source()}`);
1310
+ debug(`Metalsmith destination: ${metalsmith.destination()}`);
1311
+ if (fs__default["default"].existsSync(sourceImagesDir)) {
1312
+ debug(`Scanning source directory: ${sourceImagesDir}`);
1313
+ const scanDirectory = (dir, relativePath = '') => {
1314
+ const items = fs__default["default"].readdirSync(dir);
1315
+ debug(`Found ${items.length} items in ${dir}`);
1316
+ for (const item of items) {
1317
+ if (item === '.DS_Store') {
1318
+ continue;
1319
+ }
1320
+ const fullPath = path__default["default"].join(dir, item);
1321
+ const itemRelativePath = path__default["default"].join(relativePath, item);
1322
+ if (fs__default["default"].statSync(fullPath).isDirectory()) {
1323
+ debug(`Scanning subdirectory: ${item}`);
1324
+ scanDirectory(fullPath, itemRelativePath);
1325
+ } else {
1326
+ const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'];
1327
+ if (imageExtensions.some(ext => item.toLowerCase().endsWith(ext))) {
1328
+ // Skip if this is in the responsive output directory
1329
+ if (itemRelativePath.startsWith('responsive/') || itemRelativePath.includes('/responsive/') || fullPath.includes(config.outputDir)) {
1330
+ debug(`Skipping responsive variant: ${itemRelativePath}`);
1331
+ continue;
1332
+ }
1333
+ const buildPath = path__default["default"].join('assets/images', itemRelativePath);
1334
+ const normalizedBuildPath = buildPath.replace(/\\/g, '/');
1335
+ debug(`Found filesystem image: ${item} -> ${normalizedBuildPath}`);
1336
+ debug(`Already processed? ${processedImagePaths.has(normalizedBuildPath)}`);
1337
+ if (!processedImagePaths.has(normalizedBuildPath)) {
1338
+ debug(`Found unprocessed filesystem image: ${itemRelativePath}`);
1339
+ const imageBuffer = fs__default["default"].readFileSync(fullPath);
1340
+ unprocessedImages.push({
1341
+ path: itemRelativePath,
1342
+ buffer: imageBuffer,
1343
+ source: 'filesystem'
1344
+ });
1345
+ }
1346
+ }
1347
+ }
1348
+ }
1349
+ };
1350
+ scanDirectory(sourceImagesDir);
1351
+ } else {
1352
+ debug(`Source directory does not exist, trying alternative paths...`);
1353
+
1354
+ // Try alternative paths
1355
+ const altPaths = [path__default["default"].join(metalsmith.source(), 'assets/images'), path__default["default"].join(metalsmith.source(), 'images'), path__default["default"].join(metalsmith.destination(), 'assets/images'), path__default["default"].join(process.cwd(), 'lib/assets/images'), path__default["default"].join(process.cwd(), 'src/assets/images')];
1356
+ for (const altPath of altPaths) {
1357
+ debug(`Trying alternative path: ${altPath} - exists: ${fs__default["default"].existsSync(altPath)}`);
1358
+ if (fs__default["default"].existsSync(altPath)) {
1359
+ debug(`Found images at alternative path: ${altPath}`);
1360
+
1361
+ // Scan the found alternative path
1362
+ const scanAlternativeDirectory = (dir, relativePath = '') => {
1363
+ const items = fs__default["default"].readdirSync(dir);
1364
+ debug(`Found ${items.length} items in alternative path ${dir}`);
1365
+ for (const item of items) {
1366
+ if (item === '.DS_Store') {
1367
+ continue;
1368
+ }
1369
+ const fullPath = path__default["default"].join(dir, item);
1370
+ const itemRelativePath = path__default["default"].join(relativePath, item);
1371
+ if (fs__default["default"].statSync(fullPath).isDirectory()) {
1372
+ debug(`Scanning alternative subdirectory: ${item}`);
1373
+ scanAlternativeDirectory(fullPath, itemRelativePath);
1374
+ } else {
1375
+ const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'];
1376
+ if (imageExtensions.some(ext => item.toLowerCase().endsWith(ext))) {
1377
+ // Skip if this is in the responsive output directory
1378
+ if (itemRelativePath.startsWith('responsive/') || itemRelativePath.includes('/responsive/') || fullPath.includes(config.outputDir)) {
1379
+ debug(`Skipping responsive variant in alt scan: ${itemRelativePath}`);
1380
+ continue;
1381
+ }
1382
+
1383
+ // For build directory, the path structure is already correct
1384
+ const buildPath = altPath.includes('build') ? path__default["default"].join('assets/images', itemRelativePath) : path__default["default"].join('assets/images', itemRelativePath);
1385
+ const normalizedBuildPath = buildPath.replace(/\\/g, '/');
1386
+ debug(`Found alternative filesystem image: ${item} -> ${normalizedBuildPath}`);
1387
+ debug(`Already processed? ${processedImagePaths.has(normalizedBuildPath)}`);
1388
+ if (!processedImagePaths.has(normalizedBuildPath)) {
1389
+ debug(`Found unprocessed alternative filesystem image: ${itemRelativePath}`);
1390
+ const imageBuffer = fs__default["default"].readFileSync(fullPath);
1391
+ unprocessedImages.push({
1392
+ path: itemRelativePath,
1393
+ buffer: imageBuffer,
1394
+ source: 'filesystem-alt'
1395
+ });
1396
+ }
1397
+ }
1398
+ }
1399
+ }
1400
+ };
1401
+ scanAlternativeDirectory(altPath);
1402
+ break; // Stop after finding and scanning the first valid path
1403
+ }
1404
+ }
1405
+ }
1406
+ } catch (err) {
1407
+ debug(`Error scanning filesystem: ${err.message}`);
1408
+ }
1409
+
1410
+ // Method 2: Scan Metalsmith files object (for test scenarios and edge cases)
1411
+ debug(`Scanning Metalsmith files object`);
1412
+ const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'];
1413
+ Object.keys(files).forEach(filePath => {
1414
+ // Skip if not an image
1415
+ if (!imageExtensions.some(ext => filePath.toLowerCase().endsWith(ext))) {
1416
+ return;
1417
+ }
1418
+
1419
+ // Skip if it's already a responsive variant (comprehensive checks)
1420
+ if (filePath.startsWith(`${config.outputDir}/`) || filePath.includes('/responsive/') || filePath.includes('responsive-images-manifest.json') || filePath.match(/-\d+w(-[a-f0-9]+)?\.(avif|webp|jpg|jpeg|png)$/i)) {
1421
+ debug(`Skipping responsive variant in files object: ${filePath}`);
1422
+ return;
1423
+ }
1424
+
1425
+ // Skip if already processed during HTML scanning
1426
+ if (processedImagePaths.has(filePath)) {
1427
+ debug(`Skipping already processed files object image: ${filePath}`);
1428
+ return;
1429
+ }
1430
+
1431
+ // Check if we already found this image from filesystem scan
1432
+ const isAlreadyFound = unprocessedImages.some(img => {
1433
+ // For files object images starting with 'images/', check if filesystem found the same file
1434
+ if (filePath.startsWith('images/')) {
1435
+ const relativePath = filePath.replace('images/', '');
1436
+ return img.path === relativePath;
1437
+ }
1438
+ return false;
1439
+ });
1440
+ if (!isAlreadyFound) {
1441
+ debug(`Found unprocessed files object image: ${filePath}`);
1442
+ unprocessedImages.push({
1443
+ path: filePath,
1444
+ buffer: files[filePath].contents,
1445
+ source: 'files'
1446
+ });
1447
+ }
1448
+ });
1449
+ debug(`Found ${unprocessedImages.length} unprocessed images total`);
1450
+ return unprocessedImages;
1451
+ }
1452
+
1453
+ /**
1454
+ * Process a background image to create 1x (original) and 2x (half-size) variants
1455
+ * for use with CSS image-set() for retina displays
1456
+ * @param {Buffer} buffer - Original image buffer
1457
+ * @param {string} originalPath - Original image path
1458
+ * @param {Function} debugFn - Debug function for logging
1459
+ * @param {Object} config - Plugin configuration
1460
+ * @return {Promise<Array<Object>>} - Array of generated variants
1461
+ */
1462
+ async function processBackgroundImageVariants(buffer, originalPath, debugFn, config) {
1463
+ const image = sharp__default["default"](buffer);
1464
+ const metadata = await image.metadata();
1465
+ const variants = [];
1466
+ debugFn(`Processing background image ${originalPath}: ${metadata.width}x${metadata.height}`);
1467
+
1468
+ // Create 1x (original size) and 2x (half size) variants
1469
+ const sizes = [{
1470
+ width: metadata.width,
1471
+ density: '1x'
1472
+ }, {
1473
+ width: Math.round(metadata.width / 2),
1474
+ density: '2x'
1475
+ }];
1476
+
1477
+ // Process both sizes in parallel
1478
+ const sizePromises = sizes.map(async size => {
1479
+ // Create a Sharp instance for this size
1480
+ const resized = image.clone().resize({
1481
+ width: size.width,
1482
+ withoutEnlargement: true // Don't upscale images
1483
+ });
1484
+
1485
+ // Get actual dimensions after resize
1486
+ const resizedMeta = await resized.metadata();
1487
+
1488
+ // Process each format in parallel for this size
1489
+ const formatPromises = config.formats.map(async format => {
1490
+ try {
1491
+ // Skip problematic format combinations
1492
+ if (format === 'original' && metadata.format.toLowerCase() === 'webp') {
1493
+ return null;
1494
+ }
1495
+
1496
+ // Determine output format and Sharp method
1497
+ let outputFormat = format;
1498
+ let sharpMethod = format;
1499
+ if (format === 'original') {
1500
+ outputFormat = metadata.format.toLowerCase();
1501
+ sharpMethod = outputFormat === 'jpeg' ? 'jpeg' : outputFormat;
1502
+ }
1503
+
1504
+ // Apply format-specific processing
1505
+ let processedImage = resized.clone();
1506
+ const formatOptions = config.formatOptions[format === 'original' ? outputFormat : format] || {};
1507
+ if (sharpMethod === 'avif') {
1508
+ processedImage = processedImage.avif(formatOptions);
1509
+ } else if (sharpMethod === 'webp') {
1510
+ processedImage = processedImage.webp(formatOptions);
1511
+ } else if (sharpMethod === 'jpeg') {
1512
+ processedImage = processedImage.jpeg(formatOptions);
1513
+ } else if (sharpMethod === 'png') {
1514
+ processedImage = processedImage.png(formatOptions);
1515
+ }
1516
+
1517
+ // Generate output buffer
1518
+ const outputBuffer = await processedImage.toBuffer();
1519
+
1520
+ // Generate variant path without hash for easier CSS usage
1521
+ const variantPath = generateBackgroundVariantPath(originalPath, size.width, outputFormat, config);
1522
+ debugFn(`Generated background variant: ${variantPath} (${size.density})`);
1523
+ return {
1524
+ path: variantPath,
1525
+ buffer: outputBuffer,
1526
+ width: resizedMeta.width,
1527
+ height: resizedMeta.height,
1528
+ format: outputFormat,
1529
+ density: size.density
1530
+ };
1531
+ } catch (err) {
1532
+ debugFn(`Error processing ${format} format for ${originalPath}: ${err.message}`);
1533
+ return null;
1534
+ }
1535
+ });
1536
+ const formatResults = await Promise.all(formatPromises);
1537
+ return formatResults.filter(result => result !== null);
1538
+ });
1539
+ const sizeResults = await Promise.all(sizePromises);
1540
+
1541
+ // Flatten the results
1542
+ sizeResults.forEach(formatVariants => {
1543
+ variants.push(...formatVariants);
1544
+ });
1545
+ debugFn(`Generated ${variants.length} background variants for ${originalPath}`);
1546
+ return variants;
1547
+ }
1548
+
1549
+ /**
1550
+ * Generate background image variant path without hash for easier CSS usage
1551
+ * Creates predictable filenames that can be written in CSS without knowing the hash
1552
+ * @param {string} originalPath - Original image path
1553
+ * @param {number} width - Target width
1554
+ * @param {string} format - Target format
1555
+ * @param {Object} config - Plugin configuration
1556
+ * @return {string} - Generated path without hash
1557
+ */
1558
+ function generateBackgroundVariantPath(originalPath, width, format, config) {
1559
+ const parsedPath = path__default["default"].parse(originalPath);
1560
+ const originalFormat = parsedPath.ext.slice(1).toLowerCase();
1561
+
1562
+ // If format is 'original', use the source format
1563
+ const outputFormat = format === 'original' ? originalFormat : format;
1564
+
1565
+ // Create background pattern without hash: '[filename]-[width]w.[format]'
1566
+ // Results in: 'header1-1000w.webp' instead of 'header1-1000w-abc12345.webp'
1567
+ const outputName = config.outputPattern.replace('[filename]', parsedPath.name).replace('[width]', width).replace('[format]', outputFormat).replace('-[hash]', '') // Remove hash placeholder and preceding dash
1568
+ .replace('[hash]', ''); // Remove any remaining hash placeholder
1569
+
1570
+ return path__default["default"].join(config.outputDir, outputName);
1571
+ }
1572
+
1194
1573
  module.exports = optimizeImagesPlugin;
1195
1574
  //# sourceMappingURL=index.cjs.map