@divriots/jampack 0.2.0 → 0.2.2
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 +2 -1
- package/dist/compress.js +29 -36
- package/dist/config.d.ts +30 -0
- package/dist/config.js +59 -0
- package/dist/index.js +72 -32
- package/dist/logger.d.ts +9 -3
- package/dist/logger.js +23 -7
- package/dist/optimize.d.ts +1 -1
- package/dist/optimize.js +64 -51
- 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
|
@@ -1,4 +1,5 @@
|
|
|
1
|
+
/// <reference types="node" resolution-mode="require"/>
|
|
1
2
|
import sharp from 'sharp';
|
|
2
3
|
import type { Image } from './types.js';
|
|
3
4
|
export declare const compressImage: (data: Buffer, resize: sharp.ResizeOptions, toWebp?: boolean) => Promise<Image | undefined>;
|
|
4
|
-
export declare function compress(
|
|
5
|
+
export declare function compress(exclude: string): Promise<void>;
|
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) {
|
|
@@ -158,14 +142,23 @@ const compressImageFile = async (file, toWebP = false) => {
|
|
|
158
142
|
const buffer = await fs.readFile(file);
|
|
159
143
|
return compressImage(buffer, {}, toWebP);
|
|
160
144
|
};
|
|
161
|
-
export async function compress(
|
|
162
|
-
|
|
163
|
-
const
|
|
145
|
+
export async function compress(exclude) {
|
|
146
|
+
const spinner = ora(getProgressText()).start();
|
|
147
|
+
const globs = ['**/**'];
|
|
148
|
+
if (exclude)
|
|
149
|
+
globs.push('!' + exclude);
|
|
150
|
+
const paths = await globby(globs, { cwd: $state.dir, absolute: true });
|
|
164
151
|
// "Parallel" processing
|
|
165
152
|
await Promise.all(paths.map(async (file) => {
|
|
166
|
-
if (
|
|
153
|
+
if (!$state.compressedFiles.has(file)) {
|
|
167
154
|
await processFile(file, await fs.stat(file));
|
|
155
|
+
spinner.text = getProgressText();
|
|
168
156
|
}
|
|
169
157
|
}));
|
|
170
|
-
|
|
158
|
+
spinner.text = getProgressText();
|
|
159
|
+
spinner.succeed();
|
|
171
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
|
+
};
|
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
|
| \\__ \\ / \\\\____ \\__ \\ _/ ___\\| |/ /
|
|
@@ -23,43 +26,80 @@ program
|
|
|
23
26
|
program.command('pack', { isDefault: true })
|
|
24
27
|
.description('todo')
|
|
25
28
|
.argument('<dir>', 'Directory to pack')
|
|
29
|
+
.option('--exclude <exclude>', 'Glob to exclude')
|
|
26
30
|
.option('--nowrite', 'No write')
|
|
27
|
-
.option('--
|
|
28
|
-
.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).')
|
|
29
35
|
.action(async (dir, options) => {
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
$state.dir = dir;
|
|
37
|
+
$state.args = options;
|
|
38
|
+
// Override config with fast options
|
|
39
|
+
if (options.fast) {
|
|
40
|
+
fast();
|
|
41
|
+
}
|
|
32
42
|
if (!options.onlycomp) {
|
|
33
|
-
|
|
34
|
-
console.
|
|
35
|
-
await optimize();
|
|
43
|
+
printTitle('PASS 1 - Optimizing');
|
|
44
|
+
console.time('Done');
|
|
45
|
+
await optimize(options.exclude);
|
|
46
|
+
console.timeEnd('Done');
|
|
36
47
|
}
|
|
37
48
|
if (!options.onlyoptim) {
|
|
38
|
-
|
|
39
|
-
console.
|
|
40
|
-
await compress(
|
|
49
|
+
printTitle('PASS 2 - Compressing the rest');
|
|
50
|
+
console.time('Done');
|
|
51
|
+
await compress(options.exclude);
|
|
52
|
+
console.timeEnd('Done');
|
|
41
53
|
}
|
|
42
|
-
|
|
54
|
+
printSummary();
|
|
55
|
+
printWarningsAndErrors(options);
|
|
43
56
|
});
|
|
44
57
|
program.parse();
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
function printSummary() {
|
|
59
|
+
if ($state.summary.nbFiles > 0) {
|
|
60
|
+
printTitle('Summary');
|
|
61
|
+
const dataTable = [['Action', 'Compressed', 'Original', 'Compressed', 'Gain']];
|
|
62
|
+
const config = {
|
|
63
|
+
columns: [
|
|
64
|
+
{ alignment: 'left' },
|
|
65
|
+
{ alignment: 'right' },
|
|
66
|
+
{ alignment: 'right' },
|
|
67
|
+
{ alignment: 'right' },
|
|
68
|
+
{ alignment: 'right' }
|
|
69
|
+
],
|
|
70
|
+
drawHorizontalLine: (lineIndex, rowCount) => {
|
|
71
|
+
return lineIndex === 0 || lineIndex === 1 || lineIndex === rowCount - 1 || lineIndex === rowCount;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
Object.entries($state.summaryByExtension).forEach(([ext, summary]) => {
|
|
75
|
+
if (summary.dataLenCompressed < summary.dataLenUncompressed) {
|
|
76
|
+
const row = [ext, `${summary.nbFilesCompressed} / ${summary.nbFiles}`, formatBytes(summary.dataLenUncompressed), formatBytes(summary.dataLenCompressed), '-' + formatBytes(summary.dataLenUncompressed - summary.dataLenCompressed)];
|
|
77
|
+
dataTable.push(row);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
const total = ['Total', `${$state.summary.nbFilesCompressed} / ${$state.summary.nbFiles}`, formatBytes($state.summary.dataLenUncompressed), formatBytes($state.summary.dataLenCompressed), '-' + formatBytes($state.summary.dataLenUncompressed - $state.summary.dataLenCompressed)];
|
|
81
|
+
dataTable.push(total);
|
|
82
|
+
console.log(table(dataTable, config));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function printWarningsAndErrors(options) {
|
|
86
|
+
let issueCount = 0;
|
|
87
|
+
if ($state.issues.size === 0) {
|
|
88
|
+
printTitle('✔ No issues');
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
printTitle('Issues', kleur.bgRed);
|
|
92
|
+
console.log('');
|
|
93
|
+
for (let [file, list] of $state.issues) {
|
|
94
|
+
console.log(kleur.red('▶ ' + file + '\n'));
|
|
95
|
+
list.forEach(issue => {
|
|
96
|
+
issueCount++;
|
|
97
|
+
console.log(`${issueCount}) ${issue.msg}\n`);
|
|
98
|
+
});
|
|
60
99
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
100
|
+
printTitle(`${issueCount} issue(s) over ${$state.issues.size} files`, kleur.bgRed);
|
|
101
|
+
if (options.failOnIssues) {
|
|
102
|
+
exit(1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
65
105
|
}
|
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(bgColor(kleur.black(` ${msg} `)));
|
|
9
25
|
}
|
package/dist/optimize.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function optimize(): Promise<void>;
|
|
1
|
+
export declare function optimize(exclude?: string): Promise<void>;
|
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}" - Added 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,13 +94,13 @@ 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
|
}
|
|
92
106
|
const sharpFile = sharp(absoluteImgPath);
|
|
@@ -96,21 +110,21 @@ async function processImage(file, $, imgElement) {
|
|
|
96
110
|
* Compress image to WebP
|
|
97
111
|
*/
|
|
98
112
|
let newImage;
|
|
99
|
-
if (
|
|
113
|
+
if (!$state.optimizedFiles.has(absoluteImgPath)) {
|
|
100
114
|
// Let's avoid to optimize same images twice
|
|
101
|
-
|
|
115
|
+
$state.optimizedFiles.add(absoluteImgPath);
|
|
102
116
|
newImage = await compressImage(originalData, {}, true);
|
|
103
117
|
if ((newImage === null || newImage === void 0 ? void 0 : newImage.data) && newImage.data.length < originalData.length) {
|
|
104
118
|
// Do we need to add an new extension?
|
|
105
119
|
const newExtension = `.${newImage.format}`;
|
|
106
120
|
const additionalExtension = path.extname(absoluteImgPath) === newExtension ? '' : newExtension;
|
|
107
121
|
const newFilename = absoluteImgPath + additionalExtension;
|
|
108
|
-
if (
|
|
122
|
+
if (!$state.args.nowrite) {
|
|
109
123
|
fs.writeFile(newFilename, newImage.data);
|
|
110
124
|
}
|
|
111
|
-
|
|
125
|
+
$state.compressedFiles.add(newFilename);
|
|
112
126
|
// Report compression result
|
|
113
|
-
|
|
127
|
+
$state.reportSummary({
|
|
114
128
|
action: (newFilename !== absoluteImgPath) ? `${meta.format}->${newImage.format}` : path.extname(absoluteImgPath),
|
|
115
129
|
originalSize: originalData.length,
|
|
116
130
|
compressedSize: newImage.data.length
|
|
@@ -126,7 +140,7 @@ async function processImage(file, $, imgElement) {
|
|
|
126
140
|
* image if it fits the size.
|
|
127
141
|
*/
|
|
128
142
|
let isEmbed = false;
|
|
129
|
-
if (newImage && newImage.data.length <=
|
|
143
|
+
if (newImage && newImage.data.length <= config.image.embed_size) {
|
|
130
144
|
let datauri = undefined;
|
|
131
145
|
switch (newImage.format) {
|
|
132
146
|
case 'svg':
|
|
@@ -145,7 +159,7 @@ async function processImage(file, $, imgElement) {
|
|
|
145
159
|
img.attr('src', datauri);
|
|
146
160
|
img.removeAttr('loading');
|
|
147
161
|
img.removeAttr('decoding');
|
|
148
|
-
|
|
162
|
+
$state.reportSummary({ action: `${newImage.format}->embed`, originalSize: originalData.length, compressedSize: newImage.data.length });
|
|
149
163
|
}
|
|
150
164
|
}
|
|
151
165
|
if (isEmbed) {
|
|
@@ -162,7 +176,7 @@ async function processImage(file, $, imgElement) {
|
|
|
162
176
|
*/
|
|
163
177
|
const [w, h] = setImageSize(img, meta);
|
|
164
178
|
if (w < 0 || h < 0) {
|
|
165
|
-
|
|
179
|
+
$state.reportIssue(htmlfile, { msg: `Unexpected error in image size calculation src="${attrib_src}" - can't performance some optmizations.` });
|
|
166
180
|
return;
|
|
167
181
|
}
|
|
168
182
|
/*
|
|
@@ -186,16 +200,15 @@ async function processImage(file, $, imgElement) {
|
|
|
186
200
|
const ratio = w / h;
|
|
187
201
|
let valueW = w - step;
|
|
188
202
|
let valueH = Math.trunc(valueW / ratio);
|
|
189
|
-
while (valueW >
|
|
203
|
+
while (valueW > config.image.srcset_min_width) {
|
|
190
204
|
const src = imageSrc(`@${valueW}w`);
|
|
191
|
-
|
|
192
|
-
const absoluteFilename = translateSrc(globalState.dir, path.dirname(file), src);
|
|
205
|
+
const absoluteFilename = translateSrc($state.dir, path.dirname(htmlfile), src);
|
|
193
206
|
// Don't generate srcset file twice
|
|
194
|
-
if (
|
|
207
|
+
if (!$state.compressedFiles.has(absoluteFilename)) {
|
|
195
208
|
// Add file to list avoid recompression
|
|
196
|
-
|
|
209
|
+
$state.compressedFiles.add(absoluteFilename);
|
|
197
210
|
const compressedImage = await compressImage(originalData, { width: valueW, height: valueH }, true);
|
|
198
|
-
if ((compressedImage === null || compressedImage === void 0 ? void 0 : compressedImage.data) &&
|
|
211
|
+
if ((compressedImage === null || compressedImage === void 0 ? void 0 : compressedImage.data) && !$state.args.nowrite) {
|
|
199
212
|
fs.writeFile(absoluteFilename, compressedImage.data);
|
|
200
213
|
}
|
|
201
214
|
}
|
|
@@ -210,10 +223,7 @@ async function processImage(file, $, imgElement) {
|
|
|
210
223
|
}
|
|
211
224
|
}
|
|
212
225
|
catch (e) {
|
|
213
|
-
|
|
214
|
-
console.error(e);
|
|
215
|
-
// exit here
|
|
216
|
-
// exit(1);
|
|
226
|
+
$state.reportIssue(htmlfile, { msg: `Unexpected error while processing image: ${JSON.stringify(e)}` });
|
|
217
227
|
}
|
|
218
228
|
}
|
|
219
229
|
function setImageSize(img, meta) {
|
|
@@ -224,13 +234,13 @@ function setImageSize(img, meta) {
|
|
|
224
234
|
// Check valid values
|
|
225
235
|
if (width !== undefined) {
|
|
226
236
|
if (!isNumeric(width)) {
|
|
227
|
-
console.warn(`Invalid width attribute "${width}" - overriding`);
|
|
237
|
+
//console.warn(`Invalid width attribute "${width}" - overriding`);
|
|
228
238
|
width = undefined;
|
|
229
239
|
}
|
|
230
240
|
}
|
|
231
241
|
if (height !== undefined) {
|
|
232
242
|
if (!isNumeric(height)) {
|
|
233
|
-
console.warn(`Invalid height attribute "${height}" - overriding`);
|
|
243
|
+
//console.warn(`Invalid height attribute "${height}" - overriding`);
|
|
234
244
|
height = undefined;
|
|
235
245
|
}
|
|
236
246
|
}
|
|
@@ -247,7 +257,7 @@ function setImageSize(img, meta) {
|
|
|
247
257
|
const providedRatio = Math.round(w / h * 10) / 10;
|
|
248
258
|
const imageRatio = Math.round(originalRatio * 10) / 10;
|
|
249
259
|
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.`);
|
|
260
|
+
//console.warn(`Image aspect ratio in HTML (${providedRatio}) differs from image aspect ratio (${imageRatio}) - fix width and height or let jampack fill them.`);
|
|
251
261
|
}
|
|
252
262
|
return [w, h];
|
|
253
263
|
}
|
|
@@ -275,11 +285,14 @@ function setImageSize(img, meta) {
|
|
|
275
285
|
// Something when wrong
|
|
276
286
|
throw new Error('Unexpected issue when resolving image sizes');
|
|
277
287
|
}
|
|
278
|
-
export async function optimize() {
|
|
279
|
-
const
|
|
288
|
+
export async function optimize(exclude) {
|
|
289
|
+
const glob = ['**/*.{htm,html}'];
|
|
290
|
+
if (exclude)
|
|
291
|
+
glob.push('!' + exclude);
|
|
292
|
+
const paths = await globby(glob, { cwd: $state.dir });
|
|
280
293
|
// Sequential async
|
|
281
|
-
|
|
282
|
-
await
|
|
283
|
-
|
|
284
|
-
|
|
294
|
+
for (const file of paths) {
|
|
295
|
+
await analyse(file);
|
|
296
|
+
}
|
|
297
|
+
;
|
|
285
298
|
}
|
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.2",
|
|
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",
|