html-minifier 3.5.21 → 5.0.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.
Files changed (55) hide show
  1. package/LICENSE.txt +23 -0
  2. package/README.md +851 -109
  3. package/cli.js +991 -206
  4. package/dist/types/htmlminifier.d.ts +629 -0
  5. package/dist/types/htmlminifier.d.ts.map +1 -0
  6. package/dist/types/htmlparser.d.ts +28 -0
  7. package/dist/types/htmlparser.d.ts.map +1 -0
  8. package/dist/types/lib/attributes.d.ts +87 -0
  9. package/dist/types/lib/attributes.d.ts.map +1 -0
  10. package/dist/types/lib/constants.d.ts +421 -0
  11. package/dist/types/lib/constants.d.ts.map +1 -0
  12. package/dist/types/lib/content.d.ts +44 -0
  13. package/dist/types/lib/content.d.ts.map +1 -0
  14. package/dist/types/lib/elements.d.ts +48 -0
  15. package/dist/types/lib/elements.d.ts.map +1 -0
  16. package/dist/types/lib/fragments.d.ts +47 -0
  17. package/dist/types/lib/fragments.d.ts.map +1 -0
  18. package/dist/types/lib/option-definitions.d.ts +33 -0
  19. package/dist/types/lib/option-definitions.d.ts.map +1 -0
  20. package/dist/types/lib/options.d.ts +93 -0
  21. package/dist/types/lib/options.d.ts.map +1 -0
  22. package/dist/types/lib/unused-css.d.ts +76 -0
  23. package/dist/types/lib/unused-css.d.ts.map +1 -0
  24. package/dist/types/lib/urls.d.ts +15 -0
  25. package/dist/types/lib/urls.d.ts.map +1 -0
  26. package/dist/types/lib/utils.d.ts +132 -0
  27. package/dist/types/lib/utils.d.ts.map +1 -0
  28. package/dist/types/lib/whitespace.d.ts +97 -0
  29. package/dist/types/lib/whitespace.d.ts.map +1 -0
  30. package/dist/types/presets.d.ts +50 -0
  31. package/dist/types/presets.d.ts.map +1 -0
  32. package/dist/types/tokenchain.d.ts +36 -0
  33. package/dist/types/tokenchain.d.ts.map +1 -0
  34. package/html-minifier.schema.json +322 -0
  35. package/package.json +70 -58
  36. package/src/htmlminifier.js +2848 -1095
  37. package/src/htmlparser.js +1165 -450
  38. package/src/lib/attributes.js +1196 -0
  39. package/src/lib/constants.js +521 -0
  40. package/src/lib/content.js +237 -0
  41. package/src/lib/elements.js +268 -0
  42. package/src/lib/file-pool.js +193 -0
  43. package/src/lib/file-write.js +87 -0
  44. package/src/lib/fragments.js +340 -0
  45. package/src/lib/option-definitions.js +246 -0
  46. package/src/lib/options.js +841 -0
  47. package/src/lib/unused-css.js +499 -0
  48. package/src/lib/urls.js +146 -0
  49. package/src/lib/utils.js +1026 -0
  50. package/src/lib/whitespace.js +431 -0
  51. package/src/presets.js +58 -0
  52. package/src/tokenchain.js +142 -56
  53. package/LICENSE +0 -22
  54. package/sample-cli-config-file.conf +0 -39
  55. package/src/utils.js +0 -18
@@ -0,0 +1,237 @@
1
+ /**
2
+ * CSS and script content wrapping for minification
3
+ */
4
+
5
+ import {
6
+ RE_ESCAPE_LT,
7
+ jsonScriptTypes
8
+ } from './constants.js';
9
+ import { isExecutableScript } from './attributes.js';
10
+ import { findTagEnd } from './utils.js';
11
+ import { trimWhitespace } from './whitespace.js';
12
+
13
+ /** @import { ProcessedOptions } from './options.js' */
14
+
15
+ // CSS processing
16
+
17
+ // Give bare declarations and media queries a parseable context for CSS minifiers
18
+ /**
19
+ * @param {string} text
20
+ * @param {string} [type]
21
+ */
22
+ function wrapCSS(text, type) {
23
+ switch (type) {
24
+ case 'inline':
25
+ return '*{' + text + '}';
26
+ case 'media':
27
+ return '@media ' + text + '{a{top:0}}';
28
+ default:
29
+ return text;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * @param {string} text
35
+ * @param {string} [type]
36
+ */
37
+ function unwrapCSS(text, type) {
38
+ let matches;
39
+ switch (type) {
40
+ case 'inline':
41
+ matches = text.match(/^\*\{([\s\S]*)\}$/);
42
+ break;
43
+ case 'media':
44
+ matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/);
45
+ break;
46
+ }
47
+ return matches ? matches[1] ?? text : text;
48
+ }
49
+
50
+ // Script processing
51
+
52
+ /** @param {number} code */
53
+ function isJsonWhitespace(code) {
54
+ return code === 32 || code === 10 || code === 13 || code === 9;
55
+ }
56
+
57
+ // Remove whitespace between tokens of valid JSON in one pass, copying strings as written
58
+ // unless they hold escapes (re-serialized to shorten them) or `<` (escaped)
59
+ /** @param {string} text */
60
+ function stripJsonWhitespace(text) {
61
+ const length = text.length;
62
+ let out = '';
63
+ let start = 0;
64
+ let i = 0;
65
+ while (i < length) {
66
+ const code = text.charCodeAt(i);
67
+ if (code === 34) { // `"`
68
+ let end = i + 1;
69
+ let hasEscape = false;
70
+ let hasLt = false;
71
+ for (;;) {
72
+ const c = text.charCodeAt(end);
73
+ if (c === 34) break;
74
+ if (c === 92) { // `\`
75
+ hasEscape = true;
76
+ end += 2;
77
+ continue;
78
+ }
79
+ if (c === 60) hasLt = true; // `<`
80
+ end++;
81
+ }
82
+ if (hasEscape || hasLt) {
83
+ let string = text.slice(i, end + 1);
84
+ if (hasEscape) string = JSON.stringify(JSON.parse(string));
85
+ out += text.slice(start, i) + string.replace(RE_ESCAPE_LT, '\\u003C');
86
+ start = end + 1;
87
+ }
88
+ i = end + 1;
89
+ } else if (isJsonWhitespace(code)) {
90
+ out += text.slice(start, i);
91
+ do i++;
92
+ while (i < length && isJsonWhitespace(text.charCodeAt(i)));
93
+ start = i;
94
+ } else {
95
+ i++;
96
+ }
97
+ }
98
+ return out + text.slice(start);
99
+ }
100
+
101
+ // Minify JSON script content, keeping `<` escaped—parsing only validates,
102
+ // as serializing the parsed value would change some values (e.g., large numbers)
103
+ /**
104
+ * @param {string} text
105
+ * @param {{continueOnMinifyError?: boolean, log: Function}} options
106
+ */
107
+ function minifyJson(text, options) {
108
+ try {
109
+ JSON.parse(text);
110
+ return stripJsonWhitespace(text);
111
+ }
112
+ catch (err) {
113
+ if (!options.continueOnMinifyError) {
114
+ throw err;
115
+ }
116
+ options.log(err);
117
+ return text;
118
+ }
119
+ }
120
+
121
+ /** @param {Array<{name: string, value?: string}>} attrs */
122
+ function hasJsonScriptType(attrs) {
123
+ for (const attr of attrs) {
124
+ if (attr.name.toLowerCase() === 'type') {
125
+ const attrValue = trimWhitespace((attr.value || '').split(/;/, 2)[0] ?? '').toLowerCase();
126
+ if (jsonScriptTypes.has(attrValue)) {
127
+ return true;
128
+ }
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+
134
+ /**
135
+ * @param {string} text
136
+ * @param {ProcessedOptions} options
137
+ * @param {string} tag
138
+ * @param {Array<{name: string, value?: string | undefined}>} currentAttrs
139
+ * @param {Function} minifyHTML
140
+ */
141
+ async function processScript(text, options, tag, currentAttrs, minifyHTML) {
142
+ for (const attr of currentAttrs) {
143
+ const attrName = attr.name.toLowerCase();
144
+ if (attrName === 'type') {
145
+ const rawValue = attr.value;
146
+ const normalizedValue = trimWhitespace((rawValue || '').split(/;/, 2)[0] ?? '').toLowerCase();
147
+ if (tag === 'script' && jsonScriptTypes.has(normalizedValue)) {
148
+ return options.minifyJSON ? minifyJson(text, options) : text;
149
+ }
150
+ if (options.processScripts && rawValue && options.processScripts.indexOf(rawValue) > -1) {
151
+ return await minifyHTML(text, options);
152
+ }
153
+ }
154
+ }
155
+ return text;
156
+ }
157
+
158
+ // A `script` element is walked in three steps rather than matched by one pattern—
159
+ // a pattern spanning the whole element rescans the same text for every candidate
160
+ // tag, which turns markup repeating `<script` or `</script` into a quadratic scan
161
+ const RE_SCRIPT_START = /<script\b/gi;
162
+ // A tag name ends at whitespace, a slash, or the closing bracket, so `</scriptx>` names a
163
+ // different element and leaves the body running, as it does for the parser
164
+ const RE_SCRIPT_END = /<\/script(?=[\s/>])/gi;
165
+ const RE_TYPE_ATTRIBUTE = /(?:^|\s)type\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i;
166
+
167
+ /**
168
+ * Collects the bodies of executable inline scripts, in document order, so they can be
169
+ * minified as one batch ahead of the parse. Duplicates are dropped: The minifier is
170
+ * content-keyed, so the same body only needs dispatching once.
171
+ * @param {string} html
172
+ * @returns {Array<{code: string, isModule: boolean}>}
173
+ */
174
+ function extractScriptBodies(html) {
175
+ /** @type {Array<{code: string, isModule: boolean}>} */
176
+ const bodies = [];
177
+ const seen = new Set();
178
+
179
+ RE_SCRIPT_START.lastIndex = 0;
180
+ let startTag;
181
+ while ((startTag = RE_SCRIPT_START.exec(html))) {
182
+ const tagEnd = findTagEnd(html, RE_SCRIPT_START.lastIndex);
183
+ if (tagEnd === -1) {
184
+ break;
185
+ }
186
+ // Script content is raw text, so the body runs to the first `</script`—
187
+ // no nesting to account for
188
+ RE_SCRIPT_END.lastIndex = tagEnd + 1;
189
+ const endTag = RE_SCRIPT_END.exec(html);
190
+ if (!endTag) {
191
+ break;
192
+ }
193
+ const closeEnd = html.indexOf('>', RE_SCRIPT_END.lastIndex);
194
+ if (closeEnd === -1) {
195
+ break;
196
+ }
197
+ // Resuming past the element is what keeps every character visited once
198
+ RE_SCRIPT_START.lastIndex = closeEnd + 1;
199
+
200
+ const code = html.slice(tagEnd + 1, endTag.index);
201
+ // Whitespace-only and external scripts carry no work the minifier would do
202
+ if (!code.trim()) {
203
+ continue;
204
+ }
205
+
206
+ const typeMatch = RE_TYPE_ATTRIBUTE.exec(html.slice(startTag.index + '<script'.length, tagEnd));
207
+ /** @type {Array<{name: string, value: string}>} */
208
+ const attrs = typeMatch
209
+ ? [{ name: 'type', value: typeMatch[1] ?? typeMatch[2] ?? typeMatch[3] ?? '' }]
210
+ : [];
211
+ if (!isExecutableScript('script', attrs) || hasJsonScriptType(attrs)) {
212
+ continue;
213
+ }
214
+
215
+ // Module and classic scripts minify differently, so identical bodies in the two
216
+ // modes stay separate entries
217
+ const isModule = trimWhitespace((attrs[0]?.value ?? '')).toLowerCase() === 'module';
218
+ const key = (isModule ? 'm|' : '|') + code;
219
+ if (!seen.has(key)) {
220
+ seen.add(key);
221
+ bodies.push({ code, isModule });
222
+ }
223
+ }
224
+
225
+ return bodies;
226
+ }
227
+
228
+ export {
229
+ // CSS
230
+ wrapCSS,
231
+ unwrapCSS,
232
+
233
+ // Scripts
234
+ hasJsonScriptType,
235
+ extractScriptBodies,
236
+ processScript
237
+ };
@@ -0,0 +1,268 @@
1
+ /**
2
+ * HTML element tag omission rules
3
+ */
4
+
5
+ import {
6
+ headerElements,
7
+ descriptionElements,
8
+ pBlockElements,
9
+ rubyEndTagOmission,
10
+ rubyRtcEndTagOmission,
11
+ optionElements,
12
+ tableContentElements,
13
+ tableSectionElements,
14
+ cellElements
15
+ } from './constants.js';
16
+ import { hasAttrName } from './attributes.js';
17
+
18
+ /** @import { HTMLAttribute } from './attributes.js' */
19
+ /** @import { ProcessedOptions } from './options.js' */
20
+
21
+ /**
22
+ * @param {string} optionalStartTag
23
+ * @param {string} tag
24
+ */
25
+ function canRemoveParentTag(optionalStartTag, tag) {
26
+ switch (optionalStartTag) {
27
+ case 'html':
28
+ case 'head':
29
+ return true;
30
+ case 'body':
31
+ return !headerElements.has(tag);
32
+ case 'colgroup':
33
+ return tag === 'col';
34
+ case 'tbody':
35
+ return tag === 'tr';
36
+ }
37
+ return false;
38
+ }
39
+
40
+ /**
41
+ * @param {string} optionalEndTag
42
+ * @param {string} tag
43
+ */
44
+ function isStartTagMandatory(optionalEndTag, tag) {
45
+ switch (tag) {
46
+ case 'colgroup':
47
+ return optionalEndTag === 'colgroup';
48
+ case 'tbody':
49
+ return tableSectionElements.has(optionalEndTag);
50
+ }
51
+ return false;
52
+ }
53
+
54
+ /**
55
+ * @param {string} optionalEndTag
56
+ * @param {string} tag
57
+ */
58
+ function canRemovePrecedingTag(optionalEndTag, tag) {
59
+ switch (optionalEndTag) {
60
+ case 'html':
61
+ case 'head':
62
+ case 'body':
63
+ case 'colgroup':
64
+ case 'caption':
65
+ return true;
66
+ case 'li':
67
+ case 'optgroup':
68
+ case 'tr':
69
+ return tag === optionalEndTag;
70
+ case 'dt':
71
+ case 'dd':
72
+ return descriptionElements.has(tag);
73
+ case 'p':
74
+ return pBlockElements.has(tag);
75
+ case 'rb':
76
+ case 'rt':
77
+ case 'rp':
78
+ return rubyEndTagOmission.has(tag);
79
+ case 'rtc':
80
+ return rubyRtcEndTagOmission.has(tag);
81
+ case 'option':
82
+ return optionElements.has(tag);
83
+ case 'thead':
84
+ case 'tbody':
85
+ return tableContentElements.has(tag);
86
+ case 'tfoot':
87
+ return tag === 'tbody';
88
+ case 'td':
89
+ case 'th':
90
+ return cellElements.has(tag);
91
+ }
92
+ return false;
93
+ }
94
+
95
+ /**
96
+ * @param {string} tag
97
+ * @param {HTMLAttribute[]} attrs
98
+ */
99
+ function canRemoveElement(tag, attrs) {
100
+ // Elements with `id` attribute must never be removed—they serve as:
101
+ // - Navigation targets (skip links, URL fragments)
102
+ // - JavaScript selector targets (`getElementById`, `querySelector`)
103
+ // - CSS targets (`:target` pseudo-class, ID selectors)
104
+ // - Accessibility landmarks (ARIA references)
105
+ // - Portal mount points (React portals, etc.)
106
+ if (hasAttrName('id', attrs)) {
107
+ return false;
108
+ }
109
+
110
+ // Nor must elements that carry accessibility semantics, take focus, or point elsewhere
111
+ // with `for` (a `label` activates a control, an `output` shows its result, a `template`
112
+ // replaces content, even with nothing), unless the value is empty
113
+ const takesFor = tag === 'label' || tag === 'output' || tag === 'template';
114
+ for (const attr of attrs) {
115
+ const name = attr.name.toLowerCase();
116
+ if ((name === 'role' || name === 'tabindex' || name.startsWith('aria-') || (takesFor && name === 'for')) && attr.value?.trim()) {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ // Custom elements and `canvas` are there for scripts to fill or draw into
122
+ if (tag.includes('-')) {
123
+ return false;
124
+ }
125
+
126
+ switch (tag) {
127
+ case 'canvas':
128
+ case 'textarea':
129
+ return false;
130
+ case 'audio':
131
+ case 'script':
132
+ case 'video':
133
+ if (hasAttrName('src', attrs)) {
134
+ return false;
135
+ }
136
+ break;
137
+ case 'iframe':
138
+ if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
139
+ return false;
140
+ }
141
+ break;
142
+ case 'object':
143
+ if (hasAttrName('data', attrs)) {
144
+ return false;
145
+ }
146
+ break;
147
+ case 'applet':
148
+ if (hasAttrName('code', attrs)) {
149
+ return false;
150
+ }
151
+ break;
152
+ }
153
+ return true;
154
+ }
155
+
156
+ /**
157
+ * @param {string} str - Tag name or HTML-like element spec (e.g., `td` or `<span aria-hidden='true'>`)
158
+ * @param {ProcessedOptions} options - Options object for name normalization
159
+ * @returns {{tag: string, attrs: Object.<string, string|undefined>|null}|null} Parsed spec or null if invalid
160
+ */
161
+ function parseElementSpec(str, options) {
162
+ const trimmed = str.trim();
163
+ if (!trimmed) {
164
+ return null;
165
+ }
166
+
167
+ // Simple tag name: `td`
168
+ if (!/[<>]/.test(trimmed)) {
169
+ return { tag: options.name(trimmed), attrs: null };
170
+ }
171
+
172
+ // HTML-like markup: `<span aria-hidden='true'>` or `<td></td>`
173
+ const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
174
+ if (!match) {
175
+ return null;
176
+ }
177
+
178
+ const tag = options.name(match[1] ?? '');
179
+ const attrString = match[2] ?? '';
180
+
181
+ if (!attrString.trim()) {
182
+ return { tag, attrs: null };
183
+ }
184
+
185
+ /** @type {{[key: string]: string | undefined}} */
186
+ const attrs = {};
187
+ const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
188
+ let attrMatch;
189
+
190
+ while ((attrMatch = attrRegex.exec(attrString))) {
191
+ const attrName = options.name(attrMatch[1] ?? '');
192
+ const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
193
+ // Boolean attributes have no value (undefined)
194
+ attrs[attrName] = attrValue;
195
+ }
196
+
197
+ return {
198
+ tag,
199
+ attrs: Object.keys(attrs).length > 0 ? attrs : null
200
+ };
201
+ }
202
+
203
+ /**
204
+ * @param {string[]} input - Array of element specifications from `removeEmptyElementsExcept` option
205
+ * @param {ProcessedOptions} options - Options object for parsing
206
+ * @returns {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} Array of parsed element specs
207
+ */
208
+ function parseRemoveEmptyElementsExcept(input, options) {
209
+ return /** @type {Array<{tag: string, attrs: {[x: string]: string | undefined} | null}>} */ (input.map(item => {
210
+ if (typeof item === 'string') {
211
+ const spec = parseElementSpec(item, options);
212
+ if (!spec) {
213
+ options.log('Warning: Unable to parse `removeEmptyElementsExcept` specification: “' + item + '”');
214
+ }
215
+ return spec;
216
+ }
217
+ options.log('Warning: `removeEmptyElementsExcept` specification must be a string, received: ' + typeof item);
218
+ return null;
219
+ }).filter(Boolean));
220
+ }
221
+
222
+ /**
223
+ * @param {string} tag - Element tag name
224
+ * @param {HTMLAttribute[]} attrs - Array of element attributes
225
+ * @param {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} preserveList - Parsed preserve specs
226
+ * @returns {boolean} True if the empty element should be preserved
227
+ */
228
+ function shouldPreserveEmptyElement(tag, attrs, preserveList) {
229
+ for (const spec of preserveList) {
230
+ if (spec.tag !== tag) {
231
+ continue;
232
+ }
233
+
234
+ if (!spec.attrs) {
235
+ return true;
236
+ }
237
+
238
+ const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
239
+ const attr = attrs.find(a => a.name === name);
240
+ if (!attr) {
241
+ return false; // Attribute not present
242
+ }
243
+ // Boolean attribute in spec (undefined value) matches if attribute is present
244
+ if (value === undefined) {
245
+ return true;
246
+ }
247
+ return attr.value === value;
248
+ });
249
+
250
+ if (allAttrsMatch) {
251
+ return true;
252
+ }
253
+ }
254
+
255
+ return false;
256
+ }
257
+
258
+ export {
259
+ // Tag omission
260
+ canRemoveParentTag,
261
+ isStartTagMandatory,
262
+ canRemovePrecedingTag,
263
+
264
+ // Element removal
265
+ canRemoveElement,
266
+ parseRemoveEmptyElementsExcept,
267
+ shouldPreserveEmptyElement
268
+ };
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Worker pool for whole files, as the CLI’s directory runs need them
3
+ *
4
+ * This pools one kind of work—read a file, minify it, write it—rather than arbitrary
5
+ * tasks: The worker body, the message contract, and the result are all about files. The
6
+ * spawning and queueing below would generalize, but nothing else asks for it yet.
7
+ *
8
+ * Minifying a document is CPU-bound work on one thread, so a run over a directory
9
+ * serializes no matter how the files are awaited. Whole files are the unit handed to
10
+ * workers, which is what makes threads pay here: A document takes long enough that the
11
+ * thread it crosses costs a fraction of a percent, and files share no state, so nothing
12
+ * travels but paths, options, and the resulting sizes.
13
+ *
14
+ * Scaling is bounded by what each thread has to re-learn rather than by the cores: Every
15
+ * worker runs its own isolate and warms up its own JIT, so the useful worker count tops
16
+ * out well short of the core count.
17
+ *
18
+ * Node-only: cli.js loads this module lazily and minifies in-process wherever worker
19
+ * threads are unavailable or the options can’t cross a structured clone.
20
+ */
21
+
22
+ import fs from 'node:fs';
23
+ import { isMainThread, parentPort, workerData, Worker } from 'node:worker_threads';
24
+ import { writeFileAtomic } from './file-write.js';
25
+
26
+ // This module is its own worker entry point: The pool spawns it by URL and the branch
27
+ // below runs in the spawned copy. Keeping both halves in one file keeps the message
28
+ // contract in one place. The minifier is imported dynamically so that loading the
29
+ // pool on the main thread doesn’t drag it in; only workers ever pay for it.
30
+ if (!isMainThread && parentPort) {
31
+ const port = parentPort;
32
+ const { minify } = await import('../htmlminifier.js');
33
+ const { options, wantsLog } = workerData;
34
+
35
+ // The pool hands a worker one file at a time, so the file a diagnostic belongs to is
36
+ // simply the one in hand
37
+ let fileCurrent = '';
38
+
39
+ // `log` cannot cross a structured clone, so it is rebuilt here and forwarded, and only
40
+ // where the CLI has somewhere to put it. Whether the minifier logged an `Error` travels
41
+ // with the message: The CLI words those differently, and an error that crossed as a
42
+ // plain string would lose that wording.
43
+ const taskOptions = wantsLog
44
+ ? {
45
+ ...options,
46
+ log: (/** @type {unknown} */ message) => port.postMessage({
47
+ log: message instanceof Error ? message.message : String(message),
48
+ logIsError: message instanceof Error,
49
+ logFile: fileCurrent
50
+ })
51
+ }
52
+ : options;
53
+
54
+ port.on('message', async (/** @type {{id: number, inputFile: string, outputFile: string, dryRun: boolean}} */ task) => {
55
+ // Which step failed decides how the CLI words the failure, and reading and writing
56
+ // name different files
57
+ let stage = 'read';
58
+ try {
59
+ fileCurrent = task.inputFile;
60
+ const data = await fs.promises.readFile(task.inputFile, 'utf8');
61
+ stage = 'minify';
62
+ const minified = await minify(data, taskOptions);
63
+ if (!task.dryRun) {
64
+ stage = 'write';
65
+ await writeFileAtomic(task.outputFile, minified);
66
+ }
67
+ port.postMessage({
68
+ id: task.id,
69
+ originalSize: Buffer.byteLength(data, 'utf8'),
70
+ minifiedSize: Buffer.byteLength(minified, 'utf8')
71
+ });
72
+ } catch (err) {
73
+ port.postMessage({ id: task.id, stage, error: err instanceof Error ? err.message : String(err) });
74
+ }
75
+ });
76
+ }
77
+
78
+ const urlWorker = new URL(import.meta.url);
79
+
80
+ /**
81
+ * The order to hand files to the pool in: Largest first, so the file that takes longest
82
+ * doesn’t start last and hold up the run on its own (ties keep their order)
83
+ * @template T
84
+ * @param {T[]} items - One per file
85
+ * @param {number[]} sizes - Byte size per file, in the order of `items`
86
+ * @returns {T[]}
87
+ */
88
+ export function orderLargestFirst(items, sizes) {
89
+ return items
90
+ .map((item, index) => ({ item, size: sizes[index] ?? 0 }))
91
+ .sort((a, b) => b.size - a.size)
92
+ .map(entry => entry.item);
93
+ }
94
+
95
+ /**
96
+ * @param {object} args
97
+ * @param {import('../htmlminifier.js').MinifierOptions} args.options
98
+ * @param {number} args.size
99
+ * @param {((message: string, isError: boolean, file: string) => void) | undefined} [args.onLog]
100
+ */
101
+ export function createFilePool({ options, size, onLog }) {
102
+ /** @type {Worker[]} */
103
+ const workers = [];
104
+ /** @type {Worker[]} */
105
+ const idle = [];
106
+ /** @type {Map<number, {resolve: (value: {originalSize: number, minifiedSize: number}) => void, reject: (reason: Error) => void}>} */
107
+ const pending = new Map();
108
+ /** @type {{task: object, resolve: Function, reject: Function}[]} */
109
+ const queue = [];
110
+ let idNext = 0;
111
+ /** @type {Error | null} */
112
+ let failure = null;
113
+ let closing = false;
114
+ const wantsLog = Boolean(onLog);
115
+
116
+ // A worker that dies takes its task with it, and any task still queued would wait for a
117
+ // turn that never comes; both are failed at once so the run ends rather than hangs
118
+ const failAll = (/** @type {Error} */ err) => {
119
+ failure = err;
120
+ for (const { reject } of pending.values()) reject(err);
121
+ pending.clear();
122
+ for (const item of queue.splice(0)) item.reject(err);
123
+ };
124
+
125
+ for (let i = 0; i < size; i++) {
126
+ // Workers inherit the parent’s `execArgv`, which can carry flags that are invalid for
127
+ // a module worker and would kill it on startup
128
+ const worker = new Worker(urlWorker, { workerData: { options, wantsLog }, execArgv: [] });
129
+ worker.unref();
130
+ worker.on('message', (/** @type {{id?: number, log?: string, logIsError?: boolean, logFile?: string, error?: string, stage?: string, originalSize?: number, minifiedSize?: number}} */ message) => {
131
+ if (message.log !== undefined) {
132
+ onLog?.(message.log, Boolean(message.logIsError), message.logFile ?? '');
133
+ return;
134
+ }
135
+ const entry = pending.get(/** @type {number} */ (message.id));
136
+ pending.delete(/** @type {number} */ (message.id));
137
+ idle.push(worker);
138
+ if (!pending.size && !queue.length) worker.unref();
139
+ drain();
140
+ if (!entry) return;
141
+ if (message.error !== undefined) {
142
+ const err = new Error(message.error);
143
+ // Which step failed rides along so the CLI can word the failure as it does for a
144
+ // file it minified itself
145
+ if (message.stage) /** @type {any} */ (err).stage = message.stage;
146
+ entry.reject(err);
147
+ } else {
148
+ entry.resolve({ originalSize: message.originalSize ?? 0, minifiedSize: message.minifiedSize ?? 0 });
149
+ }
150
+ });
151
+ worker.on('error', failAll);
152
+ // A worker can also go without an `error`—killed for memory, or exiting on its own.
153
+ // Its tasks would then wait forever, so an exit before `close()` ends the run, too
154
+ worker.on('exit', (/** @type {number} */ code) => {
155
+ if (closing || failure) return;
156
+ const index = idle.indexOf(worker);
157
+ if (index !== -1) idle.splice(index, 1);
158
+ failAll(new Error(`Worker stopped unexpectedly (exit code ${code})`));
159
+ });
160
+ workers.push(worker);
161
+ idle.push(worker);
162
+ }
163
+
164
+ function drain() {
165
+ while (idle.length && queue.length) {
166
+ const worker = /** @type {Worker} */ (idle.pop());
167
+ const item = /** @type {{task: object, resolve: Function, reject: Function}} */ (queue.shift());
168
+ const id = idNext++;
169
+ pending.set(id, /** @type {any} */ ({ resolve: item.resolve, reject: item.reject }));
170
+ // Only a worker with something outstanding may hold the process open
171
+ worker.ref();
172
+ worker.postMessage({ ...item.task, id });
173
+ }
174
+ }
175
+
176
+ return {
177
+ /**
178
+ * @param {{inputFile: string, outputFile: string, dryRun: boolean}} task
179
+ * @returns {Promise<{originalSize: number, minifiedSize: number}>}
180
+ */
181
+ run(task) {
182
+ if (failure) return Promise.reject(failure);
183
+ return new Promise((resolve, reject) => {
184
+ queue.push({ task, resolve, reject });
185
+ drain();
186
+ });
187
+ },
188
+ async close() {
189
+ closing = true;
190
+ await Promise.all(workers.map(worker => worker.terminate()));
191
+ }
192
+ };
193
+ }