html-minifier-setup 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
@@ -39,7 +39,8 @@
39
39
  "minification",
40
40
  "minifier",
41
41
  "optimization",
42
- "setup"
42
+ "setup",
43
+ "wizard"
43
44
  ],
44
45
  "license": "MIT",
45
46
  "name": "html-minifier-setup",
@@ -56,5 +57,5 @@
56
57
  "test:watch": "node --test --watch \"test/**/*.test.js\""
57
58
  },
58
59
  "type": "module",
59
- "version": "0.1.0"
60
+ "version": "0.2.0"
60
61
  }
package/src/catalog.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { presets as PRESETS } from 'html-minifier';
7
8
 
8
9
  /**
9
10
  * @typedef {object} Candidate
@@ -107,6 +108,17 @@ export const EXCLUDED = {
107
108
 
108
109
  const CLASSIFIED = new Set([...CANDIDATES.map((candidate) => candidate.name), ...MODIFIERS, ...CARRIED, ...PRESERVED, ...Object.keys(EXCLUDED)]);
109
110
 
111
+ /**
112
+ * Fails on names that aren’t candidates, as options to keep or skip
113
+ * @param {string[]} names
114
+ */
115
+ export function checkCandidates(names) {
116
+ const known = CANDIDATES.map((candidate) => candidate.name);
117
+ for (const name of names) {
118
+ if (!known.includes(name)) throw new Error(`Unknown option: ${name} (options that can be kept or skipped: ${known.join(', ')})`);
119
+ }
120
+ }
121
+
110
122
  /**
111
123
  * Options without a place here
112
124
  * @param {string[]} options
@@ -143,6 +155,11 @@ export function splitConfig(config) {
143
155
  /** @type {string[]} */
144
156
  const ignored = [];
145
157
  const names = new Set(CANDIDATES.map((candidate) => candidate.name));
158
+ // A preset can set how input is read, too (e.g., `caseSensitive`)
159
+ const preset = typeof config.preset === 'string' ? /** @type {Record<string, Record<string, unknown>>} */ (PRESETS)[config.preset.toLowerCase()] : undefined;
160
+ for (const name of CARRIED) {
161
+ if (preset && name in preset) base[name] = preset[name];
162
+ }
146
163
  for (const [name, value] of Object.entries(config)) {
147
164
  if (CARRIED.includes(name)) {
148
165
  base[name] = value;
@@ -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
@@ -52,8 +52,8 @@ const COMMENTS_MEANINGFUL = [
52
52
  [/^\s*(?:Instance|Template)(?:Begin|End)\b|^\s*#(?:Begin|End)(?:Library|Template)/, 'template editor region']
53
53
  ];
54
54
 
55
- export const OPTIONS_ATTRIBUTES = new Set(['collapseAttributeWhitespace', 'collapseBooleanAttributes', 'removeDefaultTypeAttributes', 'removeEmptyAttributes', 'removeRedundantAttributes', 'sortClassNames']);
56
- export const OPTIONS_WHITESPACE = new Set(['collapseInlineTagWhitespace', 'collapseNoBreakSpaces', 'collapseWhitespace', 'trimCustomFragments']);
55
+ const OPTIONS_ATTRIBUTES = new Set(['collapseAttributeWhitespace', 'collapseBooleanAttributes', 'removeDefaultTypeAttributes', 'removeEmptyAttributes', 'removeRedundantAttributes', 'sortClassNames']);
56
+ const OPTIONS_WHITESPACE = new Set(['collapseInlineTagWhitespace', 'collapseNoBreakSpaces', 'collapseWhitespace', 'trimCustomFragments']);
57
57
 
58
58
  /** @param {string} text */
59
59
  const clip = (text, length = 60) => text.length > length ? `${text.slice(0, length - 1)}…` : text;
package/src/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  export { CANDIDATES, CARRIED, EXCLUDED, FALLBACKS, MODIFIERS, PRESERVED, splitConfig } from './catalog.js';
2
- export { loadEquivalence } from './equivalence.js';
3
2
  export { analyzePage, findHazards } from './hazards.js';
4
3
  export { measure } from './measure.js';
5
4
  export { createPage, loadPages } from './pages.js';
package/src/measure.js CHANGED
@@ -3,8 +3,8 @@ import zlib from 'node:zlib';
3
3
 
4
4
  // As in HTML Minifier’s benchmarks: common defaults for on-the-fly compression (quality 11, for
5
5
  // files precompressed at build time, takes seconds per large file)
6
- 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);