@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 +13 -15
- package/dist/compressors/images.d.ts +6 -3
- package/dist/compressors/images.js +19 -3
- package/dist/config.js +3 -3
- package/dist/optimize.js +332 -232
- 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
|
+
- [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
|
-
|
|
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
|
|
10
|
+
export type ImageOutputOptions = {
|
|
9
11
|
resize?: sharp.ResizeOptions;
|
|
10
|
-
toFormat?: 'webp' | '
|
|
11
|
-
}
|
|
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 '
|
|
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({
|
|
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:
|
|
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,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[
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
220
|
+
img.attr('src', attrib_src + additionalExtension);
|
|
147
221
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 (
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
//
|
|
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
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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: '
|
|
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: '
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@divriots/jampack",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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",
|