@divriots/jampack 0.2.1 → 0.2.3
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 +52 -5
- package/dist/compress.d.ts +1 -0
- package/dist/compress.js +25 -35
- package/dist/compressors/image.d.ts +1 -0
- package/dist/compressors/image.js +1 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +59 -0
- package/dist/index.js +71 -31
- package/dist/logger.d.ts +9 -3
- package/dist/logger.js +23 -7
- package/dist/optimize.js +69 -58
- package/dist/optimizers/img.d.ts +1 -0
- package/dist/optimizers/img.js +1 -0
- package/dist/options.d.ts +11 -10
- package/dist/options.js +28 -0
- package/dist/state.d.ts +7 -2
- package/dist/state.js +10 -1
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -39,11 +39,39 @@ Optimizes static websites for best user experience and best Core Web Vitals scor
|
|
|
39
39
|
|
|
40
40
|
## Optimizations
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
|
42
|
+
### PASS 1 - Optimizations
|
|
43
|
+
|
|
44
|
+
This pass takes all `*.{html|.htm}` files and parse them for optimisation.
|
|
45
|
+
|
|
46
|
+
| Optimization | Technical details | Related links | Available |
|
|
47
|
+
| -------------- | --------------------- |:------------:|:---------:|
|
|
48
|
+
| 🔽 `<img>` | | | |
|
|
49
|
+
| Optimize `png` to `WebP` | Given `foo.png`, creates a **near loss less** `foo.png.webp` if size reduced. | [web.dev](https://web.dev/uses-webp-images/) | ✅ |
|
|
50
|
+
| Optimize `jpeg` to `WebP` | Given `foo.jpg`, creates a **lossly** `foo.jpg.webp` if size reduced. | [web.dev](https://web.dev/uses-webp-images/) | ✅ |
|
|
51
|
+
| Optimize `svg` | | | ✅ |
|
|
52
|
+
| 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/) | ✅ |
|
|
53
|
+
| Embed small images | If optimised image size is <500 bytes, image is embed in `[src]` | | ✅ |
|
|
54
|
+
| 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) | ✅ |
|
|
55
|
+
| 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) | ✅ |
|
|
56
|
+
| Set lazy loading by default | Sets `loading="lazy"` if `[loading]` is not set. | [web.dev](https://web.dev/lazy-loading-images/) | ✅ |
|
|
57
|
+
| Set async decoding by default | Sets `decoding="async"`. | | ✅ |
|
|
58
|
+
| Warn on missing `[alt]` | Warn on missing `alt` attribute but adds `alt=""` in case image is considered decorative. | | ✅ |
|
|
59
|
+
| 🔽 `<picture>` | | | |
|
|
60
|
+
| - TODO | | | ❌ |
|
|
61
|
+
|
|
62
|
+
### PASS 2 - Compression
|
|
63
|
+
|
|
64
|
+
This pass compresses all untouched files without changing filename or format.
|
|
65
|
+
|
|
66
|
+
| Extension | Compressor | Some options |
|
|
67
|
+
| --------------- | --------------------- | ------------ |
|
|
68
|
+
| `.html`,`.htm` | html-minifier-terser | |
|
|
69
|
+
| `.css` | csso | |
|
|
70
|
+
| `.js` | swc | |
|
|
71
|
+
| `.svg` | svgo | |
|
|
72
|
+
| `.jpg`,`.jpeg` | sharp | |
|
|
73
|
+
| `.png` | sharp | |
|
|
74
|
+
| `.webp` | sharp | |
|
|
47
75
|
|
|
48
76
|
## Quick use
|
|
49
77
|
|
|
@@ -52,10 +80,29 @@ Growing list of optimizations performed:
|
|
|
52
80
|
npx @divriots/jampack ./dist
|
|
53
81
|
```
|
|
54
82
|
|
|
83
|
+
## CLI options
|
|
84
|
+
|
|
85
|
+
| Option | Description |
|
|
86
|
+
| ----------- | ------------------------------ |
|
|
87
|
+
| `--onlyoptim` | Only runs optimization (PASS 1) |
|
|
88
|
+
| `--onlycomp` | Only runs compression (PASS 2) |
|
|
89
|
+
| `--fast` | Go fast. Mostly no compression just checks for issues. |
|
|
90
|
+
| `--fail` | Exits with a non-zero return code if issues. |
|
|
91
|
+
| `--nowrite` | Don't write anything to disk (for testing) |
|
|
92
|
+
| `--exclude` | Files to exclude from processing. Expect glob format like `--exclude 'blog/**'` |
|
|
93
|
+
|
|
55
94
|
## Configuration
|
|
56
95
|
|
|
57
96
|
No configuration yet. All defaults.
|
|
58
97
|
|
|
98
|
+
## Considerations
|
|
99
|
+
|
|
100
|
+
`jampack` is meant to be used with your static website as is with no particular changes but here are some things you can do to help it extract the best performance of your pages.
|
|
101
|
+
|
|
102
|
+
### Add `loading="eager"` to your images above the fold
|
|
103
|
+
|
|
104
|
+
`jampack` sets all images to lazy by default. But, [images above the fold should not be lazy](https://web.dev/lazy-loading-images/#effects-on-largest-contentful-paint-lcp). Just add `loading="eager"` to your images above the fold and `jampack` will treat them as high priority.
|
|
105
|
+
|
|
59
106
|
## `jampack` use in the wild
|
|
60
107
|
|
|
61
108
|
- [divRIOTS.com](https://divRIOTS.com)
|
package/dist/compress.d.ts
CHANGED
package/dist/compress.js
CHANGED
|
@@ -6,27 +6,10 @@ 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';
|
|
9
|
-
import
|
|
9
|
+
import $state from './state.js';
|
|
10
10
|
import { globby } from 'globby';
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
};
|
|
14
|
-
const printProgress = () => {
|
|
15
|
-
const gain = globalState.summary.dataLenUncompressed - globalState.summary.dataLenCompressed;
|
|
16
|
-
const msg = `${globalState.summary.nbFiles} files | ${formatBytes(globalState.summary.dataLenUncompressed)} → ${formatBytes(globalState.summary.dataLenCompressed)} | -${formatBytes(gain)} `;
|
|
17
|
-
if (!process.stdout.clearLine || !process.stdout.cursorTo) {
|
|
18
|
-
// In CI we don't have access to clearLine or cursorTo
|
|
19
|
-
// Just don't log any progress
|
|
20
|
-
}
|
|
21
|
-
else {
|
|
22
|
-
process.stdout.clearLine(0);
|
|
23
|
-
process.stdout.cursorTo(0);
|
|
24
|
-
process.stdout.write(msg);
|
|
25
|
-
}
|
|
26
|
-
};
|
|
27
|
-
const endProgress = () => {
|
|
28
|
-
process.stdout.write('\n');
|
|
29
|
-
};
|
|
11
|
+
import config from './config.js';
|
|
12
|
+
import ora from 'ora';
|
|
30
13
|
const processFile = async (file, stats) => {
|
|
31
14
|
let writeData = undefined;
|
|
32
15
|
try {
|
|
@@ -77,22 +60,23 @@ const processFile = async (file, stats) => {
|
|
|
77
60
|
// Writedata
|
|
78
61
|
if (writeData && writeData.length < result.originalSize) {
|
|
79
62
|
result.compressedSize = writeData.length;
|
|
80
|
-
if (
|
|
63
|
+
if (!$state.args.nowrite) {
|
|
81
64
|
await fs.writeFile(file, writeData);
|
|
82
65
|
}
|
|
83
66
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
printProgress();
|
|
67
|
+
$state.compressedFiles.add(file);
|
|
68
|
+
$state.reportSummary(result);
|
|
87
69
|
};
|
|
88
70
|
function createWebpOptions(opt) {
|
|
89
71
|
return {
|
|
90
|
-
nearLossless:
|
|
91
|
-
quality:
|
|
92
|
-
effort:
|
|
72
|
+
nearLossless: opt.mode === 'lossless',
|
|
73
|
+
quality: opt.quality,
|
|
74
|
+
effort: opt.effort
|
|
93
75
|
};
|
|
94
76
|
}
|
|
95
77
|
export const compressImage = async (data, resize, toWebp = false) => {
|
|
78
|
+
if (!config.image.compress)
|
|
79
|
+
return undefined;
|
|
96
80
|
let sharpFile = await sharp(data, { animated: true });
|
|
97
81
|
const meta = await sharpFile.metadata();
|
|
98
82
|
if (meta.pages && meta.pages > 1) {
|
|
@@ -123,7 +107,7 @@ export const compressImage = async (data, resize, toWebp = false) => {
|
|
|
123
107
|
case 'png':
|
|
124
108
|
outputFormat = 'png';
|
|
125
109
|
if (!toWebp) {
|
|
126
|
-
sharpFile = sharpFile.png(
|
|
110
|
+
sharpFile = sharpFile.png(config.image.png.options || {});
|
|
127
111
|
}
|
|
128
112
|
doSharp = true;
|
|
129
113
|
break;
|
|
@@ -131,20 +115,20 @@ export const compressImage = async (data, resize, toWebp = false) => {
|
|
|
131
115
|
case 'jpg':
|
|
132
116
|
outputFormat = 'jpg';
|
|
133
117
|
if (!toWebp) {
|
|
134
|
-
sharpFile = sharpFile.jpeg(
|
|
118
|
+
sharpFile = sharpFile.jpeg(config.image.jpeg.options || {});
|
|
135
119
|
}
|
|
136
120
|
doSharp = true;
|
|
137
121
|
break;
|
|
138
122
|
case 'webp':
|
|
139
123
|
outputFormat = 'webp';
|
|
140
|
-
sharpFile = sharpFile.webp(createWebpOptions(
|
|
124
|
+
sharpFile = sharpFile.webp(createWebpOptions(config.image.webp.options_lossly) || {});
|
|
141
125
|
doSharp = true;
|
|
142
126
|
toWebp = false; // Don't need to convert to webP again
|
|
143
127
|
break;
|
|
144
128
|
}
|
|
145
129
|
if (doSharp) {
|
|
146
130
|
if (toWebp) {
|
|
147
|
-
sharpFile = sharpFile.webp(createWebpOptions(meta.format === 'png' ?
|
|
131
|
+
sharpFile = sharpFile.webp(createWebpOptions(meta.format === 'png' ? config.image.webp.options_lossless : config.image.webp.options_lossly));
|
|
148
132
|
outputFormat = 'webp';
|
|
149
133
|
}
|
|
150
134
|
if (resize.width && resize.height) {
|
|
@@ -159,16 +143,22 @@ const compressImageFile = async (file, toWebP = false) => {
|
|
|
159
143
|
return compressImage(buffer, {}, toWebP);
|
|
160
144
|
};
|
|
161
145
|
export async function compress(exclude) {
|
|
162
|
-
|
|
146
|
+
const spinner = ora(getProgressText()).start();
|
|
163
147
|
const globs = ['**/**'];
|
|
164
148
|
if (exclude)
|
|
165
149
|
globs.push('!' + exclude);
|
|
166
|
-
const paths = await globby(globs, { cwd:
|
|
150
|
+
const paths = await globby(globs, { cwd: $state.dir, absolute: true });
|
|
167
151
|
// "Parallel" processing
|
|
168
152
|
await Promise.all(paths.map(async (file) => {
|
|
169
|
-
if (
|
|
153
|
+
if (!$state.compressedFiles.has(file)) {
|
|
170
154
|
await processFile(file, await fs.stat(file));
|
|
155
|
+
spinner.text = getProgressText();
|
|
171
156
|
}
|
|
172
157
|
}));
|
|
173
|
-
|
|
158
|
+
spinner.text = getProgressText();
|
|
159
|
+
spinner.succeed();
|
|
174
160
|
}
|
|
161
|
+
const getProgressText = () => {
|
|
162
|
+
const gain = $state.summary.dataLenUncompressed - $state.summary.dataLenCompressed;
|
|
163
|
+
return `${$state.summary.nbFiles} files | ${formatBytes($state.summary.dataLenUncompressed)} → ${formatBytes($state.summary.dataLenCompressed)} | -${formatBytes(gain)} `;
|
|
164
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare type WebpOptions = {
|
|
2
|
+
effort: number;
|
|
3
|
+
mode: 'lossless' | 'lossly';
|
|
4
|
+
quality: number;
|
|
5
|
+
};
|
|
6
|
+
export declare type Options = {
|
|
7
|
+
image: {
|
|
8
|
+
embed_size: number;
|
|
9
|
+
srcset_min_width: number;
|
|
10
|
+
compress: boolean;
|
|
11
|
+
jpeg: {
|
|
12
|
+
options: {
|
|
13
|
+
quality: number;
|
|
14
|
+
mozjpeg: boolean;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
png: {
|
|
18
|
+
options: {
|
|
19
|
+
compressionLevel: number;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
webp: {
|
|
23
|
+
options_lossless: WebpOptions;
|
|
24
|
+
options_lossly: WebpOptions;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
declare const default_options: Options;
|
|
29
|
+
export declare function fast(): void;
|
|
30
|
+
export default default_options;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const default_options = {
|
|
2
|
+
image: {
|
|
3
|
+
embed_size: 400,
|
|
4
|
+
srcset_min_width: 400,
|
|
5
|
+
compress: true,
|
|
6
|
+
jpeg: {
|
|
7
|
+
options: {
|
|
8
|
+
quality: 80,
|
|
9
|
+
mozjpeg: true
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
png: {
|
|
13
|
+
options: {
|
|
14
|
+
compressionLevel: 9,
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
webp: {
|
|
18
|
+
options_lossless: {
|
|
19
|
+
effort: 4,
|
|
20
|
+
quality: 80,
|
|
21
|
+
mode: 'lossless'
|
|
22
|
+
},
|
|
23
|
+
options_lossly: {
|
|
24
|
+
effort: 4,
|
|
25
|
+
quality: 80,
|
|
26
|
+
mode: 'lossly'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const fast_options_override = {
|
|
32
|
+
image: {
|
|
33
|
+
embed_size: 0,
|
|
34
|
+
srcset_min_width: 16000,
|
|
35
|
+
compress: false,
|
|
36
|
+
jpeg: {
|
|
37
|
+
options: {
|
|
38
|
+
mozjpeg: false
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
png: {
|
|
42
|
+
options: {
|
|
43
|
+
compressionLevel: 0,
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
webp: {
|
|
47
|
+
options_lossless: {
|
|
48
|
+
effort: 0,
|
|
49
|
+
},
|
|
50
|
+
options_lossly: {
|
|
51
|
+
effort: 0,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
export function fast() {
|
|
57
|
+
Object.assign(default_options, fast_options_override);
|
|
58
|
+
}
|
|
59
|
+
export default default_options;
|
package/dist/index.js
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { compress } from './compress.js';
|
|
4
4
|
import { optimize } from './optimize.js';
|
|
5
|
-
import
|
|
6
|
-
import globalState from './state.js';
|
|
5
|
+
import $state from './state.js';
|
|
7
6
|
import { table } from 'table';
|
|
8
7
|
import { formatBytes } from './utils.js';
|
|
8
|
+
import { fast } from './config.js';
|
|
9
|
+
import { printTitle } from './logger.js';
|
|
10
|
+
import { exit } from 'process';
|
|
11
|
+
import kleur from 'kleur';
|
|
9
12
|
const logo = ` __ __
|
|
10
13
|
|__|____ _____ ___________ ____ | | __
|
|
11
14
|
| \\__ \\ / \\\\____ \\__ \\ _/ ___\\| |/ /
|
|
@@ -25,42 +28,79 @@ program.command('pack', { isDefault: true })
|
|
|
25
28
|
.argument('<dir>', 'Directory to pack')
|
|
26
29
|
.option('--exclude <exclude>', 'Glob to exclude')
|
|
27
30
|
.option('--nowrite', 'No write')
|
|
28
|
-
.option('--
|
|
29
|
-
.option('--
|
|
31
|
+
.option('--fast', 'Go fast. Mostly no compression just checks for issues.')
|
|
32
|
+
.option('--fail', 'Exits with a non-zero return code if issues.')
|
|
33
|
+
.option('--onlyoptim', 'Only optimize (PASS 1).')
|
|
34
|
+
.option('--onlycomp', 'Only compress (PASS 2).')
|
|
30
35
|
.action(async (dir, options) => {
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
$state.dir = dir;
|
|
37
|
+
$state.args = options;
|
|
38
|
+
// Override config with fast options
|
|
39
|
+
if (options.fast) {
|
|
40
|
+
fast();
|
|
41
|
+
}
|
|
33
42
|
if (!options.onlycomp) {
|
|
34
|
-
|
|
35
|
-
console.
|
|
43
|
+
printTitle('PASS 1 - Optimizing');
|
|
44
|
+
console.time('Done');
|
|
36
45
|
await optimize(options.exclude);
|
|
46
|
+
console.timeEnd('Done');
|
|
37
47
|
}
|
|
38
|
-
if (!options.onlyoptim) {
|
|
39
|
-
|
|
40
|
-
console.
|
|
48
|
+
if (!options.onlyoptim && !options.fast) {
|
|
49
|
+
printTitle('PASS 2 - Compressing the rest');
|
|
50
|
+
console.time('Done');
|
|
41
51
|
await compress(options.exclude);
|
|
52
|
+
console.timeEnd('Done');
|
|
53
|
+
}
|
|
54
|
+
printSummary();
|
|
55
|
+
printIssues();
|
|
56
|
+
if (options.failOnIssues && $state.issues.size > 0) {
|
|
57
|
+
exit(1);
|
|
42
58
|
}
|
|
43
|
-
printDetails();
|
|
44
59
|
});
|
|
45
60
|
program.parse();
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
function printSummary() {
|
|
62
|
+
if ($state.summary.nbFiles > 0) {
|
|
63
|
+
printTitle('Summary');
|
|
64
|
+
const dataTable = [['Action', 'Compressed', 'Original', 'Compressed', 'Gain']];
|
|
65
|
+
const config = {
|
|
66
|
+
columns: [
|
|
67
|
+
{ alignment: 'left' },
|
|
68
|
+
{ alignment: 'right' },
|
|
69
|
+
{ alignment: 'right' },
|
|
70
|
+
{ alignment: 'right' },
|
|
71
|
+
{ alignment: 'right' }
|
|
72
|
+
],
|
|
73
|
+
drawHorizontalLine: (lineIndex, rowCount) => {
|
|
74
|
+
return lineIndex === 0 || lineIndex === 1 || lineIndex === rowCount - 1 || lineIndex === rowCount;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
Object.entries($state.summaryByExtension).forEach(([ext, summary]) => {
|
|
78
|
+
if (summary.dataLenCompressed < summary.dataLenUncompressed) {
|
|
79
|
+
const row = [ext, `${summary.nbFilesCompressed} / ${summary.nbFiles}`, formatBytes(summary.dataLenUncompressed), formatBytes(summary.dataLenCompressed), '-' + formatBytes(summary.dataLenUncompressed - summary.dataLenCompressed)];
|
|
80
|
+
dataTable.push(row);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
const total = ['Total', `${$state.summary.nbFilesCompressed} / ${$state.summary.nbFiles}`, formatBytes($state.summary.dataLenUncompressed), formatBytes($state.summary.dataLenCompressed), '-' + formatBytes($state.summary.dataLenUncompressed - $state.summary.dataLenCompressed)];
|
|
84
|
+
dataTable.push(total);
|
|
85
|
+
console.log(table(dataTable, config));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function printIssues() {
|
|
89
|
+
let issueCount = 0;
|
|
90
|
+
if ($state.issues.size === 0) {
|
|
91
|
+
printTitle('✔ No issues');
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
printTitle('Issues', kleur.bgRed);
|
|
95
|
+
console.log('');
|
|
96
|
+
for (let [file, list] of $state.issues) {
|
|
97
|
+
console.log(kleur.red('▶ ' + file + '\n'));
|
|
98
|
+
list.forEach(issue => {
|
|
99
|
+
issueCount++;
|
|
100
|
+
console.log(`❌ ${issue.msg}\n`);
|
|
101
|
+
});
|
|
61
102
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
console.log(table(dataTable, config));
|
|
103
|
+
console.log('');
|
|
104
|
+
printTitle(`${issueCount} issue(s) over ${$state.issues.size} files`, kleur.bgRed);
|
|
105
|
+
}
|
|
66
106
|
}
|
package/dist/logger.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
export declare
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
export declare class Logger {
|
|
2
|
+
private prefix;
|
|
3
|
+
Logger(prefix?: string): void;
|
|
4
|
+
debug(message: string): void;
|
|
5
|
+
info(message: string): void;
|
|
6
|
+
warn(message: string): void;
|
|
7
|
+
error(message: string): void;
|
|
8
|
+
}
|
|
9
|
+
export declare function printTitle(msg: string, bgColor?: (x: string | number) => string): void;
|
package/dist/logger.js
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
export class Logger {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.prefix = '';
|
|
5
|
+
}
|
|
6
|
+
Logger(prefix = '') {
|
|
7
|
+
this.prefix = prefix;
|
|
8
|
+
}
|
|
9
|
+
debug(message) {
|
|
10
|
+
console.debug(this.prefix + message);
|
|
11
|
+
}
|
|
12
|
+
info(message) {
|
|
13
|
+
console.info(this.prefix + message);
|
|
14
|
+
}
|
|
15
|
+
warn(message) {
|
|
16
|
+
console.warn(this.prefix + message);
|
|
17
|
+
}
|
|
18
|
+
error(message) {
|
|
19
|
+
console.error(this.prefix + message);
|
|
20
|
+
}
|
|
3
21
|
}
|
|
4
|
-
export function
|
|
5
|
-
console.
|
|
6
|
-
}
|
|
7
|
-
export function error(message) {
|
|
8
|
-
console.error(message);
|
|
22
|
+
export function printTitle(msg, bgColor = kleur.bgGreen) {
|
|
23
|
+
console.log('');
|
|
24
|
+
console.log(kleur.black(bgColor(` ${msg} `)));
|
|
9
25
|
}
|
package/dist/optimize.js
CHANGED
|
@@ -4,13 +4,15 @@ import * as fs from 'fs/promises';
|
|
|
4
4
|
import cheerio from 'cheerio';
|
|
5
5
|
import { isLocal, isNumeric, translateSrc } from './utils.js';
|
|
6
6
|
import sharp from "sharp";
|
|
7
|
-
import
|
|
7
|
+
import config from './config.js';
|
|
8
8
|
import { compressImage } from './compress.js';
|
|
9
9
|
import svgToMiniDataURI from 'mini-svg-data-uri';
|
|
10
|
-
import
|
|
10
|
+
import $state from './state.js';
|
|
11
|
+
import kleur from 'kleur';
|
|
12
|
+
import ora from 'ora';
|
|
11
13
|
async function analyse(file) {
|
|
12
|
-
console.log('
|
|
13
|
-
const html = (await fs.readFile(path.join(
|
|
14
|
+
console.log('▶ ' + file);
|
|
15
|
+
const html = (await fs.readFile(path.join($state.dir, file))).toString();
|
|
14
16
|
const $ = cheerio.load(html, {});
|
|
15
17
|
const imgs = $('img');
|
|
16
18
|
const imgsArray = [];
|
|
@@ -18,34 +20,46 @@ async function analyse(file) {
|
|
|
18
20
|
imgsArray.push(imgElement);
|
|
19
21
|
});
|
|
20
22
|
// Process images sequentially
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const spinnerImg = ora({ prefixText: ' ' }).start();
|
|
24
|
+
for (let i = 0; i < imgsArray.length; i++) {
|
|
25
|
+
const imgElement = imgsArray[i];
|
|
26
|
+
spinnerImg.text = kleur.dim(`<img> [${i + 1}/${imgsArray.length}] ${$(imgElement).attr('src')} `);
|
|
27
|
+
await processImage(file, $, imgElement);
|
|
28
|
+
}
|
|
29
|
+
// Reset spinner
|
|
30
|
+
spinnerImg.text = kleur.dim(`<img> [${imgsArray.length}/${imgsArray.length}]`);
|
|
31
|
+
// Notify issues
|
|
32
|
+
const issues = $state.issues.get(file);
|
|
33
|
+
if (issues) {
|
|
34
|
+
spinnerImg.fail();
|
|
35
|
+
console.log(kleur.red(` ${issues.length} issue${issues.length > 1 ? 's' : ''}`));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
spinnerImg.succeed();
|
|
39
|
+
}
|
|
40
|
+
if (!$state.args.nowrite) {
|
|
41
|
+
await fs.writeFile(path.join($state.dir, file), $.html());
|
|
27
42
|
}
|
|
28
43
|
}
|
|
29
|
-
async function processImage(
|
|
44
|
+
async function processImage(htmlfile, $, imgElement) {
|
|
30
45
|
try {
|
|
31
46
|
const img = $(imgElement);
|
|
32
47
|
/*
|
|
33
|
-
* Attribute 'alt'
|
|
34
|
-
*/
|
|
35
|
-
const attrib_alt = img.attr('alt');
|
|
36
|
-
if (attrib_alt === undefined) {
|
|
37
|
-
console.warn('<img> has no alt attribute - adding an empty but you should fix it');
|
|
38
|
-
img.attr('alt', "");
|
|
39
|
-
}
|
|
40
|
-
/*
|
|
41
48
|
* Attribute 'src'
|
|
42
49
|
*/
|
|
43
50
|
const attrib_src = img.attr('src');
|
|
44
|
-
console.log(`- Image ${attrib_src}`);
|
|
45
51
|
if (!attrib_src) {
|
|
46
|
-
|
|
52
|
+
$state.reportIssue(htmlfile, { msg: `Missing [src] on img - processing skipped.` });
|
|
47
53
|
return;
|
|
48
54
|
}
|
|
55
|
+
/*
|
|
56
|
+
* Attribute 'alt'
|
|
57
|
+
*/
|
|
58
|
+
const attrib_alt = img.attr('alt');
|
|
59
|
+
if (attrib_alt === undefined) {
|
|
60
|
+
$state.reportIssue(htmlfile, { msg: `Missing [alt] on img src="${attrib_src}" - Adding alt="" meanwhile.` });
|
|
61
|
+
img.attr('alt', "");
|
|
62
|
+
}
|
|
49
63
|
if (attrib_src.startsWith('data:')) {
|
|
50
64
|
// Data URI image
|
|
51
65
|
// TODO: try to compress it
|
|
@@ -71,7 +85,7 @@ async function processImage(file, $, imgElement) {
|
|
|
71
85
|
// Don't touch it
|
|
72
86
|
break;
|
|
73
87
|
default:
|
|
74
|
-
|
|
88
|
+
$state.reportIssue(htmlfile, { msg: `Invalid [loading]="${attr_loading}" on img src="${attrib_src}"` });
|
|
75
89
|
}
|
|
76
90
|
/*
|
|
77
91
|
* Attribute 'decoding'
|
|
@@ -80,37 +94,38 @@ async function processImage(file, $, imgElement) {
|
|
|
80
94
|
/*
|
|
81
95
|
* Loading image
|
|
82
96
|
*/
|
|
83
|
-
const absoluteImgPath = translateSrc(
|
|
97
|
+
const absoluteImgPath = translateSrc($state.dir, path.dirname(htmlfile), attrib_src);
|
|
84
98
|
// file exists?
|
|
85
99
|
try {
|
|
86
100
|
await fs.stat(absoluteImgPath);
|
|
87
101
|
}
|
|
88
102
|
catch (e) {
|
|
89
|
-
|
|
103
|
+
$state.reportIssue(htmlfile, { msg: `Can't find img src="${attrib_src}" in ${absoluteImgPath}` });
|
|
90
104
|
return;
|
|
91
105
|
}
|
|
106
|
+
// Read file
|
|
107
|
+
const originalData = await fs.readFile(absoluteImgPath);
|
|
92
108
|
const sharpFile = sharp(absoluteImgPath);
|
|
93
109
|
const meta = await sharpFile.metadata();
|
|
94
|
-
const originalData = await fs.readFile(absoluteImgPath);
|
|
95
110
|
/*
|
|
96
111
|
* Compress image to WebP
|
|
97
112
|
*/
|
|
98
113
|
let newImage;
|
|
99
|
-
if (
|
|
114
|
+
if (!$state.optimizedFiles.has(absoluteImgPath)) {
|
|
100
115
|
// Let's avoid to optimize same images twice
|
|
101
|
-
|
|
116
|
+
$state.optimizedFiles.add(absoluteImgPath);
|
|
102
117
|
newImage = await compressImage(originalData, {}, true);
|
|
103
118
|
if ((newImage === null || newImage === void 0 ? void 0 : newImage.data) && newImage.data.length < originalData.length) {
|
|
104
119
|
// Do we need to add an new extension?
|
|
105
120
|
const newExtension = `.${newImage.format}`;
|
|
106
121
|
const additionalExtension = path.extname(absoluteImgPath) === newExtension ? '' : newExtension;
|
|
107
122
|
const newFilename = absoluteImgPath + additionalExtension;
|
|
108
|
-
if (
|
|
123
|
+
if (!$state.args.nowrite) {
|
|
109
124
|
fs.writeFile(newFilename, newImage.data);
|
|
110
125
|
}
|
|
111
|
-
|
|
126
|
+
$state.compressedFiles.add(newFilename);
|
|
112
127
|
// Report compression result
|
|
113
|
-
|
|
128
|
+
$state.reportSummary({
|
|
114
129
|
action: (newFilename !== absoluteImgPath) ? `${meta.format}->${newImage.format}` : path.extname(absoluteImgPath),
|
|
115
130
|
originalSize: originalData.length,
|
|
116
131
|
compressedSize: newImage.data.length
|
|
@@ -126,7 +141,7 @@ async function processImage(file, $, imgElement) {
|
|
|
126
141
|
* image if it fits the size.
|
|
127
142
|
*/
|
|
128
143
|
let isEmbed = false;
|
|
129
|
-
if (newImage && newImage.data.length <=
|
|
144
|
+
if (newImage && newImage.data.length <= config.image.embed_size) {
|
|
130
145
|
let datauri = undefined;
|
|
131
146
|
switch (newImage.format) {
|
|
132
147
|
case 'svg':
|
|
@@ -145,9 +160,17 @@ async function processImage(file, $, imgElement) {
|
|
|
145
160
|
img.attr('src', datauri);
|
|
146
161
|
img.removeAttr('loading');
|
|
147
162
|
img.removeAttr('decoding');
|
|
148
|
-
|
|
163
|
+
$state.reportSummary({ action: `${newImage.format}->embed`, originalSize: originalData.length, compressedSize: newImage.data.length });
|
|
149
164
|
}
|
|
150
165
|
}
|
|
166
|
+
/*
|
|
167
|
+
* Attribute 'width' & 'height'
|
|
168
|
+
*/
|
|
169
|
+
const [w, h] = setImageSize(img, meta);
|
|
170
|
+
if (w < 0 || h < 0) {
|
|
171
|
+
$state.reportIssue(htmlfile, { msg: `Unexpected error in image size calculation src="${attrib_src}" - can't performance some optmizations.` });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
151
174
|
if (isEmbed) {
|
|
152
175
|
// Image is embed, no need for more processing
|
|
153
176
|
return;
|
|
@@ -157,14 +180,6 @@ async function processImage(file, $, imgElement) {
|
|
|
157
180
|
//
|
|
158
181
|
if (meta.format === 'svg')
|
|
159
182
|
return;
|
|
160
|
-
/*
|
|
161
|
-
* Attribute 'width' & 'height'
|
|
162
|
-
*/
|
|
163
|
-
const [w, h] = setImageSize(img, meta);
|
|
164
|
-
if (w < 0 || h < 0) {
|
|
165
|
-
console.warn(`Unexpected error in image size calculation - some optimizations can't be done`);
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
183
|
/*
|
|
169
184
|
* Attribute 'srcset'
|
|
170
185
|
*/
|
|
@@ -186,16 +201,15 @@ async function processImage(file, $, imgElement) {
|
|
|
186
201
|
const ratio = w / h;
|
|
187
202
|
let valueW = w - step;
|
|
188
203
|
let valueH = Math.trunc(valueW / ratio);
|
|
189
|
-
while (valueW >
|
|
204
|
+
while (valueW > config.image.srcset_min_width) {
|
|
190
205
|
const src = imageSrc(`@${valueW}w`);
|
|
191
|
-
|
|
192
|
-
const absoluteFilename = translateSrc(globalState.dir, path.dirname(file), src);
|
|
206
|
+
const absoluteFilename = translateSrc($state.dir, path.dirname(htmlfile), src);
|
|
193
207
|
// Don't generate srcset file twice
|
|
194
|
-
if (
|
|
208
|
+
if (!$state.compressedFiles.has(absoluteFilename)) {
|
|
195
209
|
// Add file to list avoid recompression
|
|
196
|
-
|
|
210
|
+
$state.compressedFiles.add(absoluteFilename);
|
|
197
211
|
const compressedImage = await compressImage(originalData, { width: valueW, height: valueH }, true);
|
|
198
|
-
if ((compressedImage === null || compressedImage === void 0 ? void 0 : compressedImage.data) &&
|
|
212
|
+
if ((compressedImage === null || compressedImage === void 0 ? void 0 : compressedImage.data) && !$state.args.nowrite) {
|
|
199
213
|
fs.writeFile(absoluteFilename, compressedImage.data);
|
|
200
214
|
}
|
|
201
215
|
}
|
|
@@ -210,10 +224,7 @@ async function processImage(file, $, imgElement) {
|
|
|
210
224
|
}
|
|
211
225
|
}
|
|
212
226
|
catch (e) {
|
|
213
|
-
|
|
214
|
-
console.error(e);
|
|
215
|
-
// exit here
|
|
216
|
-
// exit(1);
|
|
227
|
+
$state.reportIssue(htmlfile, { msg: `Unexpected error while processing image: ${JSON.stringify(e)}` });
|
|
217
228
|
}
|
|
218
229
|
}
|
|
219
230
|
function setImageSize(img, meta) {
|
|
@@ -224,13 +235,13 @@ function setImageSize(img, meta) {
|
|
|
224
235
|
// Check valid values
|
|
225
236
|
if (width !== undefined) {
|
|
226
237
|
if (!isNumeric(width)) {
|
|
227
|
-
console.warn(`Invalid width attribute "${width}" - overriding`);
|
|
238
|
+
//console.warn(`Invalid width attribute "${width}" - overriding`);
|
|
228
239
|
width = undefined;
|
|
229
240
|
}
|
|
230
241
|
}
|
|
231
242
|
if (height !== undefined) {
|
|
232
243
|
if (!isNumeric(height)) {
|
|
233
|
-
console.warn(`Invalid height attribute "${height}" - overriding`);
|
|
244
|
+
//console.warn(`Invalid height attribute "${height}" - overriding`);
|
|
234
245
|
height = undefined;
|
|
235
246
|
}
|
|
236
247
|
}
|
|
@@ -247,7 +258,7 @@ function setImageSize(img, meta) {
|
|
|
247
258
|
const providedRatio = Math.round(w / h * 10) / 10;
|
|
248
259
|
const imageRatio = Math.round(originalRatio * 10) / 10;
|
|
249
260
|
if (providedRatio !== imageRatio) {
|
|
250
|
-
console.warn(`Image aspect ratio in HTML (${providedRatio}) differs from image aspect ratio (${imageRatio}) - fix width and height or let jampack fill them.`);
|
|
261
|
+
//console.warn(`Image aspect ratio in HTML (${providedRatio}) differs from image aspect ratio (${imageRatio}) - fix width and height or let jampack fill them.`);
|
|
251
262
|
}
|
|
252
263
|
return [w, h];
|
|
253
264
|
}
|
|
@@ -279,10 +290,10 @@ export async function optimize(exclude) {
|
|
|
279
290
|
const glob = ['**/*.{htm,html}'];
|
|
280
291
|
if (exclude)
|
|
281
292
|
glob.push('!' + exclude);
|
|
282
|
-
const paths = await globby(glob, { cwd:
|
|
293
|
+
const paths = await globby(glob, { cwd: $state.dir });
|
|
283
294
|
// Sequential async
|
|
284
|
-
|
|
285
|
-
await
|
|
286
|
-
|
|
287
|
-
|
|
295
|
+
for (const file of paths) {
|
|
296
|
+
await analyse(file);
|
|
297
|
+
}
|
|
298
|
+
;
|
|
288
299
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/options.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare type WebpOptions = {
|
|
2
|
-
effort
|
|
3
|
-
mode
|
|
4
|
-
quality
|
|
2
|
+
effort: number;
|
|
3
|
+
mode: 'lossless' | 'lossly';
|
|
4
|
+
quality: number;
|
|
5
5
|
};
|
|
6
6
|
export declare type Options = {
|
|
7
7
|
image: {
|
|
@@ -9,21 +9,22 @@ export declare type Options = {
|
|
|
9
9
|
srcset_min_width: number;
|
|
10
10
|
srcset_max_width: number;
|
|
11
11
|
jpeg: {
|
|
12
|
-
options
|
|
13
|
-
quality
|
|
14
|
-
mozjpeg
|
|
12
|
+
options: {
|
|
13
|
+
quality: number;
|
|
14
|
+
mozjpeg: boolean;
|
|
15
15
|
};
|
|
16
16
|
};
|
|
17
17
|
png: {
|
|
18
|
-
options
|
|
19
|
-
compressionLevel
|
|
18
|
+
options: {
|
|
19
|
+
compressionLevel: number;
|
|
20
20
|
};
|
|
21
21
|
};
|
|
22
22
|
webp: {
|
|
23
|
-
options_lossless
|
|
24
|
-
options_lossly
|
|
23
|
+
options_lossless: WebpOptions;
|
|
24
|
+
options_lossly: WebpOptions;
|
|
25
25
|
};
|
|
26
26
|
};
|
|
27
27
|
};
|
|
28
28
|
declare const default_options: Options;
|
|
29
|
+
export declare function fast(): void;
|
|
29
30
|
export default default_options;
|
package/dist/options.js
CHANGED
|
@@ -28,4 +28,32 @@ const default_options = {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
};
|
|
31
|
+
const fast_options_override = {
|
|
32
|
+
image: {
|
|
33
|
+
embed_size: 0,
|
|
34
|
+
srcset_min_width: 1024,
|
|
35
|
+
srcset_max_width: 1024,
|
|
36
|
+
jpeg: {
|
|
37
|
+
options: {
|
|
38
|
+
mozjpeg: false
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
png: {
|
|
42
|
+
options: {
|
|
43
|
+
compressionLevel: 0,
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
webp: {
|
|
47
|
+
options_lossless: {
|
|
48
|
+
effort: 0,
|
|
49
|
+
},
|
|
50
|
+
options_lossly: {
|
|
51
|
+
effort: 0,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
export function fast() {
|
|
57
|
+
Object.assign(default_options, fast_options_override);
|
|
58
|
+
}
|
|
31
59
|
export default default_options;
|
package/dist/state.d.ts
CHANGED
|
@@ -6,20 +6,25 @@ export declare type ReportItem = {
|
|
|
6
6
|
originalSize: number;
|
|
7
7
|
compressedSize: number;
|
|
8
8
|
};
|
|
9
|
-
|
|
9
|
+
declare type Summary = {
|
|
10
10
|
nbFiles: number;
|
|
11
11
|
nbFilesCompressed: number;
|
|
12
12
|
dataLenUncompressed: number;
|
|
13
13
|
dataLenCompressed: number;
|
|
14
14
|
};
|
|
15
|
+
declare type Issue = {
|
|
16
|
+
msg: string;
|
|
17
|
+
};
|
|
15
18
|
export declare class GlobalState {
|
|
16
19
|
dir: string;
|
|
17
20
|
args: Args;
|
|
18
21
|
compressedFiles: Set<string>;
|
|
19
22
|
optimizedFiles: Set<string>;
|
|
23
|
+
issues: Map<string, Issue[]>;
|
|
20
24
|
summary: Summary;
|
|
21
25
|
summaryByExtension: Record<string, Summary>;
|
|
22
|
-
|
|
26
|
+
reportIssue(sourceFile: string, issue: Issue): void;
|
|
27
|
+
reportSummary(r: ReportItem): void;
|
|
23
28
|
}
|
|
24
29
|
declare const state: GlobalState;
|
|
25
30
|
export default state;
|
package/dist/state.js
CHANGED
|
@@ -4,6 +4,7 @@ export class GlobalState {
|
|
|
4
4
|
this.args = {};
|
|
5
5
|
this.compressedFiles = new Set();
|
|
6
6
|
this.optimizedFiles = new Set();
|
|
7
|
+
this.issues = new Map();
|
|
7
8
|
this.summary = {
|
|
8
9
|
nbFiles: 0,
|
|
9
10
|
nbFilesCompressed: 0,
|
|
@@ -12,7 +13,15 @@ export class GlobalState {
|
|
|
12
13
|
};
|
|
13
14
|
this.summaryByExtension = {};
|
|
14
15
|
}
|
|
15
|
-
|
|
16
|
+
reportIssue(sourceFile, issue) {
|
|
17
|
+
let issueList = this.issues.get(sourceFile);
|
|
18
|
+
if (issueList === undefined) {
|
|
19
|
+
issueList = [];
|
|
20
|
+
this.issues.set(sourceFile, issueList);
|
|
21
|
+
}
|
|
22
|
+
issueList.push(issue);
|
|
23
|
+
}
|
|
24
|
+
reportSummary(r) {
|
|
16
25
|
const isCompressed = r.compressedSize < r.originalSize ? 1 : 0;
|
|
17
26
|
this.summary.nbFiles++;
|
|
18
27
|
state.summary.nbFilesCompressed += isCompressed;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@divriots/jampack",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Packer for static websites",
|
|
5
5
|
"author": "Georges Gomes @georges-gomes",
|
|
6
6
|
"bin": {
|
|
@@ -16,23 +16,25 @@
|
|
|
16
16
|
"/dist"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@swc/core": "^1.3.
|
|
19
|
+
"@swc/core": "^1.3.4",
|
|
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
|
+
"@types/ora": "^3.2.0",
|
|
24
25
|
"@types/sharp": "^0.31.0",
|
|
25
26
|
"@types/svgo": "^2.6.4",
|
|
26
27
|
"@types/table": "^6.3.2",
|
|
27
28
|
"chalk": "^5.0.1",
|
|
28
29
|
"cheerio": "0.22.0",
|
|
29
|
-
"commander": "^9.4.
|
|
30
|
+
"commander": "^9.4.1",
|
|
30
31
|
"csso": "^5.0.5",
|
|
31
32
|
"globby": "^13.1.2",
|
|
32
33
|
"html-minifier-terser": "^7.0.0",
|
|
33
34
|
"kleur": "^4.1.5",
|
|
34
35
|
"mini-svg-data-uri": "^1.4.4",
|
|
35
|
-
"
|
|
36
|
+
"ora": "^6.1.2",
|
|
37
|
+
"sharp": "^0.31.1",
|
|
36
38
|
"svgo": "^2.8.0",
|
|
37
39
|
"table": "^6.8.0"
|
|
38
40
|
},
|
|
@@ -41,7 +43,7 @@
|
|
|
41
43
|
"shx": "^0.3.3",
|
|
42
44
|
"ts-node": "^10.2.1",
|
|
43
45
|
"tslib": "^2.3.1",
|
|
44
|
-
"typescript": "^4.4
|
|
46
|
+
"typescript": "^4.8.4"
|
|
45
47
|
},
|
|
46
48
|
"scripts": {
|
|
47
49
|
"watch": "pnpm build --watch",
|