@divriots/jampack 0.4.2 → 0.5.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 +7 -22
- package/dist/compress.d.ts +0 -1
- package/dist/compress.js +33 -12
- package/dist/config.d.ts +1 -1
- package/dist/config.js +14 -14
- package/dist/index.js +35 -12
- package/dist/optimize.js +102 -76
- package/dist/state.js +3 -3
- package/dist/types.d.ts +1 -1
- package/dist/utils/resource.js +2 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -51,9 +51,13 @@ npx @divriots/jampack ./dist
|
|
|
51
51
|
|
|
52
52
|
For more options see [CLI options](https://jampack.divriots.com/cli-options).
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
<div>
|
|
55
|
+
|
|
56
|
+
## What can `jampack` do?
|
|
55
57
|
|
|
56
|
-
|
|
58
|
+
See [the list of features](https://jampack.divriots.com).
|
|
59
|
+
|
|
60
|
+
</div>
|
|
57
61
|
|
|
58
62
|
## `jampack` use in the wild
|
|
59
63
|
|
|
@@ -68,28 +72,9 @@ Check the [Documentation side](https://jampack.divriots.com).
|
|
|
68
72
|
|
|
69
73
|
## Why is it called `jampack`?
|
|
70
74
|
|
|
71
|
-
- `
|
|
75
|
+
- `jam`: From [Jamstack](https://en.wikipedia.org/wiki/Jamstack).
|
|
72
76
|
- `pack`: Reminescent of the [Executable Packers](https://en.wikipedia.org/wiki/Executable_compression#List_of_executable_packers) from the 90s.
|
|
73
77
|
|
|
74
78
|
## License
|
|
75
79
|
|
|
76
80
|
This software is released under the terms of the [MIT license](https://github.com/divriots/jampack/blob/main/LICENSE).
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
## Optimizations
|
|
80
|
-
|
|
81
|
-
### PASS 1 - Optimizations
|
|
82
|
-
|
|
83
|
-
This pass takes all `*.{html|.htm}` files and parse them for optimisation.
|
|
84
|
-
|
|
85
|
-
| Optimization | Technical details | Related links | Available |
|
|
86
|
-
| -------------- | --------------------- |:------------:|:---------:|
|
|
87
|
-
| Generate responsive images and set `srcset` | Multiple smaller images are generated exclusively in `WebP` and `srcset` attribute is set accordingly. | [web.dev](https://web.dev/patterns/web-vitals-patterns/images/responsive-images/) | ✅ |
|
|
88
|
-
| Embed small images | If optimised image size is <500 bytes, image is embed in `[src]` | | ✅ |
|
|
89
|
-
| Set `width` and `height` if missing | Uses image size and aspect ratio to fill values | [web.dev](https://web.dev/optimize-cls/#images-without-dimensions) | ✅ |
|
|
90
|
-
| Fix `width` or `height` if not numerical | Uses image size and aspect ratio to fill values | [web.dev](https://web.dev/optimize-cls/#images-without-dimensions) | ✅ |
|
|
91
|
-
| Set lazy loading by default | Sets `loading="lazy"` if `[loading]` is not set. | [web.dev](https://web.dev/lazy-loading-images/) | ✅ |
|
|
92
|
-
| Set async decoding by default | Sets `decoding="async"`. | | ✅ |
|
|
93
|
-
| Warn on missing `[alt]` | Warn on missing `alt` attribute but adds `alt=""` in case image is considered decorative. | | ✅ |
|
|
94
|
-
| 🔽 `<picture>` | | | |
|
|
95
|
-
| - TODO | | | ❌ |
|
package/dist/compress.d.ts
CHANGED
package/dist/compress.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as fs from 'fs/promises';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import { optimize as svgo } from
|
|
3
|
+
import { optimize as svgo } from 'svgo';
|
|
4
4
|
import { minify as htmlminifier } from 'html-minifier-terser';
|
|
5
|
-
import { minify as csso } from
|
|
5
|
+
import { minify as csso } from 'csso';
|
|
6
6
|
import { formatBytes } from './utils.js';
|
|
7
7
|
import sharp from 'sharp';
|
|
8
8
|
import swc from '@swc/core';
|
|
@@ -10,7 +10,7 @@ import $state from './state.js';
|
|
|
10
10
|
import { globby } from 'globby';
|
|
11
11
|
import config from './config.js';
|
|
12
12
|
import ora from 'ora';
|
|
13
|
-
import { getFromCacheCompressImage, addToCacheCompressImage, computeCacheHash } from './cache.js';
|
|
13
|
+
import { getFromCacheCompressImage, addToCacheCompressImage, computeCacheHash, } from './cache.js';
|
|
14
14
|
async function compressJS(code) {
|
|
15
15
|
const newjsresult = await swc.minify(code, { compress: true, mangle: true });
|
|
16
16
|
return newjsresult.code;
|
|
@@ -31,6 +31,7 @@ const processFile = async (file, stats) => {
|
|
|
31
31
|
case '.jpeg':
|
|
32
32
|
case '.svg':
|
|
33
33
|
case '.webp':
|
|
34
|
+
case '.avif':
|
|
34
35
|
const newImage = await compressImageFile(file);
|
|
35
36
|
if ((newImage === null || newImage === void 0 ? void 0 : newImage.data) && newImage.data.length < stats.size) {
|
|
36
37
|
writeData = newImage.data;
|
|
@@ -39,7 +40,12 @@ const processFile = async (file, stats) => {
|
|
|
39
40
|
case '.html':
|
|
40
41
|
case '.htm':
|
|
41
42
|
const htmldata = await fs.readFile(file);
|
|
42
|
-
const newhtmlData = await htmlminifier(htmldata.toString(), {
|
|
43
|
+
const newhtmlData = await htmlminifier(htmldata.toString(), {
|
|
44
|
+
minifyCSS: true,
|
|
45
|
+
minifyJS: compressInlineJS,
|
|
46
|
+
sortClassName: true,
|
|
47
|
+
sortAttributes: true,
|
|
48
|
+
});
|
|
43
49
|
writeData = newhtmlData;
|
|
44
50
|
break;
|
|
45
51
|
case '.css':
|
|
@@ -66,7 +72,7 @@ const processFile = async (file, stats) => {
|
|
|
66
72
|
const result = {
|
|
67
73
|
action: path.extname(file),
|
|
68
74
|
originalSize: stats.size,
|
|
69
|
-
compressedSize: stats.size
|
|
75
|
+
compressedSize: stats.size,
|
|
70
76
|
};
|
|
71
77
|
// Writedata
|
|
72
78
|
if (writeData && writeData.length < result.originalSize) {
|
|
@@ -82,7 +88,7 @@ function createWebpOptions(opt) {
|
|
|
82
88
|
return {
|
|
83
89
|
nearLossless: opt.mode === 'lossless',
|
|
84
90
|
quality: opt.quality,
|
|
85
|
-
effort: opt.effort
|
|
91
|
+
effort: opt.effort,
|
|
86
92
|
};
|
|
87
93
|
}
|
|
88
94
|
export const compressImage = async (data, options) => {
|
|
@@ -108,14 +114,15 @@ export const compressImage = async (data, options) => {
|
|
|
108
114
|
case 'svg':
|
|
109
115
|
const newData = svgo(data, {
|
|
110
116
|
multipass: true,
|
|
111
|
-
plugins: [
|
|
112
|
-
|
|
117
|
+
plugins: [
|
|
118
|
+
{
|
|
119
|
+
name: 'preset-default',
|
|
113
120
|
params: {
|
|
114
121
|
overrides: {
|
|
115
122
|
removeViewBox: false,
|
|
116
123
|
},
|
|
117
124
|
},
|
|
118
|
-
}
|
|
125
|
+
},
|
|
119
126
|
],
|
|
120
127
|
});
|
|
121
128
|
if (newData.error || newData.modernError) {
|
|
@@ -144,10 +151,17 @@ export const compressImage = async (data, options) => {
|
|
|
144
151
|
doSharp = true;
|
|
145
152
|
toFormat = 'unchanged'; // Don't need to convert to webP again
|
|
146
153
|
break;
|
|
154
|
+
case 'avif':
|
|
155
|
+
outputFormat = 'avif';
|
|
156
|
+
// TODO but avif is already pretty optimized
|
|
157
|
+
toFormat = 'unchanged';
|
|
158
|
+
break;
|
|
147
159
|
}
|
|
148
160
|
if (doSharp) {
|
|
149
161
|
if (toFormat === 'webp') {
|
|
150
|
-
sharpFile = sharpFile.webp(createWebpOptions(meta.format === 'png'
|
|
162
|
+
sharpFile = sharpFile.webp(createWebpOptions(meta.format === 'png'
|
|
163
|
+
? config.image.webp.options_lossless
|
|
164
|
+
: config.image.webp.options_lossly));
|
|
151
165
|
outputFormat = 'webp';
|
|
152
166
|
}
|
|
153
167
|
else if (toFormat === 'pjpg') {
|
|
@@ -155,13 +169,20 @@ export const compressImage = async (data, options) => {
|
|
|
155
169
|
outputFormat = 'jpg';
|
|
156
170
|
}
|
|
157
171
|
if (((_a = options.resize) === null || _a === void 0 ? void 0 : _a.width) && ((_b = options.resize) === null || _b === void 0 ? void 0 : _b.height)) {
|
|
158
|
-
sharpFile = sharpFile.resize({
|
|
172
|
+
sharpFile = sharpFile.resize({
|
|
173
|
+
...options.resize,
|
|
174
|
+
fit: 'fill',
|
|
175
|
+
withoutEnlargement: true,
|
|
176
|
+
});
|
|
159
177
|
}
|
|
160
178
|
//
|
|
161
179
|
// Output image processed
|
|
162
180
|
//
|
|
163
181
|
// Add to cache
|
|
164
|
-
const outputImage = {
|
|
182
|
+
const outputImage = {
|
|
183
|
+
format: outputFormat,
|
|
184
|
+
data: await sharpFile.toBuffer(),
|
|
185
|
+
};
|
|
165
186
|
await addToCacheCompressImage(cacheHash, outputImage);
|
|
166
187
|
// Go
|
|
167
188
|
return outputImage;
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -6,27 +6,27 @@ const default_options = {
|
|
|
6
6
|
jpeg: {
|
|
7
7
|
options: {
|
|
8
8
|
quality: 80,
|
|
9
|
-
mozjpeg: true
|
|
10
|
-
}
|
|
9
|
+
mozjpeg: true,
|
|
10
|
+
},
|
|
11
11
|
},
|
|
12
12
|
png: {
|
|
13
13
|
options: {
|
|
14
14
|
compressionLevel: 9,
|
|
15
|
-
}
|
|
15
|
+
},
|
|
16
16
|
},
|
|
17
17
|
webp: {
|
|
18
18
|
options_lossless: {
|
|
19
19
|
effort: 4,
|
|
20
20
|
quality: 80,
|
|
21
|
-
mode:
|
|
21
|
+
mode: "lossless",
|
|
22
22
|
},
|
|
23
23
|
options_lossly: {
|
|
24
24
|
effort: 4,
|
|
25
25
|
quality: 80,
|
|
26
|
-
mode:
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
26
|
+
mode: "lossly",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
30
|
};
|
|
31
31
|
const fast_options_override = {
|
|
32
32
|
image: {
|
|
@@ -35,13 +35,13 @@ const fast_options_override = {
|
|
|
35
35
|
compress: false,
|
|
36
36
|
jpeg: {
|
|
37
37
|
options: {
|
|
38
|
-
mozjpeg: false
|
|
39
|
-
}
|
|
38
|
+
mozjpeg: false,
|
|
39
|
+
},
|
|
40
40
|
},
|
|
41
41
|
png: {
|
|
42
42
|
options: {
|
|
43
43
|
compressionLevel: 0,
|
|
44
|
-
}
|
|
44
|
+
},
|
|
45
45
|
},
|
|
46
46
|
webp: {
|
|
47
47
|
options_lossless: {
|
|
@@ -49,9 +49,9 @@ const fast_options_override = {
|
|
|
49
49
|
},
|
|
50
50
|
options_lossly: {
|
|
51
51
|
effort: 0,
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
55
|
};
|
|
56
56
|
export function fast() {
|
|
57
57
|
Object.assign(default_options, fast_options_override);
|
package/dist/index.js
CHANGED
|
@@ -21,11 +21,9 @@ const logo = ` __ __
|
|
|
21
21
|
`;
|
|
22
22
|
console.log(logo);
|
|
23
23
|
const program = new Command();
|
|
24
|
+
program.name('jampack').description('Static website Optimizer').version(VERSION);
|
|
24
25
|
program
|
|
25
|
-
.
|
|
26
|
-
.description('Static website Optimizer')
|
|
27
|
-
.version(VERSION);
|
|
28
|
-
program.command('pack', { isDefault: true })
|
|
26
|
+
.command('pack', { isDefault: true })
|
|
29
27
|
.description('todo')
|
|
30
28
|
.argument('<dir>', 'Directory to pack')
|
|
31
29
|
.option('--exclude <exclude>', 'Glob to exclude')
|
|
@@ -69,26 +67,51 @@ program.parse();
|
|
|
69
67
|
function printSummary() {
|
|
70
68
|
if ($state.summary.nbFiles > 0) {
|
|
71
69
|
printTitle('Summary');
|
|
72
|
-
const dataTable = [
|
|
70
|
+
const dataTable = [
|
|
71
|
+
['Action', 'Compressed', 'Original', 'Compressed', 'Gain'],
|
|
72
|
+
];
|
|
73
73
|
const config = {
|
|
74
74
|
columns: [
|
|
75
75
|
{ alignment: 'left' },
|
|
76
76
|
{ alignment: 'right' },
|
|
77
77
|
{ alignment: 'right' },
|
|
78
78
|
{ alignment: 'right' },
|
|
79
|
-
{ alignment: 'right' }
|
|
79
|
+
{ alignment: 'right' },
|
|
80
80
|
],
|
|
81
81
|
drawHorizontalLine: (lineIndex, rowCount) => {
|
|
82
|
-
return lineIndex === 0 ||
|
|
83
|
-
|
|
82
|
+
return (lineIndex === 0 ||
|
|
83
|
+
lineIndex === 1 ||
|
|
84
|
+
lineIndex === rowCount - 1 ||
|
|
85
|
+
lineIndex === rowCount);
|
|
86
|
+
},
|
|
84
87
|
};
|
|
88
|
+
const unCompressedDataRows = [];
|
|
85
89
|
Object.entries($state.summaryByExtension).forEach(([ext, summary]) => {
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
const gain = summary.dataLenUncompressed - summary.dataLenCompressed;
|
|
91
|
+
const row = [
|
|
92
|
+
ext,
|
|
93
|
+
`${summary.nbFilesCompressed} / ${summary.nbFiles}`,
|
|
94
|
+
formatBytes(summary.dataLenUncompressed),
|
|
95
|
+
formatBytes(summary.dataLenCompressed),
|
|
96
|
+
gain > 0 ? '-' + formatBytes(gain) : '',
|
|
97
|
+
];
|
|
98
|
+
if (gain > 0) {
|
|
88
99
|
dataTable.push(row);
|
|
89
100
|
}
|
|
101
|
+
else {
|
|
102
|
+
unCompressedDataRows.push(row);
|
|
103
|
+
}
|
|
90
104
|
});
|
|
91
|
-
|
|
105
|
+
// Add uncompress rows at the end
|
|
106
|
+
dataTable.push(...unCompressedDataRows);
|
|
107
|
+
const total = [
|
|
108
|
+
'Total',
|
|
109
|
+
`${$state.summary.nbFilesCompressed} / ${$state.summary.nbFiles}`,
|
|
110
|
+
formatBytes($state.summary.dataLenUncompressed),
|
|
111
|
+
formatBytes($state.summary.dataLenCompressed),
|
|
112
|
+
'-' +
|
|
113
|
+
formatBytes($state.summary.dataLenUncompressed - $state.summary.dataLenCompressed),
|
|
114
|
+
];
|
|
92
115
|
dataTable.push(total);
|
|
93
116
|
console.log(table(dataTable, config));
|
|
94
117
|
}
|
|
@@ -103,7 +126,7 @@ function printIssues() {
|
|
|
103
126
|
console.log('');
|
|
104
127
|
for (let [file, list] of $state.issues) {
|
|
105
128
|
console.log('▶ ' + file + '\n');
|
|
106
|
-
list.forEach(issue => {
|
|
129
|
+
list.forEach((issue) => {
|
|
107
130
|
issueCount++;
|
|
108
131
|
console.log(`${kleur.bgYellow(` ${issue.type} `)} ${issue.msg}\n`);
|
|
109
132
|
});
|
package/dist/optimize.js
CHANGED
|
@@ -1,30 +1,33 @@
|
|
|
1
|
-
import { globby } from
|
|
2
|
-
import * as path from
|
|
3
|
-
import * as fs from
|
|
4
|
-
import cheerio from
|
|
5
|
-
import { isNumeric } from
|
|
6
|
-
import config from
|
|
7
|
-
import { compressImage } from
|
|
8
|
-
import svgToMiniDataURI from
|
|
9
|
-
import $state from
|
|
10
|
-
import kleur from
|
|
11
|
-
import ora from
|
|
12
|
-
import { isLocal, Resource, translateSrc } from
|
|
1
|
+
import { globby } from 'globby';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs/promises';
|
|
4
|
+
import cheerio from 'cheerio';
|
|
5
|
+
import { isNumeric } from './utils.js';
|
|
6
|
+
import config from './config.js';
|
|
7
|
+
import { compressImage } from './compress.js';
|
|
8
|
+
import svgToMiniDataURI from 'mini-svg-data-uri';
|
|
9
|
+
import $state from './state.js';
|
|
10
|
+
import kleur from 'kleur';
|
|
11
|
+
import ora from 'ora';
|
|
12
|
+
import { isLocal, Resource, translateSrc } from './utils/resource.js';
|
|
13
13
|
async function analyse(file) {
|
|
14
|
-
console.log(
|
|
14
|
+
console.log('▶ ' + file);
|
|
15
15
|
const html = (await fs.readFile(path.join($state.dir, file))).toString();
|
|
16
|
-
const $ = cheerio.load(html, {
|
|
16
|
+
const $ = cheerio.load(html, {
|
|
17
|
+
withStartIndices: true,
|
|
18
|
+
decodeEntities: false,
|
|
19
|
+
});
|
|
17
20
|
const theFold = getTheFold($);
|
|
18
|
-
const imgs = $(
|
|
21
|
+
const imgs = $('img');
|
|
19
22
|
const imgsArray = [];
|
|
20
23
|
imgs.each(async (index, imgElement) => {
|
|
21
24
|
imgsArray.push(imgElement);
|
|
22
25
|
});
|
|
23
26
|
// Process images sequentially
|
|
24
|
-
const spinnerImg = ora({ prefixText:
|
|
27
|
+
const spinnerImg = ora({ prefixText: ' ' }).start();
|
|
25
28
|
for (let i = 0; i < imgsArray.length; i++) {
|
|
26
29
|
const imgElement = imgsArray[i];
|
|
27
|
-
spinnerImg.text = kleur.dim(`<img> [${i + 1}/${imgsArray.length}] ${$(imgElement).attr(
|
|
30
|
+
spinnerImg.text = kleur.dim(`<img> [${i + 1}/${imgsArray.length}] ${$(imgElement).attr('src')} `);
|
|
28
31
|
const isAboveTheFold = imgElement.startIndex < theFold;
|
|
29
32
|
await processImage(file, $, imgElement, isAboveTheFold);
|
|
30
33
|
}
|
|
@@ -34,21 +37,21 @@ async function analyse(file) {
|
|
|
34
37
|
const issues = $state.issues.get(file);
|
|
35
38
|
if (issues) {
|
|
36
39
|
spinnerImg.fail();
|
|
37
|
-
console.log(kleur.red(` ${issues.length} issue${issues.length > 1 ?
|
|
40
|
+
console.log(kleur.red(` ${issues.length} issue${issues.length > 1 ? 's' : ''}`));
|
|
38
41
|
}
|
|
39
42
|
else {
|
|
40
43
|
spinnerImg.succeed();
|
|
41
44
|
}
|
|
42
45
|
// Remove the fold
|
|
43
46
|
if (theFold) {
|
|
44
|
-
$(
|
|
47
|
+
$('the-fold').remove();
|
|
45
48
|
}
|
|
46
49
|
if (!$state.args.nowrite) {
|
|
47
50
|
await fs.writeFile(path.join($state.dir, file), $.html());
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
function getTheFold($) {
|
|
51
|
-
const theFolds = $(
|
|
54
|
+
const theFolds = $('the-fold');
|
|
52
55
|
if (!theFolds[0]) {
|
|
53
56
|
return 0;
|
|
54
57
|
}
|
|
@@ -61,10 +64,10 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
|
|
|
61
64
|
/*
|
|
62
65
|
* Attribute 'src'
|
|
63
66
|
*/
|
|
64
|
-
const attrib_src = img.attr(
|
|
67
|
+
const attrib_src = img.attr('src');
|
|
65
68
|
if (!attrib_src) {
|
|
66
69
|
$state.reportIssue(htmlfile, {
|
|
67
|
-
type:
|
|
70
|
+
type: 'warn',
|
|
68
71
|
msg: `Missing [src] on img - processing skipped.`,
|
|
69
72
|
});
|
|
70
73
|
return;
|
|
@@ -72,15 +75,15 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
|
|
|
72
75
|
/*
|
|
73
76
|
* Attribute 'alt'
|
|
74
77
|
*/
|
|
75
|
-
const attrib_alt = img.attr(
|
|
78
|
+
const attrib_alt = img.attr('alt');
|
|
76
79
|
if (attrib_alt === undefined) {
|
|
77
80
|
$state.reportIssue(htmlfile, {
|
|
78
|
-
type:
|
|
81
|
+
type: 'a11y',
|
|
79
82
|
msg: `Missing [alt] on img src="${attrib_src}" - Adding alt="" meanwhile.`,
|
|
80
83
|
});
|
|
81
|
-
img.attr(
|
|
84
|
+
img.attr('alt', '');
|
|
82
85
|
}
|
|
83
|
-
if (attrib_src.startsWith(
|
|
86
|
+
if (attrib_src.startsWith('data:')) {
|
|
84
87
|
// Data URI image
|
|
85
88
|
// TODO: try to compress it
|
|
86
89
|
return;
|
|
@@ -88,26 +91,26 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
|
|
|
88
91
|
/*
|
|
89
92
|
* Attribute 'loading'
|
|
90
93
|
*/
|
|
91
|
-
const attr_loading = img.attr(
|
|
94
|
+
const attr_loading = img.attr('loading');
|
|
92
95
|
if (isAboveTheFold) {
|
|
93
|
-
img.removeAttr(
|
|
94
|
-
img.attr(
|
|
96
|
+
img.removeAttr('loading');
|
|
97
|
+
img.attr('fetchpriority', 'high');
|
|
95
98
|
}
|
|
96
99
|
else {
|
|
97
100
|
switch (attr_loading) {
|
|
98
101
|
case undefined:
|
|
99
102
|
// Go lazy by default
|
|
100
|
-
img.attr(
|
|
103
|
+
img.attr('loading', 'lazy');
|
|
101
104
|
break;
|
|
102
|
-
case
|
|
103
|
-
img.removeAttr(
|
|
105
|
+
case 'eager':
|
|
106
|
+
img.removeAttr('loading');
|
|
104
107
|
break;
|
|
105
|
-
case
|
|
108
|
+
case 'lazy':
|
|
106
109
|
// Don't touch it
|
|
107
110
|
break;
|
|
108
111
|
default:
|
|
109
112
|
$state.reportIssue(htmlfile, {
|
|
110
|
-
type:
|
|
113
|
+
type: 'invalid',
|
|
111
114
|
msg: `Invalid [loading]="${attr_loading}" on img src="${attrib_src}"`,
|
|
112
115
|
});
|
|
113
116
|
}
|
|
@@ -115,7 +118,7 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
|
|
|
115
118
|
/*
|
|
116
119
|
* Attribute 'decoding'
|
|
117
120
|
*/
|
|
118
|
-
img.attr(
|
|
121
|
+
img.attr('decoding', 'async');
|
|
119
122
|
if (!isLocal(attrib_src)) {
|
|
120
123
|
// Image not local, don't touch it
|
|
121
124
|
return;
|
|
@@ -127,34 +130,37 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
|
|
|
127
130
|
// No file -> give up
|
|
128
131
|
if (!originalImage) {
|
|
129
132
|
$state.reportIssue(htmlfile, {
|
|
130
|
-
type:
|
|
133
|
+
type: 'erro',
|
|
131
134
|
msg: `Can't find img on disk src="${attrib_src}"`,
|
|
132
135
|
});
|
|
133
136
|
return;
|
|
134
137
|
}
|
|
135
138
|
/*
|
|
136
|
-
* Compress image
|
|
139
|
+
* Compress image
|
|
137
140
|
*/
|
|
138
141
|
let newImage;
|
|
142
|
+
const originalImageMeta = await originalImage.getImageMeta();
|
|
143
|
+
const canBeProgressiveJpeg = isAboveTheFold && originalImageMeta && !originalImageMeta.hasAlpha;
|
|
139
144
|
if (!$state.optimizedFiles.has(originalImage.filePathAbsolute)) {
|
|
140
145
|
// Let's avoid to optimize same images twice
|
|
141
146
|
$state.optimizedFiles.add(originalImage.filePathAbsolute);
|
|
142
147
|
newImage = await compressImage(await originalImage.getData(), {
|
|
143
|
-
toFormat:
|
|
148
|
+
toFormat: canBeProgressiveJpeg ? 'pjpg' : 'webp',
|
|
144
149
|
});
|
|
145
150
|
if ((newImage === null || newImage === void 0 ? void 0 : newImage.data) &&
|
|
146
151
|
(newImage.data.length < (await originalImage.getLen()) ||
|
|
147
|
-
|
|
152
|
+
canBeProgressiveJpeg) // Progressive jpg above the fold should get replaced even if bigger
|
|
148
153
|
) {
|
|
149
154
|
// Do we need to add an new extension?
|
|
150
155
|
const newExtension = `.${newImage.format}`;
|
|
151
156
|
const additionalExtension = path.extname(originalImage.filePathAbsolute) === newExtension
|
|
152
|
-
?
|
|
157
|
+
? ''
|
|
153
158
|
: newExtension;
|
|
154
159
|
const newFilename = originalImage.filePathAbsolute + additionalExtension;
|
|
155
160
|
if (!$state.args.nowrite) {
|
|
156
161
|
fs.writeFile(newFilename, newImage.data);
|
|
157
162
|
}
|
|
163
|
+
// Mark new file as optimized
|
|
158
164
|
$state.compressedFiles.add(newFilename);
|
|
159
165
|
// Report compression result
|
|
160
166
|
$state.reportSummary({
|
|
@@ -164,7 +170,15 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
|
|
|
164
170
|
originalSize: await originalImage.getLen(),
|
|
165
171
|
compressedSize: newImage.data.length,
|
|
166
172
|
});
|
|
167
|
-
img.attr(
|
|
173
|
+
img.attr('src', attrib_src + additionalExtension);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
// Report non-compression
|
|
177
|
+
$state.reportSummary({
|
|
178
|
+
action: path.extname(originalImage.filePathAbsolute),
|
|
179
|
+
originalSize: await originalImage.getLen(),
|
|
180
|
+
compressedSize: await originalImage.getLen(),
|
|
181
|
+
});
|
|
168
182
|
}
|
|
169
183
|
}
|
|
170
184
|
/*
|
|
@@ -178,22 +192,22 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
|
|
|
178
192
|
if (newImage && newImage.data.length <= config.image.embed_size) {
|
|
179
193
|
let datauri = undefined;
|
|
180
194
|
switch (newImage.format) {
|
|
181
|
-
case
|
|
195
|
+
case 'svg':
|
|
182
196
|
datauri = svgToMiniDataURI(newImage.data.toString());
|
|
183
197
|
break;
|
|
184
|
-
case
|
|
185
|
-
datauri = `data:image/webp;base64,${newImage.data.toString(
|
|
198
|
+
case 'webp':
|
|
199
|
+
datauri = `data:image/webp;base64,${newImage.data.toString('base64')}`;
|
|
186
200
|
break;
|
|
187
|
-
case
|
|
188
|
-
case
|
|
201
|
+
case 'jpg':
|
|
202
|
+
case 'png':
|
|
189
203
|
// TODO but not possible in current code
|
|
190
204
|
break;
|
|
191
205
|
}
|
|
192
206
|
if (datauri) {
|
|
193
207
|
isEmbed = true;
|
|
194
|
-
img.attr(
|
|
195
|
-
img.removeAttr(
|
|
196
|
-
img.removeAttr(
|
|
208
|
+
img.attr('src', datauri);
|
|
209
|
+
img.removeAttr('loading');
|
|
210
|
+
img.removeAttr('decoding');
|
|
197
211
|
$state.reportSummary({
|
|
198
212
|
action: `${newImage.format}->embed`,
|
|
199
213
|
originalSize: await originalImage.getLen(),
|
|
@@ -201,24 +215,24 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
|
|
|
201
215
|
});
|
|
202
216
|
}
|
|
203
217
|
}
|
|
204
|
-
/*
|
|
205
|
-
* Attribute 'width' & 'height'
|
|
206
|
-
*/
|
|
207
|
-
const [w, h] = await setImageSize(img, originalImage);
|
|
208
218
|
if (isEmbed) {
|
|
209
219
|
// Image is embed, no need for more processing
|
|
210
220
|
return;
|
|
211
221
|
}
|
|
222
|
+
/*
|
|
223
|
+
* Attribute 'width' & 'height'
|
|
224
|
+
*/
|
|
225
|
+
const [w, h] = await setImageSize(htmlfile, img, originalImage);
|
|
212
226
|
//
|
|
213
227
|
// Stop here if svg
|
|
214
228
|
//
|
|
215
|
-
if ((await originalImage.getExt()) ===
|
|
229
|
+
if ((await originalImage.getExt()) === 'svg') {
|
|
216
230
|
return;
|
|
217
231
|
}
|
|
218
232
|
/*
|
|
219
233
|
* Attribute 'srcset'
|
|
220
234
|
*/
|
|
221
|
-
const attr_srcset = img.attr(
|
|
235
|
+
const attr_srcset = img.attr('srcset');
|
|
222
236
|
if (attr_srcset) {
|
|
223
237
|
// If srcset is set, don't touch it.
|
|
224
238
|
// The compress pass will compress the images
|
|
@@ -228,9 +242,9 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
|
|
|
228
242
|
// Generate image set
|
|
229
243
|
const ext = path.extname(attrib_src);
|
|
230
244
|
const fullbasename = attrib_src.slice(0, -ext.length);
|
|
231
|
-
const imageSrc = (addition) => `${fullbasename}${addition}
|
|
245
|
+
const imageSrc = (addition) => `${fullbasename}${addition}.${canBeProgressiveJpeg ? 'jpg' : 'webp'}`;
|
|
232
246
|
// Start from original image
|
|
233
|
-
let new_srcset =
|
|
247
|
+
let new_srcset = '';
|
|
234
248
|
// Start reduction
|
|
235
249
|
const step = 300; //px
|
|
236
250
|
const ratio = w / h;
|
|
@@ -243,7 +257,10 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
|
|
|
243
257
|
if (!$state.compressedFiles.has(absoluteFilename)) {
|
|
244
258
|
// Add file to list avoid recompression
|
|
245
259
|
$state.compressedFiles.add(absoluteFilename);
|
|
246
|
-
const compressedImage = await compressImage(await originalImage.getData(), {
|
|
260
|
+
const compressedImage = await compressImage(await originalImage.getData(), {
|
|
261
|
+
resize: { width: valueW, height: valueH },
|
|
262
|
+
toFormat: canBeProgressiveJpeg ? 'pjpg' : 'webp',
|
|
263
|
+
});
|
|
247
264
|
if ((compressedImage === null || compressedImage === void 0 ? void 0 : compressedImage.data) && !$state.args.nowrite) {
|
|
248
265
|
fs.writeFile(absoluteFilename, compressedImage.data);
|
|
249
266
|
}
|
|
@@ -254,33 +271,39 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
|
|
|
254
271
|
valueH = Math.trunc(valueW / ratio);
|
|
255
272
|
}
|
|
256
273
|
if (new_srcset) {
|
|
257
|
-
img.attr(
|
|
274
|
+
img.attr('srcset', `${img.attr('src')} ${w}w` + new_srcset);
|
|
258
275
|
}
|
|
259
276
|
}
|
|
260
277
|
}
|
|
261
278
|
catch (e) {
|
|
262
279
|
$state.reportIssue(htmlfile, {
|
|
263
|
-
type:
|
|
280
|
+
type: 'erro',
|
|
264
281
|
msg: e.message ||
|
|
265
282
|
`Unexpected error while processing image: ${JSON.stringify(e)}`,
|
|
266
283
|
});
|
|
267
284
|
}
|
|
268
285
|
}
|
|
269
|
-
async function setImageSize(img, image) {
|
|
270
|
-
let width = img.attr(
|
|
271
|
-
let height = img.attr(
|
|
286
|
+
async function setImageSize(htmlfile, img, image) {
|
|
287
|
+
let width = img.attr('width');
|
|
288
|
+
let height = img.attr('height');
|
|
272
289
|
let width_new = undefined;
|
|
273
290
|
let height_new = undefined;
|
|
274
291
|
// Check valid values
|
|
275
292
|
if (width !== undefined) {
|
|
276
293
|
if (!isNumeric(width)) {
|
|
277
|
-
|
|
294
|
+
$state.reportIssue(htmlfile, {
|
|
295
|
+
type: 'warn',
|
|
296
|
+
msg: `Invalid width attribute "${width}" format - overriding`,
|
|
297
|
+
});
|
|
278
298
|
width = undefined;
|
|
279
299
|
}
|
|
280
300
|
}
|
|
281
301
|
if (height !== undefined) {
|
|
282
302
|
if (!isNumeric(height)) {
|
|
283
|
-
|
|
303
|
+
$state.reportIssue(htmlfile, {
|
|
304
|
+
type: 'warn',
|
|
305
|
+
msg: `Invalid height attribute "${height}" format - overriding`,
|
|
306
|
+
});
|
|
284
307
|
height = undefined;
|
|
285
308
|
}
|
|
286
309
|
}
|
|
@@ -301,7 +324,10 @@ async function setImageSize(img, image) {
|
|
|
301
324
|
const providedRatio = Math.round((w / h) * 10) / 10;
|
|
302
325
|
const imageRatio = Math.round(originalRatio * 10) / 10;
|
|
303
326
|
if (providedRatio !== imageRatio) {
|
|
304
|
-
|
|
327
|
+
$state.reportIssue(htmlfile, {
|
|
328
|
+
type: 'warn',
|
|
329
|
+
msg: `Image aspect ratio in HTML (${providedRatio}) differs from image aspect ratio (${imageRatio}) - fix width and height or let jampack fill them.`,
|
|
330
|
+
});
|
|
305
331
|
}
|
|
306
332
|
return [w, h];
|
|
307
333
|
}
|
|
@@ -317,20 +343,20 @@ async function setImageSize(img, image) {
|
|
|
317
343
|
}
|
|
318
344
|
else {
|
|
319
345
|
// No width or height provided - set both to image size
|
|
320
|
-
if ((await image.getExt()) ===
|
|
346
|
+
if ((await image.getExt()) === 'svg') {
|
|
321
347
|
// svg with no height and width has special sizing by browsers
|
|
322
348
|
// They size it inside 300x150 unless they have width and height
|
|
323
349
|
// attributes
|
|
324
350
|
// Load svg
|
|
325
351
|
const c = cheerio.load(await image.getData(), {});
|
|
326
|
-
const svg = c(
|
|
327
|
-
const svg_viewbox = svg.attr(
|
|
328
|
-
const svg_width = svg.attr(
|
|
329
|
-
const svg_height = svg.attr(
|
|
352
|
+
const svg = c('svg').first();
|
|
353
|
+
const svg_viewbox = svg.attr('viewbox'); // bug in cheerio here, should be "viewBox"
|
|
354
|
+
const svg_width = svg.attr('width');
|
|
355
|
+
const svg_height = svg.attr('height');
|
|
330
356
|
// Calculate aspect ratio from viewbox
|
|
331
357
|
let svg_aspectratio_from_viewbox = undefined;
|
|
332
358
|
if (svg_viewbox) {
|
|
333
|
-
const box = svg_viewbox.split(
|
|
359
|
+
const box = svg_viewbox.split(' ');
|
|
334
360
|
const w = parseInt(box[2], 10);
|
|
335
361
|
const h = parseInt(box[3], 10);
|
|
336
362
|
svg_aspectratio_from_viewbox = w / h;
|
|
@@ -378,17 +404,17 @@ async function setImageSize(img, image) {
|
|
|
378
404
|
if (width_new !== undefined && height_new !== undefined) {
|
|
379
405
|
const result_w = Math.round(width_new);
|
|
380
406
|
const result_h = Math.round(height_new);
|
|
381
|
-
img.attr(
|
|
382
|
-
img.attr(
|
|
407
|
+
img.attr('width', result_w.toFixed(0));
|
|
408
|
+
img.attr('height', result_h.toFixed(0));
|
|
383
409
|
return [result_w, result_h];
|
|
384
410
|
}
|
|
385
411
|
// Something when wrong
|
|
386
412
|
throw new Error(`Unexpected issue when resolving image size "${image.src}"`);
|
|
387
413
|
}
|
|
388
414
|
export async function optimize(exclude) {
|
|
389
|
-
const glob = [
|
|
415
|
+
const glob = ['**/*.{htm,html}'];
|
|
390
416
|
if (exclude)
|
|
391
|
-
glob.push(
|
|
417
|
+
glob.push('!' + exclude);
|
|
392
418
|
const paths = await globby(glob, { cwd: $state.dir });
|
|
393
419
|
// Sequential async
|
|
394
420
|
for (const file of paths) {
|
package/dist/state.js
CHANGED
|
@@ -9,7 +9,7 @@ export class GlobalState {
|
|
|
9
9
|
nbFiles: 0,
|
|
10
10
|
nbFilesCompressed: 0,
|
|
11
11
|
dataLenCompressed: 0,
|
|
12
|
-
dataLenUncompressed: 0
|
|
12
|
+
dataLenUncompressed: 0,
|
|
13
13
|
};
|
|
14
14
|
this.summaryByExtension = {};
|
|
15
15
|
}
|
|
@@ -27,6 +27,7 @@ export class GlobalState {
|
|
|
27
27
|
state.summary.nbFilesCompressed += isCompressed;
|
|
28
28
|
state.summary.dataLenUncompressed += r.originalSize;
|
|
29
29
|
state.summary.dataLenCompressed += r.compressedSize;
|
|
30
|
+
console.log(r.action);
|
|
30
31
|
if (r.action) {
|
|
31
32
|
let summary = this.summaryByExtension[r.action];
|
|
32
33
|
if (!summary) {
|
|
@@ -34,7 +35,7 @@ export class GlobalState {
|
|
|
34
35
|
nbFiles: 0,
|
|
35
36
|
nbFilesCompressed: 0,
|
|
36
37
|
dataLenUncompressed: 0,
|
|
37
|
-
dataLenCompressed: 0
|
|
38
|
+
dataLenCompressed: 0,
|
|
38
39
|
};
|
|
39
40
|
this.summaryByExtension[r.action] = summary;
|
|
40
41
|
}
|
|
@@ -44,7 +45,6 @@ export class GlobalState {
|
|
|
44
45
|
summary.dataLenCompressed += r.compressedSize;
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
|
-
;
|
|
48
48
|
}
|
|
49
49
|
const state = new GlobalState();
|
|
50
50
|
export default state;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/// <reference types="node" resolution-mode="require"/>
|
|
2
|
-
export type ImageFormat = 'webp' | 'svg' | 'jpg' | 'png' | undefined;
|
|
2
|
+
export type ImageFormat = 'webp' | 'svg' | 'jpg' | 'png' | 'avif' | undefined;
|
|
3
3
|
export type Image = {
|
|
4
4
|
format: ImageFormat;
|
|
5
5
|
data: Buffer;
|
package/dist/utils/resource.js
CHANGED
|
@@ -27,13 +27,14 @@ export class Resource {
|
|
|
27
27
|
case 'png':
|
|
28
28
|
case 'webp':
|
|
29
29
|
case 'tif':
|
|
30
|
+
case 'avif':
|
|
30
31
|
let sharpFile = await sharp(await this.getData(), { animated: true });
|
|
31
32
|
const meta = await sharpFile.metadata();
|
|
32
33
|
this.image_meta = {
|
|
33
34
|
width: meta.width,
|
|
34
35
|
height: meta.height,
|
|
35
36
|
isProgressive: meta.isProgressive || false,
|
|
36
|
-
hasAlpha: meta.hasAlpha || false
|
|
37
|
+
hasAlpha: meta.hasAlpha || false,
|
|
37
38
|
};
|
|
38
39
|
break;
|
|
39
40
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@divriots/jampack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Packer for static websites",
|
|
5
5
|
"author": "Georges Gomes @georges-gomes",
|
|
6
6
|
"bin": {
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
51
|
"watch": "pnpm build --watch",
|
|
52
|
-
"try": "shx rm -fr demo && cp -R
|
|
52
|
+
"try": "shx rm -fr demo && cp -R demo_avif demo && node ./dist/index.js ./demo",
|
|
53
53
|
"build": "shx rm -rf dist && tsc -b",
|
|
54
54
|
"lint": "eslint . --ext .ts --config .eslintrc"
|
|
55
55
|
},
|