html-minifier-next 5.1.2 → 5.1.4
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/dist/htmlminifier.cjs +504 -375
- 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 +2 -6
- package/dist/types/lib/attributes.d.ts.map +1 -1
- package/dist/types/lib/utils.d.ts +1 -1
- package/dist/types/lib/utils.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/htmlminifier.js +245 -194
- package/src/htmlparser.js +34 -10
- package/src/lib/attributes.js +126 -70
- package/src/lib/options.js +3 -3
- package/src/lib/utils.js +4 -4
package/dist/htmlminifier.cjs
CHANGED
|
@@ -2,6 +2,106 @@
|
|
|
2
2
|
|
|
3
3
|
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
4
|
|
|
5
|
+
// Stringify for options signatures (sorted keys, shallow, nested objects)
|
|
6
|
+
|
|
7
|
+
function stableStringify(obj) {
|
|
8
|
+
if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
9
|
+
if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
|
|
10
|
+
const keys = Object.keys(obj).sort();
|
|
11
|
+
let out = '{';
|
|
12
|
+
for (let i = 0; i < keys.length; i++) {
|
|
13
|
+
const k = keys[i];
|
|
14
|
+
out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
|
|
15
|
+
}
|
|
16
|
+
return out + '}';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// LRU cache for strings and promises
|
|
20
|
+
|
|
21
|
+
class LRU {
|
|
22
|
+
constructor(limit = 200) {
|
|
23
|
+
this.limit = limit;
|
|
24
|
+
this.map = new Map();
|
|
25
|
+
}
|
|
26
|
+
get(key) {
|
|
27
|
+
if (this.map.has(key)) {
|
|
28
|
+
const v = this.map.get(key);
|
|
29
|
+
this.map.delete(key);
|
|
30
|
+
this.map.set(key, v);
|
|
31
|
+
return v;
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
set(key, value) {
|
|
36
|
+
if (this.map.has(key)) this.map.delete(key);
|
|
37
|
+
this.map.set(key, value);
|
|
38
|
+
if (this.map.size > this.limit) {
|
|
39
|
+
const first = this.map.keys().next().value;
|
|
40
|
+
this.map.delete(first);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
delete(key) { this.map.delete(key); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Unique ID generator
|
|
47
|
+
|
|
48
|
+
function uniqueId(value) {
|
|
49
|
+
let id;
|
|
50
|
+
do {
|
|
51
|
+
id = 'u' + crypto.randomUUID().replace(/-/g, '');
|
|
52
|
+
} while (~value.indexOf(id));
|
|
53
|
+
return id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Identity and transform functions
|
|
57
|
+
|
|
58
|
+
function identity(value) {
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isThenable(value) {
|
|
63
|
+
return value != null && typeof value === 'object' && typeof value.then === 'function';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function lowercase(value) {
|
|
67
|
+
return value.toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Replace async helper
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Asynchronously replace matches in a string
|
|
74
|
+
* @param {string} str - Input string
|
|
75
|
+
* @param {RegExp} regex - Regular expression with global flag
|
|
76
|
+
* @param {Function} asyncFn - Async function to process each match
|
|
77
|
+
* @returns {Promise<string>} Processed string
|
|
78
|
+
*/
|
|
79
|
+
async function replaceAsync(str, regex, asyncFn) {
|
|
80
|
+
const promises = [];
|
|
81
|
+
|
|
82
|
+
str.replace(regex, (match, ...args) => {
|
|
83
|
+
const promise = asyncFn(match, ...args);
|
|
84
|
+
promises.push(promise);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const data = await Promise.all(promises);
|
|
88
|
+
return str.replace(regex, () => data.shift());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// String patterns to RegExp conversion (for JSON config support)
|
|
92
|
+
|
|
93
|
+
function parseRegExp(value) {
|
|
94
|
+
if (typeof value === 'string') {
|
|
95
|
+
if (!value) return undefined; // Empty string = not configured
|
|
96
|
+
const match = value.match(/^\/(.+)\/([dgimsuvy]*)$/);
|
|
97
|
+
if (match) {
|
|
98
|
+
return new RegExp(match[1], match[2]);
|
|
99
|
+
}
|
|
100
|
+
return new RegExp(value);
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
5
105
|
/*
|
|
6
106
|
* HTML Parser By John Resig (ejohn.org)
|
|
7
107
|
* Modified by Juriy “kangax” Zaytsev
|
|
@@ -9,6 +109,7 @@ Object.defineProperty(exports, '__esModule', { value: true });
|
|
|
9
109
|
* http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
|
|
10
110
|
*/
|
|
11
111
|
|
|
112
|
+
|
|
12
113
|
/*
|
|
13
114
|
* Use like so:
|
|
14
115
|
*
|
|
@@ -84,6 +185,23 @@ const preCompiledStackedTags = {
|
|
|
84
185
|
// Cache for compiled attribute regexes per handler configuration
|
|
85
186
|
const attrRegexCache = new WeakMap();
|
|
86
187
|
|
|
188
|
+
// O(n) helper: Strip all occurrences of `open…close` delimiters, keeping inner content
|
|
189
|
+
// Used instead of a regex replace to avoid O(n²) behavior on adversarial inputs
|
|
190
|
+
function stripDelimited(str, open, close) {
|
|
191
|
+
let result = '';
|
|
192
|
+
let i = 0;
|
|
193
|
+
while (i < str.length) {
|
|
194
|
+
const start = str.indexOf(open, i);
|
|
195
|
+
if (start === -1) { result += str.slice(i); break; }
|
|
196
|
+
result += str.slice(i, start);
|
|
197
|
+
const end = str.indexOf(close, start + open.length);
|
|
198
|
+
if (end === -1) { result += str.slice(start); break; }
|
|
199
|
+
result += str.slice(start + open.length, end);
|
|
200
|
+
i = end + close.length;
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
87
205
|
function buildAttrRegex(handler) {
|
|
88
206
|
let pattern = singleAttrIdentifier.source +
|
|
89
207
|
'(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
|
|
@@ -155,9 +273,10 @@ class HTMLParser {
|
|
|
155
273
|
|
|
156
274
|
// Sticky regex versions for position-based matching (avoids string slicing)
|
|
157
275
|
const startTagOpenY = new RegExp(startTagOpen.source.slice(1), 'y');
|
|
276
|
+
// `\s*` with sticky flag is O(n) at worst—no retry from different positions possible
|
|
158
277
|
const startTagCloseY = /\s*(\/?)>/y;
|
|
159
278
|
const endTagY = new RegExp(endTag.source.slice(1), 'y');
|
|
160
|
-
const doctypeY = /<!DOCTYPE
|
|
279
|
+
const doctypeY = /<!DOCTYPE[^<>]+>/iy;
|
|
161
280
|
const commentTestY = /<!--/y;
|
|
162
281
|
const conditionalTestY = /<!\[/y;
|
|
163
282
|
|
|
@@ -232,7 +351,8 @@ class HTMLParser {
|
|
|
232
351
|
|
|
233
352
|
if (commentEnd >= 0) {
|
|
234
353
|
if (handler.comment) {
|
|
235
|
-
|
|
354
|
+
const result = handler.comment(fullHtml.substring(pos + 4, commentEnd));
|
|
355
|
+
if (isThenable(result)) await result;
|
|
236
356
|
}
|
|
237
357
|
advance(commentEnd + 3 - pos);
|
|
238
358
|
prevTag = '';
|
|
@@ -248,7 +368,8 @@ class HTMLParser {
|
|
|
248
368
|
|
|
249
369
|
if (conditionalEnd >= 0) {
|
|
250
370
|
if (handler.comment) {
|
|
251
|
-
|
|
371
|
+
const result = handler.comment(fullHtml.substring(pos + 2, conditionalEnd + 1), true /* Non-standard */);
|
|
372
|
+
if (isThenable(result)) await result;
|
|
252
373
|
}
|
|
253
374
|
advance(conditionalEnd + 2 - pos);
|
|
254
375
|
prevTag = '';
|
|
@@ -326,7 +447,8 @@ class HTMLParser {
|
|
|
326
447
|
}
|
|
327
448
|
|
|
328
449
|
if (handler.chars) {
|
|
329
|
-
|
|
450
|
+
const result = handler.chars(text, prevTag, nextTag, prevAttrs, nextAttrs);
|
|
451
|
+
if (isThenable(result)) await result;
|
|
330
452
|
}
|
|
331
453
|
prevTag = '';
|
|
332
454
|
prevAttrs = [];
|
|
@@ -340,12 +462,11 @@ class HTMLParser {
|
|
|
340
462
|
if (m && m.index === 0) {
|
|
341
463
|
let text = m[1];
|
|
342
464
|
if (stackedTag !== 'script' && stackedTag !== 'style' && stackedTag !== 'noscript') {
|
|
343
|
-
text = text
|
|
344
|
-
.replace(/<!--([\s\S]*?)-->/g, '$1')
|
|
345
|
-
.replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1');
|
|
465
|
+
text = stripDelimited(stripDelimited(text, '<!--', '-->'), '<![CDATA[', ']]>');
|
|
346
466
|
}
|
|
347
467
|
if (handler.chars) {
|
|
348
|
-
|
|
468
|
+
const result = handler.chars(text);
|
|
469
|
+
if (isThenable(result)) await result;
|
|
349
470
|
}
|
|
350
471
|
// Advance HTML past the matched special tag content and its closing tag
|
|
351
472
|
advance(m[0].length);
|
|
@@ -353,7 +474,8 @@ class HTMLParser {
|
|
|
353
474
|
} else {
|
|
354
475
|
// No closing tag found; to avoid infinite loop, break similarly to previous behavior
|
|
355
476
|
if (handler.continueOnParseError && handler.chars && pos < fullLength) {
|
|
356
|
-
|
|
477
|
+
const result = handler.chars(fullHtml[pos], prevTag, '', prevAttrs, []);
|
|
478
|
+
if (isThenable(result)) await result;
|
|
357
479
|
advance(1);
|
|
358
480
|
} else {
|
|
359
481
|
break;
|
|
@@ -365,7 +487,8 @@ class HTMLParser {
|
|
|
365
487
|
if (handler.continueOnParseError) {
|
|
366
488
|
// Skip the problematic character and continue
|
|
367
489
|
if (handler.chars) {
|
|
368
|
-
|
|
490
|
+
const result = handler.chars(fullHtml[pos], prevTag, '', prevAttrs, []);
|
|
491
|
+
if (isThenable(result)) await result;
|
|
369
492
|
}
|
|
370
493
|
advance(1);
|
|
371
494
|
prevTag = '';
|
|
@@ -913,106 +1036,6 @@ function getPresetNames() {
|
|
|
913
1036
|
return Object.keys(presets);
|
|
914
1037
|
}
|
|
915
1038
|
|
|
916
|
-
// Stringify for options signatures (sorted keys, shallow, nested objects)
|
|
917
|
-
|
|
918
|
-
function stableStringify(obj) {
|
|
919
|
-
if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
920
|
-
if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
|
|
921
|
-
const keys = Object.keys(obj).sort();
|
|
922
|
-
let out = '{';
|
|
923
|
-
for (let i = 0; i < keys.length; i++) {
|
|
924
|
-
const k = keys[i];
|
|
925
|
-
out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
|
|
926
|
-
}
|
|
927
|
-
return out + '}';
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
// LRU cache for strings and promises
|
|
931
|
-
|
|
932
|
-
class LRU {
|
|
933
|
-
constructor(limit = 200) {
|
|
934
|
-
this.limit = limit;
|
|
935
|
-
this.map = new Map();
|
|
936
|
-
}
|
|
937
|
-
get(key) {
|
|
938
|
-
if (this.map.has(key)) {
|
|
939
|
-
const v = this.map.get(key);
|
|
940
|
-
this.map.delete(key);
|
|
941
|
-
this.map.set(key, v);
|
|
942
|
-
return v;
|
|
943
|
-
}
|
|
944
|
-
return undefined;
|
|
945
|
-
}
|
|
946
|
-
set(key, value) {
|
|
947
|
-
if (this.map.has(key)) this.map.delete(key);
|
|
948
|
-
this.map.set(key, value);
|
|
949
|
-
if (this.map.size > this.limit) {
|
|
950
|
-
const first = this.map.keys().next().value;
|
|
951
|
-
this.map.delete(first);
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
delete(key) { this.map.delete(key); }
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// Unique ID generator
|
|
958
|
-
|
|
959
|
-
function uniqueId(value) {
|
|
960
|
-
let id;
|
|
961
|
-
do {
|
|
962
|
-
id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
|
|
963
|
-
} while (~value.indexOf(id));
|
|
964
|
-
return id;
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
// Identity and transform functions
|
|
968
|
-
|
|
969
|
-
function identity(value) {
|
|
970
|
-
return value;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
function identityAsync(value) {
|
|
974
|
-
return Promise.resolve(value);
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
function lowercase(value) {
|
|
978
|
-
return value.toLowerCase();
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
// Replace async helper
|
|
982
|
-
|
|
983
|
-
/**
|
|
984
|
-
* Asynchronously replace matches in a string
|
|
985
|
-
* @param {string} str - Input string
|
|
986
|
-
* @param {RegExp} regex - Regular expression with global flag
|
|
987
|
-
* @param {Function} asyncFn - Async function to process each match
|
|
988
|
-
* @returns {Promise<string>} Processed string
|
|
989
|
-
*/
|
|
990
|
-
async function replaceAsync(str, regex, asyncFn) {
|
|
991
|
-
const promises = [];
|
|
992
|
-
|
|
993
|
-
str.replace(regex, (match, ...args) => {
|
|
994
|
-
const promise = asyncFn(match, ...args);
|
|
995
|
-
promises.push(promise);
|
|
996
|
-
});
|
|
997
|
-
|
|
998
|
-
const data = await Promise.all(promises);
|
|
999
|
-
return str.replace(regex, () => data.shift());
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
// String patterns to RegExp conversion (for JSON config support)
|
|
1003
|
-
|
|
1004
|
-
function parseRegExp(value) {
|
|
1005
|
-
if (typeof value === 'string') {
|
|
1006
|
-
if (!value) return undefined; // Empty string = not configured
|
|
1007
|
-
const match = value.match(/^\/(.+)\/([dgimsuvy]*)$/);
|
|
1008
|
-
if (match) {
|
|
1009
|
-
return new RegExp(match[1], match[2]);
|
|
1010
|
-
}
|
|
1011
|
-
return new RegExp(value);
|
|
1012
|
-
}
|
|
1013
|
-
return value;
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
1039
|
// Regex patterns (to avoid repeated allocations in hot paths)
|
|
1017
1040
|
|
|
1018
1041
|
const RE_WS_START = /^[ \n\r\t\f]+/;
|
|
@@ -1665,7 +1688,7 @@ function shouldMinifyInnerHTML(options) {
|
|
|
1665
1688
|
options.removeComments ||
|
|
1666
1689
|
options.removeOptionalTags ||
|
|
1667
1690
|
options.minifyJS !== identity ||
|
|
1668
|
-
options.minifyCSS !==
|
|
1691
|
+
options.minifyCSS !== identity ||
|
|
1669
1692
|
options.minifyURLs !== identity ||
|
|
1670
1693
|
options.minifySVG
|
|
1671
1694
|
);
|
|
@@ -1692,7 +1715,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
1692
1715
|
canTrimWhitespace,
|
|
1693
1716
|
...optionDefaults,
|
|
1694
1717
|
log: identity,
|
|
1695
|
-
minifyCSS:
|
|
1718
|
+
minifyCSS: identity,
|
|
1696
1719
|
minifyJS: identity,
|
|
1697
1720
|
minifyURLs: identity,
|
|
1698
1721
|
minifySVG: null
|
|
@@ -2330,7 +2353,9 @@ function hasAttrName(name, attrs) {
|
|
|
2330
2353
|
|
|
2331
2354
|
// Cleaners
|
|
2332
2355
|
|
|
2333
|
-
|
|
2356
|
+
// Returns the cleaned attribute value directly (sync) or as a Promise (async);
|
|
2357
|
+
// callers must handle both cases—use `isThenable()` to distinguish
|
|
2358
|
+
function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
|
|
2334
2359
|
// Apply early whitespace normalization if enabled
|
|
2335
2360
|
// Preserves special spaces (no-break space, hair space, etc.) for consistency with `collapseWhitespace`
|
|
2336
2361
|
if (options.collapseAttributeWhitespace) {
|
|
@@ -2345,16 +2370,18 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
2345
2370
|
|
|
2346
2371
|
if (isEventAttribute(attrName, options)) {
|
|
2347
2372
|
attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
return attrValue;
|
|
2373
|
+
const result = options.minifyJS(attrValue, true);
|
|
2374
|
+
if (isThenable(result)) {
|
|
2375
|
+
return result.catch(err => {
|
|
2376
|
+
if (!options.continueOnMinifyError) throw err;
|
|
2377
|
+
options.log && options.log(err);
|
|
2378
|
+
return attrValue;
|
|
2379
|
+
});
|
|
2356
2380
|
}
|
|
2357
|
-
|
|
2381
|
+
return result;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
if (attrName === 'class') {
|
|
2358
2385
|
attrValue = trimWhitespace(attrValue);
|
|
2359
2386
|
if (options.sortClassNames) {
|
|
2360
2387
|
attrValue = options.sortClassNames(attrValue);
|
|
@@ -2362,47 +2389,63 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
2362
2389
|
attrValue = collapseWhitespaceAll(attrValue);
|
|
2363
2390
|
}
|
|
2364
2391
|
return attrValue;
|
|
2365
|
-
}
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
if (isUriTypeAttribute(attrName, tag)) {
|
|
2366
2395
|
attrValue = trimWhitespace(attrValue);
|
|
2367
2396
|
if (isLinkType(tag, attrs, 'canonical')) {
|
|
2368
2397
|
return attrValue;
|
|
2369
2398
|
}
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
return
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2399
|
+
const result = options.minifyURLs(attrValue);
|
|
2400
|
+
if (isThenable(result)) {
|
|
2401
|
+
return result
|
|
2402
|
+
.then(out => typeof out === 'string' ? out : attrValue)
|
|
2403
|
+
.catch(err => {
|
|
2404
|
+
if (!options.continueOnMinifyError) throw err;
|
|
2405
|
+
options.log && options.log(err);
|
|
2406
|
+
return attrValue;
|
|
2407
|
+
});
|
|
2379
2408
|
}
|
|
2380
|
-
|
|
2409
|
+
return typeof result === 'string' ? result : attrValue;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
if (isNumberTypeAttribute(attrName, tag)) {
|
|
2381
2413
|
return trimWhitespace(attrValue);
|
|
2382
|
-
}
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
if (attrName === 'style') {
|
|
2383
2417
|
attrValue = trimWhitespace(attrValue);
|
|
2384
2418
|
if (attrValue) {
|
|
2385
2419
|
if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
|
|
2386
2420
|
attrValue = attrValue.replace(/\s*;$/, ';');
|
|
2387
2421
|
}
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2422
|
+
const originalAttrValue = attrValue;
|
|
2423
|
+
const cssResult = options.minifyCSS(attrValue, 'inline');
|
|
2424
|
+
if (isThenable(cssResult)) {
|
|
2425
|
+
return cssResult
|
|
2426
|
+
.then(minified => {
|
|
2427
|
+
// After minification, check if CSS consists entirely of invalid properties (no values)
|
|
2428
|
+
// I.e., `color:` or `margin:;padding:` should be treated as empty
|
|
2429
|
+
if (minified && /^(?:[a-z-]+:[;\s]*)+$/i.test(minified)) return '';
|
|
2430
|
+
return minified;
|
|
2431
|
+
})
|
|
2432
|
+
.catch(err => {
|
|
2433
|
+
if (!options.continueOnMinifyError) throw err;
|
|
2434
|
+
options.log && options.log(err);
|
|
2435
|
+
return originalAttrValue;
|
|
2436
|
+
});
|
|
2400
2437
|
}
|
|
2438
|
+
// Sync path (`minifyCSS` disabled—identity function)
|
|
2439
|
+
if (cssResult && /^(?:[a-z-]+:[;\s]*)+$/i.test(cssResult)) return '';
|
|
2440
|
+
return cssResult != null ? cssResult : attrValue;
|
|
2401
2441
|
}
|
|
2402
2442
|
return attrValue;
|
|
2403
|
-
}
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
if (isSrcset(attrName, tag)) {
|
|
2404
2446
|
// https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
|
|
2405
|
-
|
|
2447
|
+
const candidates = trimWhitespace(attrValue).split(/\s*,\s*/);
|
|
2448
|
+
const processed = candidates.map(candidate => {
|
|
2406
2449
|
let url = candidate;
|
|
2407
2450
|
let descriptor = '';
|
|
2408
2451
|
const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
|
|
@@ -2414,47 +2457,65 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
2414
2457
|
descriptor = ' ' + num + suffix;
|
|
2415
2458
|
}
|
|
2416
2459
|
}
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
return
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2460
|
+
const out = options.minifyURLs(url);
|
|
2461
|
+
if (isThenable(out)) {
|
|
2462
|
+
return out
|
|
2463
|
+
.then(result => (typeof result === 'string' ? result : url) + descriptor)
|
|
2464
|
+
.catch(err => {
|
|
2465
|
+
if (!options.continueOnMinifyError) throw err;
|
|
2466
|
+
options.log && options.log(err);
|
|
2467
|
+
return url + descriptor;
|
|
2468
|
+
});
|
|
2426
2469
|
}
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2470
|
+
return (typeof out === 'string' ? out : url) + descriptor;
|
|
2471
|
+
});
|
|
2472
|
+
if (processed.some(isThenable)) {
|
|
2473
|
+
return Promise.all(processed).then(results => results.join(', '));
|
|
2474
|
+
}
|
|
2475
|
+
return processed.join(', ');
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
if (isMetaViewport(tag, attrs) && attrName === 'content') {
|
|
2479
|
+
return attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
|
|
2430
2480
|
// 0.90000 → 0.9
|
|
2431
2481
|
// 1.0 → 1
|
|
2432
2482
|
// 1.0001 → 1.0001 (unchanged)
|
|
2433
2483
|
return (+numString).toString();
|
|
2434
2484
|
});
|
|
2435
|
-
}
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
|
|
2436
2488
|
return collapseWhitespaceAll(attrValue);
|
|
2437
|
-
}
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
}
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
|
|
2492
|
+
return trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
if (tag === 'script' && attrName === 'type') {
|
|
2496
|
+
return trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
if (isMediaQuery(tag, attrs, attrName)) {
|
|
2442
2500
|
attrValue = trimWhitespace(attrValue);
|
|
2443
2501
|
// Only minify actual media queries (those with features in parentheses)
|
|
2444
2502
|
// Skip simple media types like `all`, `screen`, `print` which are already minimal
|
|
2445
2503
|
if (!/[()]/.test(attrValue)) {
|
|
2446
2504
|
return attrValue;
|
|
2447
2505
|
}
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
throw err;
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2506
|
+
const originalAttrValue = attrValue;
|
|
2507
|
+
const cssResult = options.minifyCSS(attrValue, 'media');
|
|
2508
|
+
if (isThenable(cssResult)) {
|
|
2509
|
+
return cssResult.catch(err => {
|
|
2510
|
+
if (!options.continueOnMinifyError) throw err;
|
|
2511
|
+
options.log && options.log(err);
|
|
2512
|
+
return originalAttrValue;
|
|
2513
|
+
});
|
|
2456
2514
|
}
|
|
2457
|
-
|
|
2515
|
+
return cssResult != null ? cssResult : attrValue;
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
if (tag === 'iframe' && attrName === 'srcdoc') {
|
|
2458
2519
|
// Recursively minify HTML content within `srcdoc` attribute
|
|
2459
2520
|
// Fast-path: Skip if nothing would change
|
|
2460
2521
|
if (!shouldMinifyInnerHTML(options)) {
|
|
@@ -2462,6 +2523,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
2462
2523
|
}
|
|
2463
2524
|
return minifyHTMLSelf(attrValue, options, true);
|
|
2464
2525
|
}
|
|
2526
|
+
|
|
2465
2527
|
return attrValue;
|
|
2466
2528
|
}
|
|
2467
2529
|
|
|
@@ -2485,17 +2547,24 @@ function chooseAttributeQuote(attrValue, options) {
|
|
|
2485
2547
|
return apos < quot ? '\'' : '"';
|
|
2486
2548
|
}
|
|
2487
2549
|
|
|
2488
|
-
|
|
2550
|
+
// Returns the normalized attribute object directly (sync) or as a Promise (async);
|
|
2551
|
+
// callers must handle both cases—use `isThenable()` to distinguish
|
|
2552
|
+
function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
|
|
2489
2553
|
const attrName = options.name(attr.name);
|
|
2490
2554
|
let attrValue = attr.value;
|
|
2491
2555
|
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
}
|
|
2556
|
+
// Entity decoding requires a lazy import—async only when `&` is present
|
|
2557
|
+
if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
|
|
2558
|
+
return getDecodeHTMLStrict().then(decode => {
|
|
2559
|
+
return normalizeAttrContinue(attrName, decode(attrValue), attr, attrs, tag, options, minifyHTML);
|
|
2560
|
+
});
|
|
2497
2561
|
}
|
|
2498
2562
|
|
|
2563
|
+
return normalizeAttrContinue(attrName, attrValue, attr, attrs, tag, options, minifyHTML);
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
// Internal: Handles attribute normalization after entity decoding (if any)
|
|
2567
|
+
function normalizeAttrContinue(attrName, attrValue, attr, attrs, tag, options, minifyHTML) {
|
|
2499
2568
|
if ((options.removeRedundantAttributes &&
|
|
2500
2569
|
isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
|
|
2501
2570
|
(options.removeScriptTypeAttributes && tag === 'script' &&
|
|
@@ -2506,9 +2575,18 @@ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
|
|
|
2506
2575
|
}
|
|
2507
2576
|
|
|
2508
2577
|
if (attrValue) {
|
|
2509
|
-
|
|
2578
|
+
const cleaned = cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML);
|
|
2579
|
+
if (isThenable(cleaned)) {
|
|
2580
|
+
return cleaned.then(v => normalizeAttrFinish(attrName, v, attr, tag, options));
|
|
2581
|
+
}
|
|
2582
|
+
return normalizeAttrFinish(attrName, cleaned, attr, tag, options);
|
|
2510
2583
|
}
|
|
2511
2584
|
|
|
2585
|
+
return normalizeAttrFinish(attrName, attrValue, attr, tag, options);
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
// Internal: Final checks and result assembly after value cleaning
|
|
2589
|
+
function normalizeAttrFinish(attrName, attrValue, attr, tag, options) {
|
|
2512
2590
|
if (options.removeEmptyAttributes &&
|
|
2513
2591
|
canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
|
|
2514
2592
|
return;
|
|
@@ -3734,31 +3812,47 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3734
3812
|
let removeEmptyElementsExcept;
|
|
3735
3813
|
if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
|
|
3736
3814
|
if (options.log) {
|
|
3737
|
-
options.log('Warning:
|
|
3815
|
+
options.log('Warning: `removeEmptyElementsExcept` option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
|
|
3738
3816
|
}
|
|
3739
3817
|
removeEmptyElementsExcept = [];
|
|
3740
3818
|
} else {
|
|
3741
3819
|
removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
|
|
3742
3820
|
}
|
|
3743
3821
|
|
|
3744
|
-
// Temporarily replace ignored chunks with comments, so that we don’t have to worry what’s there
|
|
3745
|
-
//
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3822
|
+
// Temporarily replace ignored chunks with comments, so that we don’t have to worry what’s there;
|
|
3823
|
+
// for all we care there might be completely-horribly-broken-alien-non-html-emoji-cthulhu-filled content
|
|
3824
|
+
if (value.indexOf('<!-- htmlmin:ignore -->') !== -1) {
|
|
3825
|
+
// Use `indexOf`-based O(n) loop instead of a global regex with [\s\S]*? to avoid O(n²)
|
|
3826
|
+
// backtracking on adversarial HTML with many `<!--` prefixes but no closing marker
|
|
3827
|
+
const ignoreMarker = '<!-- htmlmin:ignore -->';
|
|
3828
|
+
const ignoreMarkerLen = ignoreMarker.length;
|
|
3829
|
+
let ignoreResult = '';
|
|
3830
|
+
let ignorePos = 0;
|
|
3831
|
+
while (ignorePos < value.length) {
|
|
3832
|
+
const ignoreStart = value.indexOf(ignoreMarker, ignorePos);
|
|
3833
|
+
if (ignoreStart === -1) { ignoreResult += value.slice(ignorePos); break; }
|
|
3834
|
+
ignoreResult += value.slice(ignorePos, ignoreStart);
|
|
3835
|
+
const ignoreEnd = value.indexOf(ignoreMarker, ignoreStart + ignoreMarkerLen);
|
|
3836
|
+
if (ignoreEnd === -1) { ignoreResult += value.slice(ignoreStart); break; }
|
|
3837
|
+
const group1 = value.slice(ignoreStart + ignoreMarkerLen, ignoreEnd);
|
|
3838
|
+
if (!uidIgnore) {
|
|
3839
|
+
uidIgnore = uniqueId(value);
|
|
3840
|
+
const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
|
|
3841
|
+
uidIgnorePlaceholderPattern = new RegExp('^<!--' + uidIgnore + '(\\d+)-->$');
|
|
3842
|
+
if (options.ignoreCustomComments) {
|
|
3843
|
+
options.ignoreCustomComments = options.ignoreCustomComments.slice();
|
|
3844
|
+
} else {
|
|
3845
|
+
options.ignoreCustomComments = [];
|
|
3846
|
+
}
|
|
3847
|
+
options.ignoreCustomComments.push(pattern);
|
|
3755
3848
|
}
|
|
3756
|
-
|
|
3849
|
+
const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
|
|
3850
|
+
ignoredMarkupChunks.push(group1);
|
|
3851
|
+
ignoreResult += token;
|
|
3852
|
+
ignorePos = ignoreEnd + ignoreMarkerLen;
|
|
3757
3853
|
}
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
return token;
|
|
3761
|
-
});
|
|
3854
|
+
value = ignoreResult;
|
|
3855
|
+
}
|
|
3762
3856
|
|
|
3763
3857
|
// Create sort functions after `htmlmin:ignore` processing but before custom fragment UID markers
|
|
3764
3858
|
// This allows proper frequency analysis with access to ignored content via UID tokens
|
|
@@ -3993,9 +4087,8 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
3993
4087
|
options.sortAttributes(tag, attrs);
|
|
3994
4088
|
}
|
|
3995
4089
|
|
|
3996
|
-
const
|
|
3997
|
-
|
|
3998
|
-
);
|
|
4090
|
+
const attrResults = attrs.map(attr => normalizeAttr(attr, attrs, tag, options, minifyHTML));
|
|
4091
|
+
const normalizedAttrs = attrResults.some(isThenable) ? await Promise.all(attrResults) : attrResults;
|
|
3999
4092
|
const parts = [];
|
|
4000
4093
|
let isLast = true;
|
|
4001
4094
|
for (let i = normalizedAttrs.length - 1; i >= 0; i--) {
|
|
@@ -4123,207 +4216,225 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
4123
4216
|
}
|
|
4124
4217
|
}
|
|
4125
4218
|
},
|
|
4126
|
-
chars:
|
|
4219
|
+
chars: function (text, prevTag, nextTag, prevAttrs, nextAttrs) {
|
|
4127
4220
|
prevTag = prevTag === '' ? 'comment' : prevTag;
|
|
4128
4221
|
nextTag = nextTag === '' ? 'comment' : nextTag;
|
|
4129
4222
|
prevAttrs = prevAttrs || [];
|
|
4130
4223
|
nextAttrs = nextAttrs || [];
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
//
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
if (
|
|
4146
|
-
|
|
4224
|
+
|
|
4225
|
+
// Detect whether any async work is actually needed for this text node
|
|
4226
|
+
const needsDecode = options.decodeEntities && text && !specialContentElements.has(currentTag) && text.indexOf('&') !== -1;
|
|
4227
|
+
const needsProcessScript = specialContentElements.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs));
|
|
4228
|
+
const needsMinifyJS = options.minifyJS !== identity && isExecutableScript(currentTag, currentAttrs);
|
|
4229
|
+
const needsMinifyCSS = options.minifyCSS !== identity && isStyleElement(currentTag, currentAttrs);
|
|
4230
|
+
|
|
4231
|
+
// Whitespace collapsing phase (sync); captures `prevTag`/`nextTag`/`prevAttrs`/`nextAttrs` from outer scope
|
|
4232
|
+
function charsCollapse(text) {
|
|
4233
|
+
// Trim outermost newline-based whitespace inside `pre`/`textarea` elements
|
|
4234
|
+
// This removes trailing newlines often added by template engines before closing tags
|
|
4235
|
+
// Only trims single trailing newlines (multiple newlines are likely intentional formatting)
|
|
4236
|
+
if (options.collapseWhitespace && stackNoTrimWhitespace.length) {
|
|
4237
|
+
const topTag = stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1];
|
|
4238
|
+
if (preTextareaDepth > 0) {
|
|
4239
|
+
// Trim trailing whitespace only if it ends with a single newline (not multiple)
|
|
4240
|
+
// Multiple newlines are likely intentional formatting, single newline is often a template artifact
|
|
4241
|
+
// Treat CRLF (`\r\n`), CR (`\r`), and LF (`\n`) as single line-ending units
|
|
4242
|
+
if (nextTag && nextTag === '/' + topTag && /[^\r\n](?:\r\n|\r|\n)[ \t]*$/.test(text)) {
|
|
4243
|
+
text = text.replace(/(?:\r\n|\r|\n)[ \t]*$/, '');
|
|
4244
|
+
}
|
|
4147
4245
|
}
|
|
4148
4246
|
}
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
}
|
|
4247
|
+
if (options.collapseWhitespace) {
|
|
4248
|
+
if (!stackNoTrimWhitespace.length) {
|
|
4249
|
+
if (prevTag === 'comment') {
|
|
4250
|
+
const prevComment = buffer[buffer.length - 1];
|
|
4251
|
+
if (!uidIgnore || prevComment.indexOf(uidIgnore) === -1) {
|
|
4252
|
+
if (!prevComment) {
|
|
4253
|
+
prevTag = charsPrevTag;
|
|
4254
|
+
}
|
|
4255
|
+
if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
|
|
4256
|
+
const charsIndex = buffer.length - 2;
|
|
4257
|
+
buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
|
|
4258
|
+
text = trailingSpaces + text;
|
|
4259
|
+
return '';
|
|
4260
|
+
});
|
|
4261
|
+
}
|
|
4164
4262
|
}
|
|
4165
4263
|
}
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4264
|
+
if (prevTag) {
|
|
4265
|
+
if (prevTag === '/nobr' || prevTag === 'wbr') {
|
|
4266
|
+
if (/^\s/.test(text)) {
|
|
4267
|
+
let tagIndex = buffer.length - 1;
|
|
4268
|
+
while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
|
|
4269
|
+
tagIndex--;
|
|
4270
|
+
}
|
|
4271
|
+
trimTrailingWhitespace(tagIndex - 1, 'br');
|
|
4173
4272
|
}
|
|
4174
|
-
|
|
4273
|
+
} else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
|
|
4274
|
+
text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
|
|
4175
4275
|
}
|
|
4176
|
-
}
|
|
4177
|
-
|
|
4276
|
+
}
|
|
4277
|
+
if (prevTag || nextTag) {
|
|
4278
|
+
text = collapseWhitespaceSmart(text, prevTag, nextTag, prevAttrs, nextAttrs, options, inlineElements, inlineTextSet);
|
|
4279
|
+
} else {
|
|
4280
|
+
text = collapseWhitespace(text, options, true, true);
|
|
4281
|
+
}
|
|
4282
|
+
if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
|
|
4283
|
+
trimTrailingWhitespace(buffer.length - 1, nextTag);
|
|
4178
4284
|
}
|
|
4179
4285
|
}
|
|
4180
|
-
if (prevTag
|
|
4181
|
-
text =
|
|
4182
|
-
} else {
|
|
4183
|
-
text = collapseWhitespace(text, options, true, true);
|
|
4286
|
+
if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
|
|
4287
|
+
text = collapseWhitespace(text, options, false, false, true);
|
|
4184
4288
|
}
|
|
4185
|
-
|
|
4186
|
-
|
|
4289
|
+
}
|
|
4290
|
+
return text;
|
|
4291
|
+
}
|
|
4292
|
+
|
|
4293
|
+
// Finalization phase (sync): Optional tag handling, entity re-encoding, buffer push
|
|
4294
|
+
function charsFinalize(text) {
|
|
4295
|
+
if (options.removeOptionalTags && text) {
|
|
4296
|
+
// `<html>` may be omitted if first thing inside is not a comment
|
|
4297
|
+
// `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`, `<script>`, `<style>`, or `<template>`
|
|
4298
|
+
if (optionalStartTag === 'html' || (optionalStartTag === 'body' && !/^\s/.test(text))) {
|
|
4299
|
+
removeStartTag();
|
|
4300
|
+
}
|
|
4301
|
+
optionalStartTag = '';
|
|
4302
|
+
// `</html>` or `</body>` may be omitted if not followed by comment
|
|
4303
|
+
// `</head>`, `</colgroup>`, or `</caption>` may be omitted if not followed by space or comment
|
|
4304
|
+
if (optionalEndTagEmitted && (compactElements.has(optionalEndTag) || (looseElements.has(optionalEndTag) && !/^\s/.test(text)))) {
|
|
4305
|
+
removeEndTag();
|
|
4306
|
+
}
|
|
4307
|
+
// Don’t reset `optionalEndTag` if text is only whitespace and will be collapsed (not conservatively)
|
|
4308
|
+
if (!/^\s+$/.test(text) || !options.collapseWhitespace || options.conservativeCollapse) {
|
|
4309
|
+
optionalEndTag = '';
|
|
4310
|
+
optionalEndTagEmitted = false;
|
|
4187
4311
|
}
|
|
4188
4312
|
}
|
|
4189
|
-
|
|
4190
|
-
|
|
4313
|
+
charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
|
|
4314
|
+
if (options.decodeEntities && text && !specialContentElements.has(currentTag)) {
|
|
4315
|
+
// Escape any `&` symbols that start either:
|
|
4316
|
+
// 1. a legacy-named character reference (i.e., one that doesn’t end with `;`)
|
|
4317
|
+
// 2. or any other character reference (i.e., one that does end with `;`)
|
|
4318
|
+
// Note that `&` can be escaped as `&`, without the semicolon.
|
|
4319
|
+
// https://mathiasbynens.be/notes/ambiguous-ampersands
|
|
4320
|
+
if (text.indexOf('&') !== -1) {
|
|
4321
|
+
text = text.replace(RE_LEGACY_ENTITIES, '&$1');
|
|
4322
|
+
}
|
|
4323
|
+
if (text.indexOf('<') !== -1) {
|
|
4324
|
+
text = text.replace(RE_ESCAPE_LT, '<');
|
|
4325
|
+
}
|
|
4191
4326
|
}
|
|
4327
|
+
if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
|
|
4328
|
+
text = text.replace(uidPattern, function (match, prefix, index) {
|
|
4329
|
+
return ignoredCustomMarkupChunks[+index][0];
|
|
4330
|
+
});
|
|
4331
|
+
}
|
|
4332
|
+
currentChars += text;
|
|
4333
|
+
if (text) {
|
|
4334
|
+
hasChars = true;
|
|
4335
|
+
}
|
|
4336
|
+
buffer.push(text);
|
|
4192
4337
|
}
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
}
|
|
4199
|
-
if (isStyleElement(currentTag, currentAttrs)) {
|
|
4200
|
-
text = await options.minifyCSS(text);
|
|
4338
|
+
|
|
4339
|
+
// Fast path: All work is sync—skip async machinery entirely
|
|
4340
|
+
if (!needsDecode && !needsProcessScript && !needsMinifyJS && !needsMinifyCSS) {
|
|
4341
|
+
charsFinalize(charsCollapse(text));
|
|
4342
|
+
return;
|
|
4201
4343
|
}
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
if (
|
|
4206
|
-
|
|
4207
|
-
}
|
|
4208
|
-
optionalStartTag = '';
|
|
4209
|
-
// `</html>` or `</body>` may be omitted if not followed by comment
|
|
4210
|
-
// `</head>`, `</colgroup>`, or `</caption>` may be omitted if not followed by space or comment
|
|
4211
|
-
if (optionalEndTagEmitted && (compactElements.has(optionalEndTag) || (looseElements.has(optionalEndTag) && !/^\s/.test(text)))) {
|
|
4212
|
-
removeEndTag();
|
|
4344
|
+
|
|
4345
|
+
// Slow path: At least one async step required
|
|
4346
|
+
return (async () => {
|
|
4347
|
+
if (needsDecode) {
|
|
4348
|
+
text = (await getDecodeHTML())(text);
|
|
4213
4349
|
}
|
|
4214
|
-
|
|
4215
|
-
if (
|
|
4216
|
-
|
|
4217
|
-
optionalEndTagEmitted = false;
|
|
4350
|
+
text = charsCollapse(text);
|
|
4351
|
+
if (needsProcessScript) {
|
|
4352
|
+
text = await processScript(text, options, currentAttrs, minifyHTML);
|
|
4218
4353
|
}
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
if (options.decodeEntities && text && !specialContentElements.has(currentTag)) {
|
|
4222
|
-
// Escape any `&` symbols that start either:
|
|
4223
|
-
// 1. a legacy-named character reference (i.e., one that doesn’t end with `;`)
|
|
4224
|
-
// 2. or any other character reference (i.e., one that does end with `;`)
|
|
4225
|
-
// Note that `&` can be escaped as `&`, without the semicolon.
|
|
4226
|
-
// https://mathiasbynens.be/notes/ambiguous-ampersands
|
|
4227
|
-
if (text.indexOf('&') !== -1) {
|
|
4228
|
-
text = text.replace(RE_LEGACY_ENTITIES, '&$1');
|
|
4354
|
+
if (needsMinifyJS) {
|
|
4355
|
+
text = await options.minifyJS(text);
|
|
4229
4356
|
}
|
|
4230
|
-
if (
|
|
4231
|
-
text =
|
|
4357
|
+
if (needsMinifyCSS) {
|
|
4358
|
+
text = await options.minifyCSS(text);
|
|
4232
4359
|
}
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
text = text.replace(uidPattern, function (match, prefix, index) {
|
|
4236
|
-
return ignoredCustomMarkupChunks[+index][0];
|
|
4237
|
-
});
|
|
4238
|
-
}
|
|
4239
|
-
currentChars += text;
|
|
4240
|
-
if (text) {
|
|
4241
|
-
hasChars = true;
|
|
4242
|
-
}
|
|
4243
|
-
buffer.push(text);
|
|
4360
|
+
charsFinalize(text);
|
|
4361
|
+
})();
|
|
4244
4362
|
},
|
|
4245
|
-
comment:
|
|
4363
|
+
comment: function (text, nonStandard) {
|
|
4246
4364
|
const prefix = nonStandard ? '<!' : '<!--';
|
|
4247
4365
|
const suffix = nonStandard ? '>' : '-->';
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
if (
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4366
|
+
|
|
4367
|
+
// Finalization phase (sync): Optional tag handling, `htmlmin:ignore` whitespace collapsing, buffer push
|
|
4368
|
+
function commentFinalize(comment) {
|
|
4369
|
+
if (options.removeOptionalTags && comment) {
|
|
4370
|
+
// Preceding comments suppress tag omissions
|
|
4371
|
+
optionalStartTag = '';
|
|
4372
|
+
optionalEndTag = '';
|
|
4373
|
+
optionalEndTagEmitted = false;
|
|
4255
4374
|
}
|
|
4256
|
-
} else {
|
|
4257
|
-
text = prefix + text + suffix;
|
|
4258
|
-
}
|
|
4259
|
-
if (options.removeOptionalTags && text) {
|
|
4260
|
-
// Preceding comments suppress tag omissions
|
|
4261
|
-
optionalStartTag = '';
|
|
4262
|
-
optionalEndTag = '';
|
|
4263
|
-
optionalEndTagEmitted = false;
|
|
4264
|
-
}
|
|
4265
4375
|
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4376
|
+
// Optimize whitespace collapsing between consecutive `htmlmin:ignore` placeholder comments
|
|
4377
|
+
if (options.collapseWhitespace && comment && uidIgnorePlaceholderPattern) {
|
|
4378
|
+
if (uidIgnorePlaceholderPattern.test(comment)) {
|
|
4379
|
+
// Check if previous buffer items are: [ignore-placeholder, whitespace-only text]
|
|
4380
|
+
if (buffer.length >= 2) {
|
|
4381
|
+
const prevText = buffer[buffer.length - 1];
|
|
4382
|
+
const prevComment = buffer[buffer.length - 2];
|
|
4383
|
+
|
|
4384
|
+
// Check if previous item is whitespace-only and item before that is ignore-placeholder
|
|
4385
|
+
if (prevText && /^\s+$/.test(prevText) && prevComment && uidIgnorePlaceholderPattern.test(prevComment)) {
|
|
4386
|
+
// Extract the index from both placeholders to check their content
|
|
4387
|
+
const currentMatch = comment.match(uidIgnorePlaceholderPattern);
|
|
4388
|
+
const prevMatch = prevComment.match(uidIgnorePlaceholderPattern);
|
|
4389
|
+
|
|
4390
|
+
if (currentMatch && prevMatch) {
|
|
4391
|
+
const currentIndex = +currentMatch[1];
|
|
4392
|
+
const prevIndex = +prevMatch[1];
|
|
4393
|
+
|
|
4394
|
+
// Defensive bounds check to ensure indices are valid
|
|
4395
|
+
if (currentIndex < ignoredMarkupChunks.length && prevIndex < ignoredMarkupChunks.length) {
|
|
4396
|
+
const currentContent = ignoredMarkupChunks[currentIndex];
|
|
4397
|
+
const prevContent = ignoredMarkupChunks[prevIndex];
|
|
4398
|
+
|
|
4399
|
+
// Only collapse whitespace if both blocks contain HTML (start with `<`)
|
|
4400
|
+
// Don’t collapse if either contains plain text, as that would change meaning
|
|
4401
|
+
// Note: This check will match HTML comments (`<!-- … -->`), but the tag name
|
|
4402
|
+
// regex below requires starting with a letter, so comments are intentionally
|
|
4403
|
+
// excluded by the `currentTagMatch && prevTagMatch` guard
|
|
4404
|
+
if (currentContent && prevContent && /^\s*</.test(currentContent) && /^\s*</.test(prevContent)) {
|
|
4405
|
+
// Extract tag names from the HTML content (excludes comments, processing instructions, etc.)
|
|
4406
|
+
const currentTagMatch = currentContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
|
|
4407
|
+
const prevTagMatch = prevContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
|
|
4408
|
+
|
|
4409
|
+
// Only collapse if both matched valid element tags (not comments/text)
|
|
4410
|
+
// and both tags are block-level (inline elements need whitespace preserved)
|
|
4411
|
+
if (currentTagMatch && prevTagMatch) {
|
|
4412
|
+
const currentTag = options.name(currentTagMatch[1]);
|
|
4413
|
+
const prevTag = options.name(prevTagMatch[1]);
|
|
4414
|
+
|
|
4415
|
+
// Don’t collapse between inline elements
|
|
4416
|
+
if (!inlineElements.has(currentTag) && !inlineElements.has(prevTag)) {
|
|
4417
|
+
// Collapse whitespace respecting context rules
|
|
4418
|
+
let collapsedText = prevText;
|
|
4419
|
+
|
|
4420
|
+
// Apply `collapseWhitespace` with appropriate context
|
|
4421
|
+
if (!stackNoTrimWhitespace.length && !stackNoCollapseWhitespace.length) {
|
|
4422
|
+
// Not in pre or other no-collapse context
|
|
4423
|
+
if (options.preserveLineBreaks && /[\n\r]/.test(prevText)) {
|
|
4424
|
+
// Preserve line break as single newline
|
|
4425
|
+
collapsedText = '\n';
|
|
4426
|
+
} else if (options.conservativeCollapse) {
|
|
4427
|
+
// Conservative mode: Keep single space
|
|
4428
|
+
collapsedText = ' ';
|
|
4429
|
+
} else {
|
|
4430
|
+
// Aggressive mode: Remove all whitespace
|
|
4431
|
+
collapsedText = '';
|
|
4432
|
+
}
|
|
4322
4433
|
}
|
|
4323
|
-
}
|
|
4324
4434
|
|
|
4325
|
-
|
|
4326
|
-
|
|
4435
|
+
// Replace the whitespace in buffer
|
|
4436
|
+
buffer[buffer.length - 1] = collapsedText;
|
|
4437
|
+
}
|
|
4327
4438
|
}
|
|
4328
4439
|
}
|
|
4329
4440
|
}
|
|
@@ -4332,9 +4443,27 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
4332
4443
|
}
|
|
4333
4444
|
}
|
|
4334
4445
|
}
|
|
4446
|
+
|
|
4447
|
+
buffer.push(comment);
|
|
4335
4448
|
}
|
|
4336
4449
|
|
|
4337
|
-
|
|
4450
|
+
// Only conditional comments require async work (recursive minification)
|
|
4451
|
+
if (isConditionalComment(text)) {
|
|
4452
|
+
return cleanConditionalComment(text, options, minifyHTML).then(cleaned => {
|
|
4453
|
+
commentFinalize(prefix + cleaned + suffix);
|
|
4454
|
+
});
|
|
4455
|
+
}
|
|
4456
|
+
|
|
4457
|
+
if (options.removeComments) {
|
|
4458
|
+
if (isIgnoredComment(text, options)) {
|
|
4459
|
+
text = '<!--' + text + '-->';
|
|
4460
|
+
} else {
|
|
4461
|
+
text = '';
|
|
4462
|
+
}
|
|
4463
|
+
} else {
|
|
4464
|
+
text = prefix + text + suffix;
|
|
4465
|
+
}
|
|
4466
|
+
commentFinalize(text);
|
|
4338
4467
|
},
|
|
4339
4468
|
doctype: function (doctype) {
|
|
4340
4469
|
buffer.push(options.useShortDoctype
|