html-minifier-next 4.16.4 → 4.17.1
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 +57 -70
- package/cli.js +29 -26
- package/dist/htmlminifier.cjs +280 -139
- package/dist/htmlminifier.esm.bundle.js +280 -139
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/htmlparser.d.ts.map +1 -1
- package/dist/types/lib/attributes.d.ts +1 -1
- package/dist/types/lib/attributes.d.ts.map +1 -1
- package/dist/types/lib/constants.d.ts +16 -15
- package/dist/types/lib/constants.d.ts.map +1 -1
- package/dist/types/lib/content.d.ts.map +1 -1
- package/dist/types/lib/options.d.ts +2 -2
- package/dist/types/lib/options.d.ts.map +1 -1
- package/dist/types/lib/whitespace.d.ts +1 -1
- package/dist/types/lib/whitespace.d.ts.map +1 -1
- package/dist/types/presets.d.ts +1 -2
- package/dist/types/presets.d.ts.map +1 -1
- package/package.json +9 -8
- package/src/htmlminifier.js +49 -47
- package/src/htmlparser.js +44 -13
- package/src/lib/attributes.js +72 -30
- package/src/lib/constants.js +46 -39
- package/src/lib/content.js +0 -1
- package/src/lib/elements.js +15 -15
- package/src/lib/options.js +26 -9
- package/src/lib/svg.js +14 -14
- package/src/lib/whitespace.js +53 -4
- package/src/presets.js +4 -5
- package/src/tokenchain.js +2 -2
- package/src/lib/index.js +0 -20
package/dist/htmlminifier.cjs
CHANGED
|
@@ -5,7 +5,7 @@ Object.defineProperty(exports, '__esModule', { value: true });
|
|
|
5
5
|
var entities = require('entities');
|
|
6
6
|
var RelateURL = require('relateurl');
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/*
|
|
9
9
|
* HTML Parser By John Resig (ejohn.org)
|
|
10
10
|
* Modified by Juriy “kangax” Zaytsev
|
|
11
11
|
* Original code by Erik Arvidsson, Mozilla Public License
|
|
@@ -16,10 +16,10 @@ var RelateURL = require('relateurl');
|
|
|
16
16
|
* Use like so:
|
|
17
17
|
*
|
|
18
18
|
* HTMLParser(htmlString, {
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* start: function(tag, attrs, unary) {},
|
|
20
|
+
* end: function(tag) {},
|
|
21
|
+
* chars: function(text) {},
|
|
22
|
+
* comment: function(text) {}
|
|
23
23
|
* });
|
|
24
24
|
*/
|
|
25
25
|
|
|
@@ -42,7 +42,7 @@ const singleAttrValues = [
|
|
|
42
42
|
];
|
|
43
43
|
// https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName
|
|
44
44
|
const qnameCapture = (function () {
|
|
45
|
-
//
|
|
45
|
+
// https://www.npmjs.com/package/ncname
|
|
46
46
|
const combiningChar = '\\u0300-\\u0345\\u0360\\u0361\\u0483-\\u0486\\u0591-\\u05A1\\u05A3-\\u05B9\\u05BB-\\u05BD\\u05BF\\u05C1\\u05C2\\u05C4\\u064B-\\u0652\\u0670\\u06D6-\\u06E4\\u06E7\\u06E8\\u06EA-\\u06ED\\u0901-\\u0903\\u093C\\u093E-\\u094D\\u0951-\\u0954\\u0962\\u0963\\u0981-\\u0983\\u09BC\\u09BE-\\u09C4\\u09C7\\u09C8\\u09CB-\\u09CD\\u09D7\\u09E2\\u09E3\\u0A02\\u0A3C\\u0A3E-\\u0A42\\u0A47\\u0A48\\u0A4B-\\u0A4D\\u0A70\\u0A71\\u0A81-\\u0A83\\u0ABC\\u0ABE-\\u0AC5\\u0AC7-\\u0AC9\\u0ACB-\\u0ACD\\u0B01-\\u0B03\\u0B3C\\u0B3E-\\u0B43\\u0B47\\u0B48\\u0B4B-\\u0B4D\\u0B56\\u0B57\\u0B82\\u0B83\\u0BBE-\\u0BC2\\u0BC6-\\u0BC8\\u0BCA-\\u0BCD\\u0BD7\\u0C01-\\u0C03\\u0C3E-\\u0C44\\u0C46-\\u0C48\\u0C4A-\\u0C4D\\u0C55\\u0C56\\u0C82\\u0C83\\u0CBE-\\u0CC4\\u0CC6-\\u0CC8\\u0CCA-\\u0CCD\\u0CD5\\u0CD6\\u0D02\\u0D03\\u0D3E-\\u0D43\\u0D46-\\u0D48\\u0D4A-\\u0D4D\\u0D57\\u0E31\\u0E34-\\u0E3A\\u0E47-\\u0E4E\\u0EB1\\u0EB4-\\u0EB9\\u0EBB\\u0EBC\\u0EC8-\\u0ECD\\u0F18\\u0F19\\u0F35\\u0F37\\u0F39\\u0F3E\\u0F3F\\u0F71-\\u0F84\\u0F86-\\u0F8B\\u0F90-\\u0F95\\u0F97\\u0F99-\\u0FAD\\u0FB1-\\u0FB7\\u0FB9\\u20D0-\\u20DC\\u20E1\\u302A-\\u302F\\u3099\\u309A';
|
|
47
47
|
const digit = '0-9\\u0660-\\u0669\\u06F0-\\u06F9\\u0966-\\u096F\\u09E6-\\u09EF\\u0A66-\\u0A6F\\u0AE6-\\u0AEF\\u0B66-\\u0B6F\\u0BE7-\\u0BEF\\u0C66-\\u0C6F\\u0CE6-\\u0CEF\\u0D66-\\u0D6F\\u0E50-\\u0E59\\u0ED0-\\u0ED9\\u0F20-\\u0F29';
|
|
48
48
|
const extender = '\\xB7\\u02D0\\u02D1\\u0387\\u0640\\u0E46\\u0EC6\\u3005\\u3031-\\u3035\\u309D\\u309E\\u30FC-\\u30FE';
|
|
@@ -82,7 +82,7 @@ const nonPhrasing = new CaseInsensitiveSet(['address', 'article', 'aside', 'base
|
|
|
82
82
|
const reCache = {};
|
|
83
83
|
|
|
84
84
|
// Pre-compiled regexes for common special elements (`script`, `style`, `noscript`)
|
|
85
|
-
// These are used frequently and pre-compiling them avoids regex creation overhead
|
|
85
|
+
// These are used frequently, and pre-compiling them avoids regex creation overhead
|
|
86
86
|
const preCompiledStackedTags = {
|
|
87
87
|
'script': /([\s\S]*?)<\/script[^>]*>/i,
|
|
88
88
|
'style': /([\s\S]*?)<\/style[^>]*>/i,
|
|
@@ -145,6 +145,7 @@ class HTMLParser {
|
|
|
145
145
|
// Use cached attribute regex for this handler configuration
|
|
146
146
|
const attribute = getAttrRegexForHandler(handler);
|
|
147
147
|
let prevTag = undefined, nextTag = undefined;
|
|
148
|
+
let prevAttrs = [], nextAttrs = [];
|
|
148
149
|
|
|
149
150
|
// Index-based parsing
|
|
150
151
|
let pos = 0;
|
|
@@ -188,6 +189,7 @@ class HTMLParser {
|
|
|
188
189
|
}
|
|
189
190
|
advance(commentEnd + 3);
|
|
190
191
|
prevTag = '';
|
|
192
|
+
prevAttrs = [];
|
|
191
193
|
continue;
|
|
192
194
|
}
|
|
193
195
|
}
|
|
@@ -202,6 +204,7 @@ class HTMLParser {
|
|
|
202
204
|
}
|
|
203
205
|
advance(conditionalEnd + 2);
|
|
204
206
|
prevTag = '';
|
|
207
|
+
prevAttrs = [];
|
|
205
208
|
continue;
|
|
206
209
|
}
|
|
207
210
|
}
|
|
@@ -214,6 +217,7 @@ class HTMLParser {
|
|
|
214
217
|
}
|
|
215
218
|
advance(doctypeMatch[0].length);
|
|
216
219
|
prevTag = '';
|
|
220
|
+
prevAttrs = [];
|
|
217
221
|
continue;
|
|
218
222
|
}
|
|
219
223
|
|
|
@@ -223,6 +227,7 @@ class HTMLParser {
|
|
|
223
227
|
advance(endTagMatch[0].length);
|
|
224
228
|
await parseEndTag(endTagMatch[0], endTagMatch[1]);
|
|
225
229
|
prevTag = '/' + endTagMatch[1].toLowerCase();
|
|
230
|
+
prevAttrs = [];
|
|
226
231
|
continue;
|
|
227
232
|
}
|
|
228
233
|
|
|
@@ -255,19 +260,24 @@ class HTMLParser {
|
|
|
255
260
|
let nextTagMatch = parseStartTag(nextHtml);
|
|
256
261
|
if (nextTagMatch) {
|
|
257
262
|
nextTag = nextTagMatch.tagName;
|
|
263
|
+
// Extract minimal attribute info for whitespace logic (just name/value pairs)
|
|
264
|
+
nextAttrs = extractAttrInfo(nextTagMatch.attrs);
|
|
258
265
|
} else {
|
|
259
266
|
nextTagMatch = nextHtml.match(endTag);
|
|
260
267
|
if (nextTagMatch) {
|
|
261
268
|
nextTag = '/' + nextTagMatch[1];
|
|
269
|
+
nextAttrs = [];
|
|
262
270
|
} else {
|
|
263
271
|
nextTag = '';
|
|
272
|
+
nextAttrs = [];
|
|
264
273
|
}
|
|
265
274
|
}
|
|
266
275
|
|
|
267
276
|
if (handler.chars) {
|
|
268
|
-
await handler.chars(text, prevTag, nextTag);
|
|
277
|
+
await handler.chars(text, prevTag, nextTag, prevAttrs, nextAttrs);
|
|
269
278
|
}
|
|
270
279
|
prevTag = '';
|
|
280
|
+
prevAttrs = [];
|
|
271
281
|
} else {
|
|
272
282
|
const stackedTag = lastTag.toLowerCase();
|
|
273
283
|
// Use pre-compiled regex for common tags (`script`, `style`, `noscript`) to avoid regex creation overhead
|
|
@@ -290,7 +300,7 @@ class HTMLParser {
|
|
|
290
300
|
} else {
|
|
291
301
|
// No closing tag found; to avoid infinite loop, break similarly to previous behavior
|
|
292
302
|
if (handler.continueOnParseError && handler.chars && html) {
|
|
293
|
-
await handler.chars(html[0], prevTag, '');
|
|
303
|
+
await handler.chars(html[0], prevTag, '', prevAttrs, []);
|
|
294
304
|
advance(1);
|
|
295
305
|
} else {
|
|
296
306
|
break;
|
|
@@ -302,10 +312,11 @@ class HTMLParser {
|
|
|
302
312
|
if (handler.continueOnParseError) {
|
|
303
313
|
// Skip the problematic character and continue
|
|
304
314
|
if (handler.chars) {
|
|
305
|
-
await handler.chars(fullHtml[pos], prevTag, '');
|
|
315
|
+
await handler.chars(fullHtml[pos], prevTag, '', prevAttrs, []);
|
|
306
316
|
}
|
|
307
317
|
advance(1);
|
|
308
318
|
prevTag = '';
|
|
319
|
+
prevAttrs = [];
|
|
309
320
|
continue;
|
|
310
321
|
}
|
|
311
322
|
const loc = getLineColumn(pos);
|
|
@@ -324,6 +335,23 @@ class HTMLParser {
|
|
|
324
335
|
await parseEndTag();
|
|
325
336
|
}
|
|
326
337
|
|
|
338
|
+
// Helper to extract minimal attribute info (name/value pairs) from raw attribute matches
|
|
339
|
+
// Used for whitespace collapsing logic—doesn’t need full processing
|
|
340
|
+
function extractAttrInfo(rawAttrs) {
|
|
341
|
+
if (!rawAttrs || !rawAttrs.length) return [];
|
|
342
|
+
|
|
343
|
+
const numCustomParts = handler.customAttrSurround ? handler.customAttrSurround.length * NCP : 0;
|
|
344
|
+
const baseIndex = 1 + numCustomParts;
|
|
345
|
+
|
|
346
|
+
return rawAttrs.map(args => {
|
|
347
|
+
// Extract attribute name (always at `baseIndex`)
|
|
348
|
+
const name = args[baseIndex];
|
|
349
|
+
// Extract value from double-quoted (`baseIndex + 2`), single-quoted (`baseIndex + 3`), or unquoted (`baseIndex + 4`)
|
|
350
|
+
const value = args[baseIndex + 2] ?? args[baseIndex + 3] ?? args[baseIndex + 4];
|
|
351
|
+
return { name: name?.toLowerCase(), value };
|
|
352
|
+
}).filter(attr => attr.name); // Filter out invalid entries
|
|
353
|
+
}
|
|
354
|
+
|
|
327
355
|
function parseStartTag(input) {
|
|
328
356
|
const start = input.match(startTagOpen);
|
|
329
357
|
if (start) {
|
|
@@ -336,7 +364,7 @@ class HTMLParser {
|
|
|
336
364
|
input = input.slice(consumed);
|
|
337
365
|
let end, attr;
|
|
338
366
|
|
|
339
|
-
// Safety limit:
|
|
367
|
+
// Safety limit: Max length of input to check for attributes
|
|
340
368
|
// Protects against catastrophic backtracking on massive attribute values
|
|
341
369
|
const MAX_ATTR_PARSE_LENGTH = 20000; // 20 KB should be enough for any reasonable tag
|
|
342
370
|
|
|
@@ -436,7 +464,7 @@ class HTMLParser {
|
|
|
436
464
|
}
|
|
437
465
|
|
|
438
466
|
async function parseEndTagAt(pos) {
|
|
439
|
-
// Close all open elements up to pos (mirrors parseEndTag
|
|
467
|
+
// Close all open elements up to `pos` (mirrors `parseEndTag`’s core branch)
|
|
440
468
|
for (let i = stack.length - 1; i >= pos; i--) {
|
|
441
469
|
if (handler.end) {
|
|
442
470
|
await handler.end(stack[i].tag, stack[i].attrs, true);
|
|
@@ -504,7 +532,7 @@ class HTMLParser {
|
|
|
504
532
|
const attrs = match.attrs.map(function (args) {
|
|
505
533
|
let name, value, customOpen, customClose, customAssign, quote;
|
|
506
534
|
|
|
507
|
-
// Hackish workaround for
|
|
535
|
+
// Hackish workaround for Firefox bug, https://bugzilla.mozilla.org/show_bug.cgi?id=369778
|
|
508
536
|
if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) {
|
|
509
537
|
if (args[3] === '') { delete args[3]; }
|
|
510
538
|
if (args[4] === '') { delete args[4]; }
|
|
@@ -561,6 +589,9 @@ class HTMLParser {
|
|
|
561
589
|
unarySlash = '';
|
|
562
590
|
}
|
|
563
591
|
|
|
592
|
+
// Store attributes for `prevAttrs` tracking (used in whitespace collapsing)
|
|
593
|
+
prevAttrs = attrs;
|
|
594
|
+
|
|
564
595
|
if (handler.start) {
|
|
565
596
|
await handler.start(tagName, attrs, unary, unarySlash);
|
|
566
597
|
}
|
|
@@ -656,7 +687,7 @@ class Sorter {
|
|
|
656
687
|
|
|
657
688
|
class TokenChain {
|
|
658
689
|
constructor() {
|
|
659
|
-
// Use
|
|
690
|
+
// Use map instead of object properties for better performance
|
|
660
691
|
this.map = new Map();
|
|
661
692
|
}
|
|
662
693
|
|
|
@@ -673,7 +704,7 @@ class TokenChain {
|
|
|
673
704
|
const sorter = new Sorter();
|
|
674
705
|
sorter.sorterMap = new Map();
|
|
675
706
|
|
|
676
|
-
// Convert
|
|
707
|
+
// Convert map entries to array and sort by frequency (descending), then alphabetically
|
|
677
708
|
const entries = Array.from(this.map.entries()).sort((a, b) => {
|
|
678
709
|
const m = a[1].arrays.length;
|
|
679
710
|
const n = b[1].arrays.length;
|
|
@@ -722,11 +753,11 @@ class TokenChain {
|
|
|
722
753
|
}
|
|
723
754
|
|
|
724
755
|
/**
|
|
725
|
-
* Preset configurations
|
|
756
|
+
* Preset configurations
|
|
726
757
|
*
|
|
727
758
|
* Presets provide curated option sets for common use cases:
|
|
728
|
-
* - conservative
|
|
729
|
-
* - comprehensive
|
|
759
|
+
* - `conservative`: Safe minification suitable for most projects
|
|
760
|
+
* - `comprehensive`: Aggressive minification for maximum file size reduction
|
|
730
761
|
*/
|
|
731
762
|
|
|
732
763
|
const presets = {
|
|
@@ -747,7 +778,6 @@ const presets = {
|
|
|
747
778
|
comprehensive: {
|
|
748
779
|
caseSensitive: true,
|
|
749
780
|
collapseBooleanAttributes: true,
|
|
750
|
-
collapseInlineTagWhitespace: true,
|
|
751
781
|
collapseWhitespace: true,
|
|
752
782
|
continueOnParseError: true,
|
|
753
783
|
decodeEntities: true,
|
|
@@ -772,7 +802,7 @@ const presets = {
|
|
|
772
802
|
|
|
773
803
|
/**
|
|
774
804
|
* Get preset configuration by name
|
|
775
|
-
* @param {string} name - Preset name (
|
|
805
|
+
* @param {string} name - Preset name (“conservative” or “comprehensive”)
|
|
776
806
|
* @returns {object|null} Preset options object or null if not found
|
|
777
807
|
*/
|
|
778
808
|
function getPreset(name) {
|
|
@@ -871,7 +901,7 @@ async function replaceAsync(str, regex, asyncFn) {
|
|
|
871
901
|
return str.replace(regex, () => data.shift());
|
|
872
902
|
}
|
|
873
903
|
|
|
874
|
-
//
|
|
904
|
+
// Regex patterns (to avoid repeated allocations in hot paths)
|
|
875
905
|
|
|
876
906
|
const RE_WS_START = /^[ \n\r\t\f]+/;
|
|
877
907
|
const RE_WS_END = /[ \n\r\t\f]+$/;
|
|
@@ -892,7 +922,7 @@ const RE_ATTR_WS_COLLAPSE = /[ \n\r\t\f]+/g;
|
|
|
892
922
|
const RE_ATTR_WS_TRIM = /^[ \n\r\t\f]+|[ \n\r\t\f]+$/g;
|
|
893
923
|
const RE_NUMERIC_VALUE = /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g;
|
|
894
924
|
|
|
895
|
-
// Inline element
|
|
925
|
+
// Inline element sets for whitespace handling
|
|
896
926
|
|
|
897
927
|
// Non-empty elements that will maintain whitespace around them
|
|
898
928
|
const inlineElementsToKeepWhitespaceAround = new Set(['a', 'abbr', 'acronym', 'b', 'bdi', 'bdo', 'big', 'button', 'cite', 'code', 'del', 'dfn', 'em', 'font', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'mark', 'math', 'meter', 'nobr', 'object', 'output', 'progress', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'select', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'svg', 'textarea', 'time', 'tt', 'u', 'var', 'wbr']);
|
|
@@ -903,6 +933,9 @@ const inlineElementsToKeepWhitespaceWithin = new Set(['a', 'abbr', 'acronym', 'b
|
|
|
903
933
|
// Elements that will always maintain whitespace around them
|
|
904
934
|
const inlineElementsToKeepWhitespace = new Set(['comment', 'img', 'input', 'wbr']);
|
|
905
935
|
|
|
936
|
+
// Form control elements (for conditional whitespace collapsing)
|
|
937
|
+
const formControlElements = new Set(['input', 'button', 'select', 'textarea', 'output', 'meter', 'progress']);
|
|
938
|
+
|
|
906
939
|
// Default attribute values
|
|
907
940
|
|
|
908
941
|
// Default attribute values (could apply to any element)
|
|
@@ -942,14 +975,17 @@ const tagDefaults = {
|
|
|
942
975
|
// Script MIME types
|
|
943
976
|
|
|
944
977
|
// https://mathiasbynens.be/demo/javascript-mime-type
|
|
945
|
-
// https://developer.mozilla.org/en/docs/Web/HTML/
|
|
978
|
+
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script
|
|
946
979
|
const executableScriptsMimetypes = new Set([
|
|
947
980
|
'text/javascript',
|
|
981
|
+
'text/x-javascript',
|
|
948
982
|
'text/ecmascript',
|
|
983
|
+
'text/x-ecmascript',
|
|
949
984
|
'text/jscript',
|
|
950
985
|
'application/javascript',
|
|
951
986
|
'application/x-javascript',
|
|
952
987
|
'application/ecmascript',
|
|
988
|
+
'application/x-ecmascript',
|
|
953
989
|
'module'
|
|
954
990
|
]);
|
|
955
991
|
|
|
@@ -957,15 +993,15 @@ const keepScriptsMimetypes = new Set([
|
|
|
957
993
|
'module'
|
|
958
994
|
]);
|
|
959
995
|
|
|
960
|
-
// Boolean attribute
|
|
996
|
+
// Boolean attribute sets
|
|
961
997
|
|
|
962
998
|
const isSimpleBoolean = new Set(['allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'compact', 'controls', 'declare', 'default', 'defaultchecked', 'defaultmuted', 'defaultselected', 'defer', 'disabled', 'enabled', 'formnovalidate', 'hidden', 'indeterminate', 'inert', 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nohref', 'noresize', 'noshade', 'novalidate', 'nowrap', 'open', 'pauseonexit', 'readonly', 'required', 'reversed', 'scoped', 'seamless', 'selected', 'sortable', 'truespeed', 'typemustmatch', 'visible']);
|
|
963
999
|
|
|
964
1000
|
const isBooleanValue = new Set(['true', 'false']);
|
|
965
1001
|
|
|
966
|
-
// `srcset`
|
|
1002
|
+
// `srcset` elements
|
|
967
1003
|
|
|
968
|
-
const
|
|
1004
|
+
const srcsetElements = new Set(['img', 'source']);
|
|
969
1005
|
|
|
970
1006
|
// JSON script types
|
|
971
1007
|
|
|
@@ -981,7 +1017,7 @@ const jsonScriptTypes = new Set([
|
|
|
981
1017
|
'speculationrules',
|
|
982
1018
|
]);
|
|
983
1019
|
|
|
984
|
-
// Tag omission rules and element
|
|
1020
|
+
// Tag omission rules and element sets
|
|
985
1021
|
|
|
986
1022
|
// Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags with the following extensions:
|
|
987
1023
|
// - retain `<body>` if followed by `<noscript>`
|
|
@@ -992,35 +1028,35 @@ const optionalStartTags = new Set(['html', 'head', 'body', 'colgroup', 'tbody'])
|
|
|
992
1028
|
|
|
993
1029
|
const optionalEndTags = new Set(['html', 'head', 'body', 'li', 'dt', 'dd', 'p', 'rb', 'rt', 'rtc', 'rp', 'optgroup', 'option', 'colgroup', 'caption', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th']);
|
|
994
1030
|
|
|
995
|
-
const
|
|
1031
|
+
const headerElements = new Set(['meta', 'link', 'script', 'style', 'template', 'noscript']);
|
|
996
1032
|
|
|
997
|
-
const
|
|
1033
|
+
const descriptionElements = new Set(['dt', 'dd']);
|
|
998
1034
|
|
|
999
|
-
const
|
|
1035
|
+
const pBlockElements = new Set(['address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'div', 'dl', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'main', 'menu', 'nav', 'ol', 'p', 'pre', 'search', 'section', 'table', 'ul']);
|
|
1000
1036
|
|
|
1001
|
-
const
|
|
1037
|
+
const pInlineElements = new Set(['a', 'audio', 'del', 'ins', 'map', 'noscript', 'video']);
|
|
1002
1038
|
|
|
1003
1039
|
const rubyEndTagOmission = new Set(['rb', 'rt', 'rtc', 'rp']); // `</rb>`, `</rt>`, `</rp>` can be omitted if followed by `<rb>`, `<rt>`, `<rtc>`, or `<rp>`
|
|
1004
1040
|
|
|
1005
1041
|
const rubyRtcEndTagOmission = new Set(['rb', 'rtc']); // `</rtc>` can be omitted if followed by `<rb>` or `<rtc>` (not `<rt>` or `<rp>`)
|
|
1006
1042
|
|
|
1007
|
-
const
|
|
1043
|
+
const optionElements = new Set(['option', 'optgroup']);
|
|
1008
1044
|
|
|
1009
|
-
const
|
|
1045
|
+
const tableContentElements = new Set(['tbody', 'tfoot']);
|
|
1010
1046
|
|
|
1011
|
-
const
|
|
1047
|
+
const tableSectionElements = new Set(['thead', 'tbody', 'tfoot']);
|
|
1012
1048
|
|
|
1013
|
-
const
|
|
1049
|
+
const cellElements = new Set(['td', 'th']);
|
|
1014
1050
|
|
|
1015
|
-
const
|
|
1051
|
+
const topLevelElements = new Set(['html', 'head', 'body']);
|
|
1016
1052
|
|
|
1017
|
-
const
|
|
1053
|
+
const compactElements = new Set(['html', 'body']);
|
|
1018
1054
|
|
|
1019
|
-
const
|
|
1055
|
+
const looseElements = new Set(['head', 'colgroup', 'caption']);
|
|
1020
1056
|
|
|
1021
|
-
const
|
|
1057
|
+
const trailingElements = new Set(['dt', 'thead']);
|
|
1022
1058
|
|
|
1023
|
-
const
|
|
1059
|
+
const htmlElements = new Set(['a', 'abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'b', 'base', 'basefont', 'bdi', 'bdo', 'bgsound', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'command', 'content', 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'embed', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'iframe', 'image', 'img', 'input', 'ins', 'isindex', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', 'listing', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meta', 'meter', 'multicol', 'nav', 'nobr', 'noembed', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', 'output', 'p', 'param', 'picture', 'plaintext', 'pre', 'progress', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'script', 'search', 'section', 'select', 'selectedcontent', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr', 'xmp']);
|
|
1024
1060
|
|
|
1025
1061
|
// Empty attribute regex
|
|
1026
1062
|
|
|
@@ -1030,7 +1066,7 @@ const reEmptyAttribute = new RegExp(
|
|
|
1030
1066
|
|
|
1031
1067
|
// Special content elements
|
|
1032
1068
|
|
|
1033
|
-
const
|
|
1069
|
+
const specialContentElements = new Set(['script', 'style']);
|
|
1034
1070
|
|
|
1035
1071
|
// Imports
|
|
1036
1072
|
|
|
@@ -1094,7 +1130,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
|
|
|
1094
1130
|
}
|
|
1095
1131
|
|
|
1096
1132
|
if (trimLeft) {
|
|
1097
|
-
//
|
|
1133
|
+
// No-break space is specifically handled inside the replacer function
|
|
1098
1134
|
str = str.replace(/^[ \n\r\t\f\xA0]+/, function (spaces) {
|
|
1099
1135
|
const conservative = !lineBreakBefore && options.conservativeCollapse;
|
|
1100
1136
|
if (conservative && spaces === '\t') {
|
|
@@ -1105,7 +1141,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
|
|
|
1105
1141
|
}
|
|
1106
1142
|
|
|
1107
1143
|
if (trimRight) {
|
|
1108
|
-
//
|
|
1144
|
+
// No-break space is specifically handled inside the replacer function
|
|
1109
1145
|
str = str.replace(/[ \n\r\t\f\xA0]+$/, function (spaces) {
|
|
1110
1146
|
const conservative = !lineBreakAfter && options.conservativeCollapse;
|
|
1111
1147
|
if (conservative && spaces === '\t') {
|
|
@@ -1129,11 +1165,42 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
|
|
|
1129
1165
|
|
|
1130
1166
|
// Collapse whitespace smartly based on surrounding tags
|
|
1131
1167
|
|
|
1132
|
-
function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements, inlineTextSet) {
|
|
1168
|
+
function collapseWhitespaceSmart(str, prevTag, nextTag, prevAttrs, nextAttrs, options, inlineElements, inlineTextSet) {
|
|
1169
|
+
const prevTagName = prevTag && (prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag);
|
|
1170
|
+
const nextTagName = nextTag && (nextTag.charAt(0) === '/' ? nextTag.slice(1) : nextTag);
|
|
1171
|
+
|
|
1172
|
+
// Helper: Check if an input element has `type="hidden"`
|
|
1173
|
+
const isHiddenInput = (tagName, attrs) => {
|
|
1174
|
+
if (tagName !== 'input' || !attrs || !attrs.length) return false;
|
|
1175
|
+
const typeAttr = attrs.find(attr => attr.name === 'type');
|
|
1176
|
+
return typeAttr && typeAttr.value === 'hidden';
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
// Check if prev/next are non-rendering (hidden) elements
|
|
1180
|
+
const prevIsHidden = isHiddenInput(prevTagName, prevAttrs);
|
|
1181
|
+
const nextIsHidden = isHiddenInput(nextTagName, nextAttrs);
|
|
1182
|
+
|
|
1133
1183
|
let trimLeft = prevTag && !inlineElementsToKeepWhitespace.has(prevTag);
|
|
1184
|
+
|
|
1185
|
+
// Smart default behavior: Collapse space after non-rendering elements (`type="hidden"`)
|
|
1186
|
+
// This happens even in basic `collapseWhitespace` mode (safe optimization)
|
|
1187
|
+
if (!trimLeft && prevIsHidden && str && !/\S/.test(str)) {
|
|
1188
|
+
trimLeft = true;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Aggressive mode: Collapse between all form controls (pure whitespace only)
|
|
1192
|
+
const isPureWhitespace = str && !/\S/.test(str);
|
|
1193
|
+
if (!trimLeft && prevTagName && nextTagName &&
|
|
1194
|
+
options.collapseInlineTagWhitespace &&
|
|
1195
|
+
isPureWhitespace &&
|
|
1196
|
+
formControlElements.has(prevTagName) && formControlElements.has(nextTagName)) {
|
|
1197
|
+
trimLeft = true;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1134
1200
|
if (trimLeft && !options.collapseInlineTagWhitespace) {
|
|
1135
1201
|
trimLeft = prevTag.charAt(0) === '/' ? !inlineElements.has(prevTag.slice(1)) : !inlineTextSet.has(prevTag);
|
|
1136
1202
|
}
|
|
1203
|
+
|
|
1137
1204
|
// When `collapseInlineTagWhitespace` is enabled, still preserve whitespace around inline text elements
|
|
1138
1205
|
if (trimLeft && options.collapseInlineTagWhitespace) {
|
|
1139
1206
|
const tagName = prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag;
|
|
@@ -1141,10 +1208,26 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements,
|
|
|
1141
1208
|
trimLeft = false;
|
|
1142
1209
|
}
|
|
1143
1210
|
}
|
|
1211
|
+
|
|
1144
1212
|
let trimRight = nextTag && !inlineElementsToKeepWhitespace.has(nextTag);
|
|
1213
|
+
|
|
1214
|
+
// Smart default behavior: Collapse space before non-rendering elements (`type="hidden"`)
|
|
1215
|
+
if (!trimRight && nextIsHidden && str && !/\S/.test(str)) {
|
|
1216
|
+
trimRight = true;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Aggressive mode: Same as `trimLeft`
|
|
1220
|
+
if (!trimRight && prevTagName && nextTagName &&
|
|
1221
|
+
options.collapseInlineTagWhitespace &&
|
|
1222
|
+
isPureWhitespace &&
|
|
1223
|
+
formControlElements.has(prevTagName) && formControlElements.has(nextTagName)) {
|
|
1224
|
+
trimRight = true;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1145
1227
|
if (trimRight && !options.collapseInlineTagWhitespace) {
|
|
1146
1228
|
trimRight = nextTag.charAt(0) === '/' ? !inlineTextSet.has(nextTag.slice(1)) : !inlineElements.has(nextTag);
|
|
1147
1229
|
}
|
|
1230
|
+
|
|
1148
1231
|
// When `collapseInlineTagWhitespace` is enabled, still preserve whitespace around inline text elements
|
|
1149
1232
|
if (trimRight && options.collapseInlineTagWhitespace) {
|
|
1150
1233
|
const tagName = nextTag.charAt(0) === '/' ? nextTag.slice(1) : nextTag;
|
|
@@ -1152,6 +1235,7 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements,
|
|
|
1152
1235
|
trimRight = false;
|
|
1153
1236
|
}
|
|
1154
1237
|
}
|
|
1238
|
+
|
|
1155
1239
|
return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag);
|
|
1156
1240
|
}
|
|
1157
1241
|
|
|
@@ -1172,7 +1256,6 @@ function canTrimWhitespace(tag) {
|
|
|
1172
1256
|
|
|
1173
1257
|
// Wrap CSS declarations for inline styles and media queries
|
|
1174
1258
|
// This ensures proper context for CSS minification
|
|
1175
|
-
|
|
1176
1259
|
function wrapCSS(text, type) {
|
|
1177
1260
|
switch (type) {
|
|
1178
1261
|
case 'inline':
|
|
@@ -1254,7 +1337,6 @@ async function processScript(text, options, currentAttrs, minifyHTML) {
|
|
|
1254
1337
|
|
|
1255
1338
|
/**
|
|
1256
1339
|
* Lightweight SVG optimizations:
|
|
1257
|
-
*
|
|
1258
1340
|
* - Numeric precision reduction for coordinates and path data
|
|
1259
1341
|
* - Whitespace removal in attribute values (numeric sequences)
|
|
1260
1342
|
* - Default attribute removal (safe, well-documented defaults)
|
|
@@ -1348,7 +1430,7 @@ function minifyNumber(num, precision = 3) {
|
|
|
1348
1430
|
if (num === '1.0' || num === '1.00' || num === '1.000') return '1';
|
|
1349
1431
|
|
|
1350
1432
|
// Check cache
|
|
1351
|
-
// (Note:
|
|
1433
|
+
// (Note: Uses input string as key, so “0.0000” and “0.00000” create separate entries.
|
|
1352
1434
|
// This is intentional to avoid parsing overhead.
|
|
1353
1435
|
// Real-world SVG files from export tools typically use consistent formats.)
|
|
1354
1436
|
const cacheKey = `${num}:${precision}`;
|
|
@@ -1387,15 +1469,15 @@ function minifyPathData(pathData, precision = 3) {
|
|
|
1387
1469
|
|
|
1388
1470
|
// Remove unnecessary spaces around path commands
|
|
1389
1471
|
// Safe to remove space after a command letter when it’s followed by a number (which may be negative)
|
|
1390
|
-
// M 10 20 → M10 20
|
|
1472
|
+
// `M 10 20` → `M10 20`, `L -5 -3` → `L-5-3`
|
|
1391
1473
|
result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\d)/g, '$1');
|
|
1392
1474
|
|
|
1393
1475
|
// Safe to remove space before command letter when preceded by a number
|
|
1394
|
-
// 0 L → 0L
|
|
1476
|
+
// `0 L` → `0L`, `20 M` → `20M`
|
|
1395
1477
|
result = result.replace(/(\d)\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
|
|
1396
1478
|
|
|
1397
1479
|
// Safe to remove space before negative number when preceded by a number
|
|
1398
|
-
// 10 -20 → 10-20 (numbers are separated by the minus sign)
|
|
1480
|
+
// `10 -20` → `10-20` (numbers are separated by the minus sign)
|
|
1399
1481
|
result = result.replace(/(\d)\s+(-\d)/g, '$1$2');
|
|
1400
1482
|
|
|
1401
1483
|
return result;
|
|
@@ -1404,9 +1486,9 @@ function minifyPathData(pathData, precision = 3) {
|
|
|
1404
1486
|
/**
|
|
1405
1487
|
* Minify whitespace in numeric attribute values
|
|
1406
1488
|
* Examples:
|
|
1407
|
-
*
|
|
1408
|
-
*
|
|
1409
|
-
*
|
|
1489
|
+
* - “10 , 20" → "10,20"
|
|
1490
|
+
* - "translate( 10 20 )" → "translate(10 20)"
|
|
1491
|
+
* - "100, 10 40, 198" → "100,10 40,198"
|
|
1410
1492
|
*
|
|
1411
1493
|
* @param {string} value - Attribute value to minify
|
|
1412
1494
|
* @returns {string} Minified value
|
|
@@ -1439,8 +1521,7 @@ function minifyColor(color) {
|
|
|
1439
1521
|
|
|
1440
1522
|
// Don’t process values that aren’t simple colors (preserve case-sensitive references)
|
|
1441
1523
|
// `url(#id)`, `var(--name)`, `inherit`, `currentColor`, etc.
|
|
1442
|
-
if (trimmed.includes('url(') || trimmed.includes('var(') ||
|
|
1443
|
-
trimmed === 'inherit' || trimmed === 'currentColor') {
|
|
1524
|
+
if (trimmed.includes('url(') || trimmed.includes('var(') || trimmed === 'inherit' || trimmed === 'currentColor') {
|
|
1444
1525
|
return trimmed;
|
|
1445
1526
|
}
|
|
1446
1527
|
|
|
@@ -1448,7 +1529,7 @@ function minifyColor(color) {
|
|
|
1448
1529
|
const lower = trimmed.toLowerCase();
|
|
1449
1530
|
|
|
1450
1531
|
// Shorten 6-digit hex to 3-digit when possible
|
|
1451
|
-
//
|
|
1532
|
+
// `#aabbcc` → `#abc`, `#000000` → `#000`
|
|
1452
1533
|
const hexMatch = lower.match(/^#([0-9a-f]{6})$/);
|
|
1453
1534
|
if (hexMatch) {
|
|
1454
1535
|
const hex = hexMatch[1];
|
|
@@ -1468,7 +1549,7 @@ function minifyColor(color) {
|
|
|
1468
1549
|
return NAMED_COLORS[lower] || lower;
|
|
1469
1550
|
}
|
|
1470
1551
|
|
|
1471
|
-
// Convert rgb(
|
|
1552
|
+
// Convert rgb() to hex
|
|
1472
1553
|
const rgbMatch = lower.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
|
|
1473
1554
|
if (rgbMatch) {
|
|
1474
1555
|
const r = parseInt(rgbMatch[1], 10);
|
|
@@ -1499,14 +1580,14 @@ function minifyColor(color) {
|
|
|
1499
1580
|
const NUMERIC_ATTRS = new Set([
|
|
1500
1581
|
'd', // Path data
|
|
1501
1582
|
'points', // Polygon/polyline points
|
|
1502
|
-
'viewBox', // viewBox coordinates
|
|
1583
|
+
'viewBox', // `viewBox` coordinates
|
|
1503
1584
|
'transform', // Transform functions
|
|
1504
1585
|
'x', 'y', 'x1', 'y1', 'x2', 'y2', // Coordinates
|
|
1505
1586
|
'cx', 'cy', 'r', 'rx', 'ry', // Circle/ellipse
|
|
1506
1587
|
'width', 'height', // Dimensions
|
|
1507
1588
|
'dx', 'dy', // Text offsets
|
|
1508
1589
|
'offset', // Gradient offset
|
|
1509
|
-
'startOffset', // textPath
|
|
1590
|
+
'startOffset', // `textPath`
|
|
1510
1591
|
'pathLength', // Path length
|
|
1511
1592
|
'stdDeviation', // Filter params
|
|
1512
1593
|
'baseFrequency', // Turbulence
|
|
@@ -1690,8 +1771,8 @@ function shouldMinifyInnerHTML(options) {
|
|
|
1690
1771
|
/**
|
|
1691
1772
|
* @param {Partial<MinifierOptions>} inputOptions - User-provided options
|
|
1692
1773
|
* @param {Object} deps - Dependencies from htmlminifier.js
|
|
1693
|
-
* @param {Function} deps.getLightningCSS - Function to lazily load
|
|
1694
|
-
* @param {Function} deps.getTerser - Function to lazily load
|
|
1774
|
+
* @param {Function} deps.getLightningCSS - Function to lazily load Lightning CSS
|
|
1775
|
+
* @param {Function} deps.getTerser - Function to lazily load Terser
|
|
1695
1776
|
* @param {Function} deps.getSwc - Function to lazily load @swc/core
|
|
1696
1777
|
* @param {LRU} deps.cssMinifyCache - CSS minification cache
|
|
1697
1778
|
* @param {LRU} deps.jsMinifyCache - JS minification cache
|
|
@@ -1728,7 +1809,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
|
|
|
1728
1809
|
if (typeof value === 'string') {
|
|
1729
1810
|
return new RegExp(value.replace(/^\/(.*)\/$/, '$1'));
|
|
1730
1811
|
}
|
|
1731
|
-
return value; // Already a RegExp or
|
|
1812
|
+
return value; // Already a RegExp or another type
|
|
1732
1813
|
};
|
|
1733
1814
|
|
|
1734
1815
|
const parseRegExpArray = (arr) => {
|
|
@@ -1748,9 +1829,25 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
|
|
|
1748
1829
|
});
|
|
1749
1830
|
};
|
|
1750
1831
|
|
|
1832
|
+
// Apply preset first if specified (so user options can override preset values)
|
|
1833
|
+
if (inputOptions.preset) {
|
|
1834
|
+
const preset = getPreset(inputOptions.preset);
|
|
1835
|
+
if (preset) {
|
|
1836
|
+
Object.assign(options, preset);
|
|
1837
|
+
} else {
|
|
1838
|
+
const available = getPresetNames().join(', ');
|
|
1839
|
+
console.warn(`HTML Minifier Next: Unknown preset “${inputOptions.preset}”. Available presets: ${available}`);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1751
1843
|
Object.keys(inputOptions).forEach(function (key) {
|
|
1752
1844
|
const option = inputOptions[key];
|
|
1753
1845
|
|
|
1846
|
+
// Skip preset key—it’s already been processed
|
|
1847
|
+
if (key === 'preset') {
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1754
1851
|
if (key === 'caseSensitive') {
|
|
1755
1852
|
if (option) {
|
|
1756
1853
|
options.name = identity;
|
|
@@ -1865,7 +1962,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
|
|
|
1865
1962
|
// Validate engine
|
|
1866
1963
|
const supportedEngines = ['terser', 'swc'];
|
|
1867
1964
|
if (!supportedEngines.includes(engine)) {
|
|
1868
|
-
throw new Error(`Unsupported JS minifier engine:
|
|
1965
|
+
throw new Error(`Unsupported JS minifier engine: “${engine}”. Supported engines: ${supportedEngines.join(', ')}`);
|
|
1869
1966
|
}
|
|
1870
1967
|
|
|
1871
1968
|
// Extract engine-specific options (excluding `engine` field itself)
|
|
@@ -1972,14 +2069,14 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
|
|
|
1972
2069
|
relateUrlOptions = {};
|
|
1973
2070
|
}
|
|
1974
2071
|
|
|
1975
|
-
// Cache
|
|
2072
|
+
// Cache relateurl instance for reuse (expensive to create)
|
|
1976
2073
|
const relateUrlInstance = new RelateURL(relateUrlOptions.site || '', relateUrlOptions);
|
|
1977
2074
|
|
|
1978
2075
|
// Create instance-specific cache (results depend on site configuration)
|
|
1979
2076
|
const instanceCache = urlMinifyCache ? new (urlMinifyCache.constructor)(500) : null;
|
|
1980
2077
|
|
|
1981
2078
|
options.minifyURLs = function (text) {
|
|
1982
|
-
// Fast-path: Skip if text doesn
|
|
2079
|
+
// Fast-path: Skip if text doesn’t look like a URL that needs processing
|
|
1983
2080
|
// Only process if contains URL-like characters (`/`, `:`, `#`, `?`) or spaces that need encoding
|
|
1984
2081
|
if (!/[/:?#\s]/.test(text)) {
|
|
1985
2082
|
return text;
|
|
@@ -2011,17 +2108,17 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
|
|
|
2011
2108
|
};
|
|
2012
2109
|
} else if (key === 'minifySVG') {
|
|
2013
2110
|
// Process SVG minification options
|
|
2014
|
-
// Unlike minifyCSS
|
|
2111
|
+
// Unlike `minifyCSS`/`minifyJS`, this is a simple options object, not a function
|
|
2015
2112
|
// The actual minification is applied inline during attribute processing
|
|
2016
2113
|
options.minifySVG = getSVGMinifierOptions(option);
|
|
2017
2114
|
} else if (key === 'customAttrCollapse') {
|
|
2018
|
-
// Single
|
|
2115
|
+
// Single regex pattern
|
|
2019
2116
|
options[key] = parseRegExp(option);
|
|
2020
2117
|
} else if (key === 'customAttrSurround') {
|
|
2021
2118
|
// Nested array of RegExp pairs: `[[openRegExp, closeRegExp], …]`
|
|
2022
2119
|
options[key] = parseNestedRegExpArray(option);
|
|
2023
2120
|
} else if (['customAttrAssign', 'customEventAttributes', 'ignoreCustomComments', 'ignoreCustomFragments'].includes(key)) {
|
|
2024
|
-
// Array of
|
|
2121
|
+
// Array of regex patterns
|
|
2025
2122
|
options[key] = parseRegExpArray(option);
|
|
2026
2123
|
} else {
|
|
2027
2124
|
options[key] = option;
|
|
@@ -2086,8 +2183,7 @@ function isAttributeRedundant(tag, attrName, attrValue, attrs) {
|
|
|
2086
2183
|
const tagHasDefaults = tag in tagDefaults;
|
|
2087
2184
|
|
|
2088
2185
|
// Check for legacy attribute rules (element- and attribute-specific)
|
|
2089
|
-
const isLegacyAttr = (tag === 'script' && (attrName === 'language' || attrName === 'charset')) ||
|
|
2090
|
-
(tag === 'a' && attrName === 'name');
|
|
2186
|
+
const isLegacyAttr = (tag === 'script' && (attrName === 'language' || attrName === 'charset')) || (tag === 'a' && attrName === 'name');
|
|
2091
2187
|
|
|
2092
2188
|
// If none of these conditions apply, attribute cannot be redundant
|
|
2093
2189
|
if (!hasGeneralDefault && !tagHasDefaults && !isLegacyAttr) {
|
|
@@ -2145,7 +2241,7 @@ function isStyleLinkTypeAttribute(attrValue = '') {
|
|
|
2145
2241
|
return attrValue === '' || attrValue === 'text/css';
|
|
2146
2242
|
}
|
|
2147
2243
|
|
|
2148
|
-
function
|
|
2244
|
+
function isStyleElement(tag, attrs) {
|
|
2149
2245
|
if (tag !== 'style') {
|
|
2150
2246
|
return false;
|
|
2151
2247
|
}
|
|
@@ -2202,11 +2298,11 @@ function isLinkType(tag, attrs, value) {
|
|
|
2202
2298
|
}
|
|
2203
2299
|
|
|
2204
2300
|
function isMediaQuery(tag, attrs, attrName) {
|
|
2205
|
-
return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') ||
|
|
2301
|
+
return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleElement(tag, attrs));
|
|
2206
2302
|
}
|
|
2207
2303
|
|
|
2208
2304
|
function isSrcset(attrName, tag) {
|
|
2209
|
-
return attrName === 'srcset' &&
|
|
2305
|
+
return attrName === 'srcset' && srcsetElements.has(tag);
|
|
2210
2306
|
}
|
|
2211
2307
|
|
|
2212
2308
|
function isMetaViewport(tag, attrs) {
|
|
@@ -2214,7 +2310,7 @@ function isMetaViewport(tag, attrs) {
|
|
|
2214
2310
|
return false;
|
|
2215
2311
|
}
|
|
2216
2312
|
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
2217
|
-
if (attrs[i].name === 'name' && attrs[i].value === 'viewport') {
|
|
2313
|
+
if (attrs[i].name.toLowerCase() === 'name' && attrs[i].value.toLowerCase() === 'viewport') {
|
|
2218
2314
|
return true;
|
|
2219
2315
|
}
|
|
2220
2316
|
}
|
|
@@ -2234,7 +2330,7 @@ function isContentSecurityPolicy(tag, attrs) {
|
|
|
2234
2330
|
}
|
|
2235
2331
|
|
|
2236
2332
|
function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
|
|
2237
|
-
const isValueEmpty = !attrValue ||
|
|
2333
|
+
const isValueEmpty = !attrValue || attrValue.trim() === '';
|
|
2238
2334
|
if (!isValueEmpty) {
|
|
2239
2335
|
return false;
|
|
2240
2336
|
}
|
|
@@ -2257,7 +2353,7 @@ function hasAttrName(name, attrs) {
|
|
|
2257
2353
|
|
|
2258
2354
|
async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
|
|
2259
2355
|
// Apply early whitespace normalization if enabled
|
|
2260
|
-
// Preserves special spaces (
|
|
2356
|
+
// Preserves special spaces (no-break space, hair space, etc.) for consistency with `collapseWhitespace`
|
|
2261
2357
|
if (options.collapseAttributeWhitespace) {
|
|
2262
2358
|
// Fast path: Only process if whitespace exists (avoids regex overhead on clean values)
|
|
2263
2359
|
if (RE_ATTR_WS_CHECK.test(attrValue)) {
|
|
@@ -2313,7 +2409,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
2313
2409
|
try {
|
|
2314
2410
|
attrValue = await options.minifyCSS(attrValue, 'inline');
|
|
2315
2411
|
// After minification, check if CSS consists entirely of invalid properties (no values)
|
|
2316
|
-
//
|
|
2412
|
+
// I.e., `color:` or `margin:;padding:` should be treated as empty
|
|
2317
2413
|
if (attrValue && /^(?:[a-z-]+:\s*;?\s*)+$/i.test(attrValue)) {
|
|
2318
2414
|
attrValue = '';
|
|
2319
2415
|
}
|
|
@@ -2433,13 +2529,13 @@ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
|
|
|
2433
2529
|
}
|
|
2434
2530
|
|
|
2435
2531
|
if ((options.removeRedundantAttributes &&
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2532
|
+
isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
|
|
2533
|
+
(options.removeScriptTypeAttributes && tag === 'script' &&
|
|
2534
|
+
attrName === 'type' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue)) ||
|
|
2535
|
+
(options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
|
|
2536
|
+
attrName === 'type' && isStyleLinkTypeAttribute(attrValue)) ||
|
|
2537
|
+
(options.insideSVG && options.minifySVG &&
|
|
2538
|
+
shouldRemoveSVGAttribute(tag, attrName, attrValue, options.minifySVG))) {
|
|
2443
2539
|
return;
|
|
2444
2540
|
}
|
|
2445
2541
|
|
|
@@ -2448,7 +2544,7 @@ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
|
|
|
2448
2544
|
}
|
|
2449
2545
|
|
|
2450
2546
|
if (options.removeEmptyAttributes &&
|
|
2451
|
-
|
|
2547
|
+
canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
|
|
2452
2548
|
return;
|
|
2453
2549
|
}
|
|
2454
2550
|
|
|
@@ -2471,19 +2567,35 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
2471
2567
|
let attrFragment;
|
|
2472
2568
|
let emittedAttrValue;
|
|
2473
2569
|
|
|
2474
|
-
|
|
2475
|
-
|
|
2570
|
+
// Determine if we need to add/keep quotes
|
|
2571
|
+
const shouldAddQuotes = typeof attrValue !== 'undefined' && (
|
|
2572
|
+
// If `removeAttributeQuotes` is enabled, add quotes only if they can’t be removed
|
|
2573
|
+
(options.removeAttributeQuotes && (attrValue.indexOf(uidAttr) !== -1 || !canRemoveAttributeQuotes(attrValue))) ||
|
|
2574
|
+
// If `removeAttributeQuotes` is not enabled, preserve original quote style or add quotes if value requires them
|
|
2575
|
+
(!options.removeAttributeQuotes && (attrQuote !== '' || !canRemoveAttributeQuotes(attrValue) ||
|
|
2576
|
+
// Special case: With `removeTagWhitespace`, unquoted values that aren’t last will have space added,
|
|
2577
|
+
// which can create ambiguous/invalid HTML—add quotes to be safe
|
|
2578
|
+
(options.removeTagWhitespace && attrQuote === '' && !isLast)))
|
|
2579
|
+
);
|
|
2580
|
+
|
|
2581
|
+
if (shouldAddQuotes) {
|
|
2476
2582
|
// Determine the appropriate quote character
|
|
2477
2583
|
if (!options.preventAttributesEscaping) {
|
|
2478
|
-
// Normal mode:
|
|
2479
|
-
|
|
2584
|
+
// Normal mode: Choose optimal quote type to minimize escaping
|
|
2585
|
+
// unless we’re preserving original quotes and they don’t need escaping
|
|
2586
|
+
const needsEscaping = (attrQuote === '"' && attrValue.indexOf('"') !== -1) || (attrQuote === "'" && attrValue.indexOf("'") !== -1);
|
|
2587
|
+
|
|
2588
|
+
if (options.removeAttributeQuotes || typeof options.quoteCharacter !== 'undefined' || needsEscaping || attrQuote === '') {
|
|
2589
|
+
attrQuote = chooseAttributeQuote(attrValue, options);
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2480
2592
|
if (attrQuote === '"') {
|
|
2481
2593
|
attrValue = attrValue.replace(/"/g, '"');
|
|
2482
2594
|
} else {
|
|
2483
2595
|
attrValue = attrValue.replace(/'/g, ''');
|
|
2484
2596
|
}
|
|
2485
2597
|
} else {
|
|
2486
|
-
// `preventAttributesEscaping` mode:
|
|
2598
|
+
// `preventAttributesEscaping` mode: Choose safe quotes but don't escape
|
|
2487
2599
|
// except when both quote types are present—then escape to prevent invalid HTML
|
|
2488
2600
|
const hasDoubleQuote = attrValue.indexOf('"') !== -1;
|
|
2489
2601
|
const hasSingleQuote = attrValue.indexOf("'") !== -1;
|
|
@@ -2502,8 +2614,18 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
2502
2614
|
attrQuote = "'";
|
|
2503
2615
|
} else if (attrQuote === "'" && hasSingleQuote && !hasDoubleQuote) {
|
|
2504
2616
|
attrQuote = '"';
|
|
2505
|
-
//
|
|
2506
|
-
} else if (attrQuote
|
|
2617
|
+
// If no quote character yet (empty string), choose based on content
|
|
2618
|
+
} else if (attrQuote === '') {
|
|
2619
|
+
if (hasSingleQuote && !hasDoubleQuote) {
|
|
2620
|
+
attrQuote = '"';
|
|
2621
|
+
} else if (hasDoubleQuote && !hasSingleQuote) {
|
|
2622
|
+
attrQuote = "'";
|
|
2623
|
+
} else {
|
|
2624
|
+
attrQuote = '"';
|
|
2625
|
+
}
|
|
2626
|
+
// Fallback for invalid/unsupported attrQuote values (not `"`, `'`, or empty string):
|
|
2627
|
+
// Choose safe default based on value content
|
|
2628
|
+
} else if (attrQuote !== '"' && attrQuote !== "'") {
|
|
2507
2629
|
if (hasSingleQuote && !hasDoubleQuote) {
|
|
2508
2630
|
attrQuote = '"';
|
|
2509
2631
|
} else if (hasDoubleQuote && !hasSingleQuote) {
|
|
@@ -2513,7 +2635,22 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
2513
2635
|
}
|
|
2514
2636
|
}
|
|
2515
2637
|
} else {
|
|
2516
|
-
|
|
2638
|
+
// `quoteCharacter` is explicitly set
|
|
2639
|
+
const preferredQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
2640
|
+
// Safety check: If the preferred quote conflicts with value content, switch to the opposite quote
|
|
2641
|
+
if ((preferredQuote === '"' && hasDoubleQuote && !hasSingleQuote) || (preferredQuote === "'" && hasSingleQuote && !hasDoubleQuote)) {
|
|
2642
|
+
attrQuote = preferredQuote === '"' ? "'" : '"';
|
|
2643
|
+
} else if ((preferredQuote === '"' && hasDoubleQuote && hasSingleQuote) || (preferredQuote === "'" && hasSingleQuote && hasDoubleQuote)) {
|
|
2644
|
+
// Both quote types present: Fall back to escaping despite `preventAttributesEscaping`
|
|
2645
|
+
attrQuote = preferredQuote;
|
|
2646
|
+
if (attrQuote === '"') {
|
|
2647
|
+
attrValue = attrValue.replace(/"/g, '"');
|
|
2648
|
+
} else {
|
|
2649
|
+
attrValue = attrValue.replace(/'/g, ''');
|
|
2650
|
+
}
|
|
2651
|
+
} else {
|
|
2652
|
+
attrQuote = preferredQuote;
|
|
2653
|
+
}
|
|
2517
2654
|
}
|
|
2518
2655
|
}
|
|
2519
2656
|
emittedAttrValue = attrQuote + attrValue + attrQuote;
|
|
@@ -2521,15 +2658,17 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
2521
2658
|
emittedAttrValue += ' ';
|
|
2522
2659
|
}
|
|
2523
2660
|
} else if (isLast && !hasUnarySlash) {
|
|
2524
|
-
// Last attribute in a non-self-closing tag:
|
|
2661
|
+
// Last attribute in a non-self-closing tag:
|
|
2662
|
+
// No space needed
|
|
2525
2663
|
emittedAttrValue = attrValue;
|
|
2526
2664
|
} else {
|
|
2527
|
-
// Not last attribute, or is a self-closing tag:
|
|
2665
|
+
// Not last attribute, or is a self-closing tag:
|
|
2666
|
+
// Unquoted values must have space after them to delimit from next attribute
|
|
2528
2667
|
emittedAttrValue = attrValue + ' ';
|
|
2529
2668
|
}
|
|
2530
2669
|
|
|
2531
2670
|
if (typeof attrValue === 'undefined' || (options.collapseBooleanAttributes &&
|
|
2532
|
-
|
|
2671
|
+
isBooleanAttribute(attrName.toLowerCase(), (attrValue || '').toLowerCase()))) {
|
|
2533
2672
|
attrFragment = attrName;
|
|
2534
2673
|
if (!isLast) {
|
|
2535
2674
|
attrFragment += ' ';
|
|
@@ -2552,7 +2691,7 @@ function canRemoveParentTag(optionalStartTag, tag) {
|
|
|
2552
2691
|
case 'head':
|
|
2553
2692
|
return true;
|
|
2554
2693
|
case 'body':
|
|
2555
|
-
return !
|
|
2694
|
+
return !headerElements.has(tag);
|
|
2556
2695
|
case 'colgroup':
|
|
2557
2696
|
return tag === 'col';
|
|
2558
2697
|
case 'tbody':
|
|
@@ -2566,7 +2705,7 @@ function isStartTagMandatory(optionalEndTag, tag) {
|
|
|
2566
2705
|
case 'colgroup':
|
|
2567
2706
|
return optionalEndTag === 'colgroup';
|
|
2568
2707
|
case 'tbody':
|
|
2569
|
-
return
|
|
2708
|
+
return tableSectionElements.has(optionalEndTag);
|
|
2570
2709
|
}
|
|
2571
2710
|
return false;
|
|
2572
2711
|
}
|
|
@@ -2585,9 +2724,9 @@ function canRemovePrecedingTag(optionalEndTag, tag) {
|
|
|
2585
2724
|
return tag === optionalEndTag;
|
|
2586
2725
|
case 'dt':
|
|
2587
2726
|
case 'dd':
|
|
2588
|
-
return
|
|
2727
|
+
return descriptionElements.has(tag);
|
|
2589
2728
|
case 'p':
|
|
2590
|
-
return
|
|
2729
|
+
return pBlockElements.has(tag);
|
|
2591
2730
|
case 'rb':
|
|
2592
2731
|
case 'rt':
|
|
2593
2732
|
case 'rp':
|
|
@@ -2595,15 +2734,15 @@ function canRemovePrecedingTag(optionalEndTag, tag) {
|
|
|
2595
2734
|
case 'rtc':
|
|
2596
2735
|
return rubyRtcEndTagOmission.has(tag);
|
|
2597
2736
|
case 'option':
|
|
2598
|
-
return
|
|
2737
|
+
return optionElements.has(tag);
|
|
2599
2738
|
case 'thead':
|
|
2600
2739
|
case 'tbody':
|
|
2601
|
-
return
|
|
2740
|
+
return tableContentElements.has(tag);
|
|
2602
2741
|
case 'tfoot':
|
|
2603
2742
|
return tag === 'tbody';
|
|
2604
2743
|
case 'td':
|
|
2605
2744
|
case 'th':
|
|
2606
|
-
return
|
|
2745
|
+
return cellElements.has(tag);
|
|
2607
2746
|
}
|
|
2608
2747
|
return false;
|
|
2609
2748
|
}
|
|
@@ -2706,7 +2845,7 @@ function parseRemoveEmptyElementsExcept(input, options) {
|
|
|
2706
2845
|
if (typeof item === 'string') {
|
|
2707
2846
|
const spec = parseElementSpec(item, options);
|
|
2708
2847
|
if (!spec && options.log) {
|
|
2709
|
-
options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification:
|
|
2848
|
+
options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: “' + item + '”');
|
|
2710
2849
|
}
|
|
2711
2850
|
return spec;
|
|
2712
2851
|
}
|
|
@@ -3219,7 +3358,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
3219
3358
|
}
|
|
3220
3359
|
|
|
3221
3360
|
// Pre-compile regex patterns for reuse (performance optimization)
|
|
3222
|
-
// These must be declared before scan() since scan uses them
|
|
3361
|
+
// These must be declared before `scan()` since scan uses them
|
|
3223
3362
|
const whitespaceSplitPatternScan = /[ \t\n\f\r]+/;
|
|
3224
3363
|
const whitespaceSplitPatternSort = /[ \n\f\r]+/;
|
|
3225
3364
|
|
|
@@ -3251,9 +3390,9 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
3251
3390
|
chars: async function (text) {
|
|
3252
3391
|
// Only recursively scan HTML content, not JSON-LD or other non-HTML script types
|
|
3253
3392
|
// `scan()` is for analyzing HTML attribute order, not for parsing JSON
|
|
3254
|
-
if (options.processScripts &&
|
|
3255
|
-
|
|
3256
|
-
|
|
3393
|
+
if (options.processScripts && specialContentElements.has(currentTag) &&
|
|
3394
|
+
options.processScripts.indexOf(currentType) > -1 &&
|
|
3395
|
+
currentType === 'text/html') {
|
|
3257
3396
|
await scan(text);
|
|
3258
3397
|
}
|
|
3259
3398
|
},
|
|
@@ -3276,7 +3415,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
3276
3415
|
// For the first pass, create a copy of options and disable aggressive minification.
|
|
3277
3416
|
// Keep attribute transformations (like `removeStyleLinkTypeAttributes`) for accurate analysis.
|
|
3278
3417
|
// This is safe because `createSortFns` is called before custom fragment UID markers (`uidAttr`) are added.
|
|
3279
|
-
// Note: `htmlmin:ignore` UID markers (uidIgnore) already exist and are expanded for analysis.
|
|
3418
|
+
// Note: `htmlmin:ignore` UID markers (`uidIgnore`) already exist and are expanded for analysis.
|
|
3280
3419
|
const firstPassOptions = Object.assign({}, options, {
|
|
3281
3420
|
// Disable sorting for the analysis pass
|
|
3282
3421
|
sortAttributes: false,
|
|
@@ -3295,7 +3434,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
3295
3434
|
});
|
|
3296
3435
|
|
|
3297
3436
|
// Temporarily enable `continueOnParseError` for the `scan()` function call below.
|
|
3298
|
-
// Note: `firstPassOptions` already has `continueOnParseError: true` for the minifyHTML call.
|
|
3437
|
+
// Note: `firstPassOptions` already has `continueOnParseError: true` for the `minifyHTML` call.
|
|
3299
3438
|
const originalContinueOnParseError = options.continueOnParseError;
|
|
3300
3439
|
options.continueOnParseError = true;
|
|
3301
3440
|
|
|
@@ -3308,7 +3447,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
3308
3447
|
: null;
|
|
3309
3448
|
|
|
3310
3449
|
try {
|
|
3311
|
-
// Expand UID tokens back to original content for frequency analysis
|
|
3450
|
+
// Expand UID tokens back to the original content for frequency analysis
|
|
3312
3451
|
let expandedValue = value;
|
|
3313
3452
|
if (uidReplacePattern) {
|
|
3314
3453
|
expandedValue = value.replace(uidReplacePattern, function (match, index) {
|
|
@@ -3357,7 +3496,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
3357
3496
|
attrOrderCache.set(cacheKey, sortedNames);
|
|
3358
3497
|
}
|
|
3359
3498
|
|
|
3360
|
-
// Apply the sorted order to attrs
|
|
3499
|
+
// Apply the sorted order to `attrs`
|
|
3361
3500
|
const attrMap = Object.create(null);
|
|
3362
3501
|
names.forEach(function (name, index) {
|
|
3363
3502
|
(attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
|
|
@@ -3444,7 +3583,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3444
3583
|
const customElementsInput = options.inlineCustomElements ?? [];
|
|
3445
3584
|
const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
|
|
3446
3585
|
const normalizedCustomElements = customElementsArr.map(name => options.name(name));
|
|
3447
|
-
// Fast path: Reuse base
|
|
3586
|
+
// Fast path: Reuse base sets if no custom elements
|
|
3448
3587
|
const inlineTextSet = normalizedCustomElements.length
|
|
3449
3588
|
? new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements])
|
|
3450
3589
|
: inlineElementsToKeepWhitespaceWithin;
|
|
@@ -3464,7 +3603,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3464
3603
|
}
|
|
3465
3604
|
|
|
3466
3605
|
// Temporarily replace ignored chunks with comments, so that we don’t have to worry what’s there.
|
|
3467
|
-
// For all we care there might be completely-horribly-broken-alien-non-html-
|
|
3606
|
+
// For all we care there might be completely-horribly-broken-alien-non-html-emoji-cthulhu-filled content
|
|
3468
3607
|
value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
|
|
3469
3608
|
if (!uidIgnore) {
|
|
3470
3609
|
uidIgnore = uniqueId(value);
|
|
@@ -3485,7 +3624,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3485
3624
|
// Create sort functions after `htmlmin:ignore` processing but before custom fragment UID markers
|
|
3486
3625
|
// This allows proper frequency analysis with access to ignored content via UID tokens
|
|
3487
3626
|
if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
|
|
3488
|
-
|
|
3627
|
+
(options.sortClassName && typeof options.sortClassName !== 'function')) {
|
|
3489
3628
|
await createSortFns(value, options, uidIgnore, null, ignoredMarkupChunks);
|
|
3490
3629
|
}
|
|
3491
3630
|
|
|
@@ -3547,11 +3686,11 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3547
3686
|
});
|
|
3548
3687
|
}
|
|
3549
3688
|
|
|
3550
|
-
function
|
|
3689
|
+
function canCollapseWhitespace$1(tag, attrs) {
|
|
3551
3690
|
return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
|
|
3552
3691
|
}
|
|
3553
3692
|
|
|
3554
|
-
function
|
|
3693
|
+
function canTrimWhitespace$1(tag, attrs) {
|
|
3555
3694
|
return options.canTrimWhitespace(tag, attrs, canTrimWhitespace);
|
|
3556
3695
|
}
|
|
3557
3696
|
|
|
@@ -3573,12 +3712,12 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3573
3712
|
|
|
3574
3713
|
// Look for trailing whitespaces, bypass any inline tags
|
|
3575
3714
|
function trimTrailingWhitespace(index, nextTag) {
|
|
3576
|
-
for (let endTag = null; index >= 0 &&
|
|
3715
|
+
for (let endTag = null; index >= 0 && canTrimWhitespace$1(endTag); index--) {
|
|
3577
3716
|
const str = buffer[index];
|
|
3578
3717
|
const match = str.match(/^<\/([\w:-]+)>$/);
|
|
3579
3718
|
if (match) {
|
|
3580
3719
|
endTag = match[1];
|
|
3581
|
-
} else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, null, nextTag, options, inlineElements, inlineTextSet))) {
|
|
3720
|
+
} else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, null, nextTag, [], [], options, inlineElements, inlineTextSet))) {
|
|
3582
3721
|
break;
|
|
3583
3722
|
}
|
|
3584
3723
|
}
|
|
@@ -3627,10 +3766,10 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3627
3766
|
|
|
3628
3767
|
let optional = options.removeOptionalTags;
|
|
3629
3768
|
if (optional) {
|
|
3630
|
-
const htmlTag =
|
|
3769
|
+
const htmlTag = htmlElements.has(tag);
|
|
3631
3770
|
// `<html>` may be omitted if first thing inside is not a comment
|
|
3632
3771
|
// `<head>` may be omitted if first thing inside is an element
|
|
3633
|
-
// `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`, `<script>`,
|
|
3772
|
+
// `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`, `<script>`, `<style>`, or `<template>`
|
|
3634
3773
|
// `<colgroup>` may be omitted if first thing inside is `<col>`
|
|
3635
3774
|
// `<tbody>` may be omitted if first thing inside is `<tr>`
|
|
3636
3775
|
if (htmlTag && canRemoveParentTag(optionalStartTag, tag)) {
|
|
@@ -3647,16 +3786,16 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3647
3786
|
optionalEndTag = '';
|
|
3648
3787
|
}
|
|
3649
3788
|
|
|
3650
|
-
// Set whitespace flags for nested tags (e.g.,
|
|
3789
|
+
// Set whitespace flags for nested tags (e.g., `<code>` within a `<pre>`)
|
|
3651
3790
|
if (options.collapseWhitespace) {
|
|
3652
3791
|
if (!stackNoTrimWhitespace.length) {
|
|
3653
3792
|
squashTrailingWhitespace(tag);
|
|
3654
3793
|
}
|
|
3655
3794
|
if (!unary) {
|
|
3656
|
-
if (!
|
|
3795
|
+
if (!canTrimWhitespace$1(tag, attrs) || stackNoTrimWhitespace.length) {
|
|
3657
3796
|
stackNoTrimWhitespace.push(tag);
|
|
3658
3797
|
}
|
|
3659
|
-
if (!
|
|
3798
|
+
if (!canCollapseWhitespace$1(tag, attrs) || stackNoCollapseWhitespace.length) {
|
|
3660
3799
|
stackNoCollapseWhitespace.push(tag);
|
|
3661
3800
|
}
|
|
3662
3801
|
}
|
|
@@ -3712,7 +3851,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3712
3851
|
squashTrailingWhitespace('/' + tag);
|
|
3713
3852
|
}
|
|
3714
3853
|
if (stackNoCollapseWhitespace.length &&
|
|
3715
|
-
|
|
3854
|
+
tag === stackNoCollapseWhitespace[stackNoCollapseWhitespace.length - 1]) {
|
|
3716
3855
|
stackNoCollapseWhitespace.pop();
|
|
3717
3856
|
}
|
|
3718
3857
|
}
|
|
@@ -3725,7 +3864,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3725
3864
|
|
|
3726
3865
|
if (options.removeOptionalTags) {
|
|
3727
3866
|
// `<html>`, `<head>` or `<body>` may be omitted if the element is empty
|
|
3728
|
-
if (isElementEmpty &&
|
|
3867
|
+
if (isElementEmpty && topLevelElements.has(optionalStartTag)) {
|
|
3729
3868
|
removeStartTag();
|
|
3730
3869
|
}
|
|
3731
3870
|
optionalStartTag = '';
|
|
@@ -3733,7 +3872,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3733
3872
|
// `</head>` may be omitted if not followed by space or comment
|
|
3734
3873
|
// `</p>` may be omitted if no more content in non-`</a>` parent
|
|
3735
3874
|
// except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent element
|
|
3736
|
-
if (tag && optionalEndTag && !
|
|
3875
|
+
if (tag && optionalEndTag && !trailingElements.has(optionalEndTag) && (optionalEndTag !== 'p' || !pInlineElements.has(tag))) {
|
|
3737
3876
|
removeEndTag();
|
|
3738
3877
|
}
|
|
3739
3878
|
optionalEndTag = optionalEndTags.has(tag) ? tag : '';
|
|
@@ -3780,10 +3919,12 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3780
3919
|
}
|
|
3781
3920
|
}
|
|
3782
3921
|
},
|
|
3783
|
-
chars: async function (text, prevTag, nextTag) {
|
|
3922
|
+
chars: async function (text, prevTag, nextTag, prevAttrs, nextAttrs) {
|
|
3784
3923
|
prevTag = prevTag === '' ? 'comment' : prevTag;
|
|
3785
3924
|
nextTag = nextTag === '' ? 'comment' : nextTag;
|
|
3786
|
-
|
|
3925
|
+
prevAttrs = prevAttrs || [];
|
|
3926
|
+
nextAttrs = nextAttrs || [];
|
|
3927
|
+
if (options.decodeEntities && text && !specialContentElements.has(currentTag)) {
|
|
3787
3928
|
if (text.indexOf('&') !== -1) {
|
|
3788
3929
|
text = entities.decodeHTML(text);
|
|
3789
3930
|
}
|
|
@@ -3819,7 +3960,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3819
3960
|
}
|
|
3820
3961
|
}
|
|
3821
3962
|
if (prevTag || nextTag) {
|
|
3822
|
-
text = collapseWhitespaceSmart(text, prevTag, nextTag, options, inlineElements, inlineTextSet);
|
|
3963
|
+
text = collapseWhitespaceSmart(text, prevTag, nextTag, prevAttrs, nextAttrs, options, inlineElements, inlineTextSet);
|
|
3823
3964
|
} else {
|
|
3824
3965
|
text = collapseWhitespace(text, options, true, true);
|
|
3825
3966
|
}
|
|
@@ -3831,13 +3972,13 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3831
3972
|
text = collapseWhitespace(text, options, false, false, true);
|
|
3832
3973
|
}
|
|
3833
3974
|
}
|
|
3834
|
-
if (
|
|
3975
|
+
if (specialContentElements.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs))) {
|
|
3835
3976
|
text = await processScript(text, options, currentAttrs, minifyHTML);
|
|
3836
3977
|
}
|
|
3837
3978
|
if (isExecutableScript(currentTag, currentAttrs)) {
|
|
3838
3979
|
text = await options.minifyJS(text);
|
|
3839
3980
|
}
|
|
3840
|
-
if (
|
|
3981
|
+
if (isStyleElement(currentTag, currentAttrs)) {
|
|
3841
3982
|
text = await options.minifyCSS(text);
|
|
3842
3983
|
}
|
|
3843
3984
|
if (options.removeOptionalTags && text) {
|
|
@@ -3849,7 +3990,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3849
3990
|
optionalStartTag = '';
|
|
3850
3991
|
// `</html>` or `</body>` may be omitted if not followed by comment
|
|
3851
3992
|
// `</head>`, `</colgroup>`, or `</caption>` may be omitted if not followed by space or comment
|
|
3852
|
-
if (
|
|
3993
|
+
if (compactElements.has(optionalEndTag) || (looseElements.has(optionalEndTag) && !/^\s/.test(text))) {
|
|
3853
3994
|
removeEndTag();
|
|
3854
3995
|
}
|
|
3855
3996
|
// Don’t reset optionalEndTag if text is only whitespace and will be collapsed (not conservatively)
|
|
@@ -3858,11 +3999,11 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3858
3999
|
}
|
|
3859
4000
|
}
|
|
3860
4001
|
charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
|
|
3861
|
-
if (options.decodeEntities && text && !
|
|
4002
|
+
if (options.decodeEntities && text && !specialContentElements.has(currentTag)) {
|
|
3862
4003
|
// Escape any `&` symbols that start either:
|
|
3863
|
-
// 1) a legacy
|
|
4004
|
+
// 1) a legacy-named character reference (i.e., one that doesn’t end with `;`)
|
|
3864
4005
|
// 2) or any other character reference (i.e., one that does end with `;`)
|
|
3865
|
-
// Note that `&` can be escaped as `&`, without the
|
|
4006
|
+
// Note that `&` can be escaped as `&`, without the semicolon.
|
|
3866
4007
|
// https://mathiasbynens.be/notes/ambiguous-ampersands
|
|
3867
4008
|
if (text.indexOf('&') !== -1) {
|
|
3868
4009
|
text = text.replace(RE_LEGACY_ENTITIES, '&$1');
|
|
@@ -3927,7 +4068,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3927
4068
|
|
|
3928
4069
|
// Only collapse whitespace if both blocks contain HTML (start with `<`)
|
|
3929
4070
|
// Don’t collapse if either contains plain text, as that would change meaning
|
|
3930
|
-
// Note: This check will match HTML comments (`<!-- … -->`), but the tag
|
|
4071
|
+
// Note: This check will match HTML comments (`<!-- … -->`), but the tag name
|
|
3931
4072
|
// regex below requires starting with a letter, so comments are intentionally
|
|
3932
4073
|
// excluded by the `currentTagMatch && prevTagMatch` guard
|
|
3933
4074
|
if (currentContent && prevContent && /^\s*</.test(currentContent) && /^\s*</.test(prevContent)) {
|
|
@@ -3988,11 +4129,11 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3988
4129
|
if (options.removeOptionalTags) {
|
|
3989
4130
|
// `<html>` may be omitted if first thing inside is not a comment
|
|
3990
4131
|
// `<head>` or `<body>` may be omitted if empty
|
|
3991
|
-
if (
|
|
4132
|
+
if (topLevelElements.has(optionalStartTag)) {
|
|
3992
4133
|
removeStartTag();
|
|
3993
4134
|
}
|
|
3994
4135
|
// except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent element
|
|
3995
|
-
if (optionalEndTag && !
|
|
4136
|
+
if (optionalEndTag && !trailingElements.has(optionalEndTag)) {
|
|
3996
4137
|
removeEndTag();
|
|
3997
4138
|
}
|
|
3998
4139
|
}
|