html-minifier-next 4.6.0 → 4.7.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 +34 -31
- package/cli.js +1 -1
- package/dist/htmlminifier.cjs +253 -30
- package/dist/htmlminifier.esm.bundle.js +253 -30
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/htmlparser.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/htmlminifier.js +152 -25
- package/src/htmlparser.js +101 -5
- package/src/utils.js +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# HTML Minifier Next
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/html-minifier-next) [](https://github.com/j9t/html-minifier-next/actions)
|
|
3
|
+
[](https://www.npmjs.com/package/html-minifier-next) [](https://github.com/j9t/html-minifier-next/actions) [](https://socket.dev/npm/package/html-minifier-next)
|
|
4
4
|
|
|
5
5
|
HTML Minifier Next (HMN) is a highly **configurable, well-tested, JavaScript-based HTML minifier**.
|
|
6
6
|
|
|
@@ -146,7 +146,7 @@ Options can be used in config files (camelCase) or via CLI flags (kebab-case wit
|
|
|
146
146
|
| `customEventAttributes`<br>`--custom-event-attributes` | Arrays of regexes that allow to support custom event attributes for `minifyJS` (e.g., `ng-click`) | `[ /^on[a-z]{3,}$/ ]` |
|
|
147
147
|
| `customFragmentQuantifierLimit`<br>`--custom-fragment-quantifier-limit` | Set maximum quantifier limit for custom fragments to prevent ReDoS attacks | `200` |
|
|
148
148
|
| `decodeEntities`<br>`--decode-entities` | Use direct Unicode characters whenever possible | `false` |
|
|
149
|
-
| `html5`<br>`--no-html5` | Parse input according to the HTML specification; when `false`,
|
|
149
|
+
| `html5`<br>`--no-html5` | Parse input according to the HTML specification; when `false`, enforces legacy inline/block nesting rules that may restructure modern HTML | `true` |
|
|
150
150
|
| `ignoreCustomComments`<br>`--ignore-custom-comments` | Array of regexes that allow to ignore certain comments, when matched | `[ /^!/, /^\s*#/ ]` |
|
|
151
151
|
| `ignoreCustomFragments`<br>`--ignore-custom-fragments` | Array of regexes that allow to ignore certain fragments, when matched (e.g., `<?php … ?>`, `{{ … }}`, etc.) | `[ /<%[\s\S]*?%>/, /<\?[\s\S]*?\?>/ ]` |
|
|
152
152
|
| `includeAutoGeneratedTags`<br>`--no-include-auto-generated-tags` | Insert elements generated by HTML parser; when `false`, omits auto-generated tags | `true` |
|
|
@@ -223,29 +223,32 @@ const result = await minify(html, {
|
|
|
223
223
|
|
|
224
224
|
## Minification comparison
|
|
225
225
|
|
|
226
|
-
How does HTML Minifier Next compare to other
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
|
230
|
-
|
|
|
231
|
-
| [
|
|
232
|
-
| [Apple](https://www.apple.com/) |
|
|
233
|
-
| [BBC](https://www.bbc.co.uk/) |
|
|
234
|
-
| [CSS-Tricks](https://css-tricks.com/) |
|
|
235
|
-
| [ECMAScript](https://tc39.es/ecma262/) |
|
|
236
|
-
| [EFF](https://www.eff.org/) |
|
|
237
|
-
| [FAZ](https://www.faz.net/aktuell/) |
|
|
238
|
-
| [Frontend Dogma](https://frontenddogma.com/) |
|
|
239
|
-
| [Google](https://www.google.com/) | 18 | **17** |
|
|
240
|
-
| [Ground News](https://ground.news/) |
|
|
241
|
-
| [HTML](https://html.spec.whatwg.org/multipage/) | 149 | **147** |
|
|
242
|
-
| [Leanpub](https://leanpub.com/) |
|
|
243
|
-
| [Mastodon](https://mastodon.social/explore) | 35 | **26** |
|
|
244
|
-
| [MDN](https://developer.mozilla.org/en-US/) | 107 | **62** |
|
|
245
|
-
| [
|
|
246
|
-
| [
|
|
247
|
-
| [
|
|
248
|
-
|
|
|
226
|
+
How does HTML Minifier Next compare to other minifiers, like [HTML Minifier Terser](https://github.com/terser/html-minifier-terser), [htmlnano](https://github.com/posthtml/htmlnano), [@swc/html](https://github.com/swc-project/swc), [minify-html](https://github.com/wilsonzlin/minify-html), [minimize](https://github.com/Swaagie/minimize), and [htmlcompressor.com](https://htmlcompressor.com/)? (All with the most aggressive settings, though without [hyper-optimization](https://meiert.com/blog/the-ways-of-writing-html/#toc-hyper-optimized). Minimize does not minify CSS or JS.)
|
|
227
|
+
|
|
228
|
+
<!-- Auto-generated benchmarks, don’t edit -->
|
|
229
|
+
| Site | Original Size (KB) | HTML Minifier Next | HTML Minifier Terser | htmlnano | @swc/html | minify-html | minimize | htmlcompressor.com |
|
|
230
|
+
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
231
|
+
| [A List Apart](https://alistapart.com/) | 59 | **49** | 50 | 51 | 52 | 51 | 54 | 52 |
|
|
232
|
+
| [Apple](https://www.apple.com/) | 185 | **144** | **144** | 163 | 166 | 167 | 171 | 169 |
|
|
233
|
+
| [BBC](https://www.bbc.co.uk/) | 712 | **646** | 656 | 668 | 669 | 670 | 706 | n/a |
|
|
234
|
+
| [CSS-Tricks](https://css-tricks.com/) | 161 | 121 | **119** | 127 | 142 | 142 | 147 | 144 |
|
|
235
|
+
| [ECMAScript](https://tc39.es/ecma262/) | 7237 | **6341** | **6341** | 6561 | 6444 | 6567 | 6614 | n/a |
|
|
236
|
+
| [EFF](https://www.eff.org/) | 55 | **47** | **47** | 49 | 48 | 49 | 50 | 50 |
|
|
237
|
+
| [FAZ](https://www.faz.net/aktuell/) | 1530 | 1425 | 1430 | **1375** | 1456 | 1467 | 1478 | n/a |
|
|
238
|
+
| [Frontend Dogma](https://frontenddogma.com/) | 221 | **212** | **212** | 233 | 218 | 220 | 238 | 219 |
|
|
239
|
+
| [Google](https://www.google.com/) | 18 | **17** | **17** | **17** | **17** | **17** | 18 | 18 |
|
|
240
|
+
| [Ground News](https://ground.news/) | 1503 | **1287** | 1290 | 1385 | 1407 | 1413 | 1490 | n/a |
|
|
241
|
+
| [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | **147** | **147** | 153 | **147** | 149 | 155 | 149 |
|
|
242
|
+
| [Leanpub](https://leanpub.com/) | 1723 | **1472** | **1472** | 1479 | 1478 | 1473 | 1718 | n/a |
|
|
243
|
+
| [Mastodon](https://mastodon.social/explore) | 35 | **26** | **26** | 30 | 33 | 33 | 34 | 34 |
|
|
244
|
+
| [MDN](https://developer.mozilla.org/en-US/) | 107 | **62** | **62** | 64 | 64 | 65 | 67 | 67 |
|
|
245
|
+
| [SitePoint](https://www.sitepoint.com/) | 508 | **366** | **366** | 444 | 482 | 486 | 504 | n/a |
|
|
246
|
+
| [United Nations](https://www.un.org/en/) | 153 | **114** | 116 | 123 | 127 | 126 | 132 | 125 |
|
|
247
|
+
| [W3C](https://www.w3.org/) | 50 | **36** | **36** | 38 | 38 | 38 | 40 | 38 |
|
|
248
|
+
| **Average processing time** | | 388 ms (17/17) | 434 ms (17/17) | 219 ms (17/17) | 83 ms (17/17) | **21 ms (17/17)** | 455 ms (17/17) | 1348 ms (11/17) |
|
|
249
|
+
|
|
250
|
+
(Last updated: Dec 6, 2025)
|
|
251
|
+
<!-- End auto-generated -->
|
|
249
252
|
|
|
250
253
|
## Examples
|
|
251
254
|
|
|
@@ -386,6 +389,12 @@ ignoreCustomFragments: [/\{\{[\s\S]{0,500}?\}\}/]
|
|
|
386
389
|
|
|
387
390
|
## Running HTML Minifier Next
|
|
388
391
|
|
|
392
|
+
### Local server
|
|
393
|
+
|
|
394
|
+
```shell
|
|
395
|
+
npm run serve
|
|
396
|
+
```
|
|
397
|
+
|
|
389
398
|
### Benchmarks
|
|
390
399
|
|
|
391
400
|
Benchmarks for minified HTML:
|
|
@@ -396,12 +405,6 @@ npm install
|
|
|
396
405
|
npm run benchmarks
|
|
397
406
|
```
|
|
398
407
|
|
|
399
|
-
### Local server
|
|
400
|
-
|
|
401
|
-
```shell
|
|
402
|
-
npm run serve
|
|
403
|
-
```
|
|
404
|
-
|
|
405
408
|
## Acknowledgements
|
|
406
409
|
|
|
407
410
|
With many thanks to all the previous authors of HTML Minifier, especially [Juriy Zaytsev](https://github.com/kangax), and to everyone who helped make this new edition better, particularly [Daniel Ruf](https://github.com/DanielRuf) and [Jonas Geiler](https://github.com/jonasgeiler).
|
package/cli.js
CHANGED
|
@@ -129,7 +129,7 @@ const mainOptions = {
|
|
|
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
131
|
decodeEntities: 'Use direct Unicode characters whenever possible',
|
|
132
|
-
html5: 'Don’t parse input according to the HTML specification',
|
|
132
|
+
html5: 'Don’t parse input according to the HTML specification (not recommended for modern HTML)',
|
|
133
133
|
ignoreCustomComments: ['Array of regexes that allow to ignore certain comments, when matched', parseJSONRegExpArray],
|
|
134
134
|
ignoreCustomFragments: ['Array of regexes that allow to ignore certain fragments, when matched (e.g., “<?php … ?>”, “{{ … }}”)', parseJSONRegExpArray],
|
|
135
135
|
includeAutoGeneratedTags: 'Don’t insert elements generated by HTML parser',
|
package/dist/htmlminifier.cjs
CHANGED
|
@@ -113,6 +113,9 @@ function joinSingleAttrAssigns(handler) {
|
|
|
113
113
|
}).join('|');
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// Number of captured parts per `customAttrSurround` pattern
|
|
117
|
+
const NCP = 7;
|
|
118
|
+
|
|
116
119
|
class HTMLParser {
|
|
117
120
|
constructor(html, handler) {
|
|
118
121
|
this.html = html;
|
|
@@ -125,7 +128,15 @@ class HTMLParser {
|
|
|
125
128
|
|
|
126
129
|
const stack = []; let lastTag;
|
|
127
130
|
const attribute = attrForHandler(handler);
|
|
128
|
-
let last, prevTag, nextTag;
|
|
131
|
+
let last, prevTag = undefined, nextTag = undefined;
|
|
132
|
+
|
|
133
|
+
// Track position for better error messages
|
|
134
|
+
let position = 0;
|
|
135
|
+
const getLineColumn = (pos) => {
|
|
136
|
+
const lines = this.html.slice(0, pos).split('\n');
|
|
137
|
+
return { line: lines.length, column: lines[lines.length - 1].length + 1 };
|
|
138
|
+
};
|
|
139
|
+
|
|
129
140
|
while (html) {
|
|
130
141
|
last = html;
|
|
131
142
|
// Make sure we’re not in a `script` or `style` element
|
|
@@ -243,8 +254,27 @@ class HTMLParser {
|
|
|
243
254
|
}
|
|
244
255
|
|
|
245
256
|
if (html === last) {
|
|
246
|
-
|
|
257
|
+
if (handler.continueOnParseError) {
|
|
258
|
+
// Skip the problematic character and continue
|
|
259
|
+
if (handler.chars) {
|
|
260
|
+
await handler.chars(html[0], prevTag, '');
|
|
261
|
+
}
|
|
262
|
+
html = html.substring(1);
|
|
263
|
+
position++;
|
|
264
|
+
prevTag = '';
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const loc = getLineColumn(position);
|
|
268
|
+
// Include some context before the error position so the snippet contains
|
|
269
|
+
// the offending markup plus preceding characters (e.g. "invalid<tag").
|
|
270
|
+
const CONTEXT_BEFORE = 50;
|
|
271
|
+
const startPos = Math.max(0, position - CONTEXT_BEFORE);
|
|
272
|
+
const snippet = this.html.slice(startPos, startPos + 200).replace(/\n/g, ' ');
|
|
273
|
+
throw new Error(
|
|
274
|
+
`Parse error at line ${loc.line}, column ${loc.column}:\n${snippet}${this.html.length > startPos + 200 ? '…' : ''}`
|
|
275
|
+
);
|
|
247
276
|
}
|
|
277
|
+
position = this.html.length - html.length;
|
|
248
278
|
}
|
|
249
279
|
|
|
250
280
|
if (!handler.partialMarkup) {
|
|
@@ -261,10 +291,77 @@ class HTMLParser {
|
|
|
261
291
|
};
|
|
262
292
|
input = input.slice(start[0].length);
|
|
263
293
|
let end, attr;
|
|
264
|
-
|
|
294
|
+
|
|
295
|
+
// Safety limit: max length of input to check for attributes
|
|
296
|
+
// Protects against catastrophic backtracking on massive attribute values
|
|
297
|
+
const MAX_ATTR_PARSE_LENGTH = 20000; // 20 KB should be enough for any reasonable tag
|
|
298
|
+
|
|
299
|
+
while (true) {
|
|
300
|
+
// Check for closing tag first
|
|
301
|
+
end = input.match(startTagClose);
|
|
302
|
+
if (end) {
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Limit the input length we pass to the regex to prevent catastrophic backtracking
|
|
307
|
+
const isLimited = input.length > MAX_ATTR_PARSE_LENGTH;
|
|
308
|
+
const searchInput = isLimited ? input.slice(0, MAX_ATTR_PARSE_LENGTH) : input;
|
|
309
|
+
|
|
310
|
+
attr = searchInput.match(attribute);
|
|
311
|
+
|
|
312
|
+
// If we limited the input and got a match, check if the value might be truncated
|
|
313
|
+
if (attr && isLimited) {
|
|
314
|
+
// Check if the attribute value extends beyond our search window
|
|
315
|
+
const attrEnd = attr[0].length;
|
|
316
|
+
// If the match ends near the limit, the value might be truncated
|
|
317
|
+
if (attrEnd > MAX_ATTR_PARSE_LENGTH - 100) {
|
|
318
|
+
// Manually extract this attribute to handle potentially huge value
|
|
319
|
+
const manualMatch = input.match(/^\s*([^\s"'<>/=]+)\s*=\s*/);
|
|
320
|
+
if (manualMatch) {
|
|
321
|
+
const quoteChar = input[manualMatch[0].length];
|
|
322
|
+
if (quoteChar === '"' || quoteChar === "'") {
|
|
323
|
+
const closeQuote = input.indexOf(quoteChar, manualMatch[0].length + 1);
|
|
324
|
+
if (closeQuote !== -1) {
|
|
325
|
+
const fullAttr = input.slice(0, closeQuote + 1);
|
|
326
|
+
const numCustomParts = handler.customAttrSurround
|
|
327
|
+
? handler.customAttrSurround.length * NCP
|
|
328
|
+
: 0;
|
|
329
|
+
const baseIndex = 1 + numCustomParts;
|
|
330
|
+
|
|
331
|
+
attr = [];
|
|
332
|
+
attr[0] = fullAttr;
|
|
333
|
+
attr[baseIndex] = manualMatch[1]; // Attribute name
|
|
334
|
+
attr[baseIndex + 1] = '='; // customAssign (falls back to “=” for huge attributes)
|
|
335
|
+
const value = input.slice(manualMatch[0].length + 1, closeQuote);
|
|
336
|
+
// Place value at correct index based on quote type
|
|
337
|
+
if (quoteChar === '"') {
|
|
338
|
+
attr[baseIndex + 2] = value; // Double-quoted value
|
|
339
|
+
} else {
|
|
340
|
+
attr[baseIndex + 3] = value; // Single-quoted value
|
|
341
|
+
}
|
|
342
|
+
input = input.slice(fullAttr.length);
|
|
343
|
+
match.attrs.push(attr);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Note: Unquoted attribute values are intentionally not handled here.
|
|
348
|
+
// Per HTML spec, unquoted values cannot contain spaces or special chars,
|
|
349
|
+
// making a 20 KB+ unquoted value practically impossible. If encountered,
|
|
350
|
+
// it’s malformed HTML and using the truncated regex match is acceptable.
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!attr) {
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
|
|
265
359
|
input = input.slice(attr[0].length);
|
|
266
360
|
match.attrs.push(attr);
|
|
267
361
|
}
|
|
362
|
+
|
|
363
|
+
// Check for closing tag
|
|
364
|
+
end = input.match(startTagClose);
|
|
268
365
|
if (end) {
|
|
269
366
|
match.unarySlash = end[1];
|
|
270
367
|
match.rest = input.slice(end[0].length);
|
|
@@ -357,7 +454,6 @@ class HTMLParser {
|
|
|
357
454
|
|
|
358
455
|
const attrs = match.attrs.map(function (args) {
|
|
359
456
|
let name, value, customOpen, customClose, customAssign, quote;
|
|
360
|
-
const ncp = 7; // Number of captured parts, scalar
|
|
361
457
|
|
|
362
458
|
// Hackish workaround for FF bug https://bugzilla.mozilla.org/show_bug.cgi?id=369778
|
|
363
459
|
if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) {
|
|
@@ -385,7 +481,7 @@ class HTMLParser {
|
|
|
385
481
|
|
|
386
482
|
let j = 1;
|
|
387
483
|
if (handler.customAttrSurround) {
|
|
388
|
-
for (let i = 0, l = handler.customAttrSurround.length; i < l; i++, j +=
|
|
484
|
+
for (let i = 0, l = handler.customAttrSurround.length; i < l; i++, j += NCP) {
|
|
389
485
|
name = args[j + 1];
|
|
390
486
|
if (name) {
|
|
391
487
|
quote = populate(j + 2);
|
|
@@ -608,18 +704,88 @@ function getPresetNames() {
|
|
|
608
704
|
return Object.keys(presets);
|
|
609
705
|
}
|
|
610
706
|
|
|
611
|
-
|
|
707
|
+
// Hoisted, reusable RegExp patterns and tiny helpers to avoid repeated allocations in hot paths
|
|
708
|
+
const RE_WS_START = /^[ \n\r\t\f]+/;
|
|
709
|
+
const RE_WS_END = /[ \n\r\t\f]+$/;
|
|
710
|
+
const RE_ALL_WS_NBSP = /[ \n\r\t\f\xA0]+/g;
|
|
711
|
+
const RE_NBSP_LEADING_GROUP = /(^|\xA0+)[^\xA0]+/g;
|
|
712
|
+
const RE_NBSP_LEAD_GROUP = /(\xA0+)[^\xA0]+/g;
|
|
713
|
+
const RE_NBSP_TRAILING_GROUP = /[^\xA0]+(\xA0+)/g;
|
|
714
|
+
const RE_NBSP_TRAILING_STRIP = /[^\xA0]+$/;
|
|
715
|
+
const RE_CONDITIONAL_COMMENT = /^\[if\s[^\]]+]|\[endif]$/;
|
|
716
|
+
const RE_EVENT_ATTR_DEFAULT = /^on[a-z]{3,}$/;
|
|
717
|
+
const RE_CAN_REMOVE_ATTR_QUOTES = /^[^ \t\n\f\r"'`=<>]+$/;
|
|
718
|
+
const RE_TRAILING_SEMICOLON = /;$/;
|
|
719
|
+
const RE_AMP_ENTITY = /&(#?[0-9a-zA-Z]+;)/g;
|
|
720
|
+
|
|
721
|
+
// Tiny stable stringify for options signatures (sorted keys, shallow, nested objects)
|
|
722
|
+
function stableStringify(obj) {
|
|
723
|
+
if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
724
|
+
if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
|
|
725
|
+
const keys = Object.keys(obj).sort();
|
|
726
|
+
let out = '{';
|
|
727
|
+
for (let i = 0; i < keys.length; i++) {
|
|
728
|
+
const k = keys[i];
|
|
729
|
+
out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
|
|
730
|
+
}
|
|
731
|
+
return out + '}';
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Minimal LRU cache for strings and promises
|
|
735
|
+
class LRU {
|
|
736
|
+
constructor(limit = 200) {
|
|
737
|
+
this.limit = limit;
|
|
738
|
+
this.map = new Map();
|
|
739
|
+
}
|
|
740
|
+
get(key) {
|
|
741
|
+
const v = this.map.get(key);
|
|
742
|
+
if (v !== undefined) {
|
|
743
|
+
this.map.delete(key);
|
|
744
|
+
this.map.set(key, v);
|
|
745
|
+
}
|
|
746
|
+
return v;
|
|
747
|
+
}
|
|
748
|
+
set(key, value) {
|
|
749
|
+
if (this.map.has(key)) this.map.delete(key);
|
|
750
|
+
this.map.set(key, value);
|
|
751
|
+
if (this.map.size > this.limit) {
|
|
752
|
+
const first = this.map.keys().next().value;
|
|
753
|
+
this.map.delete(first);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
delete(key) { this.map.delete(key); }
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Per-process caches
|
|
760
|
+
const jsMinifyCache = new LRU(200);
|
|
761
|
+
const cssMinifyCache = new LRU(200);
|
|
762
|
+
|
|
763
|
+
const trimWhitespace = str => {
|
|
764
|
+
if (!str) return str;
|
|
765
|
+
// Fast path: if no whitespace at start or end, return early
|
|
766
|
+
if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
|
|
767
|
+
return str;
|
|
768
|
+
}
|
|
769
|
+
return str.replace(RE_WS_START, '').replace(RE_WS_END, '');
|
|
770
|
+
};
|
|
612
771
|
|
|
613
772
|
function collapseWhitespaceAll(str) {
|
|
773
|
+
if (!str) return str;
|
|
774
|
+
// Fast path: if there are no common whitespace characters, return early
|
|
775
|
+
if (!/[ \n\r\t\f\xA0]/.test(str)) {
|
|
776
|
+
return str;
|
|
777
|
+
}
|
|
614
778
|
// Non-breaking space is specifically handled inside the replacer function here:
|
|
615
|
-
return str
|
|
616
|
-
return spaces === '\t' ? '\t' : spaces.replace(
|
|
779
|
+
return str.replace(RE_ALL_WS_NBSP, function (spaces) {
|
|
780
|
+
return spaces === '\t' ? '\t' : spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
|
|
617
781
|
});
|
|
618
782
|
}
|
|
619
783
|
|
|
620
784
|
function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
|
|
621
785
|
let lineBreakBefore = ''; let lineBreakAfter = '';
|
|
622
786
|
|
|
787
|
+
if (!str) return str;
|
|
788
|
+
|
|
623
789
|
if (options.preserveLineBreaks) {
|
|
624
790
|
str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function () {
|
|
625
791
|
lineBreakBefore = '\n';
|
|
@@ -637,7 +803,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
|
|
|
637
803
|
if (conservative && spaces === '\t') {
|
|
638
804
|
return '\t';
|
|
639
805
|
}
|
|
640
|
-
return spaces.replace(/^[^\xA0]+/, '').replace(
|
|
806
|
+
return spaces.replace(/^[^\xA0]+/, '').replace(RE_NBSP_LEAD_GROUP, '$1 ') || (conservative ? ' ' : '');
|
|
641
807
|
});
|
|
642
808
|
}
|
|
643
809
|
|
|
@@ -648,7 +814,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
|
|
|
648
814
|
if (conservative && spaces === '\t') {
|
|
649
815
|
return '\t';
|
|
650
816
|
}
|
|
651
|
-
return spaces.replace(
|
|
817
|
+
return spaces.replace(RE_NBSP_TRAILING_GROUP, ' $1').replace(RE_NBSP_TRAILING_STRIP, '') || (conservative ? ' ' : '');
|
|
652
818
|
});
|
|
653
819
|
}
|
|
654
820
|
|
|
@@ -680,7 +846,7 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements,
|
|
|
680
846
|
}
|
|
681
847
|
|
|
682
848
|
function isConditionalComment(text) {
|
|
683
|
-
return
|
|
849
|
+
return RE_CONDITIONAL_COMMENT.test(text);
|
|
684
850
|
}
|
|
685
851
|
|
|
686
852
|
function isIgnoredComment(text, options) {
|
|
@@ -702,12 +868,12 @@ function isEventAttribute(attrName, options) {
|
|
|
702
868
|
}
|
|
703
869
|
return false;
|
|
704
870
|
}
|
|
705
|
-
return
|
|
871
|
+
return RE_EVENT_ATTR_DEFAULT.test(attrName);
|
|
706
872
|
}
|
|
707
873
|
|
|
708
874
|
function canRemoveAttributeQuotes(value) {
|
|
709
875
|
// https://mathiasbynens.be/notes/unquoted-attribute-values
|
|
710
|
-
return
|
|
876
|
+
return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
|
|
711
877
|
}
|
|
712
878
|
|
|
713
879
|
function attributesInclude(attributes, attribute) {
|
|
@@ -918,7 +1084,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
918
1084
|
} else if (attrName === 'style') {
|
|
919
1085
|
attrValue = trimWhitespace(attrValue);
|
|
920
1086
|
if (attrValue) {
|
|
921
|
-
if (
|
|
1087
|
+
if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
|
|
922
1088
|
attrValue = attrValue.replace(/\s*;$/, ';');
|
|
923
1089
|
}
|
|
924
1090
|
attrValue = await options.minifyCSS(attrValue, 'inline');
|
|
@@ -1237,7 +1403,10 @@ async function normalizeAttr(attr, attrs, tag, options) {
|
|
|
1237
1403
|
let attrValue = attr.value;
|
|
1238
1404
|
|
|
1239
1405
|
if (options.decodeEntities && attrValue) {
|
|
1240
|
-
|
|
1406
|
+
// Fast path: only decode when entities are present
|
|
1407
|
+
if (attrValue.indexOf('&') !== -1) {
|
|
1408
|
+
attrValue = entities.decodeHTMLStrict(attrValue);
|
|
1409
|
+
}
|
|
1241
1410
|
}
|
|
1242
1411
|
|
|
1243
1412
|
if ((options.removeRedundantAttributes &&
|
|
@@ -1258,8 +1427,8 @@ async function normalizeAttr(attr, attrs, tag, options) {
|
|
|
1258
1427
|
return;
|
|
1259
1428
|
}
|
|
1260
1429
|
|
|
1261
|
-
if (options.decodeEntities && attrValue) {
|
|
1262
|
-
attrValue = attrValue.replace(
|
|
1430
|
+
if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
|
|
1431
|
+
attrValue = attrValue.replace(RE_AMP_ENTITY, '&$1');
|
|
1263
1432
|
}
|
|
1264
1433
|
|
|
1265
1434
|
return {
|
|
@@ -1379,6 +1548,10 @@ const processOptions = (inputOptions) => {
|
|
|
1379
1548
|
const lightningCssOptions = typeof option === 'object' ? option : {};
|
|
1380
1549
|
|
|
1381
1550
|
options.minifyCSS = async function (text, type) {
|
|
1551
|
+
// Fast path: nothing to minify
|
|
1552
|
+
if (!text || !text.trim()) {
|
|
1553
|
+
return text;
|
|
1554
|
+
}
|
|
1382
1555
|
text = await replaceAsync(
|
|
1383
1556
|
text,
|
|
1384
1557
|
/(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
|
|
@@ -1397,10 +1570,20 @@ const processOptions = (inputOptions) => {
|
|
|
1397
1570
|
}
|
|
1398
1571
|
}
|
|
1399
1572
|
);
|
|
1400
|
-
|
|
1573
|
+
// Cache key: wrapped content, type, options signature
|
|
1401
1574
|
const inputCSS = wrapCSS(text, type);
|
|
1575
|
+
const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
|
|
1576
|
+
// For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
|
|
1577
|
+
const cssKey = inputCSS.length > 2048
|
|
1578
|
+
? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
|
|
1579
|
+
: (inputCSS + '|' + type + '|' + cssSig);
|
|
1402
1580
|
|
|
1403
1581
|
try {
|
|
1582
|
+
const cached = cssMinifyCache.get(cssKey);
|
|
1583
|
+
if (cached) {
|
|
1584
|
+
return cached;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1404
1587
|
const result = lightningcss.transform({
|
|
1405
1588
|
filename: 'input.css',
|
|
1406
1589
|
code: Buffer.from(inputCSS),
|
|
@@ -1423,12 +1606,12 @@ const processOptions = (inputOptions) => {
|
|
|
1423
1606
|
|
|
1424
1607
|
// Preserve if output is empty and input had template syntax or UIDs
|
|
1425
1608
|
// This catches cases where Lightning CSS removed content that should be preserved
|
|
1426
|
-
|
|
1427
|
-
return text;
|
|
1428
|
-
}
|
|
1609
|
+
const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
|
|
1429
1610
|
|
|
1430
|
-
|
|
1611
|
+
cssMinifyCache.set(cssKey, finalOutput);
|
|
1612
|
+
return finalOutput;
|
|
1431
1613
|
} catch (err) {
|
|
1614
|
+
cssMinifyCache.delete(cssKey);
|
|
1432
1615
|
if (!options.continueOnMinifyError) {
|
|
1433
1616
|
throw err;
|
|
1434
1617
|
}
|
|
@@ -1454,10 +1637,39 @@ const processOptions = (inputOptions) => {
|
|
|
1454
1637
|
|
|
1455
1638
|
terserOptions.parse.bare_returns = inline;
|
|
1456
1639
|
|
|
1640
|
+
let jsKey;
|
|
1457
1641
|
try {
|
|
1458
|
-
|
|
1459
|
-
|
|
1642
|
+
// Fast path: avoid invoking Terser for empty/whitespace-only content
|
|
1643
|
+
if (!code || !code.trim()) {
|
|
1644
|
+
return '';
|
|
1645
|
+
}
|
|
1646
|
+
// Cache key: content, inline, options signature (subset)
|
|
1647
|
+
const terserSig = stableStringify({
|
|
1648
|
+
compress: terserOptions.compress,
|
|
1649
|
+
mangle: terserOptions.mangle,
|
|
1650
|
+
ecma: terserOptions.ecma,
|
|
1651
|
+
toplevel: terserOptions.toplevel,
|
|
1652
|
+
module: terserOptions.module,
|
|
1653
|
+
keep_fnames: terserOptions.keep_fnames,
|
|
1654
|
+
format: terserOptions.format,
|
|
1655
|
+
cont: !!options.continueOnMinifyError,
|
|
1656
|
+
});
|
|
1657
|
+
// For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
|
|
1658
|
+
jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
|
|
1659
|
+
const cached = jsMinifyCache.get(jsKey);
|
|
1660
|
+
if (cached) {
|
|
1661
|
+
return await cached;
|
|
1662
|
+
}
|
|
1663
|
+
const inFlight = (async () => {
|
|
1664
|
+
const result = await terser.minify(code, terserOptions);
|
|
1665
|
+
return result.code.replace(RE_TRAILING_SEMICOLON, '');
|
|
1666
|
+
})();
|
|
1667
|
+
jsMinifyCache.set(jsKey, inFlight);
|
|
1668
|
+
const resolved = await inFlight;
|
|
1669
|
+
jsMinifyCache.set(jsKey, resolved);
|
|
1670
|
+
return resolved;
|
|
1460
1671
|
} catch (err) {
|
|
1672
|
+
if (jsKey) jsMinifyCache.delete(jsKey);
|
|
1461
1673
|
if (!options.continueOnMinifyError) {
|
|
1462
1674
|
throw err;
|
|
1463
1675
|
}
|
|
@@ -1548,8 +1760,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
1548
1760
|
currentTag = '';
|
|
1549
1761
|
},
|
|
1550
1762
|
chars: async function (text) {
|
|
1763
|
+
// Only recursively scan HTML content, not JSON-LD or other non-HTML script types
|
|
1764
|
+
// `scan()` is for analyzing HTML attribute order, not for parsing JSON
|
|
1551
1765
|
if (options.processScripts && specialContentTags.has(currentTag) &&
|
|
1552
|
-
options.processScripts.indexOf(currentType) > -1
|
|
1766
|
+
options.processScripts.indexOf(currentType) > -1 &&
|
|
1767
|
+
currentType === 'text/html') {
|
|
1553
1768
|
await scan(text);
|
|
1554
1769
|
}
|
|
1555
1770
|
}
|
|
@@ -1562,7 +1777,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
1562
1777
|
options.log = identity;
|
|
1563
1778
|
options.sortAttributes = false;
|
|
1564
1779
|
options.sortClassName = false;
|
|
1565
|
-
|
|
1780
|
+
const firstPassOutput = await minifyHTML(value, options);
|
|
1781
|
+
await scan(firstPassOutput);
|
|
1566
1782
|
options.log = log;
|
|
1567
1783
|
if (attrChains) {
|
|
1568
1784
|
const attrSorters = Object.create(null);
|
|
@@ -1915,7 +2131,9 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1915
2131
|
prevTag = prevTag === '' ? 'comment' : prevTag;
|
|
1916
2132
|
nextTag = nextTag === '' ? 'comment' : nextTag;
|
|
1917
2133
|
if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
|
|
1918
|
-
text
|
|
2134
|
+
if (text.indexOf('&') !== -1) {
|
|
2135
|
+
text = entities.decodeHTML(text);
|
|
2136
|
+
}
|
|
1919
2137
|
}
|
|
1920
2138
|
if (options.collapseWhitespace) {
|
|
1921
2139
|
if (!stackNoTrimWhitespace.length) {
|
|
@@ -1989,11 +2207,16 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1989
2207
|
charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
|
|
1990
2208
|
if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
|
|
1991
2209
|
// Escape any `&` symbols that start either:
|
|
1992
|
-
// 1) a legacy named character reference (i.e
|
|
1993
|
-
// 2) or any other character reference (i.e
|
|
2210
|
+
// 1) a legacy named character reference (i.e., one that doesn’t end with `;`)
|
|
2211
|
+
// 2) or any other character reference (i.e., one that does end with `;`)
|
|
1994
2212
|
// Note that `&` can be escaped as `&`, without the semi-colon.
|
|
1995
2213
|
// https://mathiasbynens.be/notes/ambiguous-ampersands
|
|
1996
|
-
|
|
2214
|
+
if (text.indexOf('&') !== -1) {
|
|
2215
|
+
text = text.replace(/&((?:Iacute|aacute|uacute|plusmn|Otilde|otilde|agrave|Agrave|Yacute|yacute|Oslash|oslash|atilde|Atilde|brvbar|ccedil|Ccedil|Ograve|curren|divide|eacute|Eacute|ograve|Oacute|egrave|Egrave|Ugrave|frac12|frac14|frac34|ugrave|oacute|iacute|Ntilde|ntilde|Uacute|middot|igrave|Igrave|iquest|Aacute|cedil|laquo|micro|iexcl|Icirc|icirc|acirc|Ucirc|Ecirc|ocirc|Ocirc|ecirc|ucirc|Aring|aring|AElig|aelig|acute|pound|raquo|Acirc|times|THORN|szlig|thorn|COPY|auml|ordf|ordm|Uuml|macr|uuml|Auml|ouml|Ouml|para|nbsp|euml|quot|QUOT|Euml|yuml|cent|sect|copy|sup1|sup2|sup3|iuml|Iuml|ETH|shy|reg|not|yen|amp|AMP|REG|uml|eth|deg|gt|GT|LT|lt)(?!;)|(?:#?[0-9a-zA-Z]+;))/g, '&$1');
|
|
2216
|
+
}
|
|
2217
|
+
if (text.indexOf('<') !== -1) {
|
|
2218
|
+
text = text.replace(/</g, '<');
|
|
2219
|
+
}
|
|
1997
2220
|
}
|
|
1998
2221
|
if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
|
|
1999
2222
|
text = text.replace(uidPattern, function (match, prefix, index) {
|