html-minifier-setup 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -5
- package/cli.js +19 -5
- package/package.json +3 -2
- package/src/catalog.js +17 -0
- package/src/equivalence.js +17 -28
- package/src/hazards.js +2 -2
- package/src/index.js +0 -1
- package/src/measure.js +13 -3
- package/src/minify-worker.js +14 -0
- package/src/pages.js +66 -25
- package/src/render.js +2 -1
- package/src/report.js +14 -13
- package/src/select.js +53 -37
- package/src/setup.js +2 -2
- package/src/workers.js +122 -0
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ npx html-minifier-setup [inputs...] [options]
|
|
|
16
16
|
|
|
17
17
|
(Like HTML Minifier, HTML Minifier Setup requires Node.js ≥22.13.)
|
|
18
18
|
|
|
19
|
-
Inputs are HTML files, directories, or URLs (default: the current directory). From directories, up to 20 pages (`--sample`) are picked, spread across
|
|
19
|
+
Inputs are HTML files, directories, or URLs (default: the current directory). From directories, up to 20 pages (`--sample`) are picked, spread across a site’s sections first and then across the folders within them, to cover its templates. The same pages give the same sample, and the report says how many pages it was taken from.
|
|
20
20
|
|
|
21
21
|
The render check, which catches most of what static checks can’t, uses Playwright’s Chromium, or else Chrome or Edge, if installed. Where none is found, HTML Minifier Setup offers to download Chromium.
|
|
22
22
|
|
|
@@ -43,7 +43,6 @@ npx html-minifier --input-dir ./dist --output-dir ./dist
|
|
|
43
43
|
| `--keep <list>` | Options to recommend regardless of risks and gains |
|
|
44
44
|
| `--skip <list>` | Options not to evaluate |
|
|
45
45
|
| `--no-render` | Skip the render check, not recommending options that need it |
|
|
46
|
-
| `--no-equivalence` | Skip HTML Minifier’s equivalence check |
|
|
47
46
|
| `--no-presets` | Skip comparing with HTML Minifier’s presets |
|
|
48
47
|
| `--viewports <list>` | Viewports to render at (default: `1280x800,390x844`) |
|
|
49
48
|
| `--concurrency <count>` | Pages to render at once, across viewports and options (default: 4) |
|
|
@@ -85,7 +84,6 @@ Where full whitespace collapsing breaks a page, HTML Minifier Setup falls back t
|
|
|
85
84
|
| --- | --- | --- |
|
|
86
85
|
| Static | What scripts and style sheets of the page (including linked local or same-origin ones) rely on: comments that frameworks read (e.g., Knockout, Lit, hydration markers), empty elements scripts fill, classes scripts add after `removeUnusedCSS` removed their rules, attributes that style sheets or scripts select on, and CSS that keeps whitespace | Nothing |
|
|
87
86
|
| Render | Anything that changes what the page looks like on load, or adds script errors | Chromium, Chrome, or Edge |
|
|
88
|
-
| Equivalence | HTML Minifier bugs, as HTML Minifier’s own check finds output that means something else to browsers than its source—for the options that check models | An HTML Minifier checkout next to this one (for now) |
|
|
89
87
|
|
|
90
88
|
In the report, options are **recommended**, **kept** (as asked), or not used because they **don’t fit** these pages, have **no gain** by the chosen metric, have **no effect**, or were **skipped**. With `--json`, statuses are `accepted`, `kept`, `rejected`, `no-gain`, `no-effect`, and `skipped`.
|
|
91
89
|
|
|
@@ -100,7 +98,7 @@ The checks can show that options change pages, not that they never will:
|
|
|
100
98
|
* The render check sees pages as they load, not after interaction (e.g., a menu a script opens, using classes `removeUnusedCSS` removed).
|
|
101
99
|
* Scripts from other origins can’t be read, and classes or attributes a script builds at runtime (e.g., `'is-' + state`) can’t be found.
|
|
102
100
|
* Rendering must match pixel for pixel, so lossy changes (e.g., SVG coordinates rounded by `minifySVG`) count as differences.
|
|
103
|
-
* A page sample may miss templates the rest of the site uses
|
|
101
|
+
* A page sample may miss templates the rest of the site uses—for sites with many different templates, raise `--sample`.
|
|
104
102
|
* Options that have no effect can’t be judged: On pages that are minified already, most options are left out, as there’s nothing left for them to do. Run HTML Minifier Setup on pages before minification instead.
|
|
105
103
|
|
|
106
104
|
Test the configuration on your site before deploying it.
|
|
@@ -109,4 +107,39 @@ Options that newer versions of HTML Minifier add are evaluated once HTML Minifie
|
|
|
109
107
|
|
|
110
108
|
Options HTML Minifier can’t express in one configuration are not evaluated: `minifyURLs` depends on each page’s URL, and `removeTagWhitespace` produces invalid HTML.
|
|
111
109
|
|
|
112
|
-
Large pages take longer, especially by `brotli`.
|
|
110
|
+
Large pages take longer, especially by `brotli`.
|
|
111
|
+
|
|
112
|
+
## Working on HTML Minifier Setup
|
|
113
|
+
|
|
114
|
+
Note: This section assumes working with dependencies installed (`npm i`).
|
|
115
|
+
|
|
116
|
+
### Tests
|
|
117
|
+
|
|
118
|
+
```shell
|
|
119
|
+
npm test
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Runs the type check (`tsc`) and then the suites in test/. Render tests skip where no browser is installed (`npx playwright install chromium` installs one). `npm run lint` covers the rest.
|
|
123
|
+
|
|
124
|
+
### Equivalence check
|
|
125
|
+
|
|
126
|
+
With [an HTML Minifier](https://github.com/j9t/html-minifier) checkout next to this one (../html-minifier), runs also use HTML Minifier’s equivalence check (test/equivalence/ there), which finds output that means something else to browsers than its source—HTML Minifier bugs rather than what a site relies on. Options whose output it flags don’t fit, and the report names the check and the HTML Minifier versions involved. It covers the options the check models.
|
|
127
|
+
|
|
128
|
+
The check runs on the main thread and adds noticeably to the time evaluating options takes; `--no-equivalence` skips it. Without a checkout, as for users, it doesn’t run, and test/equivalence.test.js skips.
|
|
129
|
+
|
|
130
|
+
### Benchmarks
|
|
131
|
+
|
|
132
|
+
```shell
|
|
133
|
+
npm run benchmark
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Times a run by phase as the CLI does it—including the equivalence check, where available, and the presets—on the test fixtures, or on files, directories, or URLs passed as arguments. Times are each phase’s fastest iteration.
|
|
137
|
+
|
|
138
|
+
Parameters:
|
|
139
|
+
|
|
140
|
+
* No argument: Runs and, if a baseline exists, shows deltas
|
|
141
|
+
* `--save`: Saves the run as the baseline (benchmark/benchmark-baseline.json, e.g., on `main` before switching to a branch)
|
|
142
|
+
* `--no-render`: Skips the render check, timing what doesn’t need a browser
|
|
143
|
+
* `--iterations=N`: Sets the number of timed iterations (default: 3)
|
|
144
|
+
|
|
145
|
+
Runs are only compared with a baseline taken with the same inputs, render setting, and iterations. To profile, run `node --cpu-prof benchmark/benchmark.js` and load the .cpuprofile file in Chrome DevTools or with `npx speedscope`.
|
package/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import readline from 'node:readline/promises';
|
|
6
6
|
import { pathToFileURL } from 'node:url';
|
|
7
7
|
import { Command, Option } from 'commander';
|
|
8
|
-
import { splitConfig } from './src/catalog.js';
|
|
8
|
+
import { checkCandidates, splitConfig } from './src/catalog.js';
|
|
9
9
|
import { paint, supportsColor } from './src/color.js';
|
|
10
10
|
import { loadEquivalence } from './src/equivalence.js';
|
|
11
11
|
import { METRICS } from './src/measure.js';
|
|
@@ -20,6 +20,8 @@ let progress = createProgress();
|
|
|
20
20
|
|
|
21
21
|
const { version } = JSON.parse(fs.readFileSync(new URL('package.json', import.meta.url), 'utf8'));
|
|
22
22
|
|
|
23
|
+
const INTRO = 'This is HTML Minifier Setup, identifying a recommended configuration for HTML Minifier.';
|
|
24
|
+
|
|
23
25
|
/** @param {unknown} value */
|
|
24
26
|
const list = (value) => (Array.isArray(value) ? value.map(String) : String(value).split(',')).map((item) => item.trim()).filter(Boolean);
|
|
25
27
|
|
|
@@ -47,11 +49,19 @@ const viewports = (value) => {
|
|
|
47
49
|
*/
|
|
48
50
|
async function readConfig(file) {
|
|
49
51
|
const resolved = path.resolve(file);
|
|
50
|
-
|
|
52
|
+
const load = async () => {
|
|
51
53
|
const module = await import(pathToFileURL(resolved).href);
|
|
52
|
-
return module.default
|
|
54
|
+
return 'default' in module ? module.default : module;
|
|
55
|
+
};
|
|
56
|
+
if (/\.[cm]?js$/i.test(resolved)) return load();
|
|
57
|
+
const text = (await fs.promises.readFile(resolved, 'utf8')).replace(/^\uFEFF/, '');
|
|
58
|
+
// As HTML Minifier reads them: other files as JSON, or else as module
|
|
59
|
+
if (/\.json$/i.test(resolved)) return JSON.parse(text);
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(text);
|
|
62
|
+
} catch {
|
|
63
|
+
return load();
|
|
53
64
|
}
|
|
54
|
-
return JSON.parse(await fs.promises.readFile(resolved, 'utf8'));
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
const program = new Command()
|
|
@@ -68,7 +78,8 @@ const program = new Command()
|
|
|
68
78
|
.option('--keep <list>', 'Options to recommend regardless of risks and gains', list, [])
|
|
69
79
|
.option('--skip <list>', 'Options not to evaluate', list, [])
|
|
70
80
|
.option('--no-render', 'Skip the render check (not recommending options that need it)')
|
|
71
|
-
|
|
81
|
+
// For development, where an HTML Minifier checkout sits next to this one
|
|
82
|
+
.addOption(new Option('--no-equivalence', 'Skip HTML Minifier’s equivalence check').hideHelp())
|
|
72
83
|
.option('--no-presets', 'Skip comparing with HTML Minifier’s presets')
|
|
73
84
|
.option('--viewports <list>', 'Viewports to render at', viewports, VIEWPORTS_DEFAULT)
|
|
74
85
|
.option('--concurrency <count>', 'Pages to render at once', positiveInteger, CONCURRENCY_DEFAULT)
|
|
@@ -77,6 +88,7 @@ const program = new Command()
|
|
|
77
88
|
.option('-v, --verbose', 'Show every page and viewport in details, progress line by line where STDERR isn’t a terminal, and more notes')
|
|
78
89
|
.action(async (inputs, settings, command) => {
|
|
79
90
|
if (settings.verbose) progress = createProgress({ verbose: true });
|
|
91
|
+
if (process.stderr.isTTY && !settings.json) progress.log(`${INTRO}\n`);
|
|
80
92
|
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !settings.output && !settings.json;
|
|
81
93
|
// Asked while pages are evaluated, for the offers at the end
|
|
82
94
|
const latest = interactive && !process.env.CI && !process.env.NO_UPDATE_NOTIFIER ? fetchLatestVersion('html-minifier') : null;
|
|
@@ -87,6 +99,7 @@ const program = new Command()
|
|
|
87
99
|
fileConfig = CONFIG_FILE_DEFAULT;
|
|
88
100
|
progress.log(`Using config file ${CONFIG_FILE_DEFAULT}`);
|
|
89
101
|
}
|
|
102
|
+
checkCandidates([...settings.keep, ...settings.skip]);
|
|
90
103
|
const { base, values, preserved, ignored } = splitConfig(fileConfig ? await readConfig(fileConfig) : {});
|
|
91
104
|
if (ignored.length && settings.verbose) progress.log(`Not carried from ${fileConfig}: ${ignored.join(', ')}`);
|
|
92
105
|
/** @param {'fileExt' | 'ignoreDir'} name */
|
|
@@ -114,6 +127,7 @@ const program = new Command()
|
|
|
114
127
|
const result = await customize(pages, {
|
|
115
128
|
base,
|
|
116
129
|
values,
|
|
130
|
+
pagesFound: pages.found,
|
|
117
131
|
metric: settings.metric,
|
|
118
132
|
keep: settings.keep,
|
|
119
133
|
skip: settings.skip,
|
package/package.json
CHANGED
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"minification",
|
|
40
40
|
"minifier",
|
|
41
41
|
"optimization",
|
|
42
|
-
"setup"
|
|
42
|
+
"setup",
|
|
43
|
+
"wizard"
|
|
43
44
|
],
|
|
44
45
|
"license": "MIT",
|
|
45
46
|
"name": "html-minifier-setup",
|
|
@@ -56,5 +57,5 @@
|
|
|
56
57
|
"test:watch": "node --test --watch \"test/**/*.test.js\""
|
|
57
58
|
},
|
|
58
59
|
"type": "module",
|
|
59
|
-
"version": "0.
|
|
60
|
+
"version": "0.2.0"
|
|
60
61
|
}
|
package/src/catalog.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { presets as PRESETS } from 'html-minifier';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* @typedef {object} Candidate
|
|
@@ -107,6 +108,17 @@ export const EXCLUDED = {
|
|
|
107
108
|
|
|
108
109
|
const CLASSIFIED = new Set([...CANDIDATES.map((candidate) => candidate.name), ...MODIFIERS, ...CARRIED, ...PRESERVED, ...Object.keys(EXCLUDED)]);
|
|
109
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Fails on names that aren’t candidates, as options to keep or skip
|
|
113
|
+
* @param {string[]} names
|
|
114
|
+
*/
|
|
115
|
+
export function checkCandidates(names) {
|
|
116
|
+
const known = CANDIDATES.map((candidate) => candidate.name);
|
|
117
|
+
for (const name of names) {
|
|
118
|
+
if (!known.includes(name)) throw new Error(`Unknown option: ${name} (options that can be kept or skipped: ${known.join(', ')})`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
110
122
|
/**
|
|
111
123
|
* Options without a place here
|
|
112
124
|
* @param {string[]} options
|
|
@@ -143,6 +155,11 @@ export function splitConfig(config) {
|
|
|
143
155
|
/** @type {string[]} */
|
|
144
156
|
const ignored = [];
|
|
145
157
|
const names = new Set(CANDIDATES.map((candidate) => candidate.name));
|
|
158
|
+
// A preset can set how input is read, too (e.g., `caseSensitive`)
|
|
159
|
+
const preset = typeof config.preset === 'string' ? /** @type {Record<string, Record<string, unknown>>} */ (PRESETS)[config.preset.toLowerCase()] : undefined;
|
|
160
|
+
for (const name of CARRIED) {
|
|
161
|
+
if (preset && name in preset) base[name] = preset[name];
|
|
162
|
+
}
|
|
146
163
|
for (const [name, value] of Object.entries(config)) {
|
|
147
164
|
if (CARRIED.includes(name)) {
|
|
148
165
|
base[name] = value;
|
package/src/equivalence.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// HTML Minifier’s own check that output means the same to browsers
|
|
2
|
-
// minifier bugs rather than what a site relies on—
|
|
1
|
+
// For development: HTML Minifier’s own check that output means the same to browsers
|
|
2
|
+
// as its source, which catches minifier bugs rather than what a site relies on—
|
|
3
|
+
// used where an HTML Minifier checkout sits next to this one, never duplicated here
|
|
3
4
|
|
|
4
5
|
import fs from 'node:fs';
|
|
5
6
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -11,11 +12,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
11
12
|
* @prop {(input: string, output: string, options: Record<string, unknown>) => string[]} compare Differences found, if any
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
const SPECIFIER_EXPORT = 'html-minifier/equivalence';
|
|
16
|
-
const FILE_SIBLING = fileURLToPath(new URL('../../html-minifier/test/equivalence/compare.js', import.meta.url));
|
|
15
|
+
const FILE_COMPARE = fileURLToPath(new URL('../../html-minifier/test/equivalence/compare.js', import.meta.url));
|
|
17
16
|
// Lists the options the check is run with
|
|
18
|
-
const
|
|
17
|
+
const FILE_RUNNER = fileURLToPath(new URL('../../html-minifier/test/equivalence/runner.js', import.meta.url));
|
|
19
18
|
|
|
20
19
|
/**
|
|
21
20
|
* @param {unknown} difference
|
|
@@ -40,29 +39,19 @@ function versionOf(url) {
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
/**
|
|
43
|
-
* The
|
|
44
|
-
* package; `null` where neither is available
|
|
42
|
+
* The check of an HTML Minifier checkout next to this one; `null` where there is none
|
|
45
43
|
* @returns {Promise<Equivalence | null>}
|
|
46
44
|
*/
|
|
47
45
|
export async function loadEquivalence() {
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
[
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (typeof module?.compareMarkup !== 'function' || !module.OPTIONS_STRUCTURAL || !module.OPTIONS_CODE) continue;
|
|
60
|
-
const supported = new Set([...Object.keys(module.OPTIONS_STRUCTURAL), ...Object.keys(module.OPTIONS_CODE)]);
|
|
61
|
-
return {
|
|
62
|
-
source: source === FILE_SIBLING ? `${source} (HTML Minifier ${versionOf(new URL('../../package.json', pathToFileURL(FILE_SIBLING)))}, minifying with installed ${versionOf(new URL(import.meta.resolve('html-minifier/package.json')))})` : source,
|
|
63
|
-
supports: (name) => supported.has(name),
|
|
64
|
-
compare: (input, output, options) => /** @type {unknown[]} */ (module.compareMarkup(input, output, options)).map(describe)
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
46
|
+
if (!fs.existsSync(FILE_COMPARE) || !fs.existsSync(FILE_RUNNER)) return null;
|
|
47
|
+
const module = await Promise.all([import(pathToFileURL(FILE_RUNNER).href), import(pathToFileURL(FILE_COMPARE).href)])
|
|
48
|
+
.then(([runner, compare]) => ({ ...runner, ...compare }))
|
|
49
|
+
.catch(() => null);
|
|
50
|
+
if (typeof module?.compareMarkup !== 'function' || !module.OPTIONS_STRUCTURAL || !module.OPTIONS_CODE) return null;
|
|
51
|
+
const supported = new Set([...Object.keys(module.OPTIONS_STRUCTURAL), ...Object.keys(module.OPTIONS_CODE)]);
|
|
52
|
+
return {
|
|
53
|
+
source: `${FILE_COMPARE} (HTML Minifier ${versionOf(new URL('../../package.json', pathToFileURL(FILE_COMPARE)))}, minifying with installed ${versionOf(new URL(import.meta.resolve('html-minifier/package.json')))})`,
|
|
54
|
+
supports: (name) => supported.has(name),
|
|
55
|
+
compare: (input, output, options) => /** @type {unknown[]} */ (module.compareMarkup(input, output, options)).map(describe)
|
|
56
|
+
};
|
|
68
57
|
}
|
package/src/hazards.js
CHANGED
|
@@ -52,8 +52,8 @@ const COMMENTS_MEANINGFUL = [
|
|
|
52
52
|
[/^\s*(?:Instance|Template)(?:Begin|End)\b|^\s*#(?:Begin|End)(?:Library|Template)/, 'template editor region']
|
|
53
53
|
];
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
const OPTIONS_ATTRIBUTES = new Set(['collapseAttributeWhitespace', 'collapseBooleanAttributes', 'removeDefaultTypeAttributes', 'removeEmptyAttributes', 'removeRedundantAttributes', 'sortClassNames']);
|
|
56
|
+
const OPTIONS_WHITESPACE = new Set(['collapseInlineTagWhitespace', 'collapseNoBreakSpaces', 'collapseWhitespace', 'trimCustomFragments']);
|
|
57
57
|
|
|
58
58
|
/** @param {string} text */
|
|
59
59
|
const clip = (text, length = 60) => text.length > length ? `${text.slice(0, length - 1)}…` : text;
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export { CANDIDATES, CARRIED, EXCLUDED, FALLBACKS, MODIFIERS, PRESERVED, splitConfig } from './catalog.js';
|
|
2
|
-
export { loadEquivalence } from './equivalence.js';
|
|
3
2
|
export { analyzePage, findHazards } from './hazards.js';
|
|
4
3
|
export { measure } from './measure.js';
|
|
5
4
|
export { createPage, loadPages } from './pages.js';
|
package/src/measure.js
CHANGED
|
@@ -3,8 +3,8 @@ import zlib from 'node:zlib';
|
|
|
3
3
|
|
|
4
4
|
// As in HTML Minifier’s benchmarks: common defaults for on-the-fly compression (quality 11, for
|
|
5
5
|
// files precompressed at build time, takes seconds per large file)
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const GZIP_LEVEL = 6;
|
|
7
|
+
const BROTLI_QUALITY = 6;
|
|
8
8
|
|
|
9
9
|
const brotliCompress = promisify(zlib.brotliCompress);
|
|
10
10
|
const gzipCompress = promisify(zlib.gzip);
|
|
@@ -15,8 +15,18 @@ const gzipCompress = promisify(zlib.gzip);
|
|
|
15
15
|
/** @type {Metric[]} */
|
|
16
16
|
export const METRICS = ['raw', 'gzip', 'brotli'];
|
|
17
17
|
|
|
18
|
+
// For prose
|
|
19
|
+
/** @type {Record<Metric, string>} */
|
|
20
|
+
export const NAMES_METRIC = { raw: 'uncompressed', gzip: 'Gzip', brotli: 'Brotli' };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How much an option saves by the metric, in prose (e.g., “saves nothing with Gzip”)
|
|
24
|
+
* @param {Metric} metric
|
|
25
|
+
*/
|
|
26
|
+
export const byMetric = (metric) => metric === 'raw' ? 'uncompressed' : `with ${NAMES_METRIC[metric]}`;
|
|
27
|
+
|
|
18
28
|
/** @type {Sizes} */
|
|
19
|
-
|
|
29
|
+
const SIZES_ZERO = Object.freeze({ raw: 0, gzip: 0, brotli: 0 });
|
|
20
30
|
|
|
21
31
|
/**
|
|
22
32
|
* Size by one metric as servers would send it, compressed off the main thread
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Minifies what the pool in workers.js sends, answering with the output or the error message
|
|
2
|
+
|
|
3
|
+
import { parentPort } from 'node:worker_threads';
|
|
4
|
+
import { minify } from 'html-minifier';
|
|
5
|
+
|
|
6
|
+
const port = /** @type {import('node:worker_threads').MessagePort} */ (parentPort);
|
|
7
|
+
|
|
8
|
+
port.on('message', async (/** @type {{html: string, options: Record<string, unknown>}} */ { html, options }) => {
|
|
9
|
+
try {
|
|
10
|
+
port.postMessage({ output: await minify(html, { ...options, log: () => {} }) });
|
|
11
|
+
} catch (err) {
|
|
12
|
+
port.postMessage({ error: err instanceof Error ? err.message : String(err) });
|
|
13
|
+
}
|
|
14
|
+
});
|
package/src/pages.js
CHANGED
|
@@ -102,37 +102,77 @@ async function fetchSameOrigin(pageUrl, ref) {
|
|
|
102
102
|
return null;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// Golden ratio, whose multiples spread picks evenly over a range
|
|
106
|
+
const SPREAD = 0.6180339887;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Items in an order whose every start is spread across them, rather than their first ones
|
|
110
|
+
* @template T
|
|
111
|
+
* @param {T[]} items
|
|
112
|
+
* @returns {T[]}
|
|
113
|
+
*/
|
|
114
|
+
function spread(items) {
|
|
115
|
+
/** @type {Set<number>} */
|
|
116
|
+
const picked = new Set();
|
|
117
|
+
// Bounded, as the last gaps can take a while to hit
|
|
118
|
+
for (let step = 0; step < 4 * items.length && picked.size < items.length; step++) {
|
|
119
|
+
picked.add(Math.floor(((step * SPREAD) % 1) * items.length));
|
|
120
|
+
}
|
|
121
|
+
for (let index = 0; index < items.length; index++) picked.add(index);
|
|
122
|
+
return [...picked].map((index) => /** @type {T} */ (items[index]));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Takes from each list in turn, up to `limit` items
|
|
127
|
+
* @param {string[][]} lists
|
|
128
|
+
* @param {number} limit
|
|
129
|
+
*/
|
|
130
|
+
function interleave(lists, limit) {
|
|
131
|
+
/** @type {string[]} */
|
|
132
|
+
const items = [];
|
|
133
|
+
let active = lists.filter((list) => list.length);
|
|
134
|
+
for (let round = 0; active.length && items.length < limit; round++) {
|
|
135
|
+
for (const list of active) {
|
|
136
|
+
if (items.length < limit) items.push(/** @type {string} */ (list[round]));
|
|
137
|
+
}
|
|
138
|
+
active = active.filter((list) => list.length > round + 1);
|
|
139
|
+
}
|
|
140
|
+
return items;
|
|
141
|
+
}
|
|
142
|
+
|
|
105
143
|
/**
|
|
106
|
-
* Picks pages
|
|
107
|
-
*
|
|
144
|
+
* Picks pages across a site’s sections first and then across the folders within them, so that
|
|
145
|
+
* a sample covers its templates rather than one section’s first pages; the same paths always
|
|
146
|
+
* give the same sample
|
|
108
147
|
* @param {string[]} paths
|
|
109
148
|
* @param {number} count
|
|
110
149
|
* @returns {string[]}
|
|
111
150
|
*/
|
|
112
151
|
export function samplePaths(paths, count) {
|
|
113
152
|
if (paths.length <= count) return paths;
|
|
114
|
-
/** @
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
/** @type {string[]} */
|
|
128
|
-
const sample = [];
|
|
129
|
-
for (let round = 0; sample.length < count; round++) {
|
|
130
|
-
for (const queue of queues) {
|
|
131
|
-
const item = queue[round];
|
|
132
|
-
if (item !== undefined && sample.length < count) sample.push(item);
|
|
153
|
+
/** @typedef {{files: string[], folders: Map<string, Folder>}} Folder */
|
|
154
|
+
/** @type {Folder} */
|
|
155
|
+
const root = { files: [], folders: new Map() };
|
|
156
|
+
for (const item of [...paths].sort()) {
|
|
157
|
+
const segments = item.split(/[\\/]/);
|
|
158
|
+
let folder = root;
|
|
159
|
+
for (const segment of segments.slice(0, -1)) {
|
|
160
|
+
let next = folder.folders.get(segment);
|
|
161
|
+
if (!next) {
|
|
162
|
+
next = { files: [], folders: new Map() };
|
|
163
|
+
folder.folders.set(segment, next);
|
|
164
|
+
}
|
|
165
|
+
folder = next;
|
|
133
166
|
}
|
|
167
|
+
folder.files.push(item);
|
|
134
168
|
}
|
|
135
|
-
|
|
169
|
+
/**
|
|
170
|
+
* The first `count` pages of the folder, in the order they’re picked
|
|
171
|
+
* @param {Folder} folder
|
|
172
|
+
* @returns {string[]}
|
|
173
|
+
*/
|
|
174
|
+
const order = (folder) => interleave([spread(folder.files), ...spread([...folder.folders.values()]).map(order)], count);
|
|
175
|
+
return order(root);
|
|
136
176
|
}
|
|
137
177
|
|
|
138
178
|
/**
|
|
@@ -185,10 +225,10 @@ export async function findOutsideLinks(root, ignore = ['node_modules']) {
|
|
|
185
225
|
}
|
|
186
226
|
|
|
187
227
|
/**
|
|
188
|
-
* Reads pages from files, directories, and URLs
|
|
228
|
+
* Reads pages from files, directories, and URLs, sampled where there are more
|
|
189
229
|
* @param {string[]} inputs
|
|
190
230
|
* @param {{extensions?: string[], ignore?: string[], sample?: number}} [settings]
|
|
191
|
-
* @returns {Promise<Page[]>}
|
|
231
|
+
* @returns {Promise<Page[] & {found: number}>} `found`: pages found before sampling
|
|
192
232
|
*/
|
|
193
233
|
export async function loadPages(inputs, { extensions = EXTENSIONS_DEFAULT, ignore = ['node_modules'], sample = 20 } = {}) {
|
|
194
234
|
// As HTML Minifier reads them, with `*` for all files
|
|
@@ -213,7 +253,7 @@ export async function loadPages(inputs, { extensions = EXTENSIONS_DEFAULT, ignor
|
|
|
213
253
|
}
|
|
214
254
|
|
|
215
255
|
const keys = samplePaths([...files.map(({ file }) => file), ...urls], sample);
|
|
216
|
-
|
|
256
|
+
const pages = await Promise.all(keys.map(async (key) => {
|
|
217
257
|
if (RE_URL_INPUT.test(key)) {
|
|
218
258
|
const response = await fetch(key, { signal: AbortSignal.timeout(30000) });
|
|
219
259
|
if (!response.ok) throw new Error(`Couldn’t fetch ${key}: ${response.status}`);
|
|
@@ -227,4 +267,5 @@ export async function loadPages(inputs, { extensions = EXTENSIONS_DEFAULT, ignor
|
|
|
227
267
|
root
|
|
228
268
|
});
|
|
229
269
|
}));
|
|
270
|
+
return Object.assign(pages, { found: files.length + urls.length });
|
|
230
271
|
}
|
package/src/render.js
CHANGED
|
@@ -30,6 +30,7 @@ import { isInside } from './pages.js';
|
|
|
30
30
|
/** @typedef {{width: number, height: number}} Viewport */
|
|
31
31
|
/** @typedef {{shot: Buffer, text: string, errors: string[], blocked: string[]}} Capture */
|
|
32
32
|
|
|
33
|
+
// @@ Probe a higher or core-based default, weighing speed against memory and inconclusive renders
|
|
33
34
|
export const CONCURRENCY_DEFAULT = 4;
|
|
34
35
|
|
|
35
36
|
/** @type {Viewport[]} */
|
|
@@ -151,7 +152,7 @@ export const combineOriginals = (first, second) => ({
|
|
|
151
152
|
// Playwright’s Chromium, or else browsers people tend to have, as original and minified pages are
|
|
152
153
|
// compared in the same browser
|
|
153
154
|
/** @type {Browser[]} */
|
|
154
|
-
|
|
155
|
+
const BROWSERS = [{ name: 'Chromium' }, { name: 'Chrome', channel: 'chrome' }, { name: 'Edge', channel: 'msedge' }];
|
|
155
156
|
|
|
156
157
|
/** The path of Playwright’s command line interface, if installed */
|
|
157
158
|
export function playwrightCli() {
|
package/src/report.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { presets as PRESETS } from 'html-minifier';
|
|
2
2
|
import { VISIBLE_BY_DESIGN } from './catalog.js';
|
|
3
3
|
import { paint } from './color.js';
|
|
4
|
-
import { METRICS } from './measure.js';
|
|
4
|
+
import { METRICS, NAMES_METRIC } from './measure.js';
|
|
5
5
|
import { MESSAGE_RENDERS_SAME } from './select.js';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const URL_SCHEMA = 'https://raw.githubusercontent.com/j9t/html-minifier/main/html-minifier.schema.json';
|
|
8
8
|
|
|
9
9
|
const MAX_LISTED = 3;
|
|
10
10
|
|
|
11
11
|
// Render differences, worded so as not to read as errors
|
|
12
|
-
const LABEL_RENDER = '
|
|
12
|
+
const LABEL_RENDER = 'Looks different';
|
|
13
13
|
|
|
14
14
|
/** @param {string} level */
|
|
15
|
-
const labelOf = (level) => level === 'render' ? LABEL_RENDER : level
|
|
15
|
+
const labelOf = (level) => level === 'render' ? LABEL_RENDER : `${level.charAt(0).toUpperCase()}${level.slice(1)}`;
|
|
16
16
|
|
|
17
17
|
// Share of the size below which whitespace collapsing suggests minified pages
|
|
18
18
|
const SHARE_MINIFIED = 0.01;
|
|
@@ -118,10 +118,10 @@ function condenseReasons(reasons) {
|
|
|
118
118
|
const pages = [...new Set(run.flatMap((reason) => reason.page ?? []))];
|
|
119
119
|
const count = `${pages.length} page${pages.length === 1 ? '' : 's'}`;
|
|
120
120
|
if (pages.join('\u0000') === pagesRenderedBefore) {
|
|
121
|
-
lines.push(`${LABEL_RENDER}
|
|
121
|
+
lines.push(`${LABEL_RENDER} on the same ${count}`);
|
|
122
122
|
} else {
|
|
123
123
|
const example = /** @type {import('./select.js').Reason} */ (run.find((reason) => reason.message.includes('“')) ?? run[0]);
|
|
124
|
-
lines.push(`${LABEL_RENDER}
|
|
124
|
+
lines.push(`${LABEL_RENDER} on ${count} (${listShort(pages)}), e.g., ${example.page ? `${example.page}: ` : ''}${example.message}`);
|
|
125
125
|
}
|
|
126
126
|
pagesRenderedBefore = pages.join('\u0000');
|
|
127
127
|
continue;
|
|
@@ -135,7 +135,7 @@ function condenseReasons(reasons) {
|
|
|
135
135
|
byMessage.set(key, entry);
|
|
136
136
|
}
|
|
137
137
|
for (const { level: levelEntry, message, pages } of byMessage.values()) {
|
|
138
|
-
lines.push(`${levelEntry}${pages.length ? `
|
|
138
|
+
lines.push(`${labelOf(levelEntry)}${pages.length ? ` (${listShort(pages)})` : ''}: ${message}`);
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
return lines;
|
|
@@ -174,12 +174,13 @@ export function formatReport(result, { renderReason = '', color = false, verbose
|
|
|
174
174
|
? `${renderedAs}: The recommended configuration looks the same as the original pages`
|
|
175
175
|
: paint('warning', `${renderedAs}: The recommended configuration may not look the same as the original pages (see notes)`, color);
|
|
176
176
|
const lines = [
|
|
177
|
-
`• Evaluated ${result.pages.length} page${result.pages.length === 1 ? '' : 's'}${dirShared ? ` in ${dirShared}` : ''}
|
|
177
|
+
`• Evaluated ${result.pagesFound ? `${result.pages.length} of ${result.pagesFound.toLocaleString('en-US')} pages` : `${result.pages.length} page${result.pages.length === 1 ? '' : 's'}`}${dirShared ? ` in ${dirShared}` : ''}${result.pagesFound ? ' (use `--sample` to evaluate more)' : ''}, judging gains by ${NAMES_METRIC[metric]} size`,
|
|
178
178
|
result.rendered
|
|
179
179
|
? `• ${renderSummary}`
|
|
180
180
|
: `• Not render-checked${renderReason ? `: ${renderReason}` : ''}—options that need a render check aren’t recommended`,
|
|
181
181
|
...result.rendered && blocked.length ? [`• ${paint('warning', `Rendered without ${blocked.length} file${blocked.length === 1 ? '' : 's'} that symbolic links lead to outside the site (${listShort(blocked)}), which may change how pages look—\`--follow-links\` follows them`, color)}`] : [],
|
|
182
|
-
|
|
182
|
+
// Only in development
|
|
183
|
+
...result.equivalence ? [`• Equivalence-checked with ${result.equivalence}`] : [],
|
|
183
184
|
'',
|
|
184
185
|
table([
|
|
185
186
|
['Option', 'Status', 'Saves alone', 'Contributes', 'Variant'],
|
|
@@ -192,12 +193,12 @@ export function formatReport(result, { renderReason = '', color = false, verbose
|
|
|
192
193
|
])
|
|
193
194
|
]),
|
|
194
195
|
'',
|
|
195
|
-
...METRICS.map((m) => `${m === 'raw' ? 'Uncompressed' : m
|
|
196
|
+
...METRICS.map((m) => `${m === 'raw' ? 'Uncompressed' : NAMES_METRIC[m]}: ${formatChange(result.baseline[m], result.final[m], color)}`)
|
|
196
197
|
];
|
|
197
198
|
|
|
198
199
|
if (result.presets.length) {
|
|
199
200
|
const finalMetric = result.final[metric];
|
|
200
|
-
lines.push('', `Compared with HTML Minifier’s presets (${metric
|
|
201
|
+
lines.push('', `Compared with HTML Minifier’s presets (${NAMES_METRIC[metric]}):`, table(result.presets.map((preset) => {
|
|
201
202
|
const share = finalMetric ? (preset.size / finalMetric - 1) * 100 : 0;
|
|
202
203
|
const size = preset.errors ? `fails on ${preset.errors} of ${result.pages.length}` : formatBytes(preset.size);
|
|
203
204
|
const relative = preset.errors ? '' : Math.abs(share) < 0.05 ? 'same size' : `${Math.abs(share).toFixed(1)}% ${share > 0 ? 'larger' : 'smaller'}`;
|
|
@@ -232,12 +233,12 @@ export function formatReport(result, { renderReason = '', color = false, verbose
|
|
|
232
233
|
for (const evaluation of explained) {
|
|
233
234
|
lines.push(` ${evaluation.name} (${formatStatus(evaluation.status)})`);
|
|
234
235
|
const reasonLines = verbose
|
|
235
|
-
? evaluation.reasons.map((reason) => `${labelOf(reason.level)}${reason.page ? `
|
|
236
|
+
? evaluation.reasons.map((reason) => `${labelOf(reason.level)}${reason.page ? ` (${reason.page})` : ''}: ${reason.message}`)
|
|
236
237
|
: condenseReasons(evaluation.reasons);
|
|
237
238
|
condensed ||= reasonLines.length < evaluation.reasons.length;
|
|
238
239
|
if (evaluation.reasons.some((reason) => reason.level === 'render')) {
|
|
239
240
|
const design = VISIBLE_BY_DESIGN[evaluation.name];
|
|
240
|
-
lines.push(`
|
|
241
|
+
lines.push(` Why: ${design ? `${design}—that’s how the option works, not an error` : 'Not meant to change how pages look—possibly an HTML Minifier bug worth reporting'}`);
|
|
241
242
|
}
|
|
242
243
|
lines.push(...reasonLines.map((line) => ` ${line}`));
|
|
243
244
|
}
|
package/src/select.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
// is measured and checked on its own, then the accepted ones are rendered together, isolating
|
|
3
3
|
// options that break rendering on their own or only in combination
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { CANDIDATES, FALLBACKS, readSchema, unclassified } from './catalog.js';
|
|
5
|
+
import { presets as PRESETS } from 'html-minifier';
|
|
6
|
+
import { CANDIDATES, checkCandidates, FALLBACKS, readSchema, unclassified } from './catalog.js';
|
|
7
7
|
import { analyzePage, findHazards } from './hazards.js';
|
|
8
|
-
import { sizeOf, sumSizes } from './measure.js';
|
|
8
|
+
import { byMetric, sizeOf, sumSizes } from './measure.js';
|
|
9
|
+
import { minifyInWorkers } from './workers.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* @typedef {object} Reason
|
|
@@ -33,8 +34,9 @@ import { sizeOf, sumSizes } from './measure.js';
|
|
|
33
34
|
* @prop {import('./measure.js').Sizes} final
|
|
34
35
|
* @prop {Evaluation[]} evaluations
|
|
35
36
|
* @prop {string[]} pages
|
|
37
|
+
* @prop {number | null} pagesFound Pages the sample was taken from, where it left any out
|
|
36
38
|
* @prop {boolean} rendered
|
|
37
|
-
* @prop {string | null} equivalence Source of the equivalence check, if used
|
|
39
|
+
* @prop {string | null} equivalence Source of the equivalence check, if used (in development)
|
|
38
40
|
* @prop {string[]} notes
|
|
39
41
|
* @prop {PresetComparison[]} presets
|
|
40
42
|
* @prop {boolean} renderedSame Whether the configuration was found to look the same as the original pages, with every file they requested
|
|
@@ -61,8 +63,9 @@ import { sizeOf, sumSizes } from './measure.js';
|
|
|
61
63
|
* @prop {string[]} [keep] Candidates to accept regardless of risks and gains
|
|
62
64
|
* @prop {string[]} [skip] Candidates not to evaluate
|
|
63
65
|
* @prop {import('./render.js').Renderer | null} [renderer]
|
|
64
|
-
* @prop {import('./equivalence.js').Equivalence | null} [equivalence]
|
|
66
|
+
* @prop {import('./equivalence.js').Equivalence | null} [equivalence] HTML Minifier’s check, in development
|
|
65
67
|
* @prop {(html: string, options: Record<string, unknown>) => Promise<string>} [minify]
|
|
68
|
+
* @prop {number} [pagesFound] Pages the given ones were sampled from
|
|
66
69
|
* @prop {boolean} [presets] Whether to compare the result with HTML Minifier’s presets
|
|
67
70
|
* @prop {{version: string, options: string[]} | null} [schema] HTML Minifier’s options, by default the installed version’s
|
|
68
71
|
* @prop {(event: import('./progress.js').ProgressEvent) => void} [onProgress]
|
|
@@ -97,16 +100,14 @@ export async function customize(pagesGiven, settings = {}) {
|
|
|
97
100
|
skip = [],
|
|
98
101
|
renderer = null,
|
|
99
102
|
equivalence = null,
|
|
100
|
-
minify =
|
|
103
|
+
minify = minifyInWorkers,
|
|
104
|
+
pagesFound,
|
|
101
105
|
presets = true,
|
|
102
106
|
schema = readSchema(),
|
|
103
107
|
onProgress = () => {}
|
|
104
108
|
} = settings;
|
|
105
109
|
|
|
106
|
-
|
|
107
|
-
for (const name of [...keep, ...skip]) {
|
|
108
|
-
if (!names.includes(name)) throw new Error(`Unknown option: ${name} (options that can be kept or skipped: ${names.join(', ')})`);
|
|
109
|
-
}
|
|
110
|
+
checkCandidates([...keep, ...skip]);
|
|
110
111
|
|
|
111
112
|
// By page rather than by ID, as pages from different roots may share one
|
|
112
113
|
/** @type {Map<import('./pages.js').Page, Map<string, Promise<string>>>} */
|
|
@@ -163,12 +164,13 @@ export async function customize(pagesGiven, settings = {}) {
|
|
|
163
164
|
// Pages the minifier can’t read with the carried options are left out
|
|
164
165
|
/** @type {import('./pages.js').Page[]} */
|
|
165
166
|
const pages = [];
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
const readable = await Promise.allSettled(pagesGiven.map((page) => run(page, base)));
|
|
168
|
+
for (const [index, page] of pagesGiven.entries()) {
|
|
169
|
+
const outcome = readable[index];
|
|
170
|
+
if (outcome?.status === 'fulfilled') {
|
|
169
171
|
pages.push(page);
|
|
170
|
-
}
|
|
171
|
-
notes.add(`Left out ${page.id}, which the minifier can’t read: ${messageOf(
|
|
172
|
+
} else {
|
|
173
|
+
notes.add(`Left out ${page.id}, which the minifier can’t read: ${messageOf(outcome?.reason)}`);
|
|
172
174
|
}
|
|
173
175
|
}
|
|
174
176
|
if (!pages.length) throw new Error(`The minifier can read none of the pages (${[...notes].join('; ')})—\`continueOnParseError\` in a starting configuration may help`);
|
|
@@ -231,13 +233,15 @@ export async function customize(pagesGiven, settings = {}) {
|
|
|
231
233
|
const reasons = [];
|
|
232
234
|
let changed = false;
|
|
233
235
|
let savings = 0;
|
|
236
|
+
// Started at once, to minify pages in parallel
|
|
237
|
+
const pairs = pages.map((page) => Promise.all([run(page, ctx), run(page, full)]));
|
|
238
|
+
for (const pair of pairs) pair.catch(() => {});
|
|
234
239
|
for (const [index, page] of pages.entries()) {
|
|
235
240
|
await onPage(index, page);
|
|
236
241
|
let outBefore;
|
|
237
242
|
let outAfter;
|
|
238
243
|
try {
|
|
239
|
-
outBefore = await
|
|
240
|
-
outAfter = await run(page, full);
|
|
244
|
+
[outBefore, outAfter] = await /** @type {Promise<[string, string]>} */ (pairs[index]);
|
|
241
245
|
} catch (err) {
|
|
242
246
|
reasons.push({ level: 'error', page: page.id, message: `Minification failed: ${messageOf(err)}` });
|
|
243
247
|
continue;
|
|
@@ -261,7 +265,7 @@ export async function customize(pagesGiven, settings = {}) {
|
|
|
261
265
|
if (reasons.some((reason) => reason.level === 'error')) return { status: 'rejected', savings, reasons };
|
|
262
266
|
if (keep.includes(name)) return { status: 'kept', savings, reasons };
|
|
263
267
|
if (!changed) return { status: 'no-effect', savings, reasons: [{ level: 'info', message: 'No effect on these pages' }] };
|
|
264
|
-
if (savings <= 0) return { status: 'no-gain', savings, reasons: [{ level: 'info', message: `Saves nothing ${metric
|
|
268
|
+
if (savings <= 0) return { status: 'no-gain', savings, reasons: [{ level: 'info', message: `Saves nothing ${byMetric(metric)}` }, ...reasons] };
|
|
265
269
|
return { status: isBlocking(reasons) ? 'rejected' : 'accepted', savings, reasons };
|
|
266
270
|
}
|
|
267
271
|
|
|
@@ -422,29 +426,38 @@ export async function customize(pagesGiven, settings = {}) {
|
|
|
422
426
|
*/
|
|
423
427
|
async function settleContributions() {
|
|
424
428
|
let dropped = false;
|
|
425
|
-
|
|
426
|
-
|
|
429
|
+
// After a drop, only options that gained nothing are measured again, as a drop rarely makes
|
|
430
|
+
// others gain nothing; a full round then confirms
|
|
431
|
+
/** @type {Set<Evaluation> | null} */
|
|
432
|
+
let suspects = null;
|
|
433
|
+
for (;;) {
|
|
427
434
|
const used = inUse();
|
|
435
|
+
const measured = suspects ? used.filter((evaluation) => suspects?.has(evaluation)) : used;
|
|
428
436
|
const final = await totalOf(combine(used));
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
437
|
+
let done = 0;
|
|
438
|
+
onProgress({ phase: 'contribute', detail: `${measured.length} options`, current: 0, total: measured.length });
|
|
439
|
+
await Promise.all(measured.map(async (evaluation) => {
|
|
432
440
|
const without = used.filter((other) => other !== evaluation && requirementOf(other.name) !== evaluation.name);
|
|
433
441
|
evaluation.contribution = await totalOf(combine(without)) - final;
|
|
434
|
-
|
|
435
|
-
|
|
442
|
+
onProgress({ phase: 'contribute', detail: evaluation.name, current: ++done, total: measured.length });
|
|
443
|
+
}));
|
|
444
|
+
const [weakest] = measured
|
|
436
445
|
.filter((evaluation) => evaluation.status === 'accepted' && (evaluation.contribution ?? 0) <= 0)
|
|
437
446
|
.sort((a, b) => (a.contribution ?? 0) - (b.contribution ?? 0));
|
|
438
447
|
if (weakest) {
|
|
439
448
|
weakest.status = 'no-gain';
|
|
440
|
-
weakest.reasons.push({ level: 'info', message: `Saves nothing alongside the other options (${weakest.contribution} B
|
|
449
|
+
weakest.reasons.push({ level: 'info', message: `Saves nothing alongside the other options (${weakest.contribution} B ${byMetric(metric)})` });
|
|
441
450
|
delete weakest.contribution;
|
|
442
451
|
dropDependents(weakest);
|
|
443
|
-
changed = true;
|
|
444
452
|
dropped = true;
|
|
453
|
+
const remaining = inUse().filter((evaluation) => evaluation.status === 'accepted' && (evaluation.contribution ?? 0) <= 0);
|
|
454
|
+
suspects = remaining.length ? new Set(remaining) : null;
|
|
455
|
+
} else if (suspects) {
|
|
456
|
+
suspects = null;
|
|
457
|
+
} else {
|
|
458
|
+
return dropped;
|
|
445
459
|
}
|
|
446
460
|
}
|
|
447
|
-
return dropped;
|
|
448
461
|
}
|
|
449
462
|
|
|
450
463
|
// 3. What each option contributes; as fewer options may render differently, too, rendering
|
|
@@ -460,18 +473,16 @@ export async function customize(pagesGiven, settings = {}) {
|
|
|
460
473
|
onProgress({ phase: 'presets', detail: presetsCompared.map(([name]) => name).join(', '), current: 0, total: presetsCompared.length });
|
|
461
474
|
/** @type {PresetComparison[]} */
|
|
462
475
|
const comparisons = await Promise.all(presetsCompared.map(async ([name, preset]) => {
|
|
476
|
+
/** @type {Record<string, unknown>} */
|
|
463
477
|
const optionsPreset = { ...base, ...preset };
|
|
464
478
|
/** @type {{page: import('./pages.js').Page, html: string}[]} */
|
|
465
479
|
const minified = [];
|
|
466
|
-
|
|
467
|
-
for (const page of pages) {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
} catch {
|
|
471
|
-
errors++;
|
|
472
|
-
}
|
|
473
|
-
await yieldTurn();
|
|
480
|
+
const outcomes = await Promise.allSettled(pages.map((page) => run(page, optionsPreset)));
|
|
481
|
+
for (const [index, page] of pages.entries()) {
|
|
482
|
+
const outcome = outcomes[index];
|
|
483
|
+
if (outcome?.status === 'fulfilled') minified.push({ page, html: outcome.value });
|
|
474
484
|
}
|
|
485
|
+
const errors = pages.length - minified.length;
|
|
475
486
|
const verdicts = renderer ? await Promise.all(minified.map(async ({ page, html }) => {
|
|
476
487
|
const verdict = await compare(page, html);
|
|
477
488
|
for (const note of verdict.notes) notes.add(`${page.id}: ${note}`);
|
|
@@ -484,7 +495,11 @@ export async function customize(pagesGiven, settings = {}) {
|
|
|
484
495
|
failing: verdicts ? verdicts.filter((verdict) => !verdict.equal && !verdict.inconclusive).length : null,
|
|
485
496
|
inconclusive: verdicts ? verdicts.filter((verdict) => verdict.inconclusive).length : null,
|
|
486
497
|
errors,
|
|
487
|
-
rejected: Object.keys(preset).filter((key) =>
|
|
498
|
+
rejected: Object.keys(preset).filter((key) => {
|
|
499
|
+
const evaluation = evaluations.get(key);
|
|
500
|
+
// Or in use only as a gentler variant than the preset’s
|
|
501
|
+
return optionsPreset[key] && (evaluation?.status === 'rejected' || (evaluation?.variant && Object.keys(evaluation.options).some((option) => !optionsPreset[option])));
|
|
502
|
+
})
|
|
488
503
|
};
|
|
489
504
|
}));
|
|
490
505
|
const [baseline, final] = await measuring;
|
|
@@ -496,6 +511,7 @@ export async function customize(pagesGiven, settings = {}) {
|
|
|
496
511
|
final,
|
|
497
512
|
evaluations: [...evaluations.values()],
|
|
498
513
|
pages: pages.map((page) => page.id),
|
|
514
|
+
pagesFound: pagesFound !== undefined && pagesFound > pagesGiven.length ? pagesFound : null,
|
|
499
515
|
rendered: Boolean(renderer),
|
|
500
516
|
equivalence: equivalence?.source ?? null,
|
|
501
517
|
notes: [...notes],
|
package/src/setup.js
CHANGED
|
@@ -13,7 +13,7 @@ export const CONFIG_FILE = 'html-minifier.config.json';
|
|
|
13
13
|
const LOCKFILES = [['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun'], ['package-lock.json', 'npm']];
|
|
14
14
|
|
|
15
15
|
/** @type {Record<PackageManager, string[]>} */
|
|
16
|
-
|
|
16
|
+
const INSTALL_ARGS = {
|
|
17
17
|
npm: ['install', '--save-dev'],
|
|
18
18
|
pnpm: ['add', '--save-dev'],
|
|
19
19
|
yarn: ['add', '--dev'],
|
|
@@ -200,7 +200,7 @@ export async function offerSetup({ config, dirProject, configPreset = null, cwd,
|
|
|
200
200
|
const dirInstalled = path.dirname(path.dirname(found.dir));
|
|
201
201
|
const where = dirInstalled === cwd ? '' : ` in ${describeDir(dirInstalled, cwd)}`;
|
|
202
202
|
if (!versionLatest || !isNewer(versionLatest, found.version)) {
|
|
203
|
-
log(`HTML Minifier ${found.version}
|
|
203
|
+
log(`HTML Minifier ${found.version}${versionLatest ? ' (latest)' : ''} is installed${where}`);
|
|
204
204
|
} else if (isYes(await ask(`Update HTML Minifier ${found.version}${where} to ${versionLatest} (latest)? (y/N) `))) {
|
|
205
205
|
if (!await install(dirInstalled, detectPackageManager(dirInstalled))) log('Update failed');
|
|
206
206
|
}
|
package/src/workers.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Minifies on worker threads, so that pages minify in parallel and the main thread stays free for
|
|
2
|
+
// static checks, rendering, and progress
|
|
3
|
+
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { Worker } from 'node:worker_threads';
|
|
6
|
+
import { minify } from 'html-minifier';
|
|
7
|
+
|
|
8
|
+
// One core is left to the main thread; more workers mostly add memory, as each loads the minifier
|
|
9
|
+
const SIZE_DEFAULT = Math.max(1, Math.min(os.availableParallelism() - 1, 8));
|
|
10
|
+
const FILE_WORKER = new URL('./minify-worker.js', import.meta.url);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} Job
|
|
14
|
+
* @prop {string} html
|
|
15
|
+
* @prop {Record<string, unknown>} options
|
|
16
|
+
* @prop {(output: string) => void} resolve
|
|
17
|
+
* @prop {(err: Error) => void} reject
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** @typedef {{worker: Worker, job: Job | null}} Slot */
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} html
|
|
24
|
+
* @param {Record<string, unknown>} options
|
|
25
|
+
*/
|
|
26
|
+
const minifyHere = (html, options) => minify(html, { ...options, log: () => {} });
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Workers started as needed, up to `size`, which let the process exit while idle
|
|
30
|
+
* @param {{size?: number}} [settings]
|
|
31
|
+
*/
|
|
32
|
+
export function createMinifierPool({ size = SIZE_DEFAULT } = {}) {
|
|
33
|
+
/** @type {Slot[]} */
|
|
34
|
+
const slots = [];
|
|
35
|
+
/** @type {Job[]} */
|
|
36
|
+
const queue = [];
|
|
37
|
+
|
|
38
|
+
/** @param {Slot} slot */
|
|
39
|
+
const release = (slot) => {
|
|
40
|
+
slot.job = null;
|
|
41
|
+
slot.worker.unref();
|
|
42
|
+
pump();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const spawn = () => {
|
|
46
|
+
/** @type {Slot} */
|
|
47
|
+
// Without the parent’s Node flags, some of which (e.g., `--input-type`) workers can’t take
|
|
48
|
+
const slot = { worker: new Worker(FILE_WORKER, { execArgv: [] }), job: null };
|
|
49
|
+
slot.worker.unref();
|
|
50
|
+
slot.worker.on('message', (/** @type {{output?: string, error?: string}} */ data) => {
|
|
51
|
+
const { job } = slot;
|
|
52
|
+
if (data.error === undefined) {
|
|
53
|
+
job?.resolve(data.output ?? '');
|
|
54
|
+
} else {
|
|
55
|
+
job?.reject(new Error(data.error));
|
|
56
|
+
}
|
|
57
|
+
release(slot);
|
|
58
|
+
});
|
|
59
|
+
// Where a worker fails as a whole (e.g., out of memory), its job fails and others take over
|
|
60
|
+
slot.worker.on('error', (/** @type {Error} */ err) => {
|
|
61
|
+
const index = slots.indexOf(slot);
|
|
62
|
+
if (index !== -1) slots.splice(index, 1);
|
|
63
|
+
slot.job?.reject(err);
|
|
64
|
+
slot.job = null;
|
|
65
|
+
pump();
|
|
66
|
+
});
|
|
67
|
+
slots.push(slot);
|
|
68
|
+
return slot;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function pump() {
|
|
72
|
+
while (queue.length) {
|
|
73
|
+
const slot = slots.find((candidate) => !candidate.job) ?? (slots.length < size ? spawn() : null);
|
|
74
|
+
if (!slot) return;
|
|
75
|
+
const job = /** @type {Job} */ (queue.shift());
|
|
76
|
+
slot.job = job;
|
|
77
|
+
slot.worker.ref();
|
|
78
|
+
slot.worker.postMessage({ html: job.html, options: job.options });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
size,
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} html
|
|
86
|
+
* @param {Record<string, unknown>} options `log` is left out, as workers log nothing
|
|
87
|
+
* @returns {Promise<string>}
|
|
88
|
+
*/
|
|
89
|
+
minify(html, options) {
|
|
90
|
+
const rest = { ...options };
|
|
91
|
+
delete rest.log;
|
|
92
|
+
// Functions, for one, can’t be sent to workers
|
|
93
|
+
try {
|
|
94
|
+
structuredClone(rest);
|
|
95
|
+
} catch {
|
|
96
|
+
return minifyHere(html, rest);
|
|
97
|
+
}
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
queue.push({ html, options: rest, resolve, reject });
|
|
100
|
+
pump();
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
async close() {
|
|
104
|
+
const jobs = [...queue.splice(0), ...slots.flatMap((slot) => slot.job ? [slot.job] : [])];
|
|
105
|
+
for (const job of jobs) job.reject(new Error('Minifier pool closed'));
|
|
106
|
+
await Promise.all(slots.splice(0).map((slot) => {
|
|
107
|
+
slot.job = null;
|
|
108
|
+
return slot.worker.terminate();
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @type {ReturnType<typeof createMinifierPool> | null} */
|
|
115
|
+
let poolShared = null;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Minifies on a pool shared by all runs, which needs no closing
|
|
119
|
+
* @param {string} html
|
|
120
|
+
* @param {Record<string, unknown>} options
|
|
121
|
+
*/
|
|
122
|
+
export const minifyInWorkers = (html, options) => (poolShared ??= createMinifierPool()).minify(html, options);
|