@divriots/jampack 0.7.2 → 0.8.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 +13 -15
- package/dist/compressors/images.d.ts +7 -3
- package/dist/compressors/images.js +18 -4
- package/dist/config.js +3 -3
- package/dist/optimize.js +302 -231
- package/dist/state.d.ts +1 -1
- package/package.json +6 -6
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
|
-
|
|
38
|
+
[Read the introduction blog post](https://divriots.com/blog/introducing-jampack)
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
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
|
+
- [Fixes things automatically](https://jampack.divriots.com/features/autofixes)
|
|
47
|
+
- [Raises 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
|
-
|
|
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,15 @@
|
|
|
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
|
|
10
|
+
export type ImageOutputOptions = {
|
|
9
11
|
resize?: sharp.ResizeOptions;
|
|
10
|
-
toFormat?: 'webp' | '
|
|
11
|
-
|
|
12
|
+
toFormat?: 'webp' | 'avif' | 'png' | 'jpeg' | 'unchanged';
|
|
13
|
+
progressive?: boolean;
|
|
14
|
+
};
|
|
15
|
+
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,16 @@ export async function compressImage(data, options) {
|
|
|
73
78
|
else {
|
|
74
79
|
// To format
|
|
75
80
|
switch (toFormat) {
|
|
76
|
-
case '
|
|
77
|
-
sharpFile = sharpFile.jpeg({ ...config.image.jpeg.options, progressive:
|
|
81
|
+
case 'jpeg':
|
|
82
|
+
sharpFile = sharpFile.jpeg({ ...config.image.jpeg.options, progressive: options.progressive } ||
|
|
83
|
+
{});
|
|
78
84
|
outputFormat = 'jpg';
|
|
79
85
|
break;
|
|
86
|
+
case 'png':
|
|
87
|
+
sharpFile = sharpFile.png({ ...config.image.png.options, progressive: options.progressive } ||
|
|
88
|
+
{});
|
|
89
|
+
outputFormat = 'png';
|
|
90
|
+
break;
|
|
80
91
|
case 'webp':
|
|
81
92
|
sharpFile = sharpFile.webp(createWebpOptions(meta.format === 'png'
|
|
82
93
|
? config.image.webp.options_lossless
|
|
@@ -84,7 +95,10 @@ export async function compressImage(data, options) {
|
|
|
84
95
|
outputFormat = 'webp';
|
|
85
96
|
break;
|
|
86
97
|
case 'avif':
|
|
87
|
-
sharpFile = sharpFile.avif({
|
|
98
|
+
sharpFile = sharpFile.avif({
|
|
99
|
+
effort: 4,
|
|
100
|
+
quality: meta.format === 'png' ? 80 : 60, // don't use lossless avif it doesn't compress well in most png uses cases
|
|
101
|
+
});
|
|
88
102
|
outputFormat = 'avif';
|
|
89
103
|
break;
|
|
90
104
|
}
|
package/dist/config.js
CHANGED
|
@@ -5,7 +5,7 @@ const default_options = {
|
|
|
5
5
|
compress: true,
|
|
6
6
|
jpeg: {
|
|
7
7
|
options: {
|
|
8
|
-
quality:
|
|
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:
|
|
20
|
+
quality: 77,
|
|
21
21
|
mode: 'lossless',
|
|
22
22
|
},
|
|
23
23
|
options_lossly: {
|
|
24
24
|
effort: 4,
|
|
25
|
-
quality:
|
|
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
|
-
|
|
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,320 @@ 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[
|
|
79
|
+
return theFolds[theFolds.length - 1].startIndex;
|
|
70
80
|
}
|
|
71
81
|
async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
var _a;
|
|
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
|
+
});
|
|
136
|
+
}
|
|
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 (isInPicture || isAboveTheFold) {
|
|
165
|
+
// srcToFormat should not change if in picture
|
|
166
|
+
// If above the fold, we will create progressive images
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
// Let's go to avif -> avif , webp -> webp, else * -> webp
|
|
170
|
+
srcToFormat =
|
|
171
|
+
(await originalImage.getMime()) === 'image/avif' ||
|
|
172
|
+
(await originalImage.getMime()) === 'image/webp'
|
|
173
|
+
? 'unchanged'
|
|
174
|
+
: 'webp';
|
|
175
|
+
}
|
|
176
|
+
if (!$state.optimizedFiles.has(originalImage.filePathAbsolute)) {
|
|
177
|
+
// Let's avoid to optimize same images twice
|
|
178
|
+
$state.optimizedFiles.add(originalImage.filePathAbsolute);
|
|
179
|
+
newImage = await compressImage(await originalImage.getData(), {
|
|
180
|
+
toFormat: srcToFormat,
|
|
181
|
+
progressive: isAboveTheFold,
|
|
182
|
+
});
|
|
183
|
+
if ((newImage === null || newImage === void 0 ? void 0 : newImage.data) &&
|
|
184
|
+
(newImage.data.length < (await originalImage.getLen()) || isAboveTheFold) // Always take the new image above to fold to benefit from progressive
|
|
185
|
+
) {
|
|
186
|
+
// Do we need to add an new extension?
|
|
187
|
+
const newExtension = `.${newImage.format}`;
|
|
188
|
+
const additionalExtension = path.extname(originalImage.filePathAbsolute) === newExtension
|
|
189
|
+
? ''
|
|
190
|
+
: newExtension;
|
|
191
|
+
const newFilename = originalImage.filePathAbsolute + additionalExtension;
|
|
192
|
+
if (!$state.args.nowrite) {
|
|
193
|
+
fs.writeFile(newFilename, newImage.data);
|
|
194
|
+
}
|
|
195
|
+
// Mark new file as optimized
|
|
196
|
+
$state.compressedFiles.add(newFilename);
|
|
197
|
+
// Report compression result
|
|
198
|
+
$state.reportSummary({
|
|
199
|
+
action: newFilename !== originalImage.filePathAbsolute
|
|
200
|
+
? `${await originalImage.getExt()}->${newImage.format}`
|
|
201
|
+
: path.extname(originalImage.filePathAbsolute),
|
|
202
|
+
originalSize: await originalImage.getLen(),
|
|
203
|
+
compressedSize: newImage.data.length,
|
|
82
204
|
});
|
|
83
|
-
|
|
205
|
+
img.attr('src', attrib_src + additionalExtension);
|
|
84
206
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
type: 'a11y',
|
|
92
|
-
msg: `Missing [alt] on img src="${attrib_src}" - Adding alt="" meanwhile.`,
|
|
207
|
+
else {
|
|
208
|
+
// Report non-compression
|
|
209
|
+
$state.reportSummary({
|
|
210
|
+
action: path.extname(originalImage.filePathAbsolute),
|
|
211
|
+
originalSize: await originalImage.getLen(),
|
|
212
|
+
compressedSize: await originalImage.getLen(),
|
|
93
213
|
});
|
|
94
|
-
img.attr('alt', '');
|
|
95
214
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
215
|
+
}
|
|
216
|
+
/*
|
|
217
|
+
* Embed small images
|
|
218
|
+
*
|
|
219
|
+
* TODO this is only embedding images that have
|
|
220
|
+
* successfully be compressed. Should embed original
|
|
221
|
+
* image if it fits the size.
|
|
222
|
+
*/
|
|
223
|
+
let isEmbed = false;
|
|
224
|
+
if (newImage && newImage.data.length <= config.image.embed_size) {
|
|
225
|
+
let datauri = undefined;
|
|
226
|
+
switch (newImage.format) {
|
|
227
|
+
case 'svg':
|
|
228
|
+
datauri = svgToMiniDataURI(newImage.data.toString());
|
|
229
|
+
break;
|
|
230
|
+
case 'webp':
|
|
231
|
+
datauri = `data:image/webp;base64,${newImage.data.toString('base64')}`;
|
|
232
|
+
break;
|
|
233
|
+
case 'jpg':
|
|
234
|
+
case 'png':
|
|
235
|
+
// TODO but not possible in current code
|
|
236
|
+
break;
|
|
100
237
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const attr_loading = img.attr('loading');
|
|
105
|
-
if (isAboveTheFold) {
|
|
238
|
+
if (datauri) {
|
|
239
|
+
isEmbed = true;
|
|
240
|
+
img.attr('src', datauri);
|
|
106
241
|
img.removeAttr('loading');
|
|
107
|
-
img.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
}
|
|
242
|
+
img.removeAttr('decoding');
|
|
243
|
+
$state.reportSummary({
|
|
244
|
+
action: `${newImage.format}->embed`,
|
|
245
|
+
originalSize: await originalImage.getLen(),
|
|
246
|
+
compressedSize: newImage.data.length,
|
|
247
|
+
});
|
|
127
248
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
249
|
+
}
|
|
250
|
+
if (isEmbed) {
|
|
251
|
+
// Image is embed, no need for more processing
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
/*
|
|
255
|
+
* Attribute 'width' & 'height'
|
|
256
|
+
*/
|
|
257
|
+
const [w, h] = await setImageSize(htmlfile, img, originalImage);
|
|
258
|
+
//
|
|
259
|
+
// Stop here if svg
|
|
260
|
+
//
|
|
261
|
+
if ((await originalImage.getExt()) === 'svg') {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// Image size to beat in srcsets
|
|
265
|
+
//
|
|
266
|
+
const imageLengthToBeatInSrcSet = (newImage === null || newImage === void 0 ? void 0 : newImage.data) && newImage.data.length < (await originalImage.getLen())
|
|
267
|
+
? newImage.data.length
|
|
268
|
+
: await originalImage.getLen();
|
|
269
|
+
/*
|
|
270
|
+
* Attribute 'srcset'
|
|
271
|
+
*/
|
|
272
|
+
if (img.attr('srcset')) {
|
|
273
|
+
// If srcset are set, don't touch it.
|
|
274
|
+
// The compress pass will compress the images
|
|
275
|
+
// of the srcset
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
const new_srcset = await generateSrcSet(htmlfile, originalImage, attrib_src, w, h, true, imageLengthToBeatInSrcSet, { toFormat: srcToFormat, progressive: isAboveTheFold });
|
|
279
|
+
if (new_srcset !== null) {
|
|
280
|
+
img.attr('srcset', new_srcset);
|
|
135
281
|
}
|
|
136
|
-
|
|
137
|
-
|
|
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}"`,
|
|
145
|
-
});
|
|
146
|
-
return;
|
|
282
|
+
// Add sizes attribute if not specified
|
|
283
|
+
if (img.attr('srcset') && !img.attr('sizes')) {
|
|
284
|
+
img.attr('sizes', '100vw');
|
|
147
285
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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,
|
|
286
|
+
}
|
|
287
|
+
/*
|
|
288
|
+
* Adding <source>'s to <picture>
|
|
289
|
+
*/
|
|
290
|
+
if (isInPicture) {
|
|
291
|
+
const picture = img.parent();
|
|
292
|
+
// List of possible sources to generate
|
|
293
|
+
//
|
|
294
|
+
const sourcesToGenerate = [];
|
|
295
|
+
// Only try to generate better images
|
|
296
|
+
// TODO generate more compatible formats (avif => webp/jpeg/png)
|
|
297
|
+
//
|
|
298
|
+
switch (await originalImage.getMime()) {
|
|
299
|
+
case 'image/jpeg':
|
|
300
|
+
case 'image/png':
|
|
301
|
+
sourcesToGenerate.push({
|
|
302
|
+
mime: 'image/webp',
|
|
303
|
+
format: 'webp',
|
|
182
304
|
});
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
$state.reportSummary({
|
|
188
|
-
action: path.extname(originalImage.filePathAbsolute),
|
|
189
|
-
originalSize: await originalImage.getLen(),
|
|
190
|
-
compressedSize: await originalImage.getLen(),
|
|
305
|
+
case 'image/webp':
|
|
306
|
+
sourcesToGenerate.push({
|
|
307
|
+
mime: 'image/avif',
|
|
308
|
+
format: 'avif',
|
|
191
309
|
});
|
|
192
|
-
|
|
310
|
+
case 'image/avif':
|
|
311
|
+
// Nothing better
|
|
312
|
+
break;
|
|
193
313
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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;
|
|
314
|
+
for (const s of sourcesToGenerate) {
|
|
315
|
+
const sourceWithThisMimeType = picture.children(`source[type="${s.mime}"]`);
|
|
316
|
+
if (sourceWithThisMimeType.length > 0) {
|
|
317
|
+
// Ignore the creation of sources that already exist
|
|
318
|
+
continue;
|
|
215
319
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
compressedSize: newImage.data.length,
|
|
225
|
-
});
|
|
320
|
+
const srcset = await generateSrcSet(htmlfile, originalImage, attrib_src, w, h, false, undefined, {
|
|
321
|
+
toFormat: s.format,
|
|
322
|
+
progressive: false, // avif or webp don't have progressive support
|
|
323
|
+
});
|
|
324
|
+
if (!srcset) {
|
|
325
|
+
// No sourceset generated
|
|
326
|
+
// Image is too small or can't be compressed better
|
|
327
|
+
continue;
|
|
226
328
|
}
|
|
329
|
+
const source = `<source srcset="${srcset}" type="${s.mime}">`;
|
|
330
|
+
picture.prepend(source);
|
|
227
331
|
}
|
|
228
|
-
|
|
229
|
-
// Image is embed, no need for more processing
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
/*
|
|
233
|
-
* Attribute 'width' & 'height'
|
|
234
|
-
*/
|
|
235
|
-
const [w, h] = await setImageSize(htmlfile, img, originalImage);
|
|
332
|
+
// Reorder sources to priority order 1)avif 2)webp
|
|
236
333
|
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
return;
|
|
334
|
+
function popupSource(type) {
|
|
335
|
+
const nodes = picture.children(`source[type="${type}"]`);
|
|
336
|
+
picture.prepend(nodes);
|
|
241
337
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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,
|
|
282
|
-
});
|
|
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
|
-
}
|
|
299
|
-
}
|
|
300
|
-
if (doAddToSrcSet) {
|
|
301
|
-
new_srcset += `, ${src} ${valueW}w`;
|
|
302
|
-
}
|
|
303
|
-
// reduce size
|
|
304
|
-
valueW -= step;
|
|
305
|
-
valueH = Math.trunc(valueW / ratio);
|
|
338
|
+
popupSource('image/webp');
|
|
339
|
+
popupSource('image/avif');
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async function generateSrcSet(htmlfile, originalImage, attrib_src, imageWidth, imageHeight, startWithOriginalImage, originalImageLengthToBeat, options) {
|
|
343
|
+
const ext = path.extname(attrib_src);
|
|
344
|
+
const fullbasename = attrib_src.slice(0, -ext.length);
|
|
345
|
+
const imageSrc = (addition) => `${fullbasename}${addition}${options.toFormat === 'unchanged' ? ext : '.' + options.toFormat}`;
|
|
346
|
+
// Start from original image
|
|
347
|
+
let new_srcset = '';
|
|
348
|
+
// Start reduction
|
|
349
|
+
const step = 300; //px
|
|
350
|
+
const ratio = imageWidth / imageHeight;
|
|
351
|
+
let valueW = !startWithOriginalImage ? imageWidth : imageWidth - step;
|
|
352
|
+
let valueH = Math.trunc(valueW / ratio);
|
|
353
|
+
let previousImageSize = originalImageLengthToBeat || Number.MAX_VALUE;
|
|
354
|
+
while (valueW >= config.image.srcset_min_width) {
|
|
355
|
+
const src = imageSrc(`@${valueW}w`);
|
|
356
|
+
const absoluteFilename = translateSrc($state.dir, path.dirname(htmlfile), src);
|
|
357
|
+
let doAddToSrcSet = true;
|
|
358
|
+
// Don't generate srcset file twice
|
|
359
|
+
if (!$state.compressedFiles.has(absoluteFilename)) {
|
|
360
|
+
const compressedImage = await compressImage(await originalImage.getData(), { ...options, resize: { width: valueW, height: valueH } });
|
|
361
|
+
if (!(compressedImage === null || compressedImage === void 0 ? void 0 : compressedImage.data) ||
|
|
362
|
+
compressedImage.data.length >= previousImageSize) {
|
|
363
|
+
// New image is not compressed or bigger than previous image in srcset
|
|
364
|
+
// Don't add to srcset
|
|
365
|
+
doAddToSrcSet = false;
|
|
306
366
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
367
|
+
else {
|
|
368
|
+
// Write file
|
|
369
|
+
if (!$state.args.nowrite) {
|
|
370
|
+
fs.writeFile(absoluteFilename, compressedImage.data);
|
|
371
|
+
}
|
|
372
|
+
// Set new previous size to beat
|
|
373
|
+
previousImageSize = compressedImage.data.length;
|
|
374
|
+
// Add file to list avoid recompression
|
|
375
|
+
$state.compressedFiles.add(absoluteFilename);
|
|
310
376
|
}
|
|
311
377
|
}
|
|
378
|
+
if (doAddToSrcSet) {
|
|
379
|
+
new_srcset += `, ${src} ${valueW}w`;
|
|
380
|
+
}
|
|
381
|
+
// reduce size
|
|
382
|
+
valueW -= step;
|
|
383
|
+
valueH = Math.trunc(valueW / ratio);
|
|
312
384
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
`Unexpected error while processing image: ${JSON.stringify(e)}`,
|
|
318
|
-
});
|
|
385
|
+
if (new_srcset) {
|
|
386
|
+
return startWithOriginalImage
|
|
387
|
+
? `${attrib_src} ${imageWidth}w` + new_srcset
|
|
388
|
+
: new_srcset.slice(2);
|
|
319
389
|
}
|
|
390
|
+
return null;
|
|
320
391
|
}
|
|
321
392
|
async function setImageSize(htmlfile, img, image) {
|
|
322
393
|
let width = img.attr('width');
|
|
@@ -327,7 +398,7 @@ async function setImageSize(htmlfile, img, image) {
|
|
|
327
398
|
if (width !== undefined) {
|
|
328
399
|
if (!isNumeric(width)) {
|
|
329
400
|
$state.reportIssue(htmlfile, {
|
|
330
|
-
type: '
|
|
401
|
+
type: 'fix',
|
|
331
402
|
msg: `Invalid width attribute format: "${width}" - overriding for ${image.src}`,
|
|
332
403
|
});
|
|
333
404
|
width = undefined;
|
|
@@ -336,7 +407,7 @@ async function setImageSize(htmlfile, img, image) {
|
|
|
336
407
|
if (height !== undefined) {
|
|
337
408
|
if (!isNumeric(height)) {
|
|
338
409
|
$state.reportIssue(htmlfile, {
|
|
339
|
-
type: '
|
|
410
|
+
type: 'fix',
|
|
340
411
|
msg: `Invalid height attribute format: "${height}" - overriding for ${image.src}`,
|
|
341
412
|
});
|
|
342
413
|
height = undefined;
|
package/dist/state.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@divriots/jampack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
48
|
+
"typescript": "^4.9.4"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
51
|
"watch": "pnpm build --watch",
|