html-minifier-next 7.3.0 → 7.5.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 +9 -9
- package/cli.js +12 -1
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/htmlparser.d.ts.map +1 -1
- package/dist/types/lib/options.d.ts.map +1 -1
- package/dist/types/lib/whitespace.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/htmlminifier.js +548 -400
- package/src/htmlparser.js +75 -53
- package/src/lib/attributes.js +2 -2
- package/src/lib/options.js +47 -3
- package/src/lib/whitespace.js +16 -7
package/src/htmlparser.js
CHANGED
|
@@ -37,13 +37,6 @@ import { isThenable } from './lib/utils.js';
|
|
|
37
37
|
* });
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
-
class CaseInsensitiveSet extends Set {
|
|
41
|
-
/** @override */
|
|
42
|
-
has(/** @type {string} */ str) {
|
|
43
|
-
return super.has(str.toLowerCase());
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
40
|
// Regular expressions for parsing tags and attributes
|
|
48
41
|
const singleAttrIdentifier = /([^\s"'<>/=]+)/;
|
|
49
42
|
const singleAttrAssigns = [/=/];
|
|
@@ -80,20 +73,20 @@ let IS_REGEX_CAPTURING_BROKEN = false;
|
|
|
80
73
|
});
|
|
81
74
|
|
|
82
75
|
// Empty elements
|
|
83
|
-
const empty = new
|
|
76
|
+
const empty = new Set(['area', 'base', 'basefont', 'br', 'col', 'embed', 'frame', 'hr', 'img', 'input', 'isindex', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
84
77
|
|
|
85
78
|
// Elements that you can, intentionally, leave open (and which close themselves)
|
|
86
|
-
const closeSelf = new
|
|
79
|
+
const closeSelf = new Set(['colgroup', 'dd', 'dt', 'li', 'option', 'p', 'td', 'tfoot', 'th', 'thead', 'tr', 'source']);
|
|
87
80
|
|
|
88
81
|
// Attributes that have their values filled in `disabled='disabled'`
|
|
89
|
-
const fillAttrs = new
|
|
82
|
+
const fillAttrs = new Set(['checked', 'compact', 'declare', 'defer', 'disabled', 'ismap', 'multiple', 'nohref', 'noresize', 'noshade', 'nowrap', 'readonly', 'selected']);
|
|
90
83
|
|
|
91
84
|
// Special elements (can contain anything)
|
|
92
|
-
const special = new
|
|
85
|
+
const special = new Set(['script', 'style']);
|
|
93
86
|
|
|
94
87
|
// HTML elements, https://html.spec.whatwg.org/multipage/indices.html#elements-3
|
|
95
88
|
// Phrasing content, https://html.spec.whatwg.org/multipage/dom.html#phrasing-content
|
|
96
|
-
const nonPhrasing = new
|
|
89
|
+
const nonPhrasing = new Set(['address', 'article', 'aside', 'base', 'blockquote', 'body', 'caption', 'col', 'colgroup', 'dd', 'details', 'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'legend', 'li', 'menuitem', 'meta', 'ol', 'optgroup', 'option', 'param', 'rp', 'rt', 'source', 'style', 'summary', 'tbody', 'td', 'tfoot', 'th', 'thead', 'title', 'tr', 'track', 'ul']);
|
|
97
90
|
|
|
98
91
|
const reCache = {};
|
|
99
92
|
|
|
@@ -213,6 +206,9 @@ export class HTMLParser {
|
|
|
213
206
|
const stack = [];
|
|
214
207
|
/** @type {string} */
|
|
215
208
|
let lastTag = '';
|
|
209
|
+
// Lowercase counterpart of `lastTag`, kept in sync to avoid per-iteration lowercasing
|
|
210
|
+
/** @type {string} */
|
|
211
|
+
let lastTagLower = '';
|
|
216
212
|
// Use cached attribute regex for this handler configuration
|
|
217
213
|
const attribute = getAttrRegexForHandler(handler);
|
|
218
214
|
const attributeY = getAttrRegexStickyForHandler(handler);
|
|
@@ -242,6 +238,19 @@ export class HTMLParser {
|
|
|
242
238
|
let pos = 0;
|
|
243
239
|
let lastPos;
|
|
244
240
|
|
|
241
|
+
// An end tag always ends at a `>`; without one, the `endTag` regex’s name run
|
|
242
|
+
// and its `[^>]*` tail overlap and backtrack O(n²) on a long unclosed name
|
|
243
|
+
// (e.g., `</aaaa…` with no `>`). Track the next `>` monotonically—`pos` only
|
|
244
|
+
// advances, so the aggregate scan work stays O(n)—and skip the regex when none
|
|
245
|
+
// lies ahead, where it could not match anyway.
|
|
246
|
+
let nextGtPos = fullHtml.indexOf('>');
|
|
247
|
+
const hasCloseAtOrAfter = (/** @type {number} */ p) => {
|
|
248
|
+
if (nextGtPos !== -1 && nextGtPos < p) {
|
|
249
|
+
nextGtPos = fullHtml.indexOf('>', p);
|
|
250
|
+
}
|
|
251
|
+
return nextGtPos !== -1;
|
|
252
|
+
};
|
|
253
|
+
|
|
245
254
|
// Helper to advance position
|
|
246
255
|
const advance = (/** @type {number} */ n) => { pos += n; };
|
|
247
256
|
|
|
@@ -269,7 +278,7 @@ export class HTMLParser {
|
|
|
269
278
|
lastPos = pos;
|
|
270
279
|
|
|
271
280
|
// Make sure not to be in a `script` or `style` element
|
|
272
|
-
if (!
|
|
281
|
+
if (!lastTagLower || !special.has(lastTagLower)) {
|
|
273
282
|
const textEnd = fullHtml.indexOf('<', pos);
|
|
274
283
|
|
|
275
284
|
if (textEnd === pos) {
|
|
@@ -345,15 +354,18 @@ export class HTMLParser {
|
|
|
345
354
|
continue;
|
|
346
355
|
}
|
|
347
356
|
|
|
348
|
-
// End tag
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
+
// End tag—skip when no `>` lies ahead: The regex cannot match, and would
|
|
358
|
+
// otherwise backtrack quadratically on a long unclosed name
|
|
359
|
+
if (hasCloseAtOrAfter(pos)) {
|
|
360
|
+
endTagY.lastIndex = pos;
|
|
361
|
+
const endTagMatch = endTagY.exec(fullHtml);
|
|
362
|
+
if (endTagMatch) {
|
|
363
|
+
advance((endTagMatch[0] ?? '').length);
|
|
364
|
+
await parseEndTag(endTagMatch[0] ?? '', endTagMatch[1] ?? '');
|
|
365
|
+
prevTag = '/' + (endTagMatch[1] ?? '').toLowerCase();
|
|
366
|
+
prevAttrs = [];
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
357
369
|
}
|
|
358
370
|
|
|
359
371
|
// Start tag
|
|
@@ -388,7 +400,7 @@ export class HTMLParser {
|
|
|
388
400
|
// Extract minimal attribute info for whitespace logic (just name/value pairs)
|
|
389
401
|
nextAttrs = extractAttrInfo(nextStartTagMatch.attrs);
|
|
390
402
|
cachedNextStartTag = { match: nextStartTagMatch, pos };
|
|
391
|
-
} else {
|
|
403
|
+
} else if (hasCloseAtOrAfter(pos)) {
|
|
392
404
|
endTagY.lastIndex = pos;
|
|
393
405
|
const nextEndTagMatch = endTagY.exec(fullHtml);
|
|
394
406
|
if (nextEndTagMatch) {
|
|
@@ -399,6 +411,9 @@ export class HTMLParser {
|
|
|
399
411
|
nextTag = '';
|
|
400
412
|
nextAttrs = [];
|
|
401
413
|
}
|
|
414
|
+
} else {
|
|
415
|
+
nextTag = '';
|
|
416
|
+
nextAttrs = [];
|
|
402
417
|
}
|
|
403
418
|
}
|
|
404
419
|
|
|
@@ -409,7 +424,7 @@ export class HTMLParser {
|
|
|
409
424
|
prevTag = '';
|
|
410
425
|
prevAttrs = [];
|
|
411
426
|
} else {
|
|
412
|
-
const stackedTag =
|
|
427
|
+
const stackedTag = lastTagLower;
|
|
413
428
|
// Use pre-compiled regex for common tags (`script`, `style`, `noscript`) to avoid regex creation overhead
|
|
414
429
|
const reStackedTag = /** @type {Record<string, RegExp>} */ (preCompiledStackedTags)[stackedTag] || /** @type {Record<string, RegExp>} */ (reCache)[stackedTag] || (/** @type {Record<string, RegExp>} */ (reCache)[stackedTag] = new RegExp('([\\s\\S]*?)\\x3c/' + stackedTag + '[^>]*>', 'i'));
|
|
415
430
|
|
|
@@ -640,9 +655,9 @@ export class HTMLParser {
|
|
|
640
655
|
return undefined;
|
|
641
656
|
}
|
|
642
657
|
|
|
643
|
-
|
|
658
|
+
// `needle` must already be lowercase
|
|
659
|
+
function findTagInCurrentTable(/** @type {string} */ needle) {
|
|
644
660
|
let pos;
|
|
645
|
-
const needle = tagName.toLowerCase();
|
|
646
661
|
for (pos = stack.length - 1; pos >= 0; pos--) {
|
|
647
662
|
const entry = stack[pos];
|
|
648
663
|
const currentTag = entry?.lowerTag;
|
|
@@ -657,23 +672,25 @@ export class HTMLParser {
|
|
|
657
672
|
return -1;
|
|
658
673
|
}
|
|
659
674
|
|
|
660
|
-
|
|
661
|
-
// Close all open elements up to `pos` (mirrors `parseEndTag`’s core branch)
|
|
675
|
+
function parseEndTagAt(/** @type {number} */ pos) {
|
|
676
|
+
// Close all open elements up to `pos` (mirrors `parseEndTag`’s core branch);
|
|
677
|
+
// `end` handlers are synchronous—invoked without awaiting, as in `parseEndTag`
|
|
662
678
|
for (let i = stack.length - 1; i >= pos; i--) {
|
|
663
679
|
const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
|
|
664
680
|
if (handler.end) {
|
|
665
|
-
|
|
681
|
+
handler.end(entry.tag, entry.attrs, true);
|
|
666
682
|
}
|
|
667
683
|
}
|
|
668
684
|
stack.length = pos;
|
|
669
685
|
lastTag = pos ? (stack[pos - 1]?.tag ?? '') : '';
|
|
686
|
+
lastTagLower = pos ? (stack[pos - 1]?.lowerTag ?? '') : '';
|
|
670
687
|
}
|
|
671
688
|
|
|
672
|
-
|
|
689
|
+
function closeIfFoundInCurrentTable(/** @type {string} */ tagName) {
|
|
673
690
|
const pos = findTagInCurrentTable(tagName);
|
|
674
691
|
if (pos >= 0) {
|
|
675
692
|
// Close at the specific index to avoid re-searching
|
|
676
|
-
|
|
693
|
+
parseEndTagAt(pos);
|
|
677
694
|
return true;
|
|
678
695
|
}
|
|
679
696
|
return false;
|
|
@@ -681,45 +698,47 @@ export class HTMLParser {
|
|
|
681
698
|
|
|
682
699
|
async function handleStartTag(/** @type {{tagName: string, attrs: Array<Array<string | undefined>>, advance: number, unarySlash?: string}} */ match) {
|
|
683
700
|
const tagName = match.tagName;
|
|
701
|
+
const lowerTagName = tagName.toLowerCase();
|
|
684
702
|
let unarySlash = match.unarySlash;
|
|
685
703
|
|
|
686
|
-
if (
|
|
704
|
+
if (lastTagLower === 'p' && nonPhrasing.has(lowerTagName)) {
|
|
687
705
|
await parseEndTag('', lastTag);
|
|
688
|
-
} else if (
|
|
689
|
-
if (!
|
|
690
|
-
|
|
706
|
+
} else if (lowerTagName === 'tbody') {
|
|
707
|
+
if (!closeIfFoundInCurrentTable('tfoot')) {
|
|
708
|
+
closeIfFoundInCurrentTable('thead');
|
|
691
709
|
}
|
|
692
|
-
} else if (
|
|
693
|
-
if (!
|
|
694
|
-
|
|
710
|
+
} else if (lowerTagName === 'tfoot') {
|
|
711
|
+
if (!closeIfFoundInCurrentTable('tbody')) {
|
|
712
|
+
closeIfFoundInCurrentTable('thead');
|
|
695
713
|
}
|
|
696
|
-
} else if (
|
|
714
|
+
} else if (lowerTagName === 'thead') {
|
|
697
715
|
// If a `tbody` or `tfoot` is open in the current table, close it
|
|
698
|
-
if (!
|
|
699
|
-
|
|
716
|
+
if (!closeIfFoundInCurrentTable('tbody')) {
|
|
717
|
+
closeIfFoundInCurrentTable('tfoot');
|
|
700
718
|
}
|
|
701
719
|
}
|
|
702
|
-
if (
|
|
720
|
+
if (lowerTagName === 'col' && findTagInCurrentTable('colgroup') < 0) {
|
|
703
721
|
lastTag = 'colgroup';
|
|
722
|
+
lastTagLower = 'colgroup';
|
|
704
723
|
stack.push({ tag: lastTag, lowerTag: 'colgroup', attrs: [] });
|
|
705
724
|
if (handler.start) {
|
|
706
725
|
await handler.start(lastTag, [], false, '', true);
|
|
707
726
|
}
|
|
708
|
-
} else if (
|
|
727
|
+
} else if (lowerTagName !== 'col' && lastTagLower === 'colgroup') {
|
|
709
728
|
// Auto-close synthetic `<colgroup>` when a non-`col` element starts
|
|
710
729
|
await parseEndTag('', 'colgroup');
|
|
711
730
|
}
|
|
712
731
|
|
|
713
|
-
if (closeSelf.has(
|
|
732
|
+
if (closeSelf.has(lowerTagName) && lastTagLower === lowerTagName) {
|
|
714
733
|
await parseEndTag('', tagName);
|
|
715
734
|
}
|
|
716
735
|
|
|
717
736
|
// Handle `dt`/`dd` cross-closing: `dt` followed by `dd`, or `dd` followed by `dt`
|
|
718
|
-
if ((
|
|
737
|
+
if ((lowerTagName === 'dt' || lowerTagName === 'dd') && (lastTagLower === 'dt' || lastTagLower === 'dd')) {
|
|
719
738
|
await parseEndTag('', lastTag);
|
|
720
739
|
}
|
|
721
740
|
|
|
722
|
-
const unary = empty.has(
|
|
741
|
+
const unary = empty.has(lowerTagName) || (lowerTagName === 'html' && lastTagLower === 'head') || !!unarySlash;
|
|
723
742
|
|
|
724
743
|
const attrs = /** @type {HTMLAttribute[]} */ (match.attrs.map(function (/** @type {Array<string | undefined>} */ args) {
|
|
725
744
|
/** @type {string | undefined} */
|
|
@@ -743,7 +762,7 @@ export class HTMLParser {
|
|
|
743
762
|
return '\'';
|
|
744
763
|
}
|
|
745
764
|
value = args[index + 3];
|
|
746
|
-
if (typeof value === 'undefined' && name && fillAttrs.has(name)) {
|
|
765
|
+
if (typeof value === 'undefined' && name && fillAttrs.has(name.toLowerCase())) {
|
|
747
766
|
value = name;
|
|
748
767
|
}
|
|
749
768
|
return '';
|
|
@@ -777,8 +796,9 @@ export class HTMLParser {
|
|
|
777
796
|
}));
|
|
778
797
|
|
|
779
798
|
if (!unary) {
|
|
780
|
-
stack.push({ tag: tagName, lowerTag:
|
|
799
|
+
stack.push({ tag: tagName, lowerTag: lowerTagName, attrs });
|
|
781
800
|
lastTag = tagName;
|
|
801
|
+
lastTagLower = lowerTagName;
|
|
782
802
|
unarySlash = '';
|
|
783
803
|
}
|
|
784
804
|
|
|
@@ -790,9 +810,9 @@ export class HTMLParser {
|
|
|
790
810
|
}
|
|
791
811
|
}
|
|
792
812
|
|
|
793
|
-
|
|
813
|
+
// `needle` must already be lowercase
|
|
814
|
+
function findTag(/** @type {string} */ needle) {
|
|
794
815
|
let pos;
|
|
795
|
-
const needle = tagName.toLowerCase();
|
|
796
816
|
for (pos = stack.length - 1; pos >= 0; pos--) {
|
|
797
817
|
if (stack[pos]?.lowerTag === needle) {
|
|
798
818
|
break;
|
|
@@ -803,10 +823,11 @@ export class HTMLParser {
|
|
|
803
823
|
|
|
804
824
|
async function parseEndTag(/** @type {string} */ tag, /** @type {string} */ tagName) {
|
|
805
825
|
let pos;
|
|
826
|
+
const lowerTagName = tagName ? tagName.toLowerCase() : '';
|
|
806
827
|
|
|
807
828
|
// Find the closest opened tag of the same type
|
|
808
829
|
if (tagName) {
|
|
809
|
-
pos = findTag(
|
|
830
|
+
pos = findTag(lowerTagName);
|
|
810
831
|
} else { // If no tag name is provided, clean shop
|
|
811
832
|
pos = 0;
|
|
812
833
|
}
|
|
@@ -822,16 +843,17 @@ export class HTMLParser {
|
|
|
822
843
|
// Remove the open elements from the stack
|
|
823
844
|
stack.length = pos;
|
|
824
845
|
lastTag = pos ? (stack[pos - 1]?.tag ?? '') : '';
|
|
846
|
+
lastTagLower = pos ? (stack[pos - 1]?.lowerTag ?? '') : '';
|
|
825
847
|
} else if (handler.partialMarkup && tagName) {
|
|
826
848
|
// In partial markup mode, preserve stray end tags
|
|
827
849
|
if (handler.end) {
|
|
828
850
|
handler.end(tagName, [], false);
|
|
829
851
|
}
|
|
830
|
-
} else if (
|
|
852
|
+
} else if (lowerTagName === 'br') {
|
|
831
853
|
if (handler.start) {
|
|
832
854
|
await handler.start(tagName, [], true, '');
|
|
833
855
|
}
|
|
834
|
-
} else if (
|
|
856
|
+
} else if (lowerTagName === 'p') {
|
|
835
857
|
if (handler.start) {
|
|
836
858
|
await handler.start(tagName, [], false, '', true);
|
|
837
859
|
}
|
package/src/lib/attributes.js
CHANGED
|
@@ -552,7 +552,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
|
|
|
552
552
|
return processed.join(', ');
|
|
553
553
|
}
|
|
554
554
|
|
|
555
|
-
if (
|
|
555
|
+
if (attrName === 'content' && isMetaViewport(tag, attrs)) {
|
|
556
556
|
return attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
|
|
557
557
|
// 0.90000 → 0.9
|
|
558
558
|
// 1.0 → 1
|
|
@@ -561,7 +561,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
|
|
|
561
561
|
});
|
|
562
562
|
}
|
|
563
563
|
|
|
564
|
-
if (
|
|
564
|
+
if (attrName.toLowerCase() === 'content' && isContentSecurityPolicy(tag, attrs)) {
|
|
565
565
|
return collapseWhitespaceAll(attrValue);
|
|
566
566
|
}
|
|
567
567
|
|
package/src/lib/options.js
CHANGED
|
@@ -49,6 +49,29 @@ function shouldMinifyInnerHTML(options) {
|
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Persistent per-site URL minification caches—results depend only on the `site`
|
|
53
|
+
// configuration, so entries can be shared across `minify()` calls; bounded so
|
|
54
|
+
// batch runs with many distinct sites can’t grow memory without limit
|
|
55
|
+
const MAX_URL_CACHE_SITES = 20;
|
|
56
|
+
/** @type {Map<string, LRU>} */
|
|
57
|
+
const urlMinifyCaches = new Map();
|
|
58
|
+
|
|
59
|
+
/** @param {string} site */
|
|
60
|
+
function getUrlMinifyCache(site) {
|
|
61
|
+
let cache = urlMinifyCaches.get(site);
|
|
62
|
+
if (!cache) {
|
|
63
|
+
if (urlMinifyCaches.size >= MAX_URL_CACHE_SITES) {
|
|
64
|
+
const oldestSite = urlMinifyCaches.keys().next().value;
|
|
65
|
+
if (oldestSite !== undefined) {
|
|
66
|
+
urlMinifyCaches.delete(oldestSite);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
cache = new LRU(500);
|
|
70
|
+
urlMinifyCaches.set(site, cache);
|
|
71
|
+
}
|
|
72
|
+
return cache;
|
|
73
|
+
}
|
|
74
|
+
|
|
52
75
|
// User-facing option keys that are valid but not listed in `optionDefinitions`
|
|
53
76
|
const optionKeysExtra = new Set(['preset', 'log', 'canCollapseWhitespace', 'canTrimWhitespace']);
|
|
54
77
|
|
|
@@ -56,6 +79,9 @@ const optionKeysExtra = new Set(['preset', 'log', 'canCollapseWhitespace', 'canT
|
|
|
56
79
|
// key per process, so repeated `minify` calls (e.g., batch runs) don’t flood STDERR
|
|
57
80
|
const optionKeysWarned = new Set();
|
|
58
81
|
const presetNamesWarned = new Set();
|
|
82
|
+
// The custom-fragment ReDoS warning is security-relevant, so it reaches the
|
|
83
|
+
// console even without a `log` hook—once per process, like the warnings above
|
|
84
|
+
let customFragmentQuantifierWarned = false;
|
|
59
85
|
|
|
60
86
|
// Main options processor
|
|
61
87
|
|
|
@@ -96,7 +122,14 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
96
122
|
};
|
|
97
123
|
|
|
98
124
|
// Route warnings through the user-provided `log` hook so API consumers can
|
|
99
|
-
// capture or suppress them consistently; fall back to `console.warn
|
|
125
|
+
// capture or suppress them consistently; fall back to `console.warn`.
|
|
126
|
+
//
|
|
127
|
+
// Message convention: Strings that may reach the console unsolicited (via
|
|
128
|
+
// this fallback) carry the “HTML Minifier Next: ” prefix for attribution and
|
|
129
|
+
// no “Warning:” label—the channel already conveys severity. Strings sent
|
|
130
|
+
// only to an explicitly provided `log` hook carry “Warning: ” instead, since
|
|
131
|
+
// the hook mixes severities (info strings, warnings, `Error` objects) and
|
|
132
|
+
// its consumer already knows the source.
|
|
100
133
|
const warn = typeof inputOptions.log === 'function' ? inputOptions.log : console.warn;
|
|
101
134
|
|
|
102
135
|
// Warn about unrecognized options—catches typos as well as options removed in earlier versions
|
|
@@ -373,8 +406,8 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
373
406
|
|
|
374
407
|
const relate = createUrlMinifier(urlOptions.site || '');
|
|
375
408
|
|
|
376
|
-
//
|
|
377
|
-
const instanceCache =
|
|
409
|
+
// Reuse the persistent cache for this site configuration
|
|
410
|
+
const instanceCache = getUrlMinifyCache(urlOptions.site || '');
|
|
378
411
|
|
|
379
412
|
options.minifyURLs = function (/** @type {string} */ text) {
|
|
380
413
|
// Fast-path: Skip if text doesn’t look like a URL that needs processing
|
|
@@ -467,6 +500,17 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
467
500
|
} else if (['customAttrAssign', 'customEventAttributes', 'ignoreCustomComments', 'ignoreCustomFragments'].includes(key)) {
|
|
468
501
|
// Array of regex patterns
|
|
469
502
|
optionsDynamic[key] = parseRegExpArray(option);
|
|
503
|
+
// Warn about potential ReDoS when user-provided fragments use unlimited
|
|
504
|
+
// quantifiers; only explicitly passed fragments are checked
|
|
505
|
+
if (key === 'ignoreCustomFragments' && !customFragmentQuantifierWarned) {
|
|
506
|
+
for (const re of /** @type {RegExp[]} */ (optionsDynamic[key])) {
|
|
507
|
+
if (/[*+]/.test(re.source)) {
|
|
508
|
+
customFragmentQuantifierWarned = true;
|
|
509
|
+
warn('HTML Minifier Next: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
470
514
|
} else {
|
|
471
515
|
optionsDynamic[key] = option;
|
|
472
516
|
}
|
package/src/lib/whitespace.js
CHANGED
|
@@ -142,6 +142,22 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll = fal
|
|
|
142
142
|
|
|
143
143
|
// Collapse whitespace smartly based on surrounding tags
|
|
144
144
|
|
|
145
|
+
// Check if an input element has `type="hidden"`; module-scope so the hot path
|
|
146
|
+
// doesn’t allocate a closure per text node
|
|
147
|
+
/**
|
|
148
|
+
* @param {string} tagName
|
|
149
|
+
* @param {Array<{name: string, value?: string}>} attrs
|
|
150
|
+
*/
|
|
151
|
+
function isHiddenInput(tagName, attrs) {
|
|
152
|
+
if (tagName !== 'input' || !attrs || !attrs.length) return false;
|
|
153
|
+
for (const attr of attrs) {
|
|
154
|
+
if (attr.name === 'type') {
|
|
155
|
+
return attr.value === 'hidden';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
145
161
|
/**
|
|
146
162
|
* @param {string} str
|
|
147
163
|
* @param {string} prevTag
|
|
@@ -156,13 +172,6 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, prevAttrs, nextAttrs, op
|
|
|
156
172
|
const prevTagName = prevTag && (prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag);
|
|
157
173
|
const nextTagName = nextTag && (nextTag.charAt(0) === '/' ? nextTag.slice(1) : nextTag);
|
|
158
174
|
|
|
159
|
-
// Helper: Check if an input element has `type="hidden"`
|
|
160
|
-
const isHiddenInput = (/** @type {string} */ tagName, /** @type {Array<{name: string, value?: string}>} */ attrs) => {
|
|
161
|
-
if (tagName !== 'input' || !attrs || !attrs.length) return false;
|
|
162
|
-
const typeAttr = attrs.find((/** @type {{name: string, value?: string}} */ attr) => attr.name === 'type');
|
|
163
|
-
return typeAttr && typeAttr.value === 'hidden';
|
|
164
|
-
};
|
|
165
|
-
|
|
166
175
|
// Check if prev/next are non-rendering (hidden) elements
|
|
167
176
|
const prevIsHidden = isHiddenInput(prevTagName, prevAttrs);
|
|
168
177
|
const nextIsHidden = isHiddenInput(nextTagName, nextAttrs);
|