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,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progressive image loading processor
|
|
3
|
+
* Handles placeholder generation and smooth loading transitions
|
|
4
|
+
*/
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate placeholder image for progressive loading
|
|
10
|
+
* Creates a small, blurred, low-quality version for instant display
|
|
11
|
+
* @param {string} imagePath - Original image path
|
|
12
|
+
* @param {Buffer} imageBuffer - Original image buffer
|
|
13
|
+
* @param {Object} placeholderConfig - Placeholder configuration (width, quality, blur)
|
|
14
|
+
* @param {Object} metalsmith - Metalsmith instance
|
|
15
|
+
* @return {Promise<Object>} Placeholder data with path and contents
|
|
16
|
+
*/
|
|
17
|
+
export async function generatePlaceholder(imagePath, imageBuffer, placeholderConfig, metalsmith) {
|
|
18
|
+
const { width, quality, blur } = placeholderConfig;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
// Get original image dimensions for aspect ratio calculation
|
|
22
|
+
const image = sharp(imageBuffer);
|
|
23
|
+
const metadata = await image.metadata();
|
|
24
|
+
|
|
25
|
+
// Process image: resize to small width, blur heavily, compress heavily
|
|
26
|
+
const processed = await image
|
|
27
|
+
.resize(width) // Default: 50px wide
|
|
28
|
+
.blur(blur) // Default: 10px blur
|
|
29
|
+
.jpeg({ quality }) // Default: 30% quality
|
|
30
|
+
.toBuffer();
|
|
31
|
+
|
|
32
|
+
const fileName = `${path.basename(imagePath, path.extname(imagePath))}-placeholder.jpg`;
|
|
33
|
+
const outputPath = path.join('assets/images/responsive', fileName);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
path: outputPath,
|
|
37
|
+
contents: processed,
|
|
38
|
+
fileName,
|
|
39
|
+
originalWidth: metadata.width,
|
|
40
|
+
originalHeight: metadata.height
|
|
41
|
+
};
|
|
42
|
+
} catch (error) {
|
|
43
|
+
metalsmith.debug('metalsmith-optimize-images')(`Error generating placeholder for ${imagePath}: ${error.message}`);
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create progressive wrapper HTML structure
|
|
50
|
+
* Creates a container with both placeholder and high-res images for smooth transitions
|
|
51
|
+
* @param {Object} $ - Cheerio instance
|
|
52
|
+
* @param {Object} $img - Original img element
|
|
53
|
+
* @param {Array} variants - Generated image variants
|
|
54
|
+
* @param {Object} placeholderData - Placeholder image data
|
|
55
|
+
* @param {Object} config - Plugin configuration
|
|
56
|
+
* @return {Object} Cheerio element for progressive wrapper
|
|
57
|
+
*/
|
|
58
|
+
export function createProgressiveWrapper($, $img, variants, placeholderData, _config) {
|
|
59
|
+
// Get original attributes
|
|
60
|
+
const alt = $img.attr('alt') || '';
|
|
61
|
+
const className = $img.attr('class') || '';
|
|
62
|
+
|
|
63
|
+
// Group variants by format - use only original format for progressive mode
|
|
64
|
+
// Progressive mode focuses on smooth loading rather than format optimization
|
|
65
|
+
const variantsByFormat = {};
|
|
66
|
+
variants.forEach((v) => {
|
|
67
|
+
if (!variantsByFormat[v.format]) {
|
|
68
|
+
variantsByFormat[v.format] = [];
|
|
69
|
+
}
|
|
70
|
+
variantsByFormat[v.format].push(v);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Get original format variants (skip AVIF/WebP for progressive mode)
|
|
74
|
+
// JavaScript will handle format detection dynamically
|
|
75
|
+
const originalFormat = Object.keys(variantsByFormat).find((f) => f !== 'avif' && f !== 'webp');
|
|
76
|
+
const originalVariants = originalFormat ? variantsByFormat[originalFormat] : [];
|
|
77
|
+
|
|
78
|
+
if (originalVariants.length === 0) {
|
|
79
|
+
return $img.clone(); // Fallback if no variants
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Calculate aspect ratio using original image dimensions to prevent layout shift
|
|
83
|
+
// Fallback to variant dimensions if placeholderData doesn't have original dimensions
|
|
84
|
+
let aspectRatio;
|
|
85
|
+
if (placeholderData.originalWidth && placeholderData.originalHeight) {
|
|
86
|
+
aspectRatio = `${placeholderData.originalWidth}/${placeholderData.originalHeight}`;
|
|
87
|
+
} else {
|
|
88
|
+
// Fallback: use the largest variant for most accurate aspect ratio
|
|
89
|
+
const largestVariant = [...originalVariants].sort((a, b) => b.width - a.width)[0];
|
|
90
|
+
aspectRatio = `${largestVariant.width}/${largestVariant.height}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Find middle-sized variant for high-res image (good balance of quality/size)
|
|
94
|
+
const highResVariant = originalVariants[Math.floor(originalVariants.length / 2)];
|
|
95
|
+
|
|
96
|
+
// Create wrapper div with modern CSS aspect-ratio
|
|
97
|
+
const $wrapper = $('<div>')
|
|
98
|
+
.addClass('responsive-wrapper js-progressive-image-wrapper')
|
|
99
|
+
.attr('style', `aspect-ratio: ${aspectRatio}`);
|
|
100
|
+
|
|
101
|
+
// Add class from original image if present
|
|
102
|
+
if (className) {
|
|
103
|
+
$wrapper.addClass(className);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Create low-res image (placeholder) - shown immediately
|
|
107
|
+
const $lowRes = $('<img>').addClass('low-res').attr('src', `/${placeholderData.path}`).attr('alt', alt);
|
|
108
|
+
|
|
109
|
+
// Create high-res image (empty with data source) - loaded by JavaScript
|
|
110
|
+
const $highRes = $('<img>')
|
|
111
|
+
.addClass('high-res')
|
|
112
|
+
.attr('src', '')
|
|
113
|
+
.attr('alt', alt)
|
|
114
|
+
.attr('data-source', `/${highResVariant.path}`);
|
|
115
|
+
|
|
116
|
+
// Assemble the progressive wrapper
|
|
117
|
+
$lowRes.appendTo($wrapper);
|
|
118
|
+
$highRes.appendTo($wrapper);
|
|
119
|
+
|
|
120
|
+
return $wrapper;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Create standard picture element HTML
|
|
125
|
+
* Fallback function for when progressive loading fails
|
|
126
|
+
* @param {Object} $ - Cheerio instance
|
|
127
|
+
* @param {Object} $img - Original img element
|
|
128
|
+
* @param {Array} variants - Generated image variants
|
|
129
|
+
* @param {Object} config - Plugin configuration
|
|
130
|
+
* @return {Object} Cheerio element for picture
|
|
131
|
+
*/
|
|
132
|
+
export function createStandardPicture($, $img, variants, config) {
|
|
133
|
+
// Get original attributes
|
|
134
|
+
const src = $img.attr('src');
|
|
135
|
+
const alt = $img.attr('alt') || '';
|
|
136
|
+
const className = $img.attr('class') || '';
|
|
137
|
+
const sizesAttr = $img.attr('sizes') || config.sizes;
|
|
138
|
+
|
|
139
|
+
// Group variants by format
|
|
140
|
+
const variantsByFormat = {};
|
|
141
|
+
variants.forEach((v) => {
|
|
142
|
+
if (!variantsByFormat[v.format]) {
|
|
143
|
+
variantsByFormat[v.format] = [];
|
|
144
|
+
}
|
|
145
|
+
variantsByFormat[v.format].push(v);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Create picture element with all formats (standard mode)
|
|
149
|
+
const $picture = $('<picture>');
|
|
150
|
+
|
|
151
|
+
// Add format-specific source elements in preference order
|
|
152
|
+
['avif', 'webp'].forEach((format) => {
|
|
153
|
+
const formatVariants = variantsByFormat[format];
|
|
154
|
+
if (!formatVariants || formatVariants.length === 0) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Sort variants by width
|
|
159
|
+
formatVariants.sort((a, b) => a.width - b.width);
|
|
160
|
+
|
|
161
|
+
// Create srcset string
|
|
162
|
+
const srcset = formatVariants.map((v) => `/${v.path} ${v.width}w`).join(', ');
|
|
163
|
+
|
|
164
|
+
// Create source element
|
|
165
|
+
$('<source>').attr('type', `image/${format}`).attr('srcset', srcset).attr('sizes', sizesAttr).appendTo($picture);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Add original format as img element
|
|
169
|
+
const originalFormat = Object.keys(variantsByFormat).find((f) => f !== 'avif' && f !== 'webp');
|
|
170
|
+
|
|
171
|
+
if (originalFormat && variantsByFormat[originalFormat]) {
|
|
172
|
+
const formatVariants = variantsByFormat[originalFormat];
|
|
173
|
+
formatVariants.sort((a, b) => a.width - b.width);
|
|
174
|
+
|
|
175
|
+
const srcset = formatVariants.map((v) => `/${v.path} ${v.width}w`).join(', ');
|
|
176
|
+
const defaultSrc = formatVariants[Math.floor(formatVariants.length / 2)]?.path;
|
|
177
|
+
|
|
178
|
+
// Create new img element
|
|
179
|
+
const $newImg = $('<img>')
|
|
180
|
+
.attr('src', defaultSrc ? `/${defaultSrc}` : src)
|
|
181
|
+
.attr('srcset', srcset)
|
|
182
|
+
.attr('sizes', sizesAttr)
|
|
183
|
+
.attr('alt', alt)
|
|
184
|
+
.attr('loading', 'lazy');
|
|
185
|
+
|
|
186
|
+
// Add class if present
|
|
187
|
+
if (className) {
|
|
188
|
+
$newImg.attr('class', className);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Add width/height attributes if configured and available
|
|
192
|
+
if (config.dimensionAttributes && variants.length > 0) {
|
|
193
|
+
const largestVariant = [...variants].sort((a, b) => b.width - a.width)[0];
|
|
194
|
+
$newImg.attr('width', largestVariant.width);
|
|
195
|
+
$newImg.attr('height', largestVariant.height);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
$newImg.appendTo($picture);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return $picture;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Progressive image loader JavaScript
|
|
206
|
+
* Handles intersection observer, format detection, and smooth loading transitions
|
|
207
|
+
*/
|
|
208
|
+
export const progressiveImageLoader = `
|
|
209
|
+
(function() {
|
|
210
|
+
'use strict';
|
|
211
|
+
|
|
212
|
+
// Cache for detected format support
|
|
213
|
+
let bestFormat = null;
|
|
214
|
+
|
|
215
|
+
// Main function called when images enter the viewport
|
|
216
|
+
const loadImage = function(entries, observer) {
|
|
217
|
+
for (let entry of entries) {
|
|
218
|
+
if (entry.isIntersecting) {
|
|
219
|
+
const thisWrapper = entry.target;
|
|
220
|
+
|
|
221
|
+
// Find the high res image in the wrapper
|
|
222
|
+
const thisImage = thisWrapper.querySelector('.high-res');
|
|
223
|
+
const thisImageSource = thisImage.dataset.source;
|
|
224
|
+
|
|
225
|
+
if (!thisImageSource) {
|
|
226
|
+
console.warn('No data-source found for high-res image');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Apply format based on detected support
|
|
231
|
+
let finalImageSource = thisImageSource;
|
|
232
|
+
|
|
233
|
+
if (bestFormat === 'avif') {
|
|
234
|
+
finalImageSource = thisImageSource.replace(/.(jpg|jpeg|png)$/i, '.avif');
|
|
235
|
+
} else if (bestFormat === 'webp') {
|
|
236
|
+
finalImageSource = thisImageSource.replace(/.(jpg|jpeg|png)$/i, '.webp');
|
|
237
|
+
}
|
|
238
|
+
// If 'original' or null, use original (no change needed)
|
|
239
|
+
|
|
240
|
+
thisImage.src = finalImageSource;
|
|
241
|
+
|
|
242
|
+
// Take this image off the observe list to prevent duplicate loading
|
|
243
|
+
observer.unobserve(thisWrapper);
|
|
244
|
+
|
|
245
|
+
// Once the hi-res image has been loaded, add done class to trigger CSS transition
|
|
246
|
+
thisImage.onload = function() {
|
|
247
|
+
thisWrapper.classList.add('done');
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Handle loading errors gracefully
|
|
251
|
+
thisImage.onerror = function() {
|
|
252
|
+
thisWrapper.classList.add('error');
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const init = async function() {
|
|
259
|
+
// Detect best supported format first
|
|
260
|
+
bestFormat = await detectBestFormat();
|
|
261
|
+
|
|
262
|
+
// Check for Intersection Observer support (not available in older browsers)
|
|
263
|
+
if (!('IntersectionObserver' in window)) {
|
|
264
|
+
// Fallback: load all images immediately for older browsers
|
|
265
|
+
document.querySelectorAll('.js-progressive-image-wrapper').forEach(function(wrapper) {
|
|
266
|
+
const img = wrapper.querySelector('.high-res');
|
|
267
|
+
if (img && img.dataset.source) {
|
|
268
|
+
let finalImageSource = img.dataset.source;
|
|
269
|
+
|
|
270
|
+
// Apply detected format for fallback
|
|
271
|
+
if (bestFormat === 'avif') {
|
|
272
|
+
finalImageSource = img.dataset.source.replace(/.(jpg|jpeg|png)$/i, '.avif');
|
|
273
|
+
} else if (bestFormat === 'webp') {
|
|
274
|
+
finalImageSource = img.dataset.source.replace(/.(jpg|jpeg|png)$/i, '.webp');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
img.src = finalImageSource;
|
|
278
|
+
wrapper.classList.add('done');
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Create intersection observer with 50px margin (loads images slightly before they're visible)
|
|
285
|
+
const observer = new IntersectionObserver(loadImage, {
|
|
286
|
+
rootMargin: '50px'
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Loop over all image wrappers and add to intersection observer
|
|
290
|
+
const allImageWrappers = document.querySelectorAll('.js-progressive-image-wrapper');
|
|
291
|
+
for (let imageWrapper of allImageWrappers) {
|
|
292
|
+
observer.observe(imageWrapper);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Format detection using createImageBitmap - more reliable than canvas encoding
|
|
297
|
+
async function detectBestFormat() {
|
|
298
|
+
const fallbackFormat = 'original';
|
|
299
|
+
|
|
300
|
+
if (!window.createImageBitmap) return fallbackFormat;
|
|
301
|
+
|
|
302
|
+
const avifData = 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUEAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAABYAAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgSAAAAAAABNjb2xybmNseAACAAIABoAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB5tZGF0EgAKBzgADlAgIGkyCR/wAABAAACvcA==';
|
|
303
|
+
const webpData = 'data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoCAAEAAQAcJaQAA3AA/v3AgAA=';
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const avifBlob = await fetch(avifData).then(r => r.blob());
|
|
307
|
+
await createImageBitmap(avifBlob);
|
|
308
|
+
return 'avif';
|
|
309
|
+
} catch {
|
|
310
|
+
try {
|
|
311
|
+
const webpBlob = await fetch(webpData).then(r => r.blob());
|
|
312
|
+
await createImageBitmap(webpBlob);
|
|
313
|
+
return 'webp';
|
|
314
|
+
} catch {
|
|
315
|
+
return fallbackFormat;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Initialize when DOM is ready
|
|
321
|
+
if (document.readyState === 'loading') {
|
|
322
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
323
|
+
} else {
|
|
324
|
+
init();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
})();
|
|
328
|
+
`;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Progressive image CSS styles
|
|
332
|
+
* Handles aspect ratio, positioning, and smooth transitions between placeholder and high-res images
|
|
333
|
+
*/
|
|
334
|
+
export const progressiveImageCSS = `
|
|
335
|
+
.responsive-wrapper {
|
|
336
|
+
position: relative;
|
|
337
|
+
overflow: hidden;
|
|
338
|
+
background-color: #f0f0f0;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.responsive-wrapper .low-res {
|
|
342
|
+
position: absolute;
|
|
343
|
+
top: 0;
|
|
344
|
+
left: 0;
|
|
345
|
+
width: 100%;
|
|
346
|
+
height: 100%;
|
|
347
|
+
object-fit: cover;
|
|
348
|
+
transition: opacity 0.4s ease;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.responsive-wrapper .high-res {
|
|
352
|
+
position: absolute;
|
|
353
|
+
top: 0;
|
|
354
|
+
left: 0;
|
|
355
|
+
width: 100%;
|
|
356
|
+
height: 100%;
|
|
357
|
+
object-fit: cover;
|
|
358
|
+
opacity: 0;
|
|
359
|
+
transition: opacity 0.4s ease;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.responsive-wrapper.done .high-res {
|
|
363
|
+
opacity: 1;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.responsive-wrapper.done .low-res {
|
|
367
|
+
opacity: 0;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.responsive-wrapper.error .low-res {
|
|
371
|
+
filter: none;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* Ensure images are responsive */
|
|
375
|
+
.responsive-wrapper img {
|
|
376
|
+
max-width: 100%;
|
|
377
|
+
height: auto;
|
|
378
|
+
}
|
|
379
|
+
`;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration utility for the plugin
|
|
3
|
+
* Handles merging user options with sensible defaults
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Deep merge for objects. Handles nested objects properly, which is needed for
|
|
8
|
+
* the formatOptions and placeholder options. Implemented as a plain loop rather
|
|
9
|
+
* than a spread-into-reduce accumulator to avoid quadratic copying.
|
|
10
|
+
* @param {Object} target - Target object
|
|
11
|
+
* @param {Object} source - Source object
|
|
12
|
+
* @return {Object} - Merged result
|
|
13
|
+
*/
|
|
14
|
+
const deepMerge = (target, source) => {
|
|
15
|
+
const result = { ...target };
|
|
16
|
+
for (const key of Object.keys(source)) {
|
|
17
|
+
result[key] = source[key]?.constructor === Object ? deepMerge(target[key] || {}, source[key]) : source[key];
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Builds configuration with sensible defaults
|
|
24
|
+
* @param {Object} options - User provided plugin options
|
|
25
|
+
* @return {Object} - Complete config with defaults
|
|
26
|
+
*/
|
|
27
|
+
export function buildConfig(options = {}) {
|
|
28
|
+
// Default configuration with sensible defaults
|
|
29
|
+
const defaults = {
|
|
30
|
+
// Responsive breakpoints to generate
|
|
31
|
+
widths: [320, 640, 960, 1280, 1920],
|
|
32
|
+
|
|
33
|
+
// Formats to generate in order of preference (first is most preferred)
|
|
34
|
+
formats: ['avif', 'webp', 'original'],
|
|
35
|
+
|
|
36
|
+
// Format-specific compression settings
|
|
37
|
+
formatOptions: {
|
|
38
|
+
avif: { quality: 65, speed: 5 }, // Better compression but slower
|
|
39
|
+
webp: { quality: 80, lossless: false },
|
|
40
|
+
jpeg: { quality: 85, progressive: true },
|
|
41
|
+
png: { compressionLevel: 8, palette: true }
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// Which HTML files to process
|
|
45
|
+
htmlPattern: '**/*.html',
|
|
46
|
+
|
|
47
|
+
// CSS selector for images to process
|
|
48
|
+
imgSelector: 'img:not([data-no-responsive])',
|
|
49
|
+
|
|
50
|
+
// Output directory for processed images (relative to Metalsmith destination)
|
|
51
|
+
outputDir: 'assets/images/responsive',
|
|
52
|
+
|
|
53
|
+
// Output naming pattern
|
|
54
|
+
// Available tokens: [filename], [width], [format], [hash]
|
|
55
|
+
outputPattern: '[filename]-[width]w-[hash].[format]',
|
|
56
|
+
|
|
57
|
+
// Whether to skip generating sizes larger than original
|
|
58
|
+
skipLarger: true,
|
|
59
|
+
|
|
60
|
+
// Add loading="lazy" to images
|
|
61
|
+
lazy: true,
|
|
62
|
+
|
|
63
|
+
// Add width and height attributes to prevent layout shift
|
|
64
|
+
dimensionAttributes: true,
|
|
65
|
+
|
|
66
|
+
// Default sizes attribute value for responsive images
|
|
67
|
+
sizes: '(max-width: 768px) 100vw, 75vw',
|
|
68
|
+
|
|
69
|
+
// Maximum number of images to process in parallel
|
|
70
|
+
concurrency: 5,
|
|
71
|
+
|
|
72
|
+
// Whether to generate a metadata JSON file
|
|
73
|
+
generateMetadata: false,
|
|
74
|
+
|
|
75
|
+
// Progressive loading options
|
|
76
|
+
isProgressive: false, // TODO: Debug timeout issue in tests
|
|
77
|
+
|
|
78
|
+
// Placeholder image settings for progressive loading
|
|
79
|
+
placeholder: {
|
|
80
|
+
width: 50,
|
|
81
|
+
quality: 30,
|
|
82
|
+
blur: 10
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// Background image processing settings
|
|
86
|
+
processUnusedImages: true, // Process images not found in HTML for background use
|
|
87
|
+
imagePattern: '**/*.{jpg,jpeg,png,gif,webp,avif}', // Pattern to find images for background processing
|
|
88
|
+
|
|
89
|
+
// Persistent cache directory for generated variants (relative to metalsmith.directory())
|
|
90
|
+
// false = disabled, true = default path ('lib/<outputDir>'), string = custom path
|
|
91
|
+
// When set, variants are written to this directory and loaded from it on subsequent builds.
|
|
92
|
+
// Commit this directory to git so CI/Netlify never needs to run Sharp.
|
|
93
|
+
cache: false
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Special handling for formatOptions to ensure deep merging
|
|
97
|
+
// This allows users to override specific format settings without losing defaults
|
|
98
|
+
// e.g., { formatOptions: { jpeg: { quality: 90 } } } only changes JPEG quality
|
|
99
|
+
if (options && options.formatOptions) {
|
|
100
|
+
options = {
|
|
101
|
+
...options,
|
|
102
|
+
formatOptions: deepMerge(defaults.formatOptions, options.formatOptions)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Special handling for placeholder options to ensure deep merging
|
|
107
|
+
// Allows partial placeholder config like { placeholder: { width: 100 } }
|
|
108
|
+
if (options && options.placeholder) {
|
|
109
|
+
options = {
|
|
110
|
+
...options,
|
|
111
|
+
placeholder: deepMerge(defaults.placeholder, options.placeholder)
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Merge the defaults with user options
|
|
116
|
+
return { ...defaults, ...(options || {}) };
|
|
117
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility for generating content hashes
|
|
3
|
+
* Used for cache-busting - ensures filenames change when image content changes
|
|
4
|
+
*/
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generates a short hash based on image content
|
|
9
|
+
* Creates an 8-character hash for cache-busting in filenames
|
|
10
|
+
* @param {Buffer} buffer - The image buffer
|
|
11
|
+
* @return {string} - A short hash string (8 characters)
|
|
12
|
+
*/
|
|
13
|
+
export function generateHash(buffer) {
|
|
14
|
+
// SHA-256 for cache-busting - using secure algorithm to satisfy security scanners
|
|
15
|
+
// Only use first 8 characters to keep filenames manageable
|
|
16
|
+
return crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 8);
|
|
17
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path utilities for image variants
|
|
3
|
+
* Handles the filename pattern system with token replacement
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate variant filename using pattern
|
|
9
|
+
* Applies token replacement to create output filenames
|
|
10
|
+
* Tokens: [filename], [width], [format], [hash]
|
|
11
|
+
* @param {string} originalPath - Original image path
|
|
12
|
+
* @param {number} width - Target width
|
|
13
|
+
* @param {string} format - Target format ('original' means keep source format)
|
|
14
|
+
* @param {string} hash - Content hash for cache busting
|
|
15
|
+
* @param {object} config - Plugin config options
|
|
16
|
+
* @return {string} - Generated path relative to Metalsmith destination
|
|
17
|
+
*/
|
|
18
|
+
export function generateVariantPath(originalPath, width, format, hash, config) {
|
|
19
|
+
const parsedPath = path.parse(originalPath);
|
|
20
|
+
const originalFormat = parsedPath.ext.slice(1).toLowerCase();
|
|
21
|
+
|
|
22
|
+
// If format is 'original', use the source format (e.g., 'jpeg' for image.jpg)
|
|
23
|
+
const outputFormat = format === 'original' ? originalFormat : format;
|
|
24
|
+
|
|
25
|
+
// Apply pattern replacements using the tokens system
|
|
26
|
+
// Default pattern: '[filename]-[width]w-[hash].[format]'
|
|
27
|
+
// Results in: 'image-320w-abc12345.webp'
|
|
28
|
+
const outputName = config.outputPattern
|
|
29
|
+
.replace('[filename]', parsedPath.name)
|
|
30
|
+
.replace('[width]', width)
|
|
31
|
+
.replace('[format]', outputFormat)
|
|
32
|
+
.replace('[hash]', hash || '');
|
|
33
|
+
|
|
34
|
+
return path.join(config.outputDir, outputName);
|
|
35
|
+
}
|