@divriots/jampack 0.7.2 → 0.8.1

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 CHANGED
@@ -35,12 +35,16 @@ Optimizes static websites for best user experience and best Core Web Vitals scor
35
35
 
36
36
  `jampack` is a post-processing tool that takes the output of your [Static Site Generator (aka SSG)](https://jamstack.org/generators/) and optimizes it for best user experience and best [Core Web Vitals](https://web.dev/learn-core-web-vitals/) scores.
37
37
 
38
- ## Why `jampack`?
38
+ [Read the introduction blog post](https://divriots.com/blog/introducing-jampack)
39
39
 
40
- - **Better user experience** for your website visitors. Specially on low-end devices.
41
- - **Better SEO**. Page experience now [affects ranking](https://developers.google.com/search/docs/appearance/page-experience).
42
- - **Easier maintenance**. Generating top performance webpages requires extra engineering in the composition of the pages that is counterproductive with the maintenance of the website.
43
- - **Time saving**. Spend less time optimizing and let `jampack` perform the heaving-lifting for you.
40
+ ## What can `jampack` do?
41
+
42
+ - [Optimize images](https://jampack.divriots.com/features/optimize-images)
43
+ - [Optimize assets above-the-fold](https://jampack.divriots.com/features/optimize-above-the-fold)
44
+ - [Embed small images](https://jampack.divriots.com/features/embed-small-images)
45
+ - [Compress all assets](https://jampack.divriots.com/features/compress-all)
46
+ - [Fix things automatically](https://jampack.divriots.com/features/autofixes)
47
+ - [Raise warnings](https://jampack.divriots.com/features/warnings)
44
48
 
45
49
  ## Quick use
46
50
 
@@ -51,15 +55,7 @@ npx @divriots/jampack ./dist
51
55
 
52
56
  For more options see [CLI options](https://jampack.divriots.com/cli-options).
53
57
 
54
- <div>
55
-
56
- ## What can `jampack` do?
57
-
58
- See [the list of features](https://jampack.divriots.com).
59
-
60
- </div>
61
-
62
- ## `jampack` use in the wild
58
+ ## `jampack` used in the wild
63
59
 
64
60
  - [divRIOTS.com](https://divRIOTS.com)
65
61
  - [Backlight.dev](https://backlight.dev)
@@ -70,7 +66,9 @@ See [the list of features](https://jampack.divriots.com).
70
66
  - [Components.studio](https://components.studio)
71
67
  - [keycloak.ch](https://keycloak.ch)
72
68
  - [bayjs.org](https://bayjs.org/)
73
-
69
+ - [qwind.pages.dev](https://qwind.pages.dev/)
70
+ - [Bloycey's Blog](https://bloycey.blog/)
71
+
74
72
  > Add yours here
75
73
 
76
74
  ## Why is it called `jampack`?
@@ -1,11 +1,14 @@
1
1
  /// <reference types="node" resolution-mode="require"/>
2
2
  import sharp from 'sharp';
3
+ import { MimeType } from 'file-type';
4
+ export type ImageMimeType = MimeType | 'image/svg+xml';
3
5
  export type ImageFormat = 'webp' | 'svg' | 'jpg' | 'png' | 'avif' | undefined;
4
6
  export type Image = {
5
7
  format: ImageFormat;
6
8
  data: Buffer;
7
9
  };
8
- export declare function compressImage(data: Buffer, options: {
10
+ export type ImageOutputOptions = {
9
11
  resize?: sharp.ResizeOptions;
10
- toFormat?: 'webp' | 'pjpg' | 'avif' | 'unchanged';
11
- }): Promise<Image | undefined>;
12
+ toFormat?: 'webp' | 'avif' | 'png' | 'jpeg' | 'jpeg+progressive' | 'unchanged';
13
+ };
14
+ export declare function compressImage(data: Buffer, options: ImageOutputOptions): Promise<Image | undefined>;
@@ -48,6 +48,10 @@ export async function compressImage(data, options) {
48
48
  }
49
49
  return { format: 'svg', data: Buffer.from(newData.data, 'utf8') };
50
50
  }
51
+ // TODO
52
+ // Use information of input image to the destination format
53
+ // - Progressive (unless overriden)
54
+ // - Lossless
51
55
  // The bitmap images
52
56
  if (toFormat === 'unchanged') {
53
57
  switch (meta.format) {
@@ -64,8 +68,9 @@ export async function compressImage(data, options) {
64
68
  sharpFile = sharpFile.webp(createWebpOptions(config.image.webp.options_lossly) || {});
65
69
  outputFormat = 'webp';
66
70
  break;
71
+ case 'heif':
67
72
  case 'avif':
68
- sharpFile = sharpFile.avif();
73
+ sharpFile = sharpFile.avif({ effort: 4, quality: 50 }); // TODO create config for avif
69
74
  outputFormat = 'avif';
70
75
  break;
71
76
  }
@@ -73,10 +78,18 @@ export async function compressImage(data, options) {
73
78
  else {
74
79
  // To format
75
80
  switch (toFormat) {
76
- case 'pjpg':
81
+ case 'jpeg':
82
+ sharpFile = sharpFile.jpeg({ ...config.image.jpeg.options } || {});
83
+ outputFormat = 'jpg';
84
+ break;
85
+ case 'jpeg+progressive':
77
86
  sharpFile = sharpFile.jpeg({ ...config.image.jpeg.options, progressive: true } || {});
78
87
  outputFormat = 'jpg';
79
88
  break;
89
+ case 'png':
90
+ sharpFile = sharpFile.png({ ...config.image.png.options } || {});
91
+ outputFormat = 'png';
92
+ break;
80
93
  case 'webp':
81
94
  sharpFile = sharpFile.webp(createWebpOptions(meta.format === 'png'
82
95
  ? config.image.webp.options_lossless
@@ -84,7 +97,10 @@ export async function compressImage(data, options) {
84
97
  outputFormat = 'webp';
85
98
  break;
86
99
  case 'avif':
87
- sharpFile = sharpFile.avif({ effort: 6 });
100
+ sharpFile = sharpFile.avif({
101
+ effort: 4,
102
+ quality: meta.format === 'png' ? 80 : 60, // don't use lossless avif it doesn't compress well in most png uses cases
103
+ });
88
104
  outputFormat = 'avif';
89
105
  break;
90
106
  }
package/dist/config.js CHANGED
@@ -5,7 +5,7 @@ const default_options = {
5
5
  compress: true,
6
6
  jpeg: {
7
7
  options: {
8
- quality: 80,
8
+ quality: 75,
9
9
  mozjpeg: true,
10
10
  },
11
11
  },
@@ -17,12 +17,12 @@ const default_options = {
17
17
  webp: {
18
18
  options_lossless: {
19
19
  effort: 4,
20
- quality: 80,
20
+ quality: 77,
21
21
  mode: 'lossless',
22
22
  },
23
23
  options_lossly: {
24
24
  effort: 4,
25
- quality: 80,
25
+ quality: 77,
26
26
  mode: 'lossly',
27
27
  },
28
28
  },
package/dist/optimize.js CHANGED
@@ -4,7 +4,7 @@ import * as fs from 'fs/promises';
4
4
  import cheerio from 'cheerio';
5
5
  import { isNumeric } from './utils.js';
6
6
  import config from './config.js';
7
- import { compressImage } from './compressors/images.js';
7
+ import { compressImage, } from './compressors/images.js';
8
8
  import svgToMiniDataURI from 'mini-svg-data-uri';
9
9
  import $state from './state.js';
10
10
  import kleur from 'kleur';
@@ -30,7 +30,16 @@ async function analyse(file) {
30
30
  const imgElement = imgsArray[i];
31
31
  spinnerImg.text = kleur.dim(`<img> [${i + 1}/${imgsArray.length}] ${$(imgElement).attr('src')} `);
32
32
  const isAboveTheFold = imgElement.startIndex < theFold;
33
- await processImage(file, $, imgElement, isAboveTheFold);
33
+ try {
34
+ await processImage(file, $, imgElement, isAboveTheFold);
35
+ }
36
+ catch (e) {
37
+ $state.reportIssue(file, {
38
+ type: 'erro',
39
+ msg: e.message ||
40
+ `Unexpected error while processing image: ${JSON.stringify(e)}`,
41
+ });
42
+ }
34
43
  }
35
44
  // Reset spinner
36
45
  spinnerImg.text = kleur.dim(`<img> [${imgsArray.length}/${imgsArray.length}]`);
@@ -65,258 +74,349 @@ function getTheFold($) {
65
74
  if (!theFolds[0]) {
66
75
  return 0;
67
76
  }
77
+ // Pickup the position of the last `<the-fold>`
68
78
  // @ts-ignore
69
- return theFolds[0].startIndex;
79
+ return theFolds[theFolds.length - 1].startIndex;
70
80
  }
71
81
  async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
72
- try {
73
- const img = $(imgElement);
74
- /*
75
- * Attribute 'src'
76
- */
77
- const attrib_src = img.attr('src');
78
- if (!attrib_src) {
79
- $state.reportIssue(htmlfile, {
80
- type: 'warn',
81
- msg: `Missing [src] on img - processing skipped.`,
82
- });
83
- return;
84
- }
85
- /*
86
- * Attribute 'alt'
87
- */
88
- const attrib_alt = img.attr('alt');
89
- if (attrib_alt === undefined) {
90
- $state.reportIssue(htmlfile, {
91
- type: 'a11y',
92
- msg: `Missing [alt] on img src="${attrib_src}" - Adding alt="" meanwhile.`,
93
- });
94
- img.attr('alt', '');
95
- }
96
- if (attrib_src.startsWith('data:')) {
97
- // Data URI image
98
- // TODO: try to compress it
99
- return;
82
+ var _a, _b;
83
+ const img = $(imgElement);
84
+ /*
85
+ * Attribute 'src'
86
+ */
87
+ const attrib_src = img.attr('src');
88
+ if (!attrib_src) {
89
+ $state.reportIssue(htmlfile, {
90
+ type: 'warn',
91
+ msg: `Missing [src] on img - processing skipped.`,
92
+ });
93
+ return;
94
+ }
95
+ /*
96
+ * Attribute 'alt'
97
+ */
98
+ const attrib_alt = img.attr('alt');
99
+ if (attrib_alt === undefined) {
100
+ $state.reportIssue(htmlfile, {
101
+ type: 'a11y',
102
+ msg: `Missing [alt] on img src="${attrib_src}" - Adding alt="" meanwhile.`,
103
+ });
104
+ img.attr('alt', '');
105
+ }
106
+ if (attrib_src.startsWith('data:')) {
107
+ // Data URI image
108
+ // TODO: try to compress it
109
+ return;
110
+ }
111
+ /*
112
+ * Attribute 'loading'
113
+ */
114
+ const attr_loading = img.attr('loading');
115
+ if (isAboveTheFold) {
116
+ img.removeAttr('loading');
117
+ img.attr('fetchpriority', 'high');
118
+ }
119
+ else {
120
+ switch (attr_loading) {
121
+ case undefined:
122
+ // Go lazy by default
123
+ img.attr('loading', 'lazy');
124
+ break;
125
+ case 'eager':
126
+ img.removeAttr('loading');
127
+ break;
128
+ case 'lazy':
129
+ // Don't touch it
130
+ break;
131
+ default:
132
+ $state.reportIssue(htmlfile, {
133
+ type: 'invalid',
134
+ msg: `Invalid [loading]="${attr_loading}" on img src="${attrib_src}"`,
135
+ });
100
136
  }
101
- /*
102
- * Attribute 'loading'
103
- */
104
- const attr_loading = img.attr('loading');
105
- if (isAboveTheFold) {
106
- img.removeAttr('loading');
107
- img.attr('fetchpriority', 'high');
137
+ }
138
+ /*
139
+ * Attribute 'decoding'
140
+ */
141
+ img.attr('decoding', 'async');
142
+ if (!isLocal(attrib_src)) {
143
+ // Image not local, don't touch it
144
+ return;
145
+ }
146
+ /*
147
+ * Loading image
148
+ */
149
+ const originalImage = await Resource.loadResource($state.dir, htmlfile, attrib_src);
150
+ // No file -> give up
151
+ if (!originalImage) {
152
+ $state.reportIssue(htmlfile, {
153
+ type: 'erro',
154
+ msg: `Can't find img on disk src="${attrib_src}"`,
155
+ });
156
+ return;
157
+ }
158
+ /*
159
+ * Compress image
160
+ */
161
+ let newImage;
162
+ const isInPicture = (_a = img.parent()) === null || _a === void 0 ? void 0 : _a.is('picture');
163
+ let srcToFormat = 'unchanged';
164
+ if (isAboveTheFold) {
165
+ // Above the fold, nothing beats progressive JPEG for LCP
166
+ // but it's not transparent
167
+ if ((_b = (await originalImage.getImageMeta())) === null || _b === void 0 ? void 0 : _b.hasAlpha) {
168
+ // TODO some images have Alpha metadata but have 0 alpha pixels
169
+ // We should be able to detect these to take advantage of Progressive JPEG
170
+ srcToFormat =
171
+ (await originalImage.getMime()) === 'image/avif' ||
172
+ (await originalImage.getMime()) === 'image/webp'
173
+ ? 'unchanged'
174
+ : 'webp';
108
175
  }
109
176
  else {
110
- switch (attr_loading) {
111
- case undefined:
112
- // Go lazy by default
113
- img.attr('loading', 'lazy');
114
- break;
115
- case 'eager':
116
- img.removeAttr('loading');
117
- break;
118
- case 'lazy':
119
- // Don't touch it
120
- break;
121
- default:
122
- $state.reportIssue(htmlfile, {
123
- type: 'invalid',
124
- msg: `Invalid [loading]="${attr_loading}" on img src="${attrib_src}"`,
125
- });
126
- }
177
+ srcToFormat = 'jpeg+progressive';
127
178
  }
128
- /*
129
- * Attribute 'decoding'
130
- */
131
- img.attr('decoding', 'async');
132
- if (!isLocal(attrib_src)) {
133
- // Image not local, don't touch it
134
- return;
135
- }
136
- /*
137
- * Loading image
138
- */
139
- const originalImage = await Resource.loadResource($state.dir, htmlfile, attrib_src);
140
- // No file -> give up
141
- if (!originalImage) {
142
- $state.reportIssue(htmlfile, {
143
- type: 'erro',
144
- msg: `Can't find img on disk src="${attrib_src}"`,
179
+ }
180
+ else if (isInPicture) {
181
+ // srcToFormat should not change if in picture
182
+ }
183
+ else {
184
+ // Let's go to avif -> avif , webp -> webp, else * -> webp
185
+ srcToFormat =
186
+ (await originalImage.getMime()) === 'image/avif' ||
187
+ (await originalImage.getMime()) === 'image/webp'
188
+ ? 'unchanged'
189
+ : 'webp';
190
+ }
191
+ if (!$state.optimizedFiles.has(originalImage.filePathAbsolute)) {
192
+ // Let's avoid to optimize same images twice
193
+ $state.optimizedFiles.add(originalImage.filePathAbsolute);
194
+ newImage = await compressImage(await originalImage.getData(), {
195
+ toFormat: srcToFormat,
196
+ });
197
+ if ((newImage === null || newImage === void 0 ? void 0 : newImage.data) &&
198
+ (newImage.data.length < (await originalImage.getLen()) ||
199
+ srcToFormat === 'jpeg+progressive') // Progressive override all even if worse
200
+ ) {
201
+ // Do we need to add an new extension?
202
+ const newExtension = `.${newImage.format}`;
203
+ const additionalExtension = path.extname(originalImage.filePathAbsolute) === newExtension
204
+ ? ''
205
+ : newExtension;
206
+ const newFilename = originalImage.filePathAbsolute + additionalExtension;
207
+ if (!$state.args.nowrite) {
208
+ fs.writeFile(newFilename, newImage.data);
209
+ }
210
+ // Mark new file as optimized
211
+ $state.compressedFiles.add(newFilename);
212
+ // Report compression result
213
+ $state.reportSummary({
214
+ action: newFilename !== originalImage.filePathAbsolute
215
+ ? `${await originalImage.getExt()}->${newImage.format}`
216
+ : path.extname(originalImage.filePathAbsolute),
217
+ originalSize: await originalImage.getLen(),
218
+ compressedSize: newImage.data.length,
145
219
  });
146
- return;
220
+ img.attr('src', attrib_src + additionalExtension);
147
221
  }
148
- /*
149
- * Compress image
150
- */
151
- let newImage;
152
- const originalImageMeta = await originalImage.getImageMeta();
153
- const canBeProgressiveJpeg = isAboveTheFold && originalImageMeta && !originalImageMeta.hasAlpha;
154
- if (!$state.optimizedFiles.has(originalImage.filePathAbsolute)) {
155
- // Let's avoid to optimize same images twice
156
- $state.optimizedFiles.add(originalImage.filePathAbsolute);
157
- newImage = await compressImage(await originalImage.getData(), {
158
- toFormat: canBeProgressiveJpeg ? 'pjpg' : 'webp',
222
+ else {
223
+ // Report non-compression
224
+ $state.reportSummary({
225
+ action: path.extname(originalImage.filePathAbsolute),
226
+ originalSize: await originalImage.getLen(),
227
+ compressedSize: await originalImage.getLen(),
159
228
  });
160
- if ((newImage === null || newImage === void 0 ? void 0 : newImage.data) &&
161
- (newImage.data.length < (await originalImage.getLen()) ||
162
- canBeProgressiveJpeg) // Progressive jpg above the fold should get replaced even if bigger
163
- ) {
164
- // Do we need to add an new extension?
165
- const newExtension = `.${newImage.format}`;
166
- const additionalExtension = path.extname(originalImage.filePathAbsolute) === newExtension
167
- ? ''
168
- : newExtension;
169
- const newFilename = originalImage.filePathAbsolute + additionalExtension;
170
- if (!$state.args.nowrite) {
171
- fs.writeFile(newFilename, newImage.data);
172
- }
173
- // Mark new file as optimized
174
- $state.compressedFiles.add(newFilename);
175
- // Report compression result
176
- $state.reportSummary({
177
- action: newFilename !== originalImage.filePathAbsolute
178
- ? `${await originalImage.getExt()}->${newImage.format}`
179
- : path.extname(originalImage.filePathAbsolute),
180
- originalSize: await originalImage.getLen(),
181
- compressedSize: newImage.data.length,
182
- });
183
- img.attr('src', attrib_src + additionalExtension);
184
- }
185
- else {
186
- // Report non-compression
187
- $state.reportSummary({
188
- action: path.extname(originalImage.filePathAbsolute),
189
- originalSize: await originalImage.getLen(),
190
- compressedSize: await originalImage.getLen(),
191
- });
192
- }
193
229
  }
194
- /*
195
- * Embed small images
196
- *
197
- * TODO this is only embedding images that have
198
- * successfully be compressed. Should embed original
199
- * image if it fits the size.
200
- */
201
- let isEmbed = false;
202
- if (newImage && newImage.data.length <= config.image.embed_size) {
203
- let datauri = undefined;
204
- switch (newImage.format) {
205
- case 'svg':
206
- datauri = svgToMiniDataURI(newImage.data.toString());
207
- break;
208
- case 'webp':
209
- datauri = `data:image/webp;base64,${newImage.data.toString('base64')}`;
210
- break;
211
- case 'jpg':
212
- case 'png':
213
- // TODO but not possible in current code
214
- break;
215
- }
216
- if (datauri) {
217
- isEmbed = true;
218
- img.attr('src', datauri);
219
- img.removeAttr('loading');
220
- img.removeAttr('decoding');
221
- $state.reportSummary({
222
- action: `${newImage.format}->embed`,
223
- originalSize: await originalImage.getLen(),
224
- compressedSize: newImage.data.length,
225
- });
226
- }
230
+ }
231
+ /*
232
+ * Embed small images
233
+ *
234
+ * TODO this is only embedding images that have
235
+ * successfully be compressed. Should embed original
236
+ * image if it fits the size.
237
+ */
238
+ let isEmbed = false;
239
+ if (newImage && newImage.data.length <= config.image.embed_size) {
240
+ let datauri = undefined;
241
+ switch (newImage.format) {
242
+ case 'svg':
243
+ datauri = svgToMiniDataURI(newImage.data.toString());
244
+ break;
245
+ case 'webp':
246
+ datauri = `data:image/webp;base64,${newImage.data.toString('base64')}`;
247
+ break;
248
+ case 'jpg':
249
+ case 'png':
250
+ // TODO but not possible in current code
251
+ break;
227
252
  }
228
- if (isEmbed) {
229
- // Image is embed, no need for more processing
230
- return;
253
+ if (datauri) {
254
+ isEmbed = true;
255
+ img.attr('src', datauri);
256
+ img.removeAttr('loading');
257
+ img.removeAttr('decoding');
258
+ $state.reportSummary({
259
+ action: `${newImage.format}->embed`,
260
+ originalSize: await originalImage.getLen(),
261
+ compressedSize: newImage.data.length,
262
+ });
231
263
  }
232
- /*
233
- * Attribute 'width' & 'height'
234
- */
235
- const [w, h] = await setImageSize(htmlfile, img, originalImage);
236
- //
237
- // Stop here if svg
238
- //
239
- if ((await originalImage.getExt()) === 'svg') {
240
- return;
264
+ }
265
+ if (isEmbed) {
266
+ // Image is embed, no need for more processing
267
+ return;
268
+ }
269
+ /*
270
+ * Attribute 'width' & 'height'
271
+ */
272
+ const [w, h] = await setImageSize(htmlfile, img, originalImage);
273
+ //
274
+ // Stop here if svg
275
+ //
276
+ if ((await originalImage.getExt()) === 'svg') {
277
+ return;
278
+ }
279
+ // Image size to beat in srcsets
280
+ //
281
+ const imageLengthToBeatInSrcSet = (newImage === null || newImage === void 0 ? void 0 : newImage.data) && newImage.data.length < (await originalImage.getLen())
282
+ ? newImage.data.length
283
+ : await originalImage.getLen();
284
+ /*
285
+ * Attribute 'srcset'
286
+ */
287
+ if (img.attr('srcset')) {
288
+ // If srcset are set, don't touch it.
289
+ // The compress pass will compress the images
290
+ // of the srcset
291
+ }
292
+ else {
293
+ const new_srcset = await generateSrcSet(htmlfile, originalImage, img.attr('src'), imageLengthToBeatInSrcSet, { toFormat: srcToFormat });
294
+ if (new_srcset !== null) {
295
+ img.attr('srcset', new_srcset);
241
296
  }
242
- /*
243
- * Attribute 'srcset'
244
- */
245
- const attr_srcset = img.attr('srcset');
246
- const attr_sizes = img.attr('sizes');
247
- if (attr_srcset || attr_sizes) {
248
- // If srcset or sizes are set, don't touch it.
249
- // The compress pass will compress the images
250
- // of the srcset
251
- //
252
- // TODO
253
- // support if sizes alone is set (no srcset) and generate the appropriate srcset
297
+ // Add sizes attribute if not specified
298
+ if (img.attr('srcset') && !img.attr('sizes')) {
299
+ img.attr('sizes', '100vw');
300
+ }
301
+ }
302
+ /*
303
+ * Adding <source>'s to <picture>
304
+ */
305
+ if (isInPicture) {
306
+ const picture = img.parent();
307
+ if (isAboveTheFold && srcToFormat === 'jpeg+progressive') {
308
+ // We don't need anything else than jpeg progressive above the fold
309
+ const nodes = picture.children(`source`);
310
+ nodes.remove();
254
311
  }
255
312
  else {
256
- // Generate image set
257
- // Avif => Avif
258
- // png, jpg, ... => WebP
259
- const targetFormat = (await originalImage.getMime()) === 'image/avif' ? 'avif' : 'webp';
260
- const ext = path.extname(attrib_src);
261
- const fullbasename = attrib_src.slice(0, -ext.length);
262
- const imageSrc = (addition) => `${fullbasename}${addition}.${canBeProgressiveJpeg ? 'jpg' : targetFormat}`;
263
- // Start from original image
264
- let new_srcset = '';
265
- // Start reduction
266
- const step = 300; //px
267
- const ratio = w / h;
268
- let valueW = w - step;
269
- let valueH = Math.trunc(valueW / ratio);
270
- let previousImageSize = (newImage === null || newImage === void 0 ? void 0 : newImage.data)
271
- ? Math.min(newImage.data.length, await originalImage.getLen())
272
- : await originalImage.getLen();
273
- while (valueW > config.image.srcset_min_width) {
274
- const src = imageSrc(`@${valueW}w`);
275
- const absoluteFilename = translateSrc($state.dir, path.dirname(htmlfile), src);
276
- let doAddToSrcSet = true;
277
- // Don't generate srcset file twice
278
- if (!$state.compressedFiles.has(absoluteFilename)) {
279
- const compressedImage = await compressImage(await originalImage.getData(), {
280
- resize: { width: valueW, height: valueH },
281
- toFormat: canBeProgressiveJpeg ? 'pjpg' : targetFormat,
313
+ // List of possible sources to generate
314
+ //
315
+ const sourcesToGenerate = [];
316
+ // Only try to generate better images
317
+ // TODO generate more compatible formats (avif => webp/jpeg/png)
318
+ //
319
+ switch (await originalImage.getMime()) {
320
+ case 'image/jpeg':
321
+ case 'image/png':
322
+ sourcesToGenerate.push({
323
+ mime: 'image/webp',
324
+ format: 'webp',
325
+ });
326
+ case 'image/webp':
327
+ sourcesToGenerate.push({
328
+ mime: 'image/avif',
329
+ format: 'avif',
282
330
  });
283
- if (!(compressedImage === null || compressedImage === void 0 ? void 0 : compressedImage.data) ||
284
- compressedImage.data.length >= previousImageSize) {
285
- // New image is not compressed or bigger than previous image in srcset
286
- // Don't add to srcset
287
- doAddToSrcSet = false;
288
- }
289
- else {
290
- // Write file
291
- if (!$state.args.nowrite) {
292
- fs.writeFile(absoluteFilename, compressedImage.data);
293
- }
294
- // Set new previous size to beat
295
- previousImageSize = compressedImage.data.length;
296
- // Add file to list avoid recompression
297
- $state.compressedFiles.add(absoluteFilename);
298
- }
331
+ case 'image/avif':
332
+ // Nothing better
333
+ break;
334
+ }
335
+ const sizes = img.attr('sizes');
336
+ for (const s of sourcesToGenerate) {
337
+ const sourceWithThisMimeType = picture.children(`source[type="${s.mime}"]`);
338
+ if (sourceWithThisMimeType.length > 0) {
339
+ // Ignore the creation of sources that already exist
340
+ continue;
299
341
  }
300
- if (doAddToSrcSet) {
301
- new_srcset += `, ${src} ${valueW}w`;
342
+ const srcset = await generateSrcSet(htmlfile, originalImage, undefined, undefined, {
343
+ toFormat: s.format,
344
+ });
345
+ if (!srcset) {
346
+ // No sourceset generated
347
+ // Image is too small or can't be compressed better
348
+ continue;
302
349
  }
303
- // reduce size
304
- valueW -= step;
305
- valueH = Math.trunc(valueW / ratio);
350
+ const source = `<source ${sizes ? `sizes="${sizes}"` : ''} srcset="${srcset}" type="${s.mime}">`;
351
+ picture.prepend(source);
306
352
  }
307
- if (new_srcset) {
308
- img.attr('srcset', `${img.attr('src')} ${w}w` + new_srcset);
309
- img.attr('sizes', '100vw');
353
+ // Reorder sources to priority order 1)avif 2)webp
354
+ //
355
+ function popupSource(type) {
356
+ const nodes = picture.children(`source[type="${type}"]`);
357
+ picture.prepend(nodes);
310
358
  }
359
+ popupSource('image/webp');
360
+ popupSource('image/avif');
311
361
  }
312
362
  }
313
- catch (e) {
314
- $state.reportIssue(htmlfile, {
315
- type: 'erro',
316
- msg: e.message ||
317
- `Unexpected error while processing image: ${JSON.stringify(e)}`,
318
- });
363
+ }
364
+ async function generateSrcSet(htmlfile, originalImage, startSrc, startSrcLength, options) {
365
+ const ext = path.extname(originalImage.src);
366
+ const fullbasename = originalImage.src.slice(0, -ext.length);
367
+ const imageSrc = (addition) => `${fullbasename}${addition}${options.toFormat === 'unchanged' ? ext : '.' + options.toFormat}`;
368
+ // Start from original image
369
+ let new_srcset = '';
370
+ const meta = await originalImage.getImageMeta();
371
+ const imageWidth = (meta === null || meta === void 0 ? void 0 : meta.width) || 0;
372
+ const imageHeight = (meta === null || meta === void 0 ? void 0 : meta.height) || 0;
373
+ if (!imageWidth || !imageHeight) {
374
+ // Forget about srcset
375
+ return null;
376
+ }
377
+ // Start reduction
378
+ const step = 300; //px
379
+ const ratio = imageWidth / imageHeight;
380
+ let valueW = !startSrc ? imageWidth : imageWidth - step;
381
+ let valueH = Math.trunc(valueW / ratio);
382
+ let previousImageSize = startSrcLength || Number.MAX_VALUE;
383
+ while (valueW >= config.image.srcset_min_width) {
384
+ const src = imageSrc(`@${valueW}w`);
385
+ const absoluteFilename = translateSrc($state.dir, path.dirname(htmlfile), src);
386
+ let doAddToSrcSet = true;
387
+ // Don't generate srcset file twice
388
+ if (!$state.compressedFiles.has(absoluteFilename)) {
389
+ const compressedImage = await compressImage(await originalImage.getData(), { ...options, resize: { width: valueW, height: valueH } });
390
+ if (!(compressedImage === null || compressedImage === void 0 ? void 0 : compressedImage.data) ||
391
+ compressedImage.data.length >= previousImageSize) {
392
+ // New image is not compressed or bigger than previous image in srcset
393
+ // Don't add to srcset
394
+ doAddToSrcSet = false;
395
+ }
396
+ else {
397
+ // Write file
398
+ if (!$state.args.nowrite) {
399
+ fs.writeFile(absoluteFilename, compressedImage.data);
400
+ }
401
+ // Set new previous size to beat
402
+ previousImageSize = compressedImage.data.length;
403
+ // Add file to list avoid recompression
404
+ $state.compressedFiles.add(absoluteFilename);
405
+ }
406
+ }
407
+ if (doAddToSrcSet) {
408
+ new_srcset += `, ${src} ${valueW}w`;
409
+ }
410
+ // reduce size
411
+ valueW -= step;
412
+ valueH = Math.trunc(valueW / ratio);
413
+ }
414
+ if (new_srcset) {
415
+ return startSrc
416
+ ? `${startSrc} ${imageWidth}w` + new_srcset
417
+ : new_srcset.slice(2);
319
418
  }
419
+ return null;
320
420
  }
321
421
  async function setImageSize(htmlfile, img, image) {
322
422
  let width = img.attr('width');
@@ -327,7 +427,7 @@ async function setImageSize(htmlfile, img, image) {
327
427
  if (width !== undefined) {
328
428
  if (!isNumeric(width)) {
329
429
  $state.reportIssue(htmlfile, {
330
- type: 'warn',
430
+ type: 'fix',
331
431
  msg: `Invalid width attribute format: "${width}" - overriding for ${image.src}`,
332
432
  });
333
433
  width = undefined;
@@ -336,7 +436,7 @@ async function setImageSize(htmlfile, img, image) {
336
436
  if (height !== undefined) {
337
437
  if (!isNumeric(height)) {
338
438
  $state.reportIssue(htmlfile, {
339
- type: 'warn',
439
+ type: 'fix',
340
440
  msg: `Invalid height attribute format: "${height}" - overriding for ${image.src}`,
341
441
  });
342
442
  height = undefined;
package/dist/state.d.ts CHANGED
@@ -14,7 +14,7 @@ type Summary = {
14
14
  dataLenCompressed: number;
15
15
  };
16
16
  type Issue = {
17
- type: 'invalid' | 'a11y' | 'perf' | 'erro' | 'warn';
17
+ type: 'invalid' | 'a11y' | 'perf' | 'erro' | 'fix' | 'warn';
18
18
  msg: string;
19
19
  };
20
20
  export declare class GlobalState {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@divriots/jampack",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
4
4
  "description": "Packer for static websites",
5
5
  "author": "Georges Gomes @georges-gomes",
6
6
  "bin": {
@@ -16,27 +16,27 @@
16
16
  "/dist"
17
17
  ],
18
18
  "dependencies": {
19
- "@swc/core": "^1.3.21",
19
+ "@swc/core": "^1.3.30",
20
20
  "@types/cheerio": "^0.22.31",
21
21
  "@types/commander": "^2.12.2",
22
22
  "@types/csso": "^5.0.0",
23
23
  "@types/html-minifier-terser": "^7.0.0",
24
24
  "@types/ora": "^3.2.0",
25
- "@types/sharp": "^0.31.0",
25
+ "@types/sharp": "^0.31.1",
26
26
  "@types/svgo": "^2.6.4",
27
27
  "@types/table": "^6.3.2",
28
28
  "cheerio": "0.22.0",
29
29
  "commander": "^9.4.1",
30
30
  "csso": "^5.0.5",
31
31
  "file-type": "^18.0.0",
32
- "globby": "^13.1.2",
32
+ "globby": "^13.1.3",
33
33
  "hasha": "^5.2.2",
34
34
  "html-minifier-terser": "^7.1.0",
35
35
  "kleur": "^4.1.5",
36
36
  "lightningcss": "^1.18.0",
37
37
  "mini-svg-data-uri": "^1.4.4",
38
38
  "ora": "^6.1.2",
39
- "sharp": "^0.31.2",
39
+ "sharp": "^0.31.3",
40
40
  "svgo": "^2.8.0",
41
41
  "table": "^6.8.1"
42
42
  },
@@ -45,7 +45,7 @@
45
45
  "shx": "^0.3.3",
46
46
  "ts-node": "^10.2.1",
47
47
  "tslib": "^2.4.1",
48
- "typescript": "^4.9.3"
48
+ "typescript": "^4.9.4"
49
49
  },
50
50
  "scripts": {
51
51
  "watch": "pnpm build --watch",