html-minifier-next 4.10.0 → 4.11.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 +21 -20
- package/cli.js +3 -2
- package/dist/htmlminifier.cjs +176 -21
- package/dist/htmlminifier.esm.bundle.js +176 -21
- package/dist/types/htmlminifier.d.ts +9 -0
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/presets.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/htmlminifier.js +175 -21
- package/src/presets.js +1 -0
package/README.md
CHANGED
|
@@ -139,10 +139,11 @@ Options can be used in config files (camelCase) or via CLI flags (kebab-case wit
|
|
|
139
139
|
| Option (config/CLI) | Description | Default |
|
|
140
140
|
| --- | --- | --- |
|
|
141
141
|
| `caseSensitive`<br>`--case-sensitive` | Treat attributes in case-sensitive manner (useful for custom HTML elements) | `false` |
|
|
142
|
+
| `collapseAttributeWhitespace`<br>`--collapse-attribute-whitespace` | Trim and collapse whitespace characters within attribute values | `false` |
|
|
142
143
|
| `collapseBooleanAttributes`<br>`--collapse-boolean-attributes` | [Omit attribute values from boolean attributes](https://perfectionkills.com/experimenting-with-html-minifier#collapse_boolean_attributes) | `false` |
|
|
143
144
|
| `collapseInlineTagWhitespace`<br>`--collapse-inline-tag-whitespace` | Don’t leave any spaces between `display: inline;` elements when collapsing—use with `collapseWhitespace: true` | `false` |
|
|
144
145
|
| `collapseWhitespace`<br>`--collapse-whitespace` | [Collapse whitespace that contributes to text nodes in a document tree](https://perfectionkills.com/experimenting-with-html-minifier#collapse_whitespace) | `false` |
|
|
145
|
-
| `conservativeCollapse`<br>`--conservative-collapse` | Always collapse to
|
|
146
|
+
| `conservativeCollapse`<br>`--conservative-collapse` | Always collapse to one space (never remove it entirely)—use with `collapseWhitespace: true` | `false` |
|
|
146
147
|
| `continueOnMinifyError`<br>`--no-continue-on-minify-error` | Continue on minification errors; when `false`, minification errors throw and abort processing | `true` |
|
|
147
148
|
| `continueOnParseError`<br>`--continue-on-parse-error` | [Handle parse errors](https://html.spec.whatwg.org/multipage/parsing.html#parse-errors) instead of aborting | `false` |
|
|
148
149
|
| `customAttrAssign`<br>`--custom-attr-assign` | Arrays of regexes that allow to support custom attribute assign expressions (e.g., `<div flex?="{{mode != cover}}"></div>`) | `[]` |
|
|
@@ -235,34 +236,34 @@ How does HTML Minifier Next compare to other minifiers? (All with the most aggre
|
|
|
235
236
|
| Site | Original Size (KB) | [HTML Minifier Next](https://github.com/j9t/html-minifier-next)<br>[](https://socket.dev/npm/package/html-minifier-next) | [HTML Minifier Terser](https://github.com/terser/html-minifier-terser)<br>[](https://socket.dev/npm/package/html-minifier-terser) | [htmlnano](https://github.com/posthtml/htmlnano)<br>[](https://socket.dev/npm/package/htmlnano) | [@swc/html](https://github.com/swc-project/swc)<br>[](https://socket.dev/npm/package/@swc/html) | [minify-html](https://github.com/wilsonzlin/minify-html)<br>[](https://socket.dev/npm/package/@minify-html/node) | [minimize](https://github.com/Swaagie/minimize)<br>[](https://socket.dev/npm/package/minimize) | [htmlcompressor.com](https://htmlcompressor.com/) |
|
|
236
237
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
237
238
|
| [A List Apart](https://alistapart.com/) | 59 | **49** | 50 | 51 | 52 | 51 | 54 | 52 |
|
|
238
|
-
| [Apple](https://www.apple.com/) |
|
|
239
|
-
| [BBC](https://www.bbc.co.uk/) |
|
|
240
|
-
| [CERN](https://home.cern/) | 156 | **87** |
|
|
241
|
-
| [CSS-Tricks](https://css-tricks.com/) | 163 | 122 |
|
|
242
|
-
| [ECMAScript](https://tc39.es/ecma262/) | 7238 | **
|
|
239
|
+
| [Apple](https://www.apple.com/) | 266 | **206** | n/a | 236 | 239 | 240 | 242 | 243 |
|
|
240
|
+
| [BBC](https://www.bbc.co.uk/) | 697 | **634** | n/a | 655 | 655 | 656 | 691 | n/a |
|
|
241
|
+
| [CERN](https://home.cern/) | 156 | **87** | n/a | 95 | 95 | 95 | 97 | 100 |
|
|
242
|
+
| [CSS-Tricks](https://css-tricks.com/) | 163 | **122** | n/a | 128 | 143 | 144 | 149 | 145 |
|
|
243
|
+
| [ECMAScript](https://tc39.es/ecma262/) | 7238 | **6342** | **6342** | 6561 | 6444 | 6567 | 6614 | n/a |
|
|
243
244
|
| [EDRi](https://edri.org/) | 80 | **59** | 60 | 70 | 70 | 71 | 75 | 73 |
|
|
244
|
-
| [EFF](https://www.eff.org/) |
|
|
245
|
+
| [EFF](https://www.eff.org/) | 55 | **47** | n/a | 50 | 48 | 49 | 50 | 50 |
|
|
245
246
|
| [European Alternatives](https://european-alternatives.eu/) | 48 | **30** | **30** | 32 | 32 | 32 | 32 | 32 |
|
|
246
|
-
| [FAZ](https://www.faz.net/aktuell/) |
|
|
247
|
+
| [FAZ](https://www.faz.net/aktuell/) | 1604 | 1494 | 1499 | **1437** | 1527 | 1539 | 1550 | n/a |
|
|
247
248
|
| [French Tech](https://lafrenchtech.gouv.fr/) | 152 | **121** | 122 | 125 | 125 | 125 | 132 | 126 |
|
|
248
|
-
| [Frontend Dogma](https://frontenddogma.com/) |
|
|
249
|
+
| [Frontend Dogma](https://frontenddogma.com/) | 223 | **213** | 215 | 236 | 221 | 223 | 241 | 222 |
|
|
249
250
|
| [Google](https://www.google.com/) | 18 | **17** | **17** | **17** | **17** | **17** | 18 | 18 |
|
|
250
|
-
| [Ground News](https://ground.news/) |
|
|
251
|
-
| [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | **147** |
|
|
251
|
+
| [Ground News](https://ground.news/) | 1481 | **1259** | n/a | 1361 | 1386 | 1392 | 1468 | n/a |
|
|
252
|
+
| [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | **147** | n/a | 153 | **147** | 149 | 155 | 149 |
|
|
252
253
|
| [Igalia](https://www.igalia.com/) | 50 | **33** | **33** | 36 | 36 | 36 | 37 | 36 |
|
|
253
|
-
| [Leanpub](https://leanpub.com/) |
|
|
254
|
-
| [Mastodon](https://mastodon.social/explore) | 37 | **27** |
|
|
255
|
-
| [MDN](https://developer.mozilla.org/en-US/) | 109 | **62** |
|
|
256
|
-
| [Middle East Eye](https://www.middleeasteye.net/) | 222 | **195** |
|
|
257
|
-
| [Nielsen Norman Group](https://www.nngroup.com/) |
|
|
254
|
+
| [Leanpub](https://leanpub.com/) | 1521 | **1287** | **1287** | 1294 | 1293 | 1290 | 1516 | n/a |
|
|
255
|
+
| [Mastodon](https://mastodon.social/explore) | 37 | **27** | n/a | 32 | 34 | 35 | 35 | 35 |
|
|
256
|
+
| [MDN](https://developer.mozilla.org/en-US/) | 109 | **62** | n/a | 64 | 65 | 65 | 68 | 68 |
|
|
257
|
+
| [Middle East Eye](https://www.middleeasteye.net/) | 222 | **195** | **195** | 202 | 200 | 200 | 202 | 203 |
|
|
258
|
+
| [Nielsen Norman Group](https://www.nngroup.com/) | 86 | 74 | 74 | **55** | 74 | 75 | 77 | 76 |
|
|
258
259
|
| [SitePoint](https://www.sitepoint.com/) | 485 | **354** | **354** | 425 | 459 | 464 | 481 | n/a |
|
|
259
260
|
| [TetraLogical](https://tetralogical.com/) | 44 | 38 | 38 | **35** | 38 | 38 | 39 | 39 |
|
|
260
|
-
| [TPGi](https://www.tpgi.com/) | 175 | **159** |
|
|
261
|
-
| [United Nations](https://www.un.org/en/) |
|
|
261
|
+
| [TPGi](https://www.tpgi.com/) | 175 | **159** | n/a | 160 | 164 | 166 | 172 | 171 |
|
|
262
|
+
| [United Nations](https://www.un.org/en/) | 150 | **112** | 113 | 120 | 124 | 124 | 129 | 122 |
|
|
262
263
|
| [W3C](https://www.w3.org/) | 50 | **36** | **36** | 38 | 38 | 38 | 40 | 38 |
|
|
263
|
-
| **Average processing time** | |
|
|
264
|
+
| **Average processing time** | | 478 ms (26/26) | 735 ms (16/26) | 174 ms (26/26) | 57 ms (26/26) | **17 ms (26/26)** | 318 ms (26/26) | 1536 ms (20/26) |
|
|
264
265
|
|
|
265
|
-
(Last updated: Dec
|
|
266
|
+
(Last updated: Dec 17, 2025)
|
|
266
267
|
<!-- End auto-generated -->
|
|
267
268
|
|
|
268
269
|
## Examples
|
package/cli.js
CHANGED
|
@@ -117,17 +117,18 @@ const parseValidInt = (optionName) => (value) => {
|
|
|
117
117
|
|
|
118
118
|
const mainOptions = {
|
|
119
119
|
caseSensitive: 'Treat attributes in case-sensitive manner (useful for custom HTML elements)',
|
|
120
|
+
collapseAttributeWhitespace: 'Trim and collapse whitespace characters within attribute values',
|
|
120
121
|
collapseBooleanAttributes: 'Omit attribute values from boolean attributes',
|
|
121
|
-
customFragmentQuantifierLimit: ['Set maximum quantifier limit for custom fragments to prevent ReDoS attacks (default: 200)', parseValidInt('customFragmentQuantifierLimit')],
|
|
122
122
|
collapseInlineTagWhitespace: 'Don’t leave any spaces between “display: inline;” elements when collapsing—use with “--collapse-whitespace”',
|
|
123
123
|
collapseWhitespace: 'Collapse whitespace that contributes to text nodes in a document tree',
|
|
124
|
-
conservativeCollapse: 'Always collapse to
|
|
124
|
+
conservativeCollapse: 'Always collapse to one space (never remove it entirely)—use with “--collapse-whitespace”',
|
|
125
125
|
continueOnMinifyError: 'Abort on minification errors',
|
|
126
126
|
continueOnParseError: 'Handle parse errors instead of aborting',
|
|
127
127
|
customAttrAssign: ['Arrays of regexes that allow to support custom attribute assign expressions (e.g., “<div flex?="{{mode != cover}}"></div>”)', parseJSONRegExpArray],
|
|
128
128
|
customAttrCollapse: ['Regex that specifies custom attribute to strip newlines from (e.g., /ng-class/)', parseRegExp],
|
|
129
129
|
customAttrSurround: ['Arrays of regexes that allow to support custom attribute surround expressions (e.g., “<input {{#if value}}checked="checked"{{/if}}>”)', parseJSONRegExpArray],
|
|
130
130
|
customEventAttributes: ['Arrays of regexes that allow to support custom event attributes for minifyJS (e.g., “ng-click”)', parseJSONRegExpArray],
|
|
131
|
+
customFragmentQuantifierLimit: ['Set maximum quantifier limit for custom fragments to prevent ReDoS attacks (default: 200)', parseValidInt('customFragmentQuantifierLimit')],
|
|
131
132
|
decodeEntities: 'Use direct Unicode characters whenever possible',
|
|
132
133
|
html5: 'Don’t parse input according to the HTML specification (not recommended for modern HTML)',
|
|
133
134
|
ignoreCustomComments: ['Array of regexes that allow to ignore certain comments, when matched', parseJSONRegExpArray],
|
package/dist/htmlminifier.cjs
CHANGED
|
@@ -761,6 +761,7 @@ const presets = {
|
|
|
761
761
|
useShortDoctype: true
|
|
762
762
|
},
|
|
763
763
|
comprehensive: {
|
|
764
|
+
// @@ Add `collapseAttributeWhitespace: true` (also add to preset in demo)
|
|
764
765
|
caseSensitive: true,
|
|
765
766
|
collapseBooleanAttributes: true,
|
|
766
767
|
collapseInlineTagWhitespace: true,
|
|
@@ -860,6 +861,14 @@ async function getTerser() {
|
|
|
860
861
|
*
|
|
861
862
|
* Default: `false`
|
|
862
863
|
*
|
|
864
|
+
* @prop {boolean} [collapseAttributeWhitespace]
|
|
865
|
+
* Collapse multiple whitespace characters within attribute values into a
|
|
866
|
+
* single space. Also trims leading and trailing whitespace from attribute
|
|
867
|
+
* values. Applied as an early normalization step before special attribute
|
|
868
|
+
* handlers (CSS minification, class sorting, etc.) run.
|
|
869
|
+
*
|
|
870
|
+
* Default: `false`
|
|
871
|
+
*
|
|
863
872
|
* @prop {boolean} [collapseBooleanAttributes]
|
|
864
873
|
* Collapse boolean attributes to their name only (for example
|
|
865
874
|
* `disabled="disabled"` → `disabled`).
|
|
@@ -1545,6 +1554,12 @@ function isSrcset(attrName, tag) {
|
|
|
1545
1554
|
}
|
|
1546
1555
|
|
|
1547
1556
|
async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
|
|
1557
|
+
// Apply early whitespace normalization if enabled
|
|
1558
|
+
// Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
|
|
1559
|
+
if (options.collapseAttributeWhitespace) {
|
|
1560
|
+
attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1548
1563
|
if (isEventAttribute(attrName, options)) {
|
|
1549
1564
|
attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
|
|
1550
1565
|
return options.minifyJS(attrValue, true);
|
|
@@ -1625,7 +1640,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
1625
1640
|
return options.minifyCSS(attrValue, 'media');
|
|
1626
1641
|
} else if (tag === 'iframe' && attrName === 'srcdoc') {
|
|
1627
1642
|
// Recursively minify HTML content within srcdoc attribute
|
|
1628
|
-
// Fast-path:
|
|
1643
|
+
// Fast-path: Skip if nothing would change
|
|
1629
1644
|
if (!shouldMinifyInnerHTML(options)) {
|
|
1630
1645
|
return attrValue;
|
|
1631
1646
|
}
|
|
@@ -2057,7 +2072,9 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
2057
2072
|
|
|
2058
2073
|
if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
|
|
2059
2074
|
~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
|
|
2075
|
+
// Determine the appropriate quote character
|
|
2060
2076
|
if (!options.preventAttributesEscaping) {
|
|
2077
|
+
// Normal mode: choose quotes and escape
|
|
2061
2078
|
if (typeof options.quoteCharacter === 'undefined') {
|
|
2062
2079
|
// Count quotes in a single pass instead of two regex operations
|
|
2063
2080
|
let apos = 0, quot = 0;
|
|
@@ -2074,6 +2091,50 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
2074
2091
|
} else {
|
|
2075
2092
|
attrValue = attrValue.replace(/'/g, ''');
|
|
2076
2093
|
}
|
|
2094
|
+
} else {
|
|
2095
|
+
// `preventAttributesEscaping` mode: choose safe quotes but don’t escape
|
|
2096
|
+
// EXCEPT when both quote types are present—then escape to prevent invalid HTML
|
|
2097
|
+
const hasDoubleQuote = attrValue.indexOf('"') !== -1;
|
|
2098
|
+
const hasSingleQuote = attrValue.indexOf("'") !== -1;
|
|
2099
|
+
|
|
2100
|
+
if (hasDoubleQuote && hasSingleQuote) {
|
|
2101
|
+
// Both quote types present: `preventAttributesEscaping` is ignored to ensure valid HTML
|
|
2102
|
+
// Choose the quote type with fewer occurrences and escape the other
|
|
2103
|
+
if (typeof options.quoteCharacter === 'undefined') {
|
|
2104
|
+
let apos = 0, quot = 0;
|
|
2105
|
+
for (let i = 0; i < attrValue.length; i++) {
|
|
2106
|
+
if (attrValue[i] === "'") apos++;
|
|
2107
|
+
else if (attrValue[i] === '"') quot++;
|
|
2108
|
+
}
|
|
2109
|
+
attrQuote = apos < quot ? '\'' : '"';
|
|
2110
|
+
} else {
|
|
2111
|
+
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
2112
|
+
}
|
|
2113
|
+
if (attrQuote === '"') {
|
|
2114
|
+
attrValue = attrValue.replace(/"/g, '"');
|
|
2115
|
+
} else {
|
|
2116
|
+
attrValue = attrValue.replace(/'/g, ''');
|
|
2117
|
+
}
|
|
2118
|
+
} else if (typeof options.quoteCharacter === 'undefined') {
|
|
2119
|
+
// Single or no quote type: Choose safe quote delimiter
|
|
2120
|
+
if (attrQuote === '"' && hasDoubleQuote && !hasSingleQuote) {
|
|
2121
|
+
attrQuote = "'";
|
|
2122
|
+
} else if (attrQuote === "'" && hasSingleQuote && !hasDoubleQuote) {
|
|
2123
|
+
attrQuote = '"';
|
|
2124
|
+
} else if (attrQuote !== '"' && attrQuote !== "'" && attrQuote !== '') {
|
|
2125
|
+
// `attrQuote` is invalid/undefined (not `"`, `'`, or empty string)
|
|
2126
|
+
// Set a safe default based on the value’s content
|
|
2127
|
+
if (hasSingleQuote && !hasDoubleQuote) {
|
|
2128
|
+
attrQuote = '"'; // Value has single quotes, use double quotes as delimiter
|
|
2129
|
+
} else if (hasDoubleQuote && !hasSingleQuote) {
|
|
2130
|
+
attrQuote = "'"; // Value has double quotes, use single quotes as delimiter
|
|
2131
|
+
} else {
|
|
2132
|
+
attrQuote = '"'; // No quotes in value, default to double quotes
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
} else {
|
|
2136
|
+
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
2137
|
+
}
|
|
2077
2138
|
}
|
|
2078
2139
|
emittedAttrValue = attrQuote + attrValue + attrQuote;
|
|
2079
2140
|
if (!isLast && !options.removeTagWhitespace) {
|
|
@@ -2338,7 +2399,7 @@ function uniqueId(value) {
|
|
|
2338
2399
|
|
|
2339
2400
|
const specialContentTags = new Set(['script', 'style']);
|
|
2340
2401
|
|
|
2341
|
-
async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
2402
|
+
async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
|
|
2342
2403
|
const attrChains = options.sortAttributes && Object.create(null);
|
|
2343
2404
|
const classChain = options.sortClassName && new TokenChain();
|
|
2344
2405
|
|
|
@@ -2352,10 +2413,20 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
2352
2413
|
return !uid || token.indexOf(uid) === -1;
|
|
2353
2414
|
}
|
|
2354
2415
|
|
|
2355
|
-
function
|
|
2416
|
+
function shouldKeepToken(token) {
|
|
2417
|
+
// Filter out any HTML comment tokens (UID placeholders)
|
|
2418
|
+
// These are temporary markers created by `htmlmin:ignore` and `ignoreCustomFragments`
|
|
2419
|
+
if (token.startsWith('<!--') && token.endsWith('-->')) {
|
|
2420
|
+
return false;
|
|
2421
|
+
}
|
|
2356
2422
|
return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
|
|
2357
2423
|
}
|
|
2358
2424
|
|
|
2425
|
+
// Pre-compile regex patterns for reuse (performance optimization)
|
|
2426
|
+
// These must be declared before scan() since scan uses them
|
|
2427
|
+
const whitespaceSplitPatternScan = /[ \t\n\f\r]+/;
|
|
2428
|
+
const whitespaceSplitPatternSort = /[ \n\f\r]+/;
|
|
2429
|
+
|
|
2359
2430
|
async function scan(input) {
|
|
2360
2431
|
let currentTag, currentType;
|
|
2361
2432
|
const parser = new HTMLParser(input, {
|
|
@@ -2364,12 +2435,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
2364
2435
|
if (!attrChains[tag]) {
|
|
2365
2436
|
attrChains[tag] = new TokenChain();
|
|
2366
2437
|
}
|
|
2367
|
-
|
|
2438
|
+
const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
|
|
2439
|
+
attrChains[tag].add(attrNamesList);
|
|
2368
2440
|
}
|
|
2369
2441
|
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
2370
2442
|
const attr = attrs[i];
|
|
2371
2443
|
if (classChain && attr.value && options.name(attr.name) === 'class') {
|
|
2372
|
-
|
|
2444
|
+
const classes = trimWhitespace(attr.value).split(whitespaceSplitPatternScan).filter(shouldKeepToken);
|
|
2445
|
+
classChain.add(classes);
|
|
2373
2446
|
} else if (options.processScripts && attr.name.toLowerCase() === 'type') {
|
|
2374
2447
|
currentTag = tag;
|
|
2375
2448
|
currentType = attr.value;
|
|
@@ -2389,19 +2462,84 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
2389
2462
|
}
|
|
2390
2463
|
},
|
|
2391
2464
|
// We never need `nextTag` information in this scan
|
|
2392
|
-
wantsNextTag: false
|
|
2465
|
+
wantsNextTag: false,
|
|
2466
|
+
// Continue on parse errors during analysis pass
|
|
2467
|
+
continueOnParseError: options.continueOnParseError
|
|
2393
2468
|
});
|
|
2394
2469
|
|
|
2395
|
-
|
|
2470
|
+
try {
|
|
2471
|
+
await parser.parse();
|
|
2472
|
+
} catch (e) {
|
|
2473
|
+
// If parsing fails during analysis pass, just skip it—we’ll still have
|
|
2474
|
+
// partial frequency data from what we could parse
|
|
2475
|
+
if (!options.continueOnParseError) {
|
|
2476
|
+
throw e;
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2396
2479
|
}
|
|
2397
2480
|
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
const
|
|
2403
|
-
|
|
2404
|
-
|
|
2481
|
+
// For the first pass, create a copy of options and disable aggressive minification.
|
|
2482
|
+
// Keep attribute transformations (like `removeStyleLinkTypeAttributes`) for accurate analysis.
|
|
2483
|
+
// This is safe because `createSortFns` is called before custom fragment UID markers (uidAttr) are added.
|
|
2484
|
+
// Note: `htmlmin:ignore` UID markers (uidIgnore) already exist and are expanded for analysis.
|
|
2485
|
+
const firstPassOptions = Object.assign({}, options, {
|
|
2486
|
+
// Disable sorting for the analysis pass
|
|
2487
|
+
sortAttributes: false,
|
|
2488
|
+
sortClassName: false,
|
|
2489
|
+
// Disable aggressive minification that doesn’t affect attribute analysis
|
|
2490
|
+
collapseWhitespace: false,
|
|
2491
|
+
removeAttributeQuotes: false,
|
|
2492
|
+
removeTagWhitespace: false,
|
|
2493
|
+
decodeEntities: false,
|
|
2494
|
+
processScripts: false,
|
|
2495
|
+
// Keep `ignoreCustomFragments` to handle template syntax correctly
|
|
2496
|
+
// This is safe because `createSortFns` is now called before UID markers are added
|
|
2497
|
+
// Continue on parse errors during analysis (e.g., template syntax)
|
|
2498
|
+
continueOnParseError: true,
|
|
2499
|
+
log: identity
|
|
2500
|
+
});
|
|
2501
|
+
|
|
2502
|
+
// Temporarily enable `continueOnParseError` for the `scan()` function call below.
|
|
2503
|
+
// Note: `firstPassOptions` already has `continueOnParseError: true` for the minifyHTML call.
|
|
2504
|
+
const originalContinueOnParseError = options.continueOnParseError;
|
|
2505
|
+
options.continueOnParseError = true;
|
|
2506
|
+
|
|
2507
|
+
// Pre-compile regex patterns for UID replacement and custom fragments
|
|
2508
|
+
const uidReplacePattern = uidIgnore && ignoredMarkupChunks
|
|
2509
|
+
? new RegExp('<!--' + uidIgnore + '(\\d+)-->', 'g')
|
|
2510
|
+
: null;
|
|
2511
|
+
const customFragmentPattern = options.ignoreCustomFragments && options.ignoreCustomFragments.length > 0
|
|
2512
|
+
? new RegExp('(' + options.ignoreCustomFragments.map(re => re.source).join('|') + ')', 'g')
|
|
2513
|
+
: null;
|
|
2514
|
+
|
|
2515
|
+
try {
|
|
2516
|
+
// Expand UID tokens back to original content for frequency analysis
|
|
2517
|
+
let expandedValue = value;
|
|
2518
|
+
if (uidReplacePattern) {
|
|
2519
|
+
expandedValue = value.replace(uidReplacePattern, function (match, index) {
|
|
2520
|
+
return ignoredMarkupChunks[+index] || '';
|
|
2521
|
+
});
|
|
2522
|
+
// Reset `lastIndex` for pattern reuse
|
|
2523
|
+
uidReplacePattern.lastIndex = 0;
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
// First pass minification applies attribute transformations
|
|
2527
|
+
// like removeStyleLinkTypeAttributes for accurate frequency analysis
|
|
2528
|
+
const firstPassOutput = await minifyHTML(expandedValue, firstPassOptions);
|
|
2529
|
+
|
|
2530
|
+
// For frequency analysis, we need to remove custom fragments temporarily
|
|
2531
|
+
// because HTML comments in opening tags prevent proper attribute parsing.
|
|
2532
|
+
// We remove them with a space to preserve attribute boundaries.
|
|
2533
|
+
let scanValue = firstPassOutput;
|
|
2534
|
+
if (customFragmentPattern) {
|
|
2535
|
+
scanValue = firstPassOutput.replace(customFragmentPattern, ' ');
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
await scan(scanValue);
|
|
2539
|
+
} finally {
|
|
2540
|
+
// Restore original option
|
|
2541
|
+
options.continueOnParseError = originalContinueOnParseError;
|
|
2542
|
+
}
|
|
2405
2543
|
if (attrChains) {
|
|
2406
2544
|
const attrSorters = Object.create(null);
|
|
2407
2545
|
for (const tag in attrChains) {
|
|
@@ -2415,7 +2553,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
2415
2553
|
names.forEach(function (name, index) {
|
|
2416
2554
|
(attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
|
|
2417
2555
|
});
|
|
2418
|
-
sorter.sort(names)
|
|
2556
|
+
const sorted = sorter.sort(names);
|
|
2557
|
+
sorted.forEach(function (name, index) {
|
|
2419
2558
|
attrs[index] = attrMap[name].shift();
|
|
2420
2559
|
});
|
|
2421
2560
|
}
|
|
@@ -2424,7 +2563,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
2424
2563
|
if (classChain) {
|
|
2425
2564
|
const sorter = classChain.createSorter();
|
|
2426
2565
|
options.sortClassName = function (value) {
|
|
2427
|
-
|
|
2566
|
+
// Expand UID tokens back to original content before sorting
|
|
2567
|
+
// Fast path: Skip if no HTML comments (UID markers) present
|
|
2568
|
+
let expandedValue = value;
|
|
2569
|
+
if (uidReplacePattern && value.indexOf('<!--') !== -1) {
|
|
2570
|
+
expandedValue = value.replace(uidReplacePattern, function (match, index) {
|
|
2571
|
+
return ignoredMarkupChunks[+index] || '';
|
|
2572
|
+
});
|
|
2573
|
+
// Reset `lastIndex` for pattern reuse
|
|
2574
|
+
uidReplacePattern.lastIndex = 0;
|
|
2575
|
+
}
|
|
2576
|
+
const classes = expandedValue.split(whitespaceSplitPatternSort).filter(function(cls) {
|
|
2577
|
+
return cls !== '';
|
|
2578
|
+
});
|
|
2579
|
+
const sorted = sorter.sort(classes);
|
|
2580
|
+
return sorted.join(' ');
|
|
2428
2581
|
};
|
|
2429
2582
|
}
|
|
2430
2583
|
}
|
|
@@ -2505,6 +2658,13 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
2505
2658
|
return token;
|
|
2506
2659
|
});
|
|
2507
2660
|
|
|
2661
|
+
// Create sort functions after `htmlmin:ignore` processing but before custom fragment UID markers
|
|
2662
|
+
// This allows proper frequency analysis with access to ignored content via UID tokens
|
|
2663
|
+
if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
|
|
2664
|
+
(options.sortClassName && typeof options.sortClassName !== 'function')) {
|
|
2665
|
+
await createSortFns(value, options, uidIgnore, null, ignoredMarkupChunks);
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2508
2668
|
const customFragments = options.ignoreCustomFragments.map(function (re) {
|
|
2509
2669
|
return re.source;
|
|
2510
2670
|
});
|
|
@@ -2563,11 +2723,6 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
2563
2723
|
});
|
|
2564
2724
|
}
|
|
2565
2725
|
|
|
2566
|
-
if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
|
|
2567
|
-
(options.sortClassName && typeof options.sortClassName !== 'function')) {
|
|
2568
|
-
await createSortFns(value, options, uidIgnore, uidAttr);
|
|
2569
|
-
}
|
|
2570
|
-
|
|
2571
2726
|
function _canCollapseWhitespace(tag, attrs) {
|
|
2572
2727
|
return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
|
|
2573
2728
|
}
|
|
@@ -5903,6 +5903,7 @@ const presets = {
|
|
|
5903
5903
|
useShortDoctype: true
|
|
5904
5904
|
},
|
|
5905
5905
|
comprehensive: {
|
|
5906
|
+
// @@ Add `collapseAttributeWhitespace: true` (also add to preset in demo)
|
|
5906
5907
|
caseSensitive: true,
|
|
5907
5908
|
collapseBooleanAttributes: true,
|
|
5908
5909
|
collapseInlineTagWhitespace: true,
|
|
@@ -6002,6 +6003,14 @@ async function getTerser() {
|
|
|
6002
6003
|
*
|
|
6003
6004
|
* Default: `false`
|
|
6004
6005
|
*
|
|
6006
|
+
* @prop {boolean} [collapseAttributeWhitespace]
|
|
6007
|
+
* Collapse multiple whitespace characters within attribute values into a
|
|
6008
|
+
* single space. Also trims leading and trailing whitespace from attribute
|
|
6009
|
+
* values. Applied as an early normalization step before special attribute
|
|
6010
|
+
* handlers (CSS minification, class sorting, etc.) run.
|
|
6011
|
+
*
|
|
6012
|
+
* Default: `false`
|
|
6013
|
+
*
|
|
6005
6014
|
* @prop {boolean} [collapseBooleanAttributes]
|
|
6006
6015
|
* Collapse boolean attributes to their name only (for example
|
|
6007
6016
|
* `disabled="disabled"` → `disabled`).
|
|
@@ -6687,6 +6696,12 @@ function isSrcset(attrName, tag) {
|
|
|
6687
6696
|
}
|
|
6688
6697
|
|
|
6689
6698
|
async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
|
|
6699
|
+
// Apply early whitespace normalization if enabled
|
|
6700
|
+
// Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
|
|
6701
|
+
if (options.collapseAttributeWhitespace) {
|
|
6702
|
+
attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
|
|
6703
|
+
}
|
|
6704
|
+
|
|
6690
6705
|
if (isEventAttribute(attrName, options)) {
|
|
6691
6706
|
attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
|
|
6692
6707
|
return options.minifyJS(attrValue, true);
|
|
@@ -6767,7 +6782,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
6767
6782
|
return options.minifyCSS(attrValue, 'media');
|
|
6768
6783
|
} else if (tag === 'iframe' && attrName === 'srcdoc') {
|
|
6769
6784
|
// Recursively minify HTML content within srcdoc attribute
|
|
6770
|
-
// Fast-path:
|
|
6785
|
+
// Fast-path: Skip if nothing would change
|
|
6771
6786
|
if (!shouldMinifyInnerHTML(options)) {
|
|
6772
6787
|
return attrValue;
|
|
6773
6788
|
}
|
|
@@ -7199,7 +7214,9 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
7199
7214
|
|
|
7200
7215
|
if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
|
|
7201
7216
|
~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
|
|
7217
|
+
// Determine the appropriate quote character
|
|
7202
7218
|
if (!options.preventAttributesEscaping) {
|
|
7219
|
+
// Normal mode: choose quotes and escape
|
|
7203
7220
|
if (typeof options.quoteCharacter === 'undefined') {
|
|
7204
7221
|
// Count quotes in a single pass instead of two regex operations
|
|
7205
7222
|
let apos = 0, quot = 0;
|
|
@@ -7216,6 +7233,50 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
7216
7233
|
} else {
|
|
7217
7234
|
attrValue = attrValue.replace(/'/g, ''');
|
|
7218
7235
|
}
|
|
7236
|
+
} else {
|
|
7237
|
+
// `preventAttributesEscaping` mode: choose safe quotes but don’t escape
|
|
7238
|
+
// EXCEPT when both quote types are present—then escape to prevent invalid HTML
|
|
7239
|
+
const hasDoubleQuote = attrValue.indexOf('"') !== -1;
|
|
7240
|
+
const hasSingleQuote = attrValue.indexOf("'") !== -1;
|
|
7241
|
+
|
|
7242
|
+
if (hasDoubleQuote && hasSingleQuote) {
|
|
7243
|
+
// Both quote types present: `preventAttributesEscaping` is ignored to ensure valid HTML
|
|
7244
|
+
// Choose the quote type with fewer occurrences and escape the other
|
|
7245
|
+
if (typeof options.quoteCharacter === 'undefined') {
|
|
7246
|
+
let apos = 0, quot = 0;
|
|
7247
|
+
for (let i = 0; i < attrValue.length; i++) {
|
|
7248
|
+
if (attrValue[i] === "'") apos++;
|
|
7249
|
+
else if (attrValue[i] === '"') quot++;
|
|
7250
|
+
}
|
|
7251
|
+
attrQuote = apos < quot ? '\'' : '"';
|
|
7252
|
+
} else {
|
|
7253
|
+
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
7254
|
+
}
|
|
7255
|
+
if (attrQuote === '"') {
|
|
7256
|
+
attrValue = attrValue.replace(/"/g, '"');
|
|
7257
|
+
} else {
|
|
7258
|
+
attrValue = attrValue.replace(/'/g, ''');
|
|
7259
|
+
}
|
|
7260
|
+
} else if (typeof options.quoteCharacter === 'undefined') {
|
|
7261
|
+
// Single or no quote type: Choose safe quote delimiter
|
|
7262
|
+
if (attrQuote === '"' && hasDoubleQuote && !hasSingleQuote) {
|
|
7263
|
+
attrQuote = "'";
|
|
7264
|
+
} else if (attrQuote === "'" && hasSingleQuote && !hasDoubleQuote) {
|
|
7265
|
+
attrQuote = '"';
|
|
7266
|
+
} else if (attrQuote !== '"' && attrQuote !== "'" && attrQuote !== '') {
|
|
7267
|
+
// `attrQuote` is invalid/undefined (not `"`, `'`, or empty string)
|
|
7268
|
+
// Set a safe default based on the value’s content
|
|
7269
|
+
if (hasSingleQuote && !hasDoubleQuote) {
|
|
7270
|
+
attrQuote = '"'; // Value has single quotes, use double quotes as delimiter
|
|
7271
|
+
} else if (hasDoubleQuote && !hasSingleQuote) {
|
|
7272
|
+
attrQuote = "'"; // Value has double quotes, use single quotes as delimiter
|
|
7273
|
+
} else {
|
|
7274
|
+
attrQuote = '"'; // No quotes in value, default to double quotes
|
|
7275
|
+
}
|
|
7276
|
+
}
|
|
7277
|
+
} else {
|
|
7278
|
+
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
7279
|
+
}
|
|
7219
7280
|
}
|
|
7220
7281
|
emittedAttrValue = attrQuote + attrValue + attrQuote;
|
|
7221
7282
|
if (!isLast && !options.removeTagWhitespace) {
|
|
@@ -7480,7 +7541,7 @@ function uniqueId(value) {
|
|
|
7480
7541
|
|
|
7481
7542
|
const specialContentTags = new Set(['script', 'style']);
|
|
7482
7543
|
|
|
7483
|
-
async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
7544
|
+
async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
|
|
7484
7545
|
const attrChains = options.sortAttributes && Object.create(null);
|
|
7485
7546
|
const classChain = options.sortClassName && new TokenChain();
|
|
7486
7547
|
|
|
@@ -7494,10 +7555,20 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
7494
7555
|
return !uid || token.indexOf(uid) === -1;
|
|
7495
7556
|
}
|
|
7496
7557
|
|
|
7497
|
-
function
|
|
7558
|
+
function shouldKeepToken(token) {
|
|
7559
|
+
// Filter out any HTML comment tokens (UID placeholders)
|
|
7560
|
+
// These are temporary markers created by `htmlmin:ignore` and `ignoreCustomFragments`
|
|
7561
|
+
if (token.startsWith('<!--') && token.endsWith('-->')) {
|
|
7562
|
+
return false;
|
|
7563
|
+
}
|
|
7498
7564
|
return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
|
|
7499
7565
|
}
|
|
7500
7566
|
|
|
7567
|
+
// Pre-compile regex patterns for reuse (performance optimization)
|
|
7568
|
+
// These must be declared before scan() since scan uses them
|
|
7569
|
+
const whitespaceSplitPatternScan = /[ \t\n\f\r]+/;
|
|
7570
|
+
const whitespaceSplitPatternSort = /[ \n\f\r]+/;
|
|
7571
|
+
|
|
7501
7572
|
async function scan(input) {
|
|
7502
7573
|
let currentTag, currentType;
|
|
7503
7574
|
const parser = new HTMLParser(input, {
|
|
@@ -7506,12 +7577,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
7506
7577
|
if (!attrChains[tag]) {
|
|
7507
7578
|
attrChains[tag] = new TokenChain();
|
|
7508
7579
|
}
|
|
7509
|
-
|
|
7580
|
+
const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
|
|
7581
|
+
attrChains[tag].add(attrNamesList);
|
|
7510
7582
|
}
|
|
7511
7583
|
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
7512
7584
|
const attr = attrs[i];
|
|
7513
7585
|
if (classChain && attr.value && options.name(attr.name) === 'class') {
|
|
7514
|
-
|
|
7586
|
+
const classes = trimWhitespace(attr.value).split(whitespaceSplitPatternScan).filter(shouldKeepToken);
|
|
7587
|
+
classChain.add(classes);
|
|
7515
7588
|
} else if (options.processScripts && attr.name.toLowerCase() === 'type') {
|
|
7516
7589
|
currentTag = tag;
|
|
7517
7590
|
currentType = attr.value;
|
|
@@ -7531,19 +7604,84 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
7531
7604
|
}
|
|
7532
7605
|
},
|
|
7533
7606
|
// We never need `nextTag` information in this scan
|
|
7534
|
-
wantsNextTag: false
|
|
7607
|
+
wantsNextTag: false,
|
|
7608
|
+
// Continue on parse errors during analysis pass
|
|
7609
|
+
continueOnParseError: options.continueOnParseError
|
|
7535
7610
|
});
|
|
7536
7611
|
|
|
7537
|
-
|
|
7612
|
+
try {
|
|
7613
|
+
await parser.parse();
|
|
7614
|
+
} catch (e) {
|
|
7615
|
+
// If parsing fails during analysis pass, just skip it—we’ll still have
|
|
7616
|
+
// partial frequency data from what we could parse
|
|
7617
|
+
if (!options.continueOnParseError) {
|
|
7618
|
+
throw e;
|
|
7619
|
+
}
|
|
7620
|
+
}
|
|
7538
7621
|
}
|
|
7539
7622
|
|
|
7540
|
-
|
|
7541
|
-
|
|
7542
|
-
|
|
7543
|
-
|
|
7544
|
-
const
|
|
7545
|
-
|
|
7546
|
-
|
|
7623
|
+
// For the first pass, create a copy of options and disable aggressive minification.
|
|
7624
|
+
// Keep attribute transformations (like `removeStyleLinkTypeAttributes`) for accurate analysis.
|
|
7625
|
+
// This is safe because `createSortFns` is called before custom fragment UID markers (uidAttr) are added.
|
|
7626
|
+
// Note: `htmlmin:ignore` UID markers (uidIgnore) already exist and are expanded for analysis.
|
|
7627
|
+
const firstPassOptions = Object.assign({}, options, {
|
|
7628
|
+
// Disable sorting for the analysis pass
|
|
7629
|
+
sortAttributes: false,
|
|
7630
|
+
sortClassName: false,
|
|
7631
|
+
// Disable aggressive minification that doesn’t affect attribute analysis
|
|
7632
|
+
collapseWhitespace: false,
|
|
7633
|
+
removeAttributeQuotes: false,
|
|
7634
|
+
removeTagWhitespace: false,
|
|
7635
|
+
decodeEntities: false,
|
|
7636
|
+
processScripts: false,
|
|
7637
|
+
// Keep `ignoreCustomFragments` to handle template syntax correctly
|
|
7638
|
+
// This is safe because `createSortFns` is now called before UID markers are added
|
|
7639
|
+
// Continue on parse errors during analysis (e.g., template syntax)
|
|
7640
|
+
continueOnParseError: true,
|
|
7641
|
+
log: identity
|
|
7642
|
+
});
|
|
7643
|
+
|
|
7644
|
+
// Temporarily enable `continueOnParseError` for the `scan()` function call below.
|
|
7645
|
+
// Note: `firstPassOptions` already has `continueOnParseError: true` for the minifyHTML call.
|
|
7646
|
+
const originalContinueOnParseError = options.continueOnParseError;
|
|
7647
|
+
options.continueOnParseError = true;
|
|
7648
|
+
|
|
7649
|
+
// Pre-compile regex patterns for UID replacement and custom fragments
|
|
7650
|
+
const uidReplacePattern = uidIgnore && ignoredMarkupChunks
|
|
7651
|
+
? new RegExp('<!--' + uidIgnore + '(\\d+)-->', 'g')
|
|
7652
|
+
: null;
|
|
7653
|
+
const customFragmentPattern = options.ignoreCustomFragments && options.ignoreCustomFragments.length > 0
|
|
7654
|
+
? new RegExp('(' + options.ignoreCustomFragments.map(re => re.source).join('|') + ')', 'g')
|
|
7655
|
+
: null;
|
|
7656
|
+
|
|
7657
|
+
try {
|
|
7658
|
+
// Expand UID tokens back to original content for frequency analysis
|
|
7659
|
+
let expandedValue = value;
|
|
7660
|
+
if (uidReplacePattern) {
|
|
7661
|
+
expandedValue = value.replace(uidReplacePattern, function (match, index) {
|
|
7662
|
+
return ignoredMarkupChunks[+index] || '';
|
|
7663
|
+
});
|
|
7664
|
+
// Reset `lastIndex` for pattern reuse
|
|
7665
|
+
uidReplacePattern.lastIndex = 0;
|
|
7666
|
+
}
|
|
7667
|
+
|
|
7668
|
+
// First pass minification applies attribute transformations
|
|
7669
|
+
// like removeStyleLinkTypeAttributes for accurate frequency analysis
|
|
7670
|
+
const firstPassOutput = await minifyHTML(expandedValue, firstPassOptions);
|
|
7671
|
+
|
|
7672
|
+
// For frequency analysis, we need to remove custom fragments temporarily
|
|
7673
|
+
// because HTML comments in opening tags prevent proper attribute parsing.
|
|
7674
|
+
// We remove them with a space to preserve attribute boundaries.
|
|
7675
|
+
let scanValue = firstPassOutput;
|
|
7676
|
+
if (customFragmentPattern) {
|
|
7677
|
+
scanValue = firstPassOutput.replace(customFragmentPattern, ' ');
|
|
7678
|
+
}
|
|
7679
|
+
|
|
7680
|
+
await scan(scanValue);
|
|
7681
|
+
} finally {
|
|
7682
|
+
// Restore original option
|
|
7683
|
+
options.continueOnParseError = originalContinueOnParseError;
|
|
7684
|
+
}
|
|
7547
7685
|
if (attrChains) {
|
|
7548
7686
|
const attrSorters = Object.create(null);
|
|
7549
7687
|
for (const tag in attrChains) {
|
|
@@ -7557,7 +7695,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
7557
7695
|
names.forEach(function (name, index) {
|
|
7558
7696
|
(attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
|
|
7559
7697
|
});
|
|
7560
|
-
sorter.sort(names)
|
|
7698
|
+
const sorted = sorter.sort(names);
|
|
7699
|
+
sorted.forEach(function (name, index) {
|
|
7561
7700
|
attrs[index] = attrMap[name].shift();
|
|
7562
7701
|
});
|
|
7563
7702
|
}
|
|
@@ -7566,7 +7705,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
7566
7705
|
if (classChain) {
|
|
7567
7706
|
const sorter = classChain.createSorter();
|
|
7568
7707
|
options.sortClassName = function (value) {
|
|
7569
|
-
|
|
7708
|
+
// Expand UID tokens back to original content before sorting
|
|
7709
|
+
// Fast path: Skip if no HTML comments (UID markers) present
|
|
7710
|
+
let expandedValue = value;
|
|
7711
|
+
if (uidReplacePattern && value.indexOf('<!--') !== -1) {
|
|
7712
|
+
expandedValue = value.replace(uidReplacePattern, function (match, index) {
|
|
7713
|
+
return ignoredMarkupChunks[+index] || '';
|
|
7714
|
+
});
|
|
7715
|
+
// Reset `lastIndex` for pattern reuse
|
|
7716
|
+
uidReplacePattern.lastIndex = 0;
|
|
7717
|
+
}
|
|
7718
|
+
const classes = expandedValue.split(whitespaceSplitPatternSort).filter(function(cls) {
|
|
7719
|
+
return cls !== '';
|
|
7720
|
+
});
|
|
7721
|
+
const sorted = sorter.sort(classes);
|
|
7722
|
+
return sorted.join(' ');
|
|
7570
7723
|
};
|
|
7571
7724
|
}
|
|
7572
7725
|
}
|
|
@@ -7647,6 +7800,13 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
7647
7800
|
return token;
|
|
7648
7801
|
});
|
|
7649
7802
|
|
|
7803
|
+
// Create sort functions after `htmlmin:ignore` processing but before custom fragment UID markers
|
|
7804
|
+
// This allows proper frequency analysis with access to ignored content via UID tokens
|
|
7805
|
+
if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
|
|
7806
|
+
(options.sortClassName && typeof options.sortClassName !== 'function')) {
|
|
7807
|
+
await createSortFns(value, options, uidIgnore, null, ignoredMarkupChunks);
|
|
7808
|
+
}
|
|
7809
|
+
|
|
7650
7810
|
const customFragments = options.ignoreCustomFragments.map(function (re) {
|
|
7651
7811
|
return re.source;
|
|
7652
7812
|
});
|
|
@@ -7705,11 +7865,6 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
7705
7865
|
});
|
|
7706
7866
|
}
|
|
7707
7867
|
|
|
7708
|
-
if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
|
|
7709
|
-
(options.sortClassName && typeof options.sortClassName !== 'function')) {
|
|
7710
|
-
await createSortFns(value, options, uidIgnore, uidAttr);
|
|
7711
|
-
}
|
|
7712
|
-
|
|
7713
7868
|
function _canCollapseWhitespace(tag, attrs) {
|
|
7714
7869
|
return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
|
|
7715
7870
|
}
|
|
@@ -44,6 +44,15 @@ export type MinifierOptions = {
|
|
|
44
44
|
* Default: `false`
|
|
45
45
|
*/
|
|
46
46
|
caseSensitive?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Collapse multiple whitespace characters within attribute values into a
|
|
49
|
+
* single space. Also trims leading and trailing whitespace from attribute
|
|
50
|
+
* values. Applied as an early normalization step before special attribute
|
|
51
|
+
* handlers (CSS minification, class sorting, etc.) run.
|
|
52
|
+
*
|
|
53
|
+
* Default: `false`
|
|
54
|
+
*/
|
|
55
|
+
collapseAttributeWhitespace?: boolean;
|
|
47
56
|
/**
|
|
48
57
|
* Collapse boolean attributes to their name only (for example
|
|
49
58
|
* `disabled="disabled"` → `disabled`).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"AA84EO,8BAJI,MAAM,YACN,eAAe,GACb,OAAO,CAAC,MAAM,CAAC,CAQ3B;;;;;;;;;;;;UAr3ES,MAAM;YACN,MAAM;YACN,MAAM;mBACN,MAAM;iBACN,MAAM;kBACN,MAAM;;;;;;;;;;;;;4BAQN,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,qBAAqB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO;;;;;;;wBAMjG,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE,GAAG,SAAS,EAAE,iBAAiB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO;;;;;;;;oBAMhH,OAAO;;;;;;;;;kCAON,OAAO;;;;;;;;gCAQR,OAAO;;;;;;;;kCAOP,OAAO;;;;;;;;yBAOP,OAAO;;;;;;;;2BAOP,OAAO;;;;;;;;4BAOP,OAAO;;;;;;;2BAOP,OAAO;;;;;;;;uBAMP,MAAM,EAAE;;;;;;yBAOR,MAAM;;;;;;yBAKN,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE;;;;;;;4BAKlB,MAAM,EAAE;;;;;;;oCAMR,MAAM;;;;;;;qBAMN,OAAO;;;;;;;YAMP,OAAO;;;;;;;;2BAMP,MAAM,EAAE;;;;;;;;;4BAOR,MAAM,EAAE;;;;;;;+BAQR,OAAO;;;;;;;2BAMP,SAAS,CAAC,MAAM,CAAC;;;;;;uBAMjB,OAAO;;;;;;;;UAKP,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI;;;;;;;;qBAO1B,MAAM;;;;;;;oBAON,MAAM;;;;;;;;;;gBAMN,OAAO,GAAG,OAAO,CAAC,OAAO,cAAc,EAAE,gBAAgB,CAAC,OAAO,cAAc,EAAE,aAAa,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;eAS9J,OAAO,GAAG,OAAO,QAAQ,EAAE,aAAa,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;iBASzG,OAAO,GAAG,MAAM,GAAG,OAAO,WAAW,EAAE,OAAO,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;WAS7F,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM;;;;;;;+BAOxB,OAAO;;;;;;;;;;oBAMP,OAAO;;;;;;;;yBASP,OAAO;;;;;;;gCAOP,OAAO;;;;;;;;iCAMP,OAAO;;;;;;;;;;qBAOP,MAAM,EAAE;;;;;;;qBASR,IAAI,GAAG,GAAG;;;;;;;4BAMV,OAAO;;;;;;;;qBAMP,OAAO;;;;;;;;;4BAOP,OAAO,GAAG,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;;;;;;;;0BAQtD,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;gCAOP,MAAM,EAAE;;;;;;;;yBAyBR,OAAO;;;;;;;;gCAOP,OAAO;;;;;;;iCAOP,OAAO;;;;;;;oCAMP,OAAO;;;;;;;;;;0BAMP,OAAO;;;;;;;;;qBASP,OAAO,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC;;;;;;;;;oBAQzD,OAAO,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;;;;;;;;0BAQrC,OAAO;;;;;;;sBAOP,OAAO;;wBA3YkC,cAAc;0BAAd,cAAc;+BAAd,cAAc"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"presets.d.ts","sourceRoot":"","sources":["../../src/presets.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"presets.d.ts","sourceRoot":"","sources":["../../src/presets.js"],"names":[],"mappings":"AAiDA;;;;GAIG;AACH,gCAHW,MAAM,GACJ,MAAM,GAAC,IAAI,CAMvB;AAED;;;GAGG;AACH,kCAFa,MAAM,EAAE,CAIpB"}
|
package/package.json
CHANGED
package/src/htmlminifier.js
CHANGED
|
@@ -61,6 +61,14 @@ async function getTerser() {
|
|
|
61
61
|
*
|
|
62
62
|
* Default: `false`
|
|
63
63
|
*
|
|
64
|
+
* @prop {boolean} [collapseAttributeWhitespace]
|
|
65
|
+
* Collapse multiple whitespace characters within attribute values into a
|
|
66
|
+
* single space. Also trims leading and trailing whitespace from attribute
|
|
67
|
+
* values. Applied as an early normalization step before special attribute
|
|
68
|
+
* handlers (CSS minification, class sorting, etc.) run.
|
|
69
|
+
*
|
|
70
|
+
* Default: `false`
|
|
71
|
+
*
|
|
64
72
|
* @prop {boolean} [collapseBooleanAttributes]
|
|
65
73
|
* Collapse boolean attributes to their name only (for example
|
|
66
74
|
* `disabled="disabled"` → `disabled`).
|
|
@@ -746,6 +754,12 @@ function isSrcset(attrName, tag) {
|
|
|
746
754
|
}
|
|
747
755
|
|
|
748
756
|
async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
|
|
757
|
+
// Apply early whitespace normalization if enabled
|
|
758
|
+
// Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
|
|
759
|
+
if (options.collapseAttributeWhitespace) {
|
|
760
|
+
attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
|
|
761
|
+
}
|
|
762
|
+
|
|
749
763
|
if (isEventAttribute(attrName, options)) {
|
|
750
764
|
attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
|
|
751
765
|
return options.minifyJS(attrValue, true);
|
|
@@ -826,7 +840,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
826
840
|
return options.minifyCSS(attrValue, 'media');
|
|
827
841
|
} else if (tag === 'iframe' && attrName === 'srcdoc') {
|
|
828
842
|
// Recursively minify HTML content within srcdoc attribute
|
|
829
|
-
// Fast-path:
|
|
843
|
+
// Fast-path: Skip if nothing would change
|
|
830
844
|
if (!shouldMinifyInnerHTML(options)) {
|
|
831
845
|
return attrValue;
|
|
832
846
|
}
|
|
@@ -1258,7 +1272,9 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
1258
1272
|
|
|
1259
1273
|
if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
|
|
1260
1274
|
~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
|
|
1275
|
+
// Determine the appropriate quote character
|
|
1261
1276
|
if (!options.preventAttributesEscaping) {
|
|
1277
|
+
// Normal mode: choose quotes and escape
|
|
1262
1278
|
if (typeof options.quoteCharacter === 'undefined') {
|
|
1263
1279
|
// Count quotes in a single pass instead of two regex operations
|
|
1264
1280
|
let apos = 0, quot = 0;
|
|
@@ -1275,6 +1291,50 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
1275
1291
|
} else {
|
|
1276
1292
|
attrValue = attrValue.replace(/'/g, ''');
|
|
1277
1293
|
}
|
|
1294
|
+
} else {
|
|
1295
|
+
// `preventAttributesEscaping` mode: choose safe quotes but don’t escape
|
|
1296
|
+
// EXCEPT when both quote types are present—then escape to prevent invalid HTML
|
|
1297
|
+
const hasDoubleQuote = attrValue.indexOf('"') !== -1;
|
|
1298
|
+
const hasSingleQuote = attrValue.indexOf("'") !== -1;
|
|
1299
|
+
|
|
1300
|
+
if (hasDoubleQuote && hasSingleQuote) {
|
|
1301
|
+
// Both quote types present: `preventAttributesEscaping` is ignored to ensure valid HTML
|
|
1302
|
+
// Choose the quote type with fewer occurrences and escape the other
|
|
1303
|
+
if (typeof options.quoteCharacter === 'undefined') {
|
|
1304
|
+
let apos = 0, quot = 0;
|
|
1305
|
+
for (let i = 0; i < attrValue.length; i++) {
|
|
1306
|
+
if (attrValue[i] === "'") apos++;
|
|
1307
|
+
else if (attrValue[i] === '"') quot++;
|
|
1308
|
+
}
|
|
1309
|
+
attrQuote = apos < quot ? '\'' : '"';
|
|
1310
|
+
} else {
|
|
1311
|
+
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
1312
|
+
}
|
|
1313
|
+
if (attrQuote === '"') {
|
|
1314
|
+
attrValue = attrValue.replace(/"/g, '"');
|
|
1315
|
+
} else {
|
|
1316
|
+
attrValue = attrValue.replace(/'/g, ''');
|
|
1317
|
+
}
|
|
1318
|
+
} else if (typeof options.quoteCharacter === 'undefined') {
|
|
1319
|
+
// Single or no quote type: Choose safe quote delimiter
|
|
1320
|
+
if (attrQuote === '"' && hasDoubleQuote && !hasSingleQuote) {
|
|
1321
|
+
attrQuote = "'";
|
|
1322
|
+
} else if (attrQuote === "'" && hasSingleQuote && !hasDoubleQuote) {
|
|
1323
|
+
attrQuote = '"';
|
|
1324
|
+
} else if (attrQuote !== '"' && attrQuote !== "'" && attrQuote !== '') {
|
|
1325
|
+
// `attrQuote` is invalid/undefined (not `"`, `'`, or empty string)
|
|
1326
|
+
// Set a safe default based on the value’s content
|
|
1327
|
+
if (hasSingleQuote && !hasDoubleQuote) {
|
|
1328
|
+
attrQuote = '"'; // Value has single quotes, use double quotes as delimiter
|
|
1329
|
+
} else if (hasDoubleQuote && !hasSingleQuote) {
|
|
1330
|
+
attrQuote = "'"; // Value has double quotes, use single quotes as delimiter
|
|
1331
|
+
} else {
|
|
1332
|
+
attrQuote = '"'; // No quotes in value, default to double quotes
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
} else {
|
|
1336
|
+
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
1337
|
+
}
|
|
1278
1338
|
}
|
|
1279
1339
|
emittedAttrValue = attrQuote + attrValue + attrQuote;
|
|
1280
1340
|
if (!isLast && !options.removeTagWhitespace) {
|
|
@@ -1539,7 +1599,7 @@ function uniqueId(value) {
|
|
|
1539
1599
|
|
|
1540
1600
|
const specialContentTags = new Set(['script', 'style']);
|
|
1541
1601
|
|
|
1542
|
-
async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
1602
|
+
async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
|
|
1543
1603
|
const attrChains = options.sortAttributes && Object.create(null);
|
|
1544
1604
|
const classChain = options.sortClassName && new TokenChain();
|
|
1545
1605
|
|
|
@@ -1553,10 +1613,20 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
1553
1613
|
return !uid || token.indexOf(uid) === -1;
|
|
1554
1614
|
}
|
|
1555
1615
|
|
|
1556
|
-
function
|
|
1616
|
+
function shouldKeepToken(token) {
|
|
1617
|
+
// Filter out any HTML comment tokens (UID placeholders)
|
|
1618
|
+
// These are temporary markers created by `htmlmin:ignore` and `ignoreCustomFragments`
|
|
1619
|
+
if (token.startsWith('<!--') && token.endsWith('-->')) {
|
|
1620
|
+
return false;
|
|
1621
|
+
}
|
|
1557
1622
|
return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
|
|
1558
1623
|
}
|
|
1559
1624
|
|
|
1625
|
+
// Pre-compile regex patterns for reuse (performance optimization)
|
|
1626
|
+
// These must be declared before scan() since scan uses them
|
|
1627
|
+
const whitespaceSplitPatternScan = /[ \t\n\f\r]+/;
|
|
1628
|
+
const whitespaceSplitPatternSort = /[ \n\f\r]+/;
|
|
1629
|
+
|
|
1560
1630
|
async function scan(input) {
|
|
1561
1631
|
let currentTag, currentType;
|
|
1562
1632
|
const parser = new HTMLParser(input, {
|
|
@@ -1565,12 +1635,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
1565
1635
|
if (!attrChains[tag]) {
|
|
1566
1636
|
attrChains[tag] = new TokenChain();
|
|
1567
1637
|
}
|
|
1568
|
-
|
|
1638
|
+
const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
|
|
1639
|
+
attrChains[tag].add(attrNamesList);
|
|
1569
1640
|
}
|
|
1570
1641
|
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
1571
1642
|
const attr = attrs[i];
|
|
1572
1643
|
if (classChain && attr.value && options.name(attr.name) === 'class') {
|
|
1573
|
-
|
|
1644
|
+
const classes = trimWhitespace(attr.value).split(whitespaceSplitPatternScan).filter(shouldKeepToken);
|
|
1645
|
+
classChain.add(classes);
|
|
1574
1646
|
} else if (options.processScripts && attr.name.toLowerCase() === 'type') {
|
|
1575
1647
|
currentTag = tag;
|
|
1576
1648
|
currentType = attr.value;
|
|
@@ -1590,19 +1662,84 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
1590
1662
|
}
|
|
1591
1663
|
},
|
|
1592
1664
|
// We never need `nextTag` information in this scan
|
|
1593
|
-
wantsNextTag: false
|
|
1665
|
+
wantsNextTag: false,
|
|
1666
|
+
// Continue on parse errors during analysis pass
|
|
1667
|
+
continueOnParseError: options.continueOnParseError
|
|
1594
1668
|
});
|
|
1595
1669
|
|
|
1596
|
-
|
|
1670
|
+
try {
|
|
1671
|
+
await parser.parse();
|
|
1672
|
+
} catch (e) {
|
|
1673
|
+
// If parsing fails during analysis pass, just skip it—we’ll still have
|
|
1674
|
+
// partial frequency data from what we could parse
|
|
1675
|
+
if (!options.continueOnParseError) {
|
|
1676
|
+
throw e;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1597
1679
|
}
|
|
1598
1680
|
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
const
|
|
1604
|
-
|
|
1605
|
-
|
|
1681
|
+
// For the first pass, create a copy of options and disable aggressive minification.
|
|
1682
|
+
// Keep attribute transformations (like `removeStyleLinkTypeAttributes`) for accurate analysis.
|
|
1683
|
+
// This is safe because `createSortFns` is called before custom fragment UID markers (uidAttr) are added.
|
|
1684
|
+
// Note: `htmlmin:ignore` UID markers (uidIgnore) already exist and are expanded for analysis.
|
|
1685
|
+
const firstPassOptions = Object.assign({}, options, {
|
|
1686
|
+
// Disable sorting for the analysis pass
|
|
1687
|
+
sortAttributes: false,
|
|
1688
|
+
sortClassName: false,
|
|
1689
|
+
// Disable aggressive minification that doesn’t affect attribute analysis
|
|
1690
|
+
collapseWhitespace: false,
|
|
1691
|
+
removeAttributeQuotes: false,
|
|
1692
|
+
removeTagWhitespace: false,
|
|
1693
|
+
decodeEntities: false,
|
|
1694
|
+
processScripts: false,
|
|
1695
|
+
// Keep `ignoreCustomFragments` to handle template syntax correctly
|
|
1696
|
+
// This is safe because `createSortFns` is now called before UID markers are added
|
|
1697
|
+
// Continue on parse errors during analysis (e.g., template syntax)
|
|
1698
|
+
continueOnParseError: true,
|
|
1699
|
+
log: identity
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
// Temporarily enable `continueOnParseError` for the `scan()` function call below.
|
|
1703
|
+
// Note: `firstPassOptions` already has `continueOnParseError: true` for the minifyHTML call.
|
|
1704
|
+
const originalContinueOnParseError = options.continueOnParseError;
|
|
1705
|
+
options.continueOnParseError = true;
|
|
1706
|
+
|
|
1707
|
+
// Pre-compile regex patterns for UID replacement and custom fragments
|
|
1708
|
+
const uidReplacePattern = uidIgnore && ignoredMarkupChunks
|
|
1709
|
+
? new RegExp('<!--' + uidIgnore + '(\\d+)-->', 'g')
|
|
1710
|
+
: null;
|
|
1711
|
+
const customFragmentPattern = options.ignoreCustomFragments && options.ignoreCustomFragments.length > 0
|
|
1712
|
+
? new RegExp('(' + options.ignoreCustomFragments.map(re => re.source).join('|') + ')', 'g')
|
|
1713
|
+
: null;
|
|
1714
|
+
|
|
1715
|
+
try {
|
|
1716
|
+
// Expand UID tokens back to original content for frequency analysis
|
|
1717
|
+
let expandedValue = value;
|
|
1718
|
+
if (uidReplacePattern) {
|
|
1719
|
+
expandedValue = value.replace(uidReplacePattern, function (match, index) {
|
|
1720
|
+
return ignoredMarkupChunks[+index] || '';
|
|
1721
|
+
});
|
|
1722
|
+
// Reset `lastIndex` for pattern reuse
|
|
1723
|
+
uidReplacePattern.lastIndex = 0;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// First pass minification applies attribute transformations
|
|
1727
|
+
// like removeStyleLinkTypeAttributes for accurate frequency analysis
|
|
1728
|
+
const firstPassOutput = await minifyHTML(expandedValue, firstPassOptions);
|
|
1729
|
+
|
|
1730
|
+
// For frequency analysis, we need to remove custom fragments temporarily
|
|
1731
|
+
// because HTML comments in opening tags prevent proper attribute parsing.
|
|
1732
|
+
// We remove them with a space to preserve attribute boundaries.
|
|
1733
|
+
let scanValue = firstPassOutput;
|
|
1734
|
+
if (customFragmentPattern) {
|
|
1735
|
+
scanValue = firstPassOutput.replace(customFragmentPattern, ' ');
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
await scan(scanValue);
|
|
1739
|
+
} finally {
|
|
1740
|
+
// Restore original option
|
|
1741
|
+
options.continueOnParseError = originalContinueOnParseError;
|
|
1742
|
+
}
|
|
1606
1743
|
if (attrChains) {
|
|
1607
1744
|
const attrSorters = Object.create(null);
|
|
1608
1745
|
for (const tag in attrChains) {
|
|
@@ -1616,7 +1753,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
1616
1753
|
names.forEach(function (name, index) {
|
|
1617
1754
|
(attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
|
|
1618
1755
|
});
|
|
1619
|
-
sorter.sort(names)
|
|
1756
|
+
const sorted = sorter.sort(names);
|
|
1757
|
+
sorted.forEach(function (name, index) {
|
|
1620
1758
|
attrs[index] = attrMap[name].shift();
|
|
1621
1759
|
});
|
|
1622
1760
|
}
|
|
@@ -1625,7 +1763,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
1625
1763
|
if (classChain) {
|
|
1626
1764
|
const sorter = classChain.createSorter();
|
|
1627
1765
|
options.sortClassName = function (value) {
|
|
1628
|
-
|
|
1766
|
+
// Expand UID tokens back to original content before sorting
|
|
1767
|
+
// Fast path: Skip if no HTML comments (UID markers) present
|
|
1768
|
+
let expandedValue = value;
|
|
1769
|
+
if (uidReplacePattern && value.indexOf('<!--') !== -1) {
|
|
1770
|
+
expandedValue = value.replace(uidReplacePattern, function (match, index) {
|
|
1771
|
+
return ignoredMarkupChunks[+index] || '';
|
|
1772
|
+
});
|
|
1773
|
+
// Reset `lastIndex` for pattern reuse
|
|
1774
|
+
uidReplacePattern.lastIndex = 0;
|
|
1775
|
+
}
|
|
1776
|
+
const classes = expandedValue.split(whitespaceSplitPatternSort).filter(function(cls) {
|
|
1777
|
+
return cls !== '';
|
|
1778
|
+
});
|
|
1779
|
+
const sorted = sorter.sort(classes);
|
|
1780
|
+
return sorted.join(' ');
|
|
1629
1781
|
};
|
|
1630
1782
|
}
|
|
1631
1783
|
}
|
|
@@ -1706,6 +1858,13 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1706
1858
|
return token;
|
|
1707
1859
|
});
|
|
1708
1860
|
|
|
1861
|
+
// Create sort functions after `htmlmin:ignore` processing but before custom fragment UID markers
|
|
1862
|
+
// This allows proper frequency analysis with access to ignored content via UID tokens
|
|
1863
|
+
if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
|
|
1864
|
+
(options.sortClassName && typeof options.sortClassName !== 'function')) {
|
|
1865
|
+
await createSortFns(value, options, uidIgnore, null, ignoredMarkupChunks);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1709
1868
|
const customFragments = options.ignoreCustomFragments.map(function (re) {
|
|
1710
1869
|
return re.source;
|
|
1711
1870
|
});
|
|
@@ -1764,11 +1923,6 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1764
1923
|
});
|
|
1765
1924
|
}
|
|
1766
1925
|
|
|
1767
|
-
if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
|
|
1768
|
-
(options.sortClassName && typeof options.sortClassName !== 'function')) {
|
|
1769
|
-
await createSortFns(value, options, uidIgnore, uidAttr);
|
|
1770
|
-
}
|
|
1771
|
-
|
|
1772
1926
|
function _canCollapseWhitespace(tag, attrs) {
|
|
1773
1927
|
return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
|
|
1774
1928
|
}
|
package/src/presets.js
CHANGED