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.
- package/LICENSE.txt +23 -0
- package/README.md +851 -109
- package/cli.js +991 -206
- package/dist/types/htmlminifier.d.ts +629 -0
- package/dist/types/htmlminifier.d.ts.map +1 -0
- package/dist/types/htmlparser.d.ts +28 -0
- package/dist/types/htmlparser.d.ts.map +1 -0
- package/dist/types/lib/attributes.d.ts +87 -0
- package/dist/types/lib/attributes.d.ts.map +1 -0
- package/dist/types/lib/constants.d.ts +421 -0
- package/dist/types/lib/constants.d.ts.map +1 -0
- package/dist/types/lib/content.d.ts +44 -0
- package/dist/types/lib/content.d.ts.map +1 -0
- package/dist/types/lib/elements.d.ts +48 -0
- package/dist/types/lib/elements.d.ts.map +1 -0
- package/dist/types/lib/fragments.d.ts +47 -0
- package/dist/types/lib/fragments.d.ts.map +1 -0
- package/dist/types/lib/option-definitions.d.ts +33 -0
- package/dist/types/lib/option-definitions.d.ts.map +1 -0
- package/dist/types/lib/options.d.ts +93 -0
- package/dist/types/lib/options.d.ts.map +1 -0
- package/dist/types/lib/unused-css.d.ts +76 -0
- package/dist/types/lib/unused-css.d.ts.map +1 -0
- package/dist/types/lib/urls.d.ts +15 -0
- package/dist/types/lib/urls.d.ts.map +1 -0
- package/dist/types/lib/utils.d.ts +132 -0
- package/dist/types/lib/utils.d.ts.map +1 -0
- package/dist/types/lib/whitespace.d.ts +97 -0
- package/dist/types/lib/whitespace.d.ts.map +1 -0
- package/dist/types/presets.d.ts +50 -0
- package/dist/types/presets.d.ts.map +1 -0
- package/dist/types/tokenchain.d.ts +36 -0
- package/dist/types/tokenchain.d.ts.map +1 -0
- package/html-minifier.schema.json +322 -0
- package/package.json +70 -58
- package/src/htmlminifier.js +2848 -1095
- package/src/htmlparser.js +1165 -450
- package/src/lib/attributes.js +1196 -0
- package/src/lib/constants.js +521 -0
- package/src/lib/content.js +237 -0
- package/src/lib/elements.js +268 -0
- package/src/lib/file-pool.js +193 -0
- package/src/lib/file-write.js +87 -0
- package/src/lib/fragments.js +340 -0
- package/src/lib/option-definitions.js +246 -0
- package/src/lib/options.js +841 -0
- package/src/lib/unused-css.js +499 -0
- package/src/lib/urls.js +146 -0
- package/src/lib/utils.js +1026 -0
- package/src/lib/whitespace.js +431 -0
- package/src/presets.js +58 -0
- package/src/tokenchain.js +142 -56
- package/LICENSE +0 -22
- package/sample-cli-config-file.conf +0 -39
- 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
|
+
}
|