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 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 subdirectories to cover a site’s templates.
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
- if (/\.[cm]?js$/.test(resolved)) {
52
+ const load = async () => {
51
53
  const module = await import(pathToFileURL(resolved).href);
52
- return module.default ?? module;
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
- .option('--no-equivalence', 'Skip HTML Minifier’s equivalence check')
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.1.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;
@@ -1,5 +1,6 @@
1
- // HTML Minifier’s own check that output means the same to browsers as its source, which catches
2
- // minifier bugs rather than what a site relies on—used where available, never duplicated here
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
- // @@ Import `html-minifier/equivalence` alone once HTML Minifier exports its check
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 FILE_SIBLING_RUNNER = fileURLToPath(new URL('../../html-minifier/test/equivalence/runner.js', import.meta.url));
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 installed HTML Minifier’s check, or that of an HTML Minifier checkout next to this
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
- /** @type {[string, () => Promise<any>][]} */
49
- const sources = [
50
- [SPECIFIER_EXPORT, () => import(SPECIFIER_EXPORT)],
51
- [FILE_SIBLING, async () => {
52
- if (!fs.existsSync(FILE_SIBLING) || !fs.existsSync(FILE_SIBLING_RUNNER)) return null;
53
- const [compare, runner] = await Promise.all([import(pathToFileURL(FILE_SIBLING).href), import(pathToFileURL(FILE_SIBLING_RUNNER).href)]);
54
- return { ...runner, ...compare };
55
- }]
56
- ];
57
- for (const [source, load] of sources) {
58
- const module = await load().catch(() => null);
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 {{selector: string, text: string}[]} whitespaceRules Rules that keep whitespace, outside `pre` and `textarea`
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
- export const OPTIONS_ATTRIBUTES = new Set(['collapseAttributeWhitespace', 'collapseBooleanAttributes', 'removeDefaultTypeAttributes', 'removeEmptyAttributes', 'removeRedundantAttributes', 'sortClassNames']);
56
- export const OPTIONS_WHITESPACE = new Set(['collapseInlineTagWhitespace', 'collapseNoBreakSpaces', 'collapseWhitespace', 'trimCustomFragments']);
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
- * Whether any element may match a selector, judged by the tag, classes, IDs, and attributes
107
- * of the last compound of each selector in the list—erring toward a match
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
- function mayMatch(selector, elements) {
112
- return selector.split(',').some((part) => {
113
- const compound = (part.trim().replace(/::?[\w-]+(?:\([^)]*\))?/g, '').split(/[\s>+~]+/).pop() ?? '').trim();
114
- const tag = compound.match(/^[a-z][\w-]*/i)?.[0].toLowerCase();
115
- const classes = [...compound.matchAll(/\.([\w-]+)/g)].map((match) => match[1] ?? '');
116
- const ids = [...compound.matchAll(/#([\w-]+)/g)].map((match) => match[1] ?? '');
117
- const attributes = [...compound.matchAll(/\[\s*([\w-]+)/g)].map((match) => (match[1] ?? '').toLowerCase());
118
- return elements.some((element) => {
119
- const own = (attr(element, 'class') ?? '').split(/\s+/);
120
- return (!tag || element.tagName === tag) &&
121
- classes.every((name) => own.includes(name)) &&
122
- ids.every((id) => attr(element, 'id') === id) &&
123
- attributes.every((name) => element.attrs.some((item) => item.name === name));
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
- /** @param {string} css */
129
- function whitespaceRulesOf(css) {
130
- /** @type {{selector: string, text: string}[]} */
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
- // Without comments and statements like `@import`, which would otherwise lead the next selector
133
- for (const match of css.replace(RE_CSS_COMMENT, '').replace(RE_CSS_STATEMENT, '').matchAll(/([^{}]*)\{([^{}]*)\}/g)) {
134
- const selector = (match[1] ?? '').trim();
135
- const declaration = (match[2] ?? '').match(RE_WHITESPACE_KEPT)?.[0];
136
- if (!declaration || selector.startsWith('@')) continue;
137
- if (selector.split(',').every((part) => SELECTORS_PREFORMATTED.has(part.trim().toLowerCase()))) continue;
138
- rules.push({ selector, text: `${clip(selector, 40)} { ${declaration} }` });
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
- /** @param {string} css */
144
- const selectorTokens = (css) => {
145
- let selectors = css.replace(RE_CSS_COMMENT, '');
146
- // Strips declaration blocks innermost first, so that nested at-rules leave their selectors
147
- for (let previous = ''; previous !== selectors;) {
148
- previous = selectors;
149
- selectors = selectors.replace(/\{[^{}]*\}/g, ',');
150
- }
151
- return [...selectors.matchAll(/[.#](-?[_a-zA-Z\u00A0-\uFFFF][\w\u00A0-\uFFFF-]*)/g)].map((match) => `${match[0][0]}${match[1]}`);
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 {string[]} */
165
- const styles = [];
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.push(css);
178
- for (const [, , ref = ''] of css.replace(RE_CSS_COMMENT, '').matchAll(RE_IMPORT)) {
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') styles.push(`[style]{${value}}`);
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
- for (const word of content.match(RE_WORD) ?? []) scriptStrings.add(word);
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
- return { scriptStrings, scriptText, scriptsUnread: scriptsUnread.sort(), attributeRefs, whitespaceRules: whitespaceRulesOf(css) };
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.filter((rule) => mayMatch(rule.selector, elements));
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(/\s+/)].filter((token) => token && context.scriptStrings.has(token));
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) => selectorTokens(textOf(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
- export const GZIP_LEVEL = 6;
7
- export const BROTLI_QUALITY = 6;
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
- export const SIZES_ZERO = Object.freeze({ raw: 0, gzip: 0, brotli: 0 });
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 round-robin across directories, so that a sample covers a site’s sections (and
107
- * so, likely, its templates) rather than the first directory’s pages
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
- /** @type {Map<string, string[]>} */
115
- const byDir = new Map();
116
- for (const p of [...paths].sort()) {
117
- const dir = path.dirname(p);
118
- const queue = byDir.get(dir) ?? [];
119
- queue.push(p);
120
- byDir.set(dir, queue);
121
- }
122
- // Within a directory, a golden-ratio order spreads picks rather than taking its first pages
123
- const queues = [...byDir.values()].map((queue) => queue
124
- .map((item, index) => ({ item, key: (index * 0.6180339887) % 1 }))
125
- .sort((a, b) => a.key - b.key)
126
- .map(({ item }) => item));
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
- return sample;
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
- return Promise.all(keys.map(async (key) => {
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
- export const BROWSERS = [{ name: 'Chromium' }, { name: 'Chrome', channel: 'chrome' }, { name: 'Edge', channel: 'msedge' }];
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
- export const URL_SCHEMA = 'https://raw.githubusercontent.com/j9t/html-minifier/main/html-minifier.schema.json';
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 = 'looks different';
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} · same ${count}`);
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} · ${count} (${listShort(pages)}), e.g., ${example.page ? `${example.page}: ` : ''}${example.message}`);
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 ? ` · ${listShort(pages)}` : ''}: ${message}`);
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}` : ''}, judging gains by ${metric === 'raw' ? 'uncompressed' : metric} size`,
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
- result.equivalence ? `• Equivalence-checked with ${result.equivalence}` : '• Not equivalence-checked',
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 === 'gzip' ? 'Gzip' : 'Brotli'}: ${formatChange(result.baseline[m], result.final[m], color)}`)
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 === 'raw' ? 'uncompressed' : metric}):`, table(result.presets.map((preset) => {
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 ? ` · ${reason.page}` : ''}: ${reason.message}`)
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(` 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
+ 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 { minify as minifyHM, presets as PRESETS } from 'html-minifier';
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 = minifyHM,
103
+ minify = minifyInWorkers,
104
+ pagesFound,
101
105
  presets = true,
102
106
  schema = readSchema(),
103
107
  onProgress = () => {}
104
108
  } = settings;
105
109
 
106
- const names = CANDIDATES.map((candidate) => candidate.name);
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
- for (const page of pagesGiven) {
167
- try {
168
- await run(page, base);
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
- } catch (err) {
171
- notes.add(`Left out ${page.id}, which the minifier can’t read: ${messageOf(err)}`);
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 run(page, ctx);
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 === 'raw' ? 'uncompressed' : `with ${metric}`}` }, ...reasons] };
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
- for (let changed = true; changed;) {
426
- changed = false;
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
- for (const [index, evaluation] of used.entries()) {
430
- onProgress({ phase: 'contribute', detail: evaluation.name, current: index, total: used.length });
431
- await yieldTurn();
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
- const [weakest] = used
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 by ${metric})` });
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
- let errors = 0;
467
- for (const page of pages) {
468
- try {
469
- minified.push({ page, html: await run(page, optionsPreset) });
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) => /** @type {Record<string, unknown>} */ (preset)[key] && evaluations.get(key)?.status === 'rejected')
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
- export const INSTALL_ARGS = {
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} is installed${where}${versionLatest ? ' (latest)' : ''}`);
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);