html-minifier-next 8.3.0 → 8.4.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 (36) hide show
  1. package/README.md +12 -6
  2. package/cli.js +137 -31
  3. package/dist/types/htmlminifier.d.ts +3 -3
  4. package/dist/types/htmlminifier.d.ts.map +1 -1
  5. package/dist/types/htmlparser.d.ts.map +1 -1
  6. package/dist/types/lib/attributes.d.ts +3 -0
  7. package/dist/types/lib/attributes.d.ts.map +1 -1
  8. package/dist/types/lib/constants.d.ts +3 -0
  9. package/dist/types/lib/constants.d.ts.map +1 -1
  10. package/dist/types/lib/content.d.ts +15 -1
  11. package/dist/types/lib/content.d.ts.map +1 -1
  12. package/dist/types/lib/elements.d.ts +3 -0
  13. package/dist/types/lib/elements.d.ts.map +1 -1
  14. package/dist/types/lib/option-definitions.d.ts +3 -0
  15. package/dist/types/lib/option-definitions.d.ts.map +1 -1
  16. package/dist/types/lib/options.d.ts +5 -0
  17. package/dist/types/lib/options.d.ts.map +1 -1
  18. package/dist/types/lib/unused-css.d.ts +3 -0
  19. package/dist/types/lib/unused-css.d.ts.map +1 -1
  20. package/dist/types/lib/utils.d.ts +3 -0
  21. package/dist/types/lib/utils.d.ts.map +1 -1
  22. package/dist/types/lib/whitespace.d.ts +3 -0
  23. package/dist/types/lib/whitespace.d.ts.map +1 -1
  24. package/package.json +7 -7
  25. package/src/htmlminifier.js +65 -11
  26. package/src/htmlparser.js +184 -137
  27. package/src/lib/attributes.js +4 -0
  28. package/src/lib/constants.js +3 -1
  29. package/src/lib/content.js +77 -0
  30. package/src/lib/elements.js +4 -0
  31. package/src/lib/file-pool.js +177 -0
  32. package/src/lib/option-definitions.js +3 -1
  33. package/src/lib/options.js +12 -0
  34. package/src/lib/unused-css.js +3 -1
  35. package/src/lib/utils.js +3 -1
  36. package/src/lib/whitespace.js +4 -0
@@ -1,6 +1,12 @@
1
+ /**
2
+ * CSS and script content wrapping for minification
3
+ */
4
+
1
5
  import {
2
6
  jsonScriptTypes
3
7
  } from './constants.js';
8
+ import { isExecutableScript } from './attributes.js';
9
+ import { findTagEnd } from './utils.js';
4
10
  import { trimWhitespace } from './whitespace.js';
5
11
 
6
12
  /** @import { ProcessedOptions } from './options.js' */
@@ -99,6 +105,76 @@ async function processScript(text, options, currentAttrs, minifyHTML) {
99
105
  return text;
100
106
  }
101
107
 
108
+ // A `script` element is walked in three steps rather than matched by one pattern—
109
+ // a pattern spanning the whole element rescans the same text for every candidate
110
+ // tag, which turns markup repeating `<script` or `</script` into a quadratic scan
111
+ const RE_SCRIPT_START = /<script\b/gi;
112
+ // A tag name ends at whitespace, a slash, or the closing bracket, so `</scriptx>` names a
113
+ // different element and leaves the body running, as it does for the parser
114
+ const RE_SCRIPT_END = /<\/script(?=[\s/>])/gi;
115
+ const RE_TYPE_ATTRIBUTE = /(?:^|\s)type\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i;
116
+
117
+ /**
118
+ * Collects the bodies of executable inline scripts, in document order, so they can be
119
+ * minified as one batch ahead of the parse. Duplicates are dropped: The minifier is
120
+ * content-keyed, so the same body only needs dispatching once.
121
+ * @param {string} html
122
+ * @returns {Array<{code: string, isModule: boolean}>}
123
+ */
124
+ function extractScriptBodies(html) {
125
+ /** @type {Array<{code: string, isModule: boolean}>} */
126
+ const bodies = [];
127
+ const seen = new Set();
128
+
129
+ RE_SCRIPT_START.lastIndex = 0;
130
+ let startTag;
131
+ while ((startTag = RE_SCRIPT_START.exec(html))) {
132
+ const tagEnd = findTagEnd(html, RE_SCRIPT_START.lastIndex);
133
+ if (tagEnd === -1) {
134
+ break;
135
+ }
136
+ // Script content is raw text, so the body runs to the first `</script`—
137
+ // no nesting to account for
138
+ RE_SCRIPT_END.lastIndex = tagEnd + 1;
139
+ const endTag = RE_SCRIPT_END.exec(html);
140
+ if (!endTag) {
141
+ break;
142
+ }
143
+ const closeEnd = html.indexOf('>', RE_SCRIPT_END.lastIndex);
144
+ if (closeEnd === -1) {
145
+ break;
146
+ }
147
+ // Resuming past the element is what keeps every character visited once
148
+ RE_SCRIPT_START.lastIndex = closeEnd + 1;
149
+
150
+ const code = html.slice(tagEnd + 1, endTag.index);
151
+ // Whitespace-only and external scripts carry no work the minifier would do
152
+ if (!code.trim()) {
153
+ continue;
154
+ }
155
+
156
+ const typeMatch = RE_TYPE_ATTRIBUTE.exec(html.slice(startTag.index + '<script'.length, tagEnd));
157
+ /** @type {Array<{name: string, value: string}>} */
158
+ const attrs = typeMatch
159
+ ? [{ name: 'type', value: typeMatch[1] ?? typeMatch[2] ?? typeMatch[3] ?? '' }]
160
+ : [];
161
+ if (!isExecutableScript('script', attrs) || hasJsonScriptType(attrs)) {
162
+ continue;
163
+ }
164
+
165
+ // Module and classic scripts minify differently, so identical bodies in the two
166
+ // modes stay separate entries
167
+ const isModule = trimWhitespace((attrs[0]?.value ?? '')).toLowerCase() === 'module';
168
+ const key = (isModule ? 'm|' : '|') + code;
169
+ if (!seen.has(key)) {
170
+ seen.add(key);
171
+ bodies.push({ code, isModule });
172
+ }
173
+ }
174
+
175
+ return bodies;
176
+ }
177
+
102
178
  // Exports
103
179
 
104
180
  export {
@@ -109,5 +185,6 @@ export {
109
185
  // Scripts
110
186
  minifyJson,
111
187
  hasJsonScriptType,
188
+ extractScriptBodies,
112
189
  processScript
113
190
  };
@@ -1,3 +1,7 @@
1
+ /**
2
+ * HTML element tag omission rules
3
+ */
4
+
1
5
  import {
2
6
  headerElements,
3
7
  descriptionElements,
@@ -0,0 +1,177 @@
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
+
25
+ // This module is its own worker entry point: The pool spawns it by URL and the branch
26
+ // below runs in the spawned copy. Keeping both halves in one file keeps the message
27
+ // contract in one place. The minifier is imported dynamically so that loading the
28
+ // pool on the main thread doesn’t drag it in; only workers ever pay for it.
29
+ if (!isMainThread && parentPort) {
30
+ const port = parentPort;
31
+ const { minify } = await import('../htmlminifier.js');
32
+ const { options, wantsLog } = workerData;
33
+
34
+ // The pool hands a worker one file at a time, so the file a diagnostic belongs to is
35
+ // simply the one in hand
36
+ let fileCurrent = '';
37
+
38
+ // `log` cannot cross a structured clone, so it is rebuilt here and forwarded, and only
39
+ // where the CLI has somewhere to put it. Whether the minifier logged an `Error` travels
40
+ // with the message: The CLI words those differently, and an error that crossed as a
41
+ // plain string would lose that wording.
42
+ const taskOptions = wantsLog
43
+ ? {
44
+ ...options,
45
+ log: (/** @type {unknown} */ message) => port.postMessage({
46
+ log: message instanceof Error ? message.message : String(message),
47
+ logIsError: message instanceof Error,
48
+ logFile: fileCurrent
49
+ })
50
+ }
51
+ : options;
52
+
53
+ port.on('message', async (/** @type {{id: number, inputFile: string, outputFile: string, dryRun: boolean}} */ task) => {
54
+ // Which step failed decides how the CLI words the failure, and reading and writing
55
+ // name different files
56
+ let stage = 'read';
57
+ try {
58
+ fileCurrent = task.inputFile;
59
+ const data = await fs.promises.readFile(task.inputFile, 'utf8');
60
+ stage = 'minify';
61
+ const minified = await minify(data, taskOptions);
62
+ if (!task.dryRun) {
63
+ stage = 'write';
64
+ await fs.promises.writeFile(task.outputFile, minified, 'utf8');
65
+ }
66
+ port.postMessage({
67
+ id: task.id,
68
+ originalSize: Buffer.byteLength(data, 'utf8'),
69
+ minifiedSize: Buffer.byteLength(minified, 'utf8')
70
+ });
71
+ } catch (err) {
72
+ port.postMessage({ id: task.id, stage, error: err instanceof Error ? err.message : String(err) });
73
+ }
74
+ });
75
+ }
76
+
77
+ const urlWorker = new URL(import.meta.url);
78
+
79
+ /**
80
+ * @param {object} args
81
+ * @param {import('../htmlminifier.js').MinifierOptions} args.options
82
+ * @param {number} args.size
83
+ * @param {((message: string, isError: boolean, file: string) => void) | undefined} [args.onLog]
84
+ */
85
+ export function createFilePool({ options, size, onLog }) {
86
+ /** @type {Worker[]} */
87
+ const workers = [];
88
+ /** @type {Worker[]} */
89
+ const idle = [];
90
+ /** @type {Map<number, {resolve: (value: {originalSize: number, minifiedSize: number}) => void, reject: (reason: Error) => void}>} */
91
+ const pending = new Map();
92
+ /** @type {{task: object, resolve: Function, reject: Function}[]} */
93
+ const queue = [];
94
+ let idNext = 0;
95
+ /** @type {Error | null} */
96
+ let failure = null;
97
+ let closing = false;
98
+ const wantsLog = Boolean(onLog);
99
+
100
+ // A worker that dies takes its task with it, and any task still queued would wait for a
101
+ // turn that never comes; both are failed at once so the run ends rather than hangs
102
+ const failAll = (/** @type {Error} */ err) => {
103
+ failure = err;
104
+ for (const { reject } of pending.values()) reject(err);
105
+ pending.clear();
106
+ for (const item of queue.splice(0)) item.reject(err);
107
+ };
108
+
109
+ for (let i = 0; i < size; i++) {
110
+ // Workers inherit the parent’s `execArgv`, which can carry flags that are invalid for
111
+ // a module worker and would kill it on startup
112
+ const worker = new Worker(urlWorker, { workerData: { options, wantsLog }, execArgv: [] });
113
+ worker.unref();
114
+ worker.on('message', (/** @type {{id?: number, log?: string, logIsError?: boolean, logFile?: string, error?: string, stage?: string, originalSize?: number, minifiedSize?: number}} */ message) => {
115
+ if (message.log !== undefined) {
116
+ onLog?.(message.log, Boolean(message.logIsError), message.logFile ?? '');
117
+ return;
118
+ }
119
+ const entry = pending.get(/** @type {number} */ (message.id));
120
+ pending.delete(/** @type {number} */ (message.id));
121
+ idle.push(worker);
122
+ if (!pending.size && !queue.length) worker.unref();
123
+ drain();
124
+ if (!entry) return;
125
+ if (message.error !== undefined) {
126
+ const err = new Error(message.error);
127
+ // Which step failed rides along so the CLI can word the failure as it does for a
128
+ // file it minified itself
129
+ if (message.stage) /** @type {any} */ (err).stage = message.stage;
130
+ entry.reject(err);
131
+ } else {
132
+ entry.resolve({ originalSize: message.originalSize ?? 0, minifiedSize: message.minifiedSize ?? 0 });
133
+ }
134
+ });
135
+ worker.on('error', failAll);
136
+ // A worker can also go without an `error`—killed for memory, or exiting on its own.
137
+ // Its tasks would then wait forever, so an exit before `close()` ends the run, too
138
+ worker.on('exit', (/** @type {number} */ code) => {
139
+ if (closing || failure) return;
140
+ const index = idle.indexOf(worker);
141
+ if (index !== -1) idle.splice(index, 1);
142
+ failAll(new Error(`Worker stopped unexpectedly (exit code ${code})`));
143
+ });
144
+ workers.push(worker);
145
+ idle.push(worker);
146
+ }
147
+
148
+ function drain() {
149
+ while (idle.length && queue.length) {
150
+ const worker = /** @type {Worker} */ (idle.pop());
151
+ const item = /** @type {{task: object, resolve: Function, reject: Function}} */ (queue.shift());
152
+ const id = idNext++;
153
+ pending.set(id, /** @type {any} */ ({ resolve: item.resolve, reject: item.reject }));
154
+ // Only a worker with something outstanding may hold the process open
155
+ worker.ref();
156
+ worker.postMessage({ ...item.task, id });
157
+ }
158
+ }
159
+
160
+ return {
161
+ /**
162
+ * @param {{inputFile: string, outputFile: string, dryRun: boolean}} task
163
+ * @returns {Promise<{originalSize: number, minifiedSize: number}>}
164
+ */
165
+ run(task) {
166
+ if (failure) return Promise.reject(failure);
167
+ return new Promise((resolve, reject) => {
168
+ queue.push({ task, resolve, reject });
169
+ drain();
170
+ });
171
+ },
172
+ async close() {
173
+ closing = true;
174
+ await Promise.all(workers.map(worker => worker.terminate()));
175
+ }
176
+ };
177
+ }
@@ -1,4 +1,6 @@
1
- // Single source of truth for minifier option names, descriptions, types, and shared defaults
1
+ /**
2
+ * Single source of truth for minifier option names, descriptions, types, and shared defaults
3
+ */
2
4
 
3
5
  /**
4
6
  * @typedef {object} OptionDefinition
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Options processing and per-document minification state
3
+ */
4
+
1
5
  import { createUrlMinifier } from './urls.js';
2
6
  import { LRU, MAX_CACHE_ENTRY_SIZE, stableStringify, hashContent, identity, lowercase, paramCase, replaceAsync, parseRegExp, describeQuantifierRisk, lostFlag } from './utils.js';
3
7
  import { RE_TRAILING_SEMICOLON } from './constants.js';
@@ -45,6 +49,7 @@ import { optionDefinitions, optionDefaults } from './option-definitions.js';
45
49
  * minifySVG: ((svgContent: string) => string | Promise<string>) | null,
46
50
  * removeUnusedCSS: {safelist: Array<string | RegExp>, scripts: boolean} | null,
47
51
  * cssContext?: CSSContext,
52
+ * parallelJS?: boolean,
48
53
  * nameParent?: (name: string) => string,
49
54
  * nameHTML?: (name: string) => string,
50
55
  * keepClosingSlashHTML?: boolean,
@@ -532,6 +537,13 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
532
537
  return text;
533
538
  }
534
539
  };
540
+
541
+ // Whether dispatching script bodies ahead of the parse pays off. It needs a minifier
542
+ // that leaves the main thread—SWC hands work to its own threadpool, where overlapping
543
+ // calls genuinely run at once, while Terser would only compete with the parse for the
544
+ // one thread both share. A user-supplied `minifyJS` is excluded either way—only this
545
+ // wrapper is content-keyed and free of side effects.
546
+ options.parallelJS = engine === 'swc';
535
547
  } else if (key === 'minifyURLs' && typeof option !== 'function') {
536
548
  if (!option) {
537
549
  return;
@@ -1,4 +1,6 @@
1
- // Unused-CSS removal
1
+ /**
2
+ * Unused-CSS removal
3
+ */
2
4
 
3
5
  import { findTagEnd } from './utils.js';
4
6
 
package/src/lib/utils.js CHANGED
@@ -1,4 +1,6 @@
1
- // Stringify for options signatures (sorted keys, shallow, nested objects)
1
+ /**
2
+ * General utility functions
3
+ */
2
4
 
3
5
  /**
4
6
  * @param {unknown} obj
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Whitespace trimming and collapsing
3
+ */
4
+
1
5
  import {
2
6
  RE_WS_START,
3
7
  RE_WS_END,