metalsmith-optimize-images 0.12.0 → 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.
@@ -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
+ }