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.
- package/README.md +12 -6
- package/cli.js +137 -31
- package/dist/types/htmlminifier.d.ts +3 -3
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/htmlparser.d.ts.map +1 -1
- package/dist/types/lib/attributes.d.ts +3 -0
- package/dist/types/lib/attributes.d.ts.map +1 -1
- package/dist/types/lib/constants.d.ts +3 -0
- package/dist/types/lib/constants.d.ts.map +1 -1
- package/dist/types/lib/content.d.ts +15 -1
- package/dist/types/lib/content.d.ts.map +1 -1
- package/dist/types/lib/elements.d.ts +3 -0
- package/dist/types/lib/elements.d.ts.map +1 -1
- package/dist/types/lib/option-definitions.d.ts +3 -0
- package/dist/types/lib/option-definitions.d.ts.map +1 -1
- package/dist/types/lib/options.d.ts +5 -0
- package/dist/types/lib/options.d.ts.map +1 -1
- package/dist/types/lib/unused-css.d.ts +3 -0
- package/dist/types/lib/unused-css.d.ts.map +1 -1
- package/dist/types/lib/utils.d.ts +3 -0
- package/dist/types/lib/utils.d.ts.map +1 -1
- package/dist/types/lib/whitespace.d.ts +3 -0
- package/dist/types/lib/whitespace.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/htmlminifier.js +65 -11
- package/src/htmlparser.js +184 -137
- package/src/lib/attributes.js +4 -0
- package/src/lib/constants.js +3 -1
- package/src/lib/content.js +77 -0
- package/src/lib/elements.js +4 -0
- package/src/lib/file-pool.js +177 -0
- package/src/lib/option-definitions.js +3 -1
- package/src/lib/options.js +12 -0
- package/src/lib/unused-css.js +3 -1
- package/src/lib/utils.js +3 -1
- package/src/lib/whitespace.js +4 -0
package/src/lib/content.js
CHANGED
|
@@ -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
|
};
|
package/src/lib/elements.js
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/options.js
CHANGED
|
@@ -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;
|
package/src/lib/unused-css.js
CHANGED
package/src/lib/utils.js
CHANGED