html-minifier-next 4.9.2 → 4.11.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 +24 -19
- package/cli.js +3 -2
- package/dist/htmlminifier.cjs +166 -75
- package/dist/htmlminifier.esm.bundle.js +166 -75
- package/dist/types/htmlminifier.d.ts +9 -0
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/htmlparser.d.ts.map +1 -1
- package/dist/types/presets.d.ts.map +1 -1
- package/dist/types/tokenchain.d.ts +1 -0
- package/dist/types/tokenchain.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/htmlminifier.js +14 -0
- package/src/htmlparser.js +75 -42
- package/src/presets.js +1 -0
- package/src/tokenchain.js +77 -34
package/README.md
CHANGED
|
@@ -112,6 +112,8 @@ HTML Minifier Next provides presets for common use cases. Presets are pre-config
|
|
|
112
112
|
* `conservative`: Safe minification suitable for most projects. Includes whitespace collapsing, comment removal, and doctype normalization.
|
|
113
113
|
* `comprehensive`: Aggressive minification for maximum file size reduction. Includes relevant conservative options plus attribute quote removal, optional tag removal, and more.
|
|
114
114
|
|
|
115
|
+
To review the specific options set, [presets.js](https://github.com/j9t/html-minifier-next/blob/main/src/presets.js) lists them in an accessible manner.
|
|
116
|
+
|
|
115
117
|
**Using presets:**
|
|
116
118
|
|
|
117
119
|
```bash
|
|
@@ -137,10 +139,11 @@ Options can be used in config files (camelCase) or via CLI flags (kebab-case wit
|
|
|
137
139
|
| Option (config/CLI) | Description | Default |
|
|
138
140
|
| --- | --- | --- |
|
|
139
141
|
| `caseSensitive`<br>`--case-sensitive` | Treat attributes in case-sensitive manner (useful for custom HTML elements) | `false` |
|
|
142
|
+
| `collapseAttributeWhitespace`<br>`--collapse-attribute-whitespace` | Trim and collapse whitespace characters within attribute values | `false` |
|
|
140
143
|
| `collapseBooleanAttributes`<br>`--collapse-boolean-attributes` | [Omit attribute values from boolean attributes](https://perfectionkills.com/experimenting-with-html-minifier#collapse_boolean_attributes) | `false` |
|
|
141
144
|
| `collapseInlineTagWhitespace`<br>`--collapse-inline-tag-whitespace` | Don’t leave any spaces between `display: inline;` elements when collapsing—use with `collapseWhitespace: true` | `false` |
|
|
142
145
|
| `collapseWhitespace`<br>`--collapse-whitespace` | [Collapse whitespace that contributes to text nodes in a document tree](https://perfectionkills.com/experimenting-with-html-minifier#collapse_whitespace) | `false` |
|
|
143
|
-
| `conservativeCollapse`<br>`--conservative-collapse` | Always collapse to
|
|
146
|
+
| `conservativeCollapse`<br>`--conservative-collapse` | Always collapse to one space (never remove it entirely)—use with `collapseWhitespace: true` | `false` |
|
|
144
147
|
| `continueOnMinifyError`<br>`--no-continue-on-minify-error` | Continue on minification errors; when `false`, minification errors throw and abort processing | `true` |
|
|
145
148
|
| `continueOnParseError`<br>`--continue-on-parse-error` | [Handle parse errors](https://html.spec.whatwg.org/multipage/parsing.html#parse-errors) instead of aborting | `false` |
|
|
146
149
|
| `customAttrAssign`<br>`--custom-attr-assign` | Arrays of regexes that allow to support custom attribute assign expressions (e.g., `<div flex?="{{mode != cover}}"></div>`) | `[]` |
|
|
@@ -233,32 +236,34 @@ How does HTML Minifier Next compare to other minifiers? (All with the most aggre
|
|
|
233
236
|
| Site | Original Size (KB) | [HTML Minifier Next](https://github.com/j9t/html-minifier-next)<br>[](https://socket.dev/npm/package/html-minifier-next) | [HTML Minifier Terser](https://github.com/terser/html-minifier-terser)<br>[](https://socket.dev/npm/package/html-minifier-terser) | [htmlnano](https://github.com/posthtml/htmlnano)<br>[](https://socket.dev/npm/package/htmlnano) | [@swc/html](https://github.com/swc-project/swc)<br>[](https://socket.dev/npm/package/@swc/html) | [minify-html](https://github.com/wilsonzlin/minify-html)<br>[](https://socket.dev/npm/package/@minify-html/node) | [minimize](https://github.com/Swaagie/minimize)<br>[](https://socket.dev/npm/package/minimize) | [htmlcompressor.com](https://htmlcompressor.com/) |
|
|
234
237
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
235
238
|
| [A List Apart](https://alistapart.com/) | 59 | **49** | 50 | 51 | 52 | 51 | 54 | 52 |
|
|
236
|
-
| [Apple](https://www.apple.com/) | 260 | **203** |
|
|
237
|
-
| [BBC](https://www.bbc.co.uk/) |
|
|
238
|
-
| [
|
|
239
|
+
| [Apple](https://www.apple.com/) | 260 | **203** | 204 | 231 | 235 | 236 | 238 | 238 |
|
|
240
|
+
| [BBC](https://www.bbc.co.uk/) | 720 | **655** | 665 | 677 | 677 | 678 | 714 | n/a |
|
|
241
|
+
| [CERN](https://home.cern/) | 156 | **87** | **87** | 95 | 95 | 95 | 97 | 100 |
|
|
242
|
+
| [CSS-Tricks](https://css-tricks.com/) | 163 | 122 | **120** | 128 | 143 | 144 | 149 | 145 |
|
|
239
243
|
| [ECMAScript](https://tc39.es/ecma262/) | 7238 | **6341** | **6341** | 6561 | 6444 | 6567 | 6614 | n/a |
|
|
240
244
|
| [EDRi](https://edri.org/) | 80 | **59** | 60 | 70 | 70 | 71 | 75 | 73 |
|
|
241
|
-
| [EFF](https://www.eff.org/) | 56 | **
|
|
245
|
+
| [EFF](https://www.eff.org/) | 56 | **48** | **48** | 50 | 49 | 49 | 51 | 51 |
|
|
242
246
|
| [European Alternatives](https://european-alternatives.eu/) | 48 | **30** | **30** | 32 | 32 | 32 | 32 | 32 |
|
|
243
|
-
| [FAZ](https://www.faz.net/aktuell/) |
|
|
244
|
-
| [
|
|
247
|
+
| [FAZ](https://www.faz.net/aktuell/) | 1562 | 1455 | 1460 | **1400** | 1487 | 1498 | 1509 | n/a |
|
|
248
|
+
| [French Tech](https://lafrenchtech.gouv.fr/) | 152 | **121** | 122 | 125 | 125 | 125 | 132 | 126 |
|
|
249
|
+
| [Frontend Dogma](https://frontenddogma.com/) | 222 | **213** | 215 | 236 | 221 | 222 | 241 | 222 |
|
|
245
250
|
| [Google](https://www.google.com/) | 18 | **17** | **17** | **17** | **17** | **17** | 18 | 18 |
|
|
246
|
-
| [Ground News](https://ground.news/) |
|
|
251
|
+
| [Ground News](https://ground.news/) | 2339 | **2052** | 2056 | 2151 | 2179 | 2182 | 2326 | n/a |
|
|
247
252
|
| [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | **147** | **147** | 153 | **147** | 149 | 155 | 149 |
|
|
248
|
-
| [Igalia](https://www.igalia.com/) |
|
|
249
|
-
| [Leanpub](https://leanpub.com/) |
|
|
250
|
-
| [Mastodon](https://mastodon.social/explore) |
|
|
253
|
+
| [Igalia](https://www.igalia.com/) | 50 | **33** | **33** | 36 | 36 | 36 | 37 | 36 |
|
|
254
|
+
| [Leanpub](https://leanpub.com/) | 1204 | **997** | **997** | 1004 | 1003 | 1001 | 1198 | n/a |
|
|
255
|
+
| [Mastodon](https://mastodon.social/explore) | 37 | **27** | **27** | 32 | 34 | 35 | 35 | 35 |
|
|
251
256
|
| [MDN](https://developer.mozilla.org/en-US/) | 109 | **62** | **62** | 64 | 65 | 65 | 68 | 68 |
|
|
252
|
-
| [Middle East Eye](https://www.middleeasteye.net/) |
|
|
253
|
-
| [Nielsen Norman Group](https://www.nngroup.com/) | 84 | 71 |
|
|
254
|
-
| [SitePoint](https://www.sitepoint.com/) |
|
|
257
|
+
| [Middle East Eye](https://www.middleeasteye.net/) | 222 | **195** | 196 | 202 | 200 | 200 | 202 | 203 |
|
|
258
|
+
| [Nielsen Norman Group](https://www.nngroup.com/) | 84 | 71 | 71 | **53** | 71 | 73 | 74 | 73 |
|
|
259
|
+
| [SitePoint](https://www.sitepoint.com/) | 485 | **354** | **354** | 425 | 459 | 464 | 481 | n/a |
|
|
255
260
|
| [TetraLogical](https://tetralogical.com/) | 44 | 38 | 38 | **35** | 38 | 38 | 39 | 39 |
|
|
256
|
-
| [TPGi](https://www.tpgi.com/) |
|
|
257
|
-
| [United Nations](https://www.un.org/en/) |
|
|
258
|
-
| [W3C](https://www.w3.org/) | 50 | **
|
|
259
|
-
| **Average processing time** | |
|
|
261
|
+
| [TPGi](https://www.tpgi.com/) | 175 | **159** | 161 | 160 | 164 | 165 | 172 | 171 |
|
|
262
|
+
| [United Nations](https://www.un.org/en/) | 151 | **112** | 114 | 121 | 125 | 124 | 130 | 123 |
|
|
263
|
+
| [W3C](https://www.w3.org/) | 50 | **36** | **36** | 38 | 38 | 38 | 40 | 38 |
|
|
264
|
+
| **Average processing time** | | 302 ms (26/26) | 356 ms (26/26) | 178 ms (26/26) | 59 ms (26/26) | **16 ms (26/26)** | 329 ms (26/26) | 1468 ms (20/26) |
|
|
260
265
|
|
|
261
|
-
(Last updated: Dec
|
|
266
|
+
(Last updated: Dec 16, 2025)
|
|
262
267
|
<!-- End auto-generated -->
|
|
263
268
|
|
|
264
269
|
## Examples
|
package/cli.js
CHANGED
|
@@ -117,17 +117,18 @@ const parseValidInt = (optionName) => (value) => {
|
|
|
117
117
|
|
|
118
118
|
const mainOptions = {
|
|
119
119
|
caseSensitive: 'Treat attributes in case-sensitive manner (useful for custom HTML elements)',
|
|
120
|
+
collapseAttributeWhitespace: 'Trim and collapse whitespace characters within attribute values',
|
|
120
121
|
collapseBooleanAttributes: 'Omit attribute values from boolean attributes',
|
|
121
|
-
customFragmentQuantifierLimit: ['Set maximum quantifier limit for custom fragments to prevent ReDoS attacks (default: 200)', parseValidInt('customFragmentQuantifierLimit')],
|
|
122
122
|
collapseInlineTagWhitespace: 'Don’t leave any spaces between “display: inline;” elements when collapsing—use with “--collapse-whitespace”',
|
|
123
123
|
collapseWhitespace: 'Collapse whitespace that contributes to text nodes in a document tree',
|
|
124
|
-
conservativeCollapse: 'Always collapse to
|
|
124
|
+
conservativeCollapse: 'Always collapse to one space (never remove it entirely)—use with “--collapse-whitespace”',
|
|
125
125
|
continueOnMinifyError: 'Abort on minification errors',
|
|
126
126
|
continueOnParseError: 'Handle parse errors instead of aborting',
|
|
127
127
|
customAttrAssign: ['Arrays of regexes that allow to support custom attribute assign expressions (e.g., “<div flex?="{{mode != cover}}"></div>”)', parseJSONRegExpArray],
|
|
128
128
|
customAttrCollapse: ['Regex that specifies custom attribute to strip newlines from (e.g., /ng-class/)', parseRegExp],
|
|
129
129
|
customAttrSurround: ['Arrays of regexes that allow to support custom attribute surround expressions (e.g., “<input {{#if value}}checked="checked"{{/if}}>”)', parseJSONRegExpArray],
|
|
130
130
|
customEventAttributes: ['Arrays of regexes that allow to support custom event attributes for minifyJS (e.g., “ng-click”)', parseJSONRegExpArray],
|
|
131
|
+
customFragmentQuantifierLimit: ['Set maximum quantifier limit for custom fragments to prevent ReDoS attacks (default: 200)', parseValidInt('customFragmentQuantifierLimit')],
|
|
131
132
|
decodeEntities: 'Use direct Unicode characters whenever possible',
|
|
132
133
|
html5: 'Don’t parse input according to the HTML specification (not recommended for modern HTML)',
|
|
133
134
|
ignoreCustomComments: ['Array of regexes that allow to ignore certain comments, when matched', parseJSONRegExpArray],
|
package/dist/htmlminifier.cjs
CHANGED
|
@@ -89,6 +89,9 @@ const preCompiledStackedTags = {
|
|
|
89
89
|
'noscript': /([\s\S]*?)<\/noscript[^>]*>/i
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
+
// Cache for compiled attribute regexes per handler configuration
|
|
93
|
+
const attrRegexCache = new WeakMap();
|
|
94
|
+
|
|
92
95
|
function attrForHandler(handler) {
|
|
93
96
|
let pattern = singleAttrIdentifier.source +
|
|
94
97
|
'(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
|
|
@@ -126,22 +129,47 @@ class HTMLParser {
|
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
async parse() {
|
|
129
|
-
let html = this.html;
|
|
130
132
|
const handler = this.handler;
|
|
133
|
+
const fullHtml = this.html;
|
|
134
|
+
const fullLength = fullHtml.length;
|
|
131
135
|
|
|
132
136
|
const stack = []; let lastTag;
|
|
133
|
-
|
|
134
|
-
let
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
// Use cached attribute regex if available
|
|
138
|
+
let attribute = attrRegexCache.get(handler);
|
|
139
|
+
if (!attribute) {
|
|
140
|
+
attribute = attrForHandler(handler);
|
|
141
|
+
attrRegexCache.set(handler, attribute);
|
|
142
|
+
}
|
|
143
|
+
let prevTag = undefined, nextTag = undefined;
|
|
144
|
+
|
|
145
|
+
// Index-based parsing
|
|
146
|
+
let pos = 0;
|
|
147
|
+
let lastPos;
|
|
148
|
+
|
|
149
|
+
// Helper to get remaining HTML from current position
|
|
150
|
+
const remaining = () => fullHtml.slice(pos);
|
|
151
|
+
|
|
152
|
+
// Helper to advance position
|
|
153
|
+
const advance = (n) => { pos += n; };
|
|
154
|
+
|
|
155
|
+
// Lazy line/column calculation—only compute on actual errors
|
|
156
|
+
const getLineColumn = (position) => {
|
|
157
|
+
let line = 1;
|
|
158
|
+
let column = 1;
|
|
159
|
+
for (let i = 0; i < position; i++) {
|
|
160
|
+
if (fullHtml[i] === '\n') {
|
|
161
|
+
line++;
|
|
162
|
+
column = 1;
|
|
163
|
+
} else {
|
|
164
|
+
column++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { line, column };
|
|
141
168
|
};
|
|
142
169
|
|
|
143
|
-
while (
|
|
144
|
-
|
|
170
|
+
while (pos < fullLength) {
|
|
171
|
+
lastPos = pos;
|
|
172
|
+
const html = remaining();
|
|
145
173
|
// Make sure we’re not in a `script` or `style` element
|
|
146
174
|
if (!lastTag || !special.has(lastTag)) {
|
|
147
175
|
let textEnd = html.indexOf('<');
|
|
@@ -154,7 +182,7 @@ class HTMLParser {
|
|
|
154
182
|
if (handler.comment) {
|
|
155
183
|
await handler.comment(html.substring(4, commentEnd));
|
|
156
184
|
}
|
|
157
|
-
|
|
185
|
+
advance(commentEnd + 3);
|
|
158
186
|
prevTag = '';
|
|
159
187
|
continue;
|
|
160
188
|
}
|
|
@@ -168,7 +196,7 @@ class HTMLParser {
|
|
|
168
196
|
if (handler.comment) {
|
|
169
197
|
await handler.comment(html.substring(2, conditionalEnd + 1), true /* non-standard */);
|
|
170
198
|
}
|
|
171
|
-
|
|
199
|
+
advance(conditionalEnd + 2);
|
|
172
200
|
prevTag = '';
|
|
173
201
|
continue;
|
|
174
202
|
}
|
|
@@ -180,7 +208,7 @@ class HTMLParser {
|
|
|
180
208
|
if (handler.doctype) {
|
|
181
209
|
handler.doctype(doctypeMatch[0]);
|
|
182
210
|
}
|
|
183
|
-
|
|
211
|
+
advance(doctypeMatch[0].length);
|
|
184
212
|
prevTag = '';
|
|
185
213
|
continue;
|
|
186
214
|
}
|
|
@@ -188,7 +216,7 @@ class HTMLParser {
|
|
|
188
216
|
// End tag
|
|
189
217
|
const endTagMatch = html.match(endTag);
|
|
190
218
|
if (endTagMatch) {
|
|
191
|
-
|
|
219
|
+
advance(endTagMatch[0].length);
|
|
192
220
|
await parseEndTag(endTagMatch[0], endTagMatch[1]);
|
|
193
221
|
prevTag = '/' + endTagMatch[1].toLowerCase();
|
|
194
222
|
continue;
|
|
@@ -197,7 +225,7 @@ class HTMLParser {
|
|
|
197
225
|
// Start tag
|
|
198
226
|
const startTagMatch = parseStartTag(html);
|
|
199
227
|
if (startTagMatch) {
|
|
200
|
-
|
|
228
|
+
advance(startTagMatch.advance);
|
|
201
229
|
await handleStartTag(startTagMatch);
|
|
202
230
|
prevTag = startTagMatch.tagName.toLowerCase();
|
|
203
231
|
continue;
|
|
@@ -212,18 +240,19 @@ class HTMLParser {
|
|
|
212
240
|
let text;
|
|
213
241
|
if (textEnd >= 0) {
|
|
214
242
|
text = html.substring(0, textEnd);
|
|
215
|
-
|
|
243
|
+
advance(textEnd);
|
|
216
244
|
} else {
|
|
217
245
|
text = html;
|
|
218
|
-
html
|
|
246
|
+
advance(html.length);
|
|
219
247
|
}
|
|
220
248
|
|
|
221
249
|
// Next tag
|
|
222
|
-
|
|
250
|
+
const nextHtml = remaining();
|
|
251
|
+
let nextTagMatch = parseStartTag(nextHtml);
|
|
223
252
|
if (nextTagMatch) {
|
|
224
253
|
nextTag = nextTagMatch.tagName;
|
|
225
254
|
} else {
|
|
226
|
-
nextTagMatch =
|
|
255
|
+
nextTagMatch = nextHtml.match(endTag);
|
|
227
256
|
if (nextTagMatch) {
|
|
228
257
|
nextTag = '/' + nextTagMatch[1];
|
|
229
258
|
} else {
|
|
@@ -252,41 +281,38 @@ class HTMLParser {
|
|
|
252
281
|
await handler.chars(text);
|
|
253
282
|
}
|
|
254
283
|
// Advance HTML past the matched special tag content and its closing tag
|
|
255
|
-
|
|
284
|
+
advance(m.index + m[0].length);
|
|
256
285
|
await parseEndTag('</' + stackedTag + '>', stackedTag);
|
|
257
286
|
} else {
|
|
258
287
|
// No closing tag found; to avoid infinite loop, break similarly to previous behavior
|
|
259
288
|
if (handler.continueOnParseError && handler.chars && html) {
|
|
260
289
|
await handler.chars(html[0], prevTag, '');
|
|
261
|
-
|
|
290
|
+
advance(1);
|
|
262
291
|
} else {
|
|
263
292
|
break;
|
|
264
293
|
}
|
|
265
294
|
}
|
|
266
295
|
}
|
|
267
296
|
|
|
268
|
-
if (
|
|
297
|
+
if (pos === lastPos) {
|
|
269
298
|
if (handler.continueOnParseError) {
|
|
270
299
|
// Skip the problematic character and continue
|
|
271
300
|
if (handler.chars) {
|
|
272
|
-
await handler.chars(
|
|
301
|
+
await handler.chars(fullHtml[pos], prevTag, '');
|
|
273
302
|
}
|
|
274
|
-
|
|
275
|
-
position++;
|
|
303
|
+
advance(1);
|
|
276
304
|
prevTag = '';
|
|
277
305
|
continue;
|
|
278
306
|
}
|
|
279
|
-
const loc = getLineColumn(
|
|
280
|
-
// Include some context before the error position so the snippet contains
|
|
281
|
-
// the offending markup plus preceding characters (e.g. "invalid<tag").
|
|
307
|
+
const loc = getLineColumn(pos);
|
|
308
|
+
// Include some context before the error position so the snippet contains the offending markup plus preceding characters (e.g., “invalid<tag”)
|
|
282
309
|
const CONTEXT_BEFORE = 50;
|
|
283
|
-
const startPos = Math.max(0,
|
|
284
|
-
const snippet =
|
|
310
|
+
const startPos = Math.max(0, pos - CONTEXT_BEFORE);
|
|
311
|
+
const snippet = fullHtml.slice(startPos, startPos + 200).replace(/\n/g, ' ');
|
|
285
312
|
throw new Error(
|
|
286
|
-
`Parse error at line ${loc.line}, column ${loc.column}:\n${snippet}${
|
|
313
|
+
`Parse error at line ${loc.line}, column ${loc.column}:\n${snippet}${fullHtml.length > startPos + 200 ? '…' : ''}`
|
|
287
314
|
);
|
|
288
315
|
}
|
|
289
|
-
position = this.html.length - html.length;
|
|
290
316
|
}
|
|
291
317
|
|
|
292
318
|
if (!handler.partialMarkup) {
|
|
@@ -299,9 +325,11 @@ class HTMLParser {
|
|
|
299
325
|
if (start) {
|
|
300
326
|
const match = {
|
|
301
327
|
tagName: start[1],
|
|
302
|
-
attrs: []
|
|
328
|
+
attrs: [],
|
|
329
|
+
advance: 0
|
|
303
330
|
};
|
|
304
|
-
|
|
331
|
+
let consumed = start[0].length;
|
|
332
|
+
input = input.slice(consumed);
|
|
305
333
|
let end, attr;
|
|
306
334
|
|
|
307
335
|
// Safety limit: max length of input to check for attributes
|
|
@@ -351,7 +379,9 @@ class HTMLParser {
|
|
|
351
379
|
} else {
|
|
352
380
|
attr[baseIndex + 3] = value; // Single-quoted value
|
|
353
381
|
}
|
|
354
|
-
|
|
382
|
+
const attrLen = fullAttr.length;
|
|
383
|
+
input = input.slice(attrLen);
|
|
384
|
+
consumed += attrLen;
|
|
355
385
|
match.attrs.push(attr);
|
|
356
386
|
continue;
|
|
357
387
|
}
|
|
@@ -368,7 +398,9 @@ class HTMLParser {
|
|
|
368
398
|
break;
|
|
369
399
|
}
|
|
370
400
|
|
|
371
|
-
|
|
401
|
+
const attrLen = attr[0].length;
|
|
402
|
+
input = input.slice(attrLen);
|
|
403
|
+
consumed += attrLen;
|
|
372
404
|
match.attrs.push(attr);
|
|
373
405
|
}
|
|
374
406
|
|
|
@@ -376,7 +408,8 @@ class HTMLParser {
|
|
|
376
408
|
end = input.match(startTagClose);
|
|
377
409
|
if (end) {
|
|
378
410
|
match.unarySlash = end[1];
|
|
379
|
-
|
|
411
|
+
consumed += end[0].length;
|
|
412
|
+
match.advance = consumed;
|
|
380
413
|
return match;
|
|
381
414
|
}
|
|
382
415
|
}
|
|
@@ -386,7 +419,7 @@ class HTMLParser {
|
|
|
386
419
|
let pos;
|
|
387
420
|
const needle = tagName.toLowerCase();
|
|
388
421
|
for (pos = stack.length - 1; pos >= 0; pos--) {
|
|
389
|
-
const currentTag = stack[pos].
|
|
422
|
+
const currentTag = stack[pos].lowerTag;
|
|
390
423
|
if (currentTag === needle) {
|
|
391
424
|
return pos;
|
|
392
425
|
}
|
|
@@ -440,7 +473,7 @@ class HTMLParser {
|
|
|
440
473
|
}
|
|
441
474
|
if (tagName === 'col' && findTag('colgroup') < 0) {
|
|
442
475
|
lastTag = 'colgroup';
|
|
443
|
-
stack.push({ tag: lastTag, attrs: [] });
|
|
476
|
+
stack.push({ tag: lastTag, lowerTag: 'colgroup', attrs: [] });
|
|
444
477
|
if (handler.start) {
|
|
445
478
|
await handler.start(lastTag, [], false, '');
|
|
446
479
|
}
|
|
@@ -519,7 +552,7 @@ class HTMLParser {
|
|
|
519
552
|
});
|
|
520
553
|
|
|
521
554
|
if (!unary) {
|
|
522
|
-
stack.push({ tag: tagName, attrs });
|
|
555
|
+
stack.push({ tag: tagName, lowerTag: tagName.toLowerCase(), attrs });
|
|
523
556
|
lastTag = tagName;
|
|
524
557
|
unarySlash = '';
|
|
525
558
|
}
|
|
@@ -533,7 +566,7 @@ class HTMLParser {
|
|
|
533
566
|
let pos;
|
|
534
567
|
const needle = tagName.toLowerCase();
|
|
535
568
|
for (pos = stack.length - 1; pos >= 0; pos--) {
|
|
536
|
-
if (stack[pos].
|
|
569
|
+
if (stack[pos].lowerTag === needle) {
|
|
537
570
|
break;
|
|
538
571
|
}
|
|
539
572
|
}
|
|
@@ -585,21 +618,40 @@ class HTMLParser {
|
|
|
585
618
|
class Sorter {
|
|
586
619
|
sort(tokens, fromIndex = 0) {
|
|
587
620
|
for (let i = 0, len = this.keys.length; i < len; i++) {
|
|
588
|
-
const
|
|
589
|
-
|
|
621
|
+
const token = this.keys[i];
|
|
622
|
+
|
|
623
|
+
// Build position map for this token to avoid repeated `indexOf`
|
|
624
|
+
const positions = [];
|
|
625
|
+
for (let j = fromIndex; j < tokens.length; j++) {
|
|
626
|
+
if (tokens[j] === token) {
|
|
627
|
+
positions.push(j);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
590
630
|
|
|
591
|
-
|
|
631
|
+
if (positions.length > 0) {
|
|
632
|
+
// Build new array with tokens in sorted order instead of splicing
|
|
633
|
+
const result = [];
|
|
592
634
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
635
|
+
// Add all instances of the current token first
|
|
636
|
+
for (let j = 0; j < positions.length; j++) {
|
|
637
|
+
result.push(token);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Add other tokens, skipping positions where current token was
|
|
641
|
+
const posSet = new Set(positions);
|
|
642
|
+
for (let j = fromIndex; j < tokens.length; j++) {
|
|
643
|
+
if (!posSet.has(j)) {
|
|
644
|
+
result.push(tokens[j]);
|
|
598
645
|
}
|
|
599
|
-
|
|
600
|
-
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Copy sorted portion back to tokens array
|
|
649
|
+
for (let j = 0; j < result.length; j++) {
|
|
650
|
+
tokens[fromIndex + j] = result[j];
|
|
651
|
+
}
|
|
601
652
|
|
|
602
|
-
|
|
653
|
+
const newFromIndex = fromIndex + positions.length;
|
|
654
|
+
return this.sorterMap.get(token).sort(tokens, newFromIndex);
|
|
603
655
|
}
|
|
604
656
|
}
|
|
605
657
|
return tokens;
|
|
@@ -607,44 +659,68 @@ class Sorter {
|
|
|
607
659
|
}
|
|
608
660
|
|
|
609
661
|
class TokenChain {
|
|
662
|
+
constructor() {
|
|
663
|
+
// Use Map instead of object properties for better performance
|
|
664
|
+
this.map = new Map();
|
|
665
|
+
}
|
|
666
|
+
|
|
610
667
|
add(tokens) {
|
|
611
668
|
tokens.forEach((token) => {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
this[key] = [];
|
|
615
|
-
this[key].processed = 0;
|
|
669
|
+
if (!this.map.has(token)) {
|
|
670
|
+
this.map.set(token, { arrays: [], processed: 0 });
|
|
616
671
|
}
|
|
617
|
-
this
|
|
672
|
+
this.map.get(token).arrays.push(tokens);
|
|
618
673
|
});
|
|
619
674
|
}
|
|
620
675
|
|
|
621
676
|
createSorter() {
|
|
622
677
|
const sorter = new Sorter();
|
|
678
|
+
sorter.sorterMap = new Map();
|
|
679
|
+
|
|
680
|
+
// Convert Map entries to array and sort
|
|
681
|
+
const entries = Array.from(this.map.entries()).sort((a, b) => {
|
|
682
|
+
const m = a[1].arrays.length;
|
|
683
|
+
const n = b[1].arrays.length;
|
|
684
|
+
// Sort by length descending (larger first)
|
|
685
|
+
const lengthDiff = n - m;
|
|
686
|
+
if (lengthDiff !== 0) return lengthDiff;
|
|
687
|
+
// If lengths equal, sort by key ascending
|
|
688
|
+
return a[0].localeCompare(b[0]);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
sorter.keys = [];
|
|
623
692
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
const n = this[k].length;
|
|
627
|
-
return m < n ? 1 : m > n ? -1 : j < k ? -1 : j > k ? 1 : 0;
|
|
628
|
-
}).filter((key) => {
|
|
629
|
-
if (this[key].processed < this[key].length) {
|
|
630
|
-
const token = key.slice(1);
|
|
693
|
+
entries.forEach(([token, data]) => {
|
|
694
|
+
if (data.processed < data.arrays.length) {
|
|
631
695
|
const chain = new TokenChain();
|
|
632
696
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
697
|
+
data.arrays.forEach((tokens) => {
|
|
698
|
+
// Build new array without the current token instead of splicing
|
|
699
|
+
const filtered = [];
|
|
700
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
701
|
+
if (tokens[i] !== token) {
|
|
702
|
+
filtered.push(tokens[i]);
|
|
703
|
+
}
|
|
637
704
|
}
|
|
638
|
-
|
|
639
|
-
|
|
705
|
+
|
|
706
|
+
// Mark remaining tokens as processed
|
|
707
|
+
filtered.forEach((t) => {
|
|
708
|
+
const tData = this.map.get(t);
|
|
709
|
+
if (tData) {
|
|
710
|
+
tData.processed++;
|
|
711
|
+
}
|
|
640
712
|
});
|
|
641
|
-
|
|
713
|
+
|
|
714
|
+
if (filtered.length > 0) {
|
|
715
|
+
chain.add(filtered);
|
|
716
|
+
}
|
|
642
717
|
});
|
|
643
|
-
|
|
644
|
-
|
|
718
|
+
|
|
719
|
+
sorter.keys.push(token);
|
|
720
|
+
sorter.sorterMap.set(token, chain.createSorter());
|
|
645
721
|
}
|
|
646
|
-
return false;
|
|
647
722
|
});
|
|
723
|
+
|
|
648
724
|
return sorter;
|
|
649
725
|
}
|
|
650
726
|
}
|
|
@@ -685,6 +761,7 @@ const presets = {
|
|
|
685
761
|
useShortDoctype: true
|
|
686
762
|
},
|
|
687
763
|
comprehensive: {
|
|
764
|
+
// @@ Add `collapseAttributeWhitespace: true` (also add to preset in demo)
|
|
688
765
|
caseSensitive: true,
|
|
689
766
|
collapseBooleanAttributes: true,
|
|
690
767
|
collapseInlineTagWhitespace: true,
|
|
@@ -784,6 +861,14 @@ async function getTerser() {
|
|
|
784
861
|
*
|
|
785
862
|
* Default: `false`
|
|
786
863
|
*
|
|
864
|
+
* @prop {boolean} [collapseAttributeWhitespace]
|
|
865
|
+
* Collapse multiple whitespace characters within attribute values into a
|
|
866
|
+
* single space. Also trims leading and trailing whitespace from attribute
|
|
867
|
+
* values. Applied as an early normalization step before special attribute
|
|
868
|
+
* handlers (CSS minification, class sorting, etc.) run.
|
|
869
|
+
*
|
|
870
|
+
* Default: `false`
|
|
871
|
+
*
|
|
787
872
|
* @prop {boolean} [collapseBooleanAttributes]
|
|
788
873
|
* Collapse boolean attributes to their name only (for example
|
|
789
874
|
* `disabled="disabled"` → `disabled`).
|
|
@@ -1469,6 +1554,12 @@ function isSrcset(attrName, tag) {
|
|
|
1469
1554
|
}
|
|
1470
1555
|
|
|
1471
1556
|
async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
|
|
1557
|
+
// Apply early whitespace normalization if enabled
|
|
1558
|
+
// Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
|
|
1559
|
+
if (options.collapseAttributeWhitespace) {
|
|
1560
|
+
attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1472
1563
|
if (isEventAttribute(attrName, options)) {
|
|
1473
1564
|
attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
|
|
1474
1565
|
return options.minifyJS(attrValue, true);
|