html-minifier-setup 0.1.0 → 0.3.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 +5 -3
- package/src/catalog.js +17 -0
- package/src/equivalence.js +17 -28
- package/src/hazards.js +191 -62
- 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
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"commander": "^15.1.0",
|
|
10
10
|
"html-minifier": "^5.1.0",
|
|
11
|
+
"lightningcss": "^1.33.0",
|
|
11
12
|
"parse5": "^8.1.1",
|
|
12
13
|
"playwright": "^1.65.0"
|
|
13
14
|
},
|
|
@@ -39,7 +40,8 @@
|
|
|
39
40
|
"minification",
|
|
40
41
|
"minifier",
|
|
41
42
|
"optimization",
|
|
42
|
-
"setup"
|
|
43
|
+
"setup",
|
|
44
|
+
"wizard"
|
|
43
45
|
],
|
|
44
46
|
"license": "MIT",
|
|
45
47
|
"name": "html-minifier-setup",
|
|
@@ -56,5 +58,5 @@
|
|
|
56
58
|
"test:watch": "node --test --watch \"test/**/*.test.js\""
|
|
57
59
|
},
|
|
58
60
|
"type": "module",
|
|
59
|
-
"version": "0.
|
|
60
|
-
}
|
|
61
|
+
"version": "0.3.0"
|
|
62
|
+
}
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// What a page’s own scripts and style sheets rely on that an option may change—found by comparing
|
|
2
2
|
// output with and without the option, and matching what changed against what the page references
|
|
3
3
|
|
|
4
|
+
import { transform, transformStyleAttribute } from 'lightningcss';
|
|
4
5
|
import { parse } from 'parse5';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -15,9 +16,18 @@ import { parse } from 'parse5';
|
|
|
15
16
|
* @prop {string} scriptText
|
|
16
17
|
* @prop {string[]} scriptsUnread Script URLs that couldn’t be read
|
|
17
18
|
* @prop {Set<string>} attributeRefs Attribute names that style sheets select on or scripts query
|
|
18
|
-
* @prop {
|
|
19
|
+
* @prop {(CssRule & {text: string})[]} whitespaceRules Rules that keep whitespace, outside `pre` and `textarea`
|
|
19
20
|
*/
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* A style rule, with its selector as written and, if nested, its parent
|
|
24
|
+
* @typedef {{selectors: Selector[], parent: CssRule | null, declarations: Declaration[], selector: string}} CssRule
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** @typedef {import('lightningcss').Declaration} Declaration */
|
|
28
|
+
/** @typedef {import('lightningcss').Rule} Rule */
|
|
29
|
+
/** @typedef {import('lightningcss').Selector} Selector */
|
|
30
|
+
/** @typedef {import('lightningcss').SelectorComponent} SelectorComponent */
|
|
21
31
|
/** @typedef {import('parse5').DefaultTreeAdapterMap['node']} Node */
|
|
22
32
|
/** @typedef {import('parse5').DefaultTreeAdapterMap['element']} Element */
|
|
23
33
|
|
|
@@ -27,15 +37,17 @@ const MAX_PER_KIND = 3;
|
|
|
27
37
|
const MODES = { 'quirks': 'quirks mode', 'limited-quirks': 'almost standards mode', 'no-quirks': 'standards mode' };
|
|
28
38
|
const RE_STRING_LITERAL = /(["'`])((?:\\.|(?!\1)[^\\\n])*)\1/g;
|
|
29
39
|
const RE_WORD = /[\w-]+/g;
|
|
40
|
+
// What separates classes in HTML, unlike JavaScript’s `\s`, which includes no-break spaces
|
|
41
|
+
const RE_WHITESPACE_ASCII = /[ \t\n\f\r]+/;
|
|
42
|
+
const RE_ESCAPE_JS = /\\(?:u\{([\da-f]+)\}|u([\da-f]{4})|x([\da-f]{2})|([\s\S]))/gi;
|
|
43
|
+
/** @type {Record<string, string>} */
|
|
44
|
+
const ESCAPES_JS = { 'b': '\b', 'f': '\f', 'n': '\n', 'r': '\r', 't': '\t', 'v': '\v', '0': '\0' };
|
|
30
45
|
const RE_ATTRIBUTE_SELECTOR = /\[\s*([\w-]+)\s*(?=[~|^$*]?=|\])/g;
|
|
31
|
-
const RE_CSS_COMMENT = /\/\*[\s\S]*?\*\//g;
|
|
32
|
-
// At-rules without a block, like `@import`, taking quoted strings and escapes whole
|
|
33
|
-
const RE_CSS_STATEMENT = /@[\w-]+(?:\\.|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|[^{};"'\\])*;/g;
|
|
34
|
-
const RE_IMPORT = /@import\s+(?:url\(\s*)?(["']?)([^"')\s;]+)\1/gi;
|
|
35
46
|
const RE_ATTRIBUTE_CALL = /\b(?:get|has|remove|set|toggle)Attribute(?:NS)?\(\s*(?:[^,()]*,\s*)?["'`]([\w-]+)/g;
|
|
36
|
-
const RE_WHITESPACE_KEPT = /white-space(?:-collapse)?\s*:\s*(?:pre-wrap|pre-line|pre|break-spaces|preserve-breaks|preserve-spaces|preserve)(?![\w-])/i;
|
|
37
47
|
const RE_READS_COMMENTS = /SHOW_COMMENT|COMMENT_NODE|nodeType\s*[!=]==?\s*8\b|\.childNodes\b[^;]*\.data\b/;
|
|
38
48
|
const RE_SCRIPT_JS = /^(?:|module|(?:application|text)\/(?:x-)?(?:java|ecma)script|text\/javascript1\.[0-5]|text\/jscript|text\/livescript)$/i;
|
|
49
|
+
const KEYWORDS_WHITESPACE_KEPT = new Set(['break-spaces', 'pre', 'pre-line', 'pre-wrap', 'preserve', 'preserve-breaks', 'preserve-spaces']);
|
|
50
|
+
const PROPERTIES_WHITESPACE = new Set(['white-space', 'white-space-collapse']);
|
|
39
51
|
const SELECTORS_PREFORMATTED = new Set(['pre', 'textarea']);
|
|
40
52
|
|
|
41
53
|
// Conditional comments only matter to Internet Explorer 9 and older
|
|
@@ -52,8 +64,8 @@ const COMMENTS_MEANINGFUL = [
|
|
|
52
64
|
[/^\s*(?:Instance|Template)(?:Begin|End)\b|^\s*#(?:Begin|End)(?:Library|Template)/, 'template editor region']
|
|
53
65
|
];
|
|
54
66
|
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
const OPTIONS_ATTRIBUTES = new Set(['collapseAttributeWhitespace', 'collapseBooleanAttributes', 'removeDefaultTypeAttributes', 'removeEmptyAttributes', 'removeRedundantAttributes', 'sortClassNames']);
|
|
68
|
+
const OPTIONS_WHITESPACE = new Set(['collapseInlineTagWhitespace', 'collapseNoBreakSpaces', 'collapseWhitespace', 'trimCustomFragments']);
|
|
57
69
|
|
|
58
70
|
/** @param {string} text */
|
|
59
71
|
const clip = (text, length = 60) => text.length > length ? `${text.slice(0, length - 1)}…` : text;
|
|
@@ -103,53 +115,148 @@ function removed(before, after) {
|
|
|
103
115
|
}
|
|
104
116
|
|
|
105
117
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* @param {string} selector
|
|
109
|
-
* @param {Element[]} elements
|
|
118
|
+
* Text of a JavaScript string literal, with escapes decoded
|
|
119
|
+
* @param {string} text
|
|
110
120
|
*/
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
const unescapeJs = (text) => text.replace(RE_ESCAPE_JS, (_, braced, unicode, hex, char) => {
|
|
122
|
+
if (char !== undefined) return ESCAPES_JS[char] ?? char;
|
|
123
|
+
const code = parseInt(braced ?? unicode ?? hex, 16);
|
|
124
|
+
return code <= 0x10FFFF ? String.fromCodePoint(code) : '\uFFFD';
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Components of selectors, including those in arguments like `:is()`, however deeply nested
|
|
129
|
+
* @param {unknown} value
|
|
130
|
+
*/
|
|
131
|
+
function componentsOf(value) {
|
|
132
|
+
/** @type {{type?: unknown, name?: unknown}[]} */
|
|
133
|
+
const components = [];
|
|
134
|
+
for (const stack = [value]; stack.length;) {
|
|
135
|
+
const item = stack.pop();
|
|
136
|
+
if (!item || typeof item !== 'object') continue;
|
|
137
|
+
if (!Array.isArray(item) && 'type' in item) components.push(item);
|
|
138
|
+
stack.push(...Object.values(item).reverse());
|
|
139
|
+
}
|
|
140
|
+
return components;
|
|
126
141
|
}
|
|
127
142
|
|
|
128
|
-
/**
|
|
129
|
-
|
|
130
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Names of the classes (`.name`), IDs (`#name`), and attributes (`[name]`) rules select on
|
|
145
|
+
* @param {CssRule[]} rules
|
|
146
|
+
*/
|
|
147
|
+
const namesOf = (rules) => rules.flatMap((rule) => componentsOf(rule.selectors).flatMap(({ type, name }) => {
|
|
148
|
+
const prefix = { class: '.', id: '#', attribute: '[' }[String(type)];
|
|
149
|
+
return prefix && typeof name === 'string' ? [prefix + name] : [];
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A declaration that keeps whitespace, as text
|
|
154
|
+
* @param {Declaration} declaration
|
|
155
|
+
*/
|
|
156
|
+
function keepingWhitespace(declaration) {
|
|
157
|
+
const [name, values] = declaration.property === 'custom' ? [declaration.value.name, declaration.value.value]
|
|
158
|
+
: declaration.property === 'unparsed' ? [declaration.value.propertyId.property, declaration.value.value]
|
|
159
|
+
: [declaration.property, [declaration.value]];
|
|
160
|
+
if (!PROPERTIES_WHITESPACE.has(name.toLowerCase())) return;
|
|
161
|
+
const keywords = /** @type {unknown[]} */ (values).flatMap((value) => {
|
|
162
|
+
if (typeof value === 'string') return value.split(' ');
|
|
163
|
+
const token = /** @type {{type?: unknown, value?: {type?: unknown, value?: unknown}}} */ (value);
|
|
164
|
+
return token.type === 'token' && token.value?.type === 'ident' ? [String(token.value.value)] : [];
|
|
165
|
+
}).map((keyword) => keyword.toLowerCase());
|
|
166
|
+
return keywords.some((keyword) => KEYWORDS_WHITESPACE_KEPT.has(keyword)) ? `${name.toLowerCase()}: ${keywords.join(' ')}` : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Style rules of a style sheet, including nested ones, and what it imports; as in browsers,
|
|
171
|
+
* invalid rules are dropped
|
|
172
|
+
* @param {string} css
|
|
173
|
+
*/
|
|
174
|
+
function readStyles(css) {
|
|
175
|
+
/** @type {CssRule[]} */
|
|
131
176
|
const rules = [];
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
177
|
+
/** @type {string[]} */
|
|
178
|
+
const imports = [];
|
|
179
|
+
const lineStarts = [0, ...[...css.matchAll(/\r\n|[\n\r\f]/g)].map((match) => match.index + match[0].length)];
|
|
180
|
+
/** @param {{line: number, column: number}} loc */
|
|
181
|
+
const selectorAt = ({ line, column }) => {
|
|
182
|
+
const offset = (lineStarts[line] ?? 0) + column - 1;
|
|
183
|
+
const end = css.indexOf('{', offset);
|
|
184
|
+
return css.slice(offset, end < 0 ? undefined : end).trim();
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* @param {Rule[]} children
|
|
188
|
+
* @param {CssRule | null} parent
|
|
189
|
+
*/
|
|
190
|
+
const collect = (children, parent) => {
|
|
191
|
+
for (const child of children) {
|
|
192
|
+
if (child.type === 'import') {
|
|
193
|
+
imports.push(child.value.url);
|
|
194
|
+
} else if (child.type === 'style') {
|
|
195
|
+
const { declarations = [], importantDeclarations = [] } = child.value.declarations ?? {};
|
|
196
|
+
const rule = { selectors: child.value.selectors, parent, declarations: [...declarations, ...importantDeclarations], selector: selectorAt(child.value.loc) };
|
|
197
|
+
rules.push(rule);
|
|
198
|
+
collect(child.value.rules ?? [], rule);
|
|
199
|
+
} else if (child.type === 'scope') {
|
|
200
|
+
// `&` in a scope stands for its root, which, without a selector, may be any element
|
|
201
|
+
const { scopeStart, scopeEnd, rules: scoped, loc } = child.value;
|
|
202
|
+
const scope = { selectors: scopeStart ?? [[]], parent, declarations: [], selector: selectorAt(loc) };
|
|
203
|
+
rules.push(scope);
|
|
204
|
+
if (scopeEnd) rules.push({ ...scope, selectors: scopeEnd, parent: scope });
|
|
205
|
+
collect(scoped, scope);
|
|
206
|
+
} else if (child.type === 'nested-declarations' && parent) {
|
|
207
|
+
const { declarations = [], importantDeclarations = [] } = child.value.declarations ?? {};
|
|
208
|
+
rules.push({ ...parent, selectors: [[{ type: 'nesting' }]], parent, declarations: [...declarations, ...importantDeclarations] });
|
|
209
|
+
} else if ('value' in child && child.value && 'rules' in child.value && Array.isArray(child.value.rules)) {
|
|
210
|
+
collect(/** @type {Rule[]} */ (child.value.rules), parent);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
try {
|
|
215
|
+
transform({ filename: 'style.css', code: Buffer.from(css), errorRecovery: true, visitor: { StyleSheet: (sheet) => collect(sheet.rules, null) } });
|
|
216
|
+
} catch {
|
|
217
|
+
// Errors aren’t recovered from in every case, and a style sheet that can’t be read has no rules
|
|
139
218
|
}
|
|
140
|
-
return rules;
|
|
219
|
+
return { rules, imports };
|
|
141
220
|
}
|
|
142
221
|
|
|
143
|
-
/**
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
222
|
+
/**
|
|
223
|
+
* Rules that some element may match, judged by the tag, classes, IDs, and attributes of the last
|
|
224
|
+
* compound of each selector—erring toward a match
|
|
225
|
+
* @template {CssRule} T
|
|
226
|
+
* @param {T[]} rules
|
|
227
|
+
* @param {Element[]} elements
|
|
228
|
+
*/
|
|
229
|
+
function rulesMatching(rules, elements) {
|
|
230
|
+
/** @type {Map<CssRule, Set<Element>>} */
|
|
231
|
+
const matched = new Map();
|
|
232
|
+
/**
|
|
233
|
+
* @param {CssRule} rule
|
|
234
|
+
* @returns {Set<Element>}
|
|
235
|
+
*/
|
|
236
|
+
const matching = (rule) => {
|
|
237
|
+
const known = matched.get(rule);
|
|
238
|
+
if (known) return known;
|
|
239
|
+
const compounds = rule.selectors.map((selector) => selector.slice(selector.findLastIndex((component) => component.type === 'combinator') + 1));
|
|
240
|
+
/**
|
|
241
|
+
* @param {SelectorComponent} component
|
|
242
|
+
* @param {Element} element
|
|
243
|
+
*/
|
|
244
|
+
const fits = (component, element) => {
|
|
245
|
+
switch (component.type) {
|
|
246
|
+
case 'type': return element.tagName.toLowerCase() === component.name.toLowerCase();
|
|
247
|
+
case 'class': return (attr(element, 'class') ?? '').split(RE_WHITESPACE_ASCII).includes(component.name);
|
|
248
|
+
case 'id': return attr(element, 'id') === component.name;
|
|
249
|
+
case 'attribute': return element.attrs.some((item) => item.name === component.name.toLowerCase());
|
|
250
|
+
case 'nesting': return !rule.parent || matching(rule.parent).has(element);
|
|
251
|
+
default: return true;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const found = new Set(elements.filter((element) => compounds.some((compound) => compound.every((component) => fits(component, element)))));
|
|
255
|
+
matched.set(rule, found);
|
|
256
|
+
return found;
|
|
257
|
+
};
|
|
258
|
+
return rules.filter((rule) => matching(rule).size);
|
|
259
|
+
}
|
|
153
260
|
|
|
154
261
|
/**
|
|
155
262
|
* Collects what a page’s scripts and style sheets reference
|
|
@@ -161,8 +268,10 @@ export async function analyzePage(page) {
|
|
|
161
268
|
const scripts = [];
|
|
162
269
|
/** @type {string[]} */
|
|
163
270
|
const scriptsUnread = [];
|
|
164
|
-
/** @type {
|
|
165
|
-
const
|
|
271
|
+
/** @type {CssRule[]} */
|
|
272
|
+
const rules = [];
|
|
273
|
+
/** @type {Set<string>} */
|
|
274
|
+
const attributeRefs = new Set();
|
|
166
275
|
/** @type {Promise<void>[]} */
|
|
167
276
|
const pending = [];
|
|
168
277
|
/** @type {Set<string>} */
|
|
@@ -174,8 +283,10 @@ export async function analyzePage(page) {
|
|
|
174
283
|
* @param {string | null} base Reference of the style sheet, if not in the page
|
|
175
284
|
*/
|
|
176
285
|
const addStyles = (css, base) => {
|
|
177
|
-
styles
|
|
178
|
-
|
|
286
|
+
const styles = readStyles(css);
|
|
287
|
+
rules.push(...styles.rules);
|
|
288
|
+
for (const name of namesOf(styles.rules)) if (name.startsWith('[')) attributeRefs.add(name.slice(1).toLowerCase());
|
|
289
|
+
for (const ref of styles.imports) {
|
|
179
290
|
let target;
|
|
180
291
|
try {
|
|
181
292
|
target = base === null ? ref : page.resolve(ref, base);
|
|
@@ -191,7 +302,16 @@ export async function analyzePage(page) {
|
|
|
191
302
|
for (const element of elementsOf(page.html)) {
|
|
192
303
|
for (const { name, value } of element.attrs) {
|
|
193
304
|
if (/^on[a-z]{3,}$/.test(name)) scripts.push(value);
|
|
194
|
-
if (name === 'style')
|
|
305
|
+
if (name === 'style') {
|
|
306
|
+
/** @type {Declaration[]} */
|
|
307
|
+
const declarations = [];
|
|
308
|
+
try {
|
|
309
|
+
transformStyleAttribute({ code: Buffer.from(value), errorRecovery: true, visitor: { Declaration: (declaration) => { declarations.push(declaration); } } });
|
|
310
|
+
} catch {
|
|
311
|
+
// As with style sheets
|
|
312
|
+
}
|
|
313
|
+
rules.push({ selectors: [[{ type: 'attribute', name: 'style', namespace: null, operation: null }]], parent: null, declarations, selector: '[style]' });
|
|
314
|
+
}
|
|
195
315
|
}
|
|
196
316
|
if (element.tagName === 'script' && RE_SCRIPT_JS.test((attr(element, 'type') ?? '').trim())) {
|
|
197
317
|
const src = attr(element, 'src');
|
|
@@ -211,20 +331,29 @@ export async function analyzePage(page) {
|
|
|
211
331
|
while (pending.length) await Promise.all(pending.splice(0));
|
|
212
332
|
|
|
213
333
|
const scriptText = scripts.join('\n;\n');
|
|
214
|
-
const css = styles.join('\n');
|
|
215
334
|
/** @type {Set<string>} */
|
|
216
335
|
const scriptStrings = new Set();
|
|
217
|
-
/** @type {Set<string>} */
|
|
218
|
-
const attributeRefs = new Set();
|
|
219
336
|
for (const literal of scriptText.matchAll(RE_STRING_LITERAL)) {
|
|
220
337
|
const content = literal[2] ?? '';
|
|
221
|
-
|
|
338
|
+
// Words, and what whitespace separates, for names like `md:open`
|
|
339
|
+
for (const word of [...content.match(RE_WORD) ?? [], ...content.split(RE_WHITESPACE_ASCII)]) if (word) scriptStrings.add(word);
|
|
340
|
+
// Names in selectors, as in `querySelector('.md\\:open')`, decoded as in style sheets
|
|
341
|
+
if (content.includes('\\')) {
|
|
342
|
+
for (const name of namesOf(readStyles(`${unescapeJs(content)}{}`).rules)) if (!name.startsWith('[')) scriptStrings.add(name.slice(1));
|
|
343
|
+
}
|
|
222
344
|
for (const match of content.matchAll(RE_ATTRIBUTE_SELECTOR)) attributeRefs.add((match[1] ?? '').toLowerCase());
|
|
223
345
|
}
|
|
224
346
|
for (const match of scriptText.matchAll(RE_ATTRIBUTE_CALL)) attributeRefs.add((match[1] ?? '').toLowerCase());
|
|
225
|
-
for (const match of css.matchAll(RE_ATTRIBUTE_SELECTOR)) attributeRefs.add((match[1] ?? '').toLowerCase());
|
|
226
347
|
|
|
227
|
-
|
|
348
|
+
/** @type {(CssRule & {text: string})[]} */
|
|
349
|
+
const whitespaceRules = [];
|
|
350
|
+
for (const rule of rules) {
|
|
351
|
+
const kept = rule.declarations.map(keepingWhitespace).find(Boolean);
|
|
352
|
+
if (!kept || rule.selectors.every(([component, ...rest]) => !rest.length && component?.type === 'type' && SELECTORS_PREFORMATTED.has(component.name.toLowerCase()))) continue;
|
|
353
|
+
whitespaceRules.push({ ...rule, text: `${clip(rule.selector, 40)} { ${kept} }` });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { scriptStrings, scriptText, scriptsUnread: scriptsUnread.sort(), attributeRefs, whitespaceRules };
|
|
228
357
|
}
|
|
229
358
|
|
|
230
359
|
/**
|
|
@@ -242,7 +371,7 @@ export function findHazards(name, context, before, after) {
|
|
|
242
371
|
|
|
243
372
|
if (OPTIONS_WHITESPACE.has(name) && context.whitespaceRules.length) {
|
|
244
373
|
const elements = elementsOf(before);
|
|
245
|
-
const applying = context.whitespaceRules
|
|
374
|
+
const applying = rulesMatching(context.whitespaceRules, elements);
|
|
246
375
|
if (applying.length) {
|
|
247
376
|
hazards.push({ level: 'check', message: `Collapses whitespace that CSS keeps (${applying.slice(0, 2).map((rule) => rule.text).join('; ')})` });
|
|
248
377
|
}
|
|
@@ -280,7 +409,7 @@ export function findHazards(name, context, before, after) {
|
|
|
280
409
|
let count = 0;
|
|
281
410
|
for (const signature of gone) {
|
|
282
411
|
const [tag, id, classes] = /** @type {[string, string, string]} */ (JSON.parse(signature));
|
|
283
|
-
const named = [id, ...classes.split(
|
|
412
|
+
const named = [id, ...classes.split(RE_WHITESPACE_ASCII)].filter((token) => token && context.scriptStrings.has(token));
|
|
284
413
|
if (named.length && count++ < MAX_PER_KIND) {
|
|
285
414
|
const shown = `<${tag}${id ? ` id="${id}"` : ''}${classes ? ` class="${clip(classes, 30)}"` : ''}>`;
|
|
286
415
|
hazards.push({ level: 'risk', message: `Removes empty ${shown}, which scripts may fill (${named.map((token) => `\`${token}\``).join(', ')})` });
|
|
@@ -293,7 +422,7 @@ export function findHazards(name, context, before, after) {
|
|
|
293
422
|
|
|
294
423
|
if (name === 'removeUnusedCSS') {
|
|
295
424
|
/** @param {string} html */
|
|
296
|
-
const tokens = (html) => [...new Set(elementsOf(html).filter((element) => element.tagName === 'style').flatMap((element) =>
|
|
425
|
+
const tokens = (html) => [...new Set(elementsOf(html).filter((element) => element.tagName === 'style').flatMap((element) => namesOf(readStyles(textOf(element)).rules).filter((token) => !token.startsWith('['))))];
|
|
297
426
|
const gone = removed(tokens(before), tokens(after));
|
|
298
427
|
const named = gone.filter((token) => context.scriptStrings.has(token.slice(1)));
|
|
299
428
|
if (named.length) {
|
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);
|