html-minifier-next 4.11.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 +19 -19
- package/dist/htmlminifier.cjs +161 -21
- package/dist/htmlminifier.esm.bundle.js +161 -21
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/htmlminifier.js +161 -21
package/README.md
CHANGED
|
@@ -236,34 +236,34 @@ How does HTML Minifier Next compare to other minifiers? (All with the most aggre
|
|
|
236
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/) |
|
|
237
237
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
238
238
|
| [A List Apart](https://alistapart.com/) | 59 | **49** | 50 | 51 | 52 | 51 | 54 | 52 |
|
|
239
|
-
| [Apple](https://www.apple.com/) |
|
|
240
|
-
| [BBC](https://www.bbc.co.uk/) |
|
|
241
|
-
| [CERN](https://home.cern/) | 156 | **87** |
|
|
242
|
-
| [CSS-Tricks](https://css-tricks.com/) | 163 | 122 |
|
|
243
|
-
| [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 |
|
|
244
244
|
| [EDRi](https://edri.org/) | 80 | **59** | 60 | 70 | 70 | 71 | 75 | 73 |
|
|
245
|
-
| [EFF](https://www.eff.org/) |
|
|
245
|
+
| [EFF](https://www.eff.org/) | 55 | **47** | n/a | 50 | 48 | 49 | 50 | 50 |
|
|
246
246
|
| [European Alternatives](https://european-alternatives.eu/) | 48 | **30** | **30** | 32 | 32 | 32 | 32 | 32 |
|
|
247
|
-
| [FAZ](https://www.faz.net/aktuell/) |
|
|
247
|
+
| [FAZ](https://www.faz.net/aktuell/) | 1604 | 1494 | 1499 | **1437** | 1527 | 1539 | 1550 | n/a |
|
|
248
248
|
| [French Tech](https://lafrenchtech.gouv.fr/) | 152 | **121** | 122 | 125 | 125 | 125 | 132 | 126 |
|
|
249
|
-
| [Frontend Dogma](https://frontenddogma.com/) |
|
|
249
|
+
| [Frontend Dogma](https://frontenddogma.com/) | 223 | **213** | 215 | 236 | 221 | 223 | 241 | 222 |
|
|
250
250
|
| [Google](https://www.google.com/) | 18 | **17** | **17** | **17** | **17** | **17** | 18 | 18 |
|
|
251
|
-
| [Ground News](https://ground.news/) |
|
|
252
|
-
| [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 |
|
|
253
253
|
| [Igalia](https://www.igalia.com/) | 50 | **33** | **33** | 36 | 36 | 36 | 37 | 36 |
|
|
254
|
-
| [Leanpub](https://leanpub.com/) |
|
|
255
|
-
| [Mastodon](https://mastodon.social/explore) | 37 | **27** |
|
|
256
|
-
| [MDN](https://developer.mozilla.org/en-US/) | 109 | **62** |
|
|
257
|
-
| [Middle East Eye](https://www.middleeasteye.net/) | 222 | **195** |
|
|
258
|
-
| [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 |
|
|
259
259
|
| [SitePoint](https://www.sitepoint.com/) | 485 | **354** | **354** | 425 | 459 | 464 | 481 | n/a |
|
|
260
260
|
| [TetraLogical](https://tetralogical.com/) | 44 | 38 | 38 | **35** | 38 | 38 | 39 | 39 |
|
|
261
|
-
| [TPGi](https://www.tpgi.com/) | 175 | **159** |
|
|
262
|
-
| [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 |
|
|
263
263
|
| [W3C](https://www.w3.org/) | 50 | **36** | **36** | 38 | 38 | 38 | 40 | 38 |
|
|
264
|
-
| **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) |
|
|
265
265
|
|
|
266
|
-
(Last updated: Dec
|
|
266
|
+
(Last updated: Dec 17, 2025)
|
|
267
267
|
<!-- End auto-generated -->
|
|
268
268
|
|
|
269
269
|
## Examples
|
package/dist/htmlminifier.cjs
CHANGED
|
@@ -1640,7 +1640,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
1640
1640
|
return options.minifyCSS(attrValue, 'media');
|
|
1641
1641
|
} else if (tag === 'iframe' && attrName === 'srcdoc') {
|
|
1642
1642
|
// Recursively minify HTML content within srcdoc attribute
|
|
1643
|
-
// Fast-path:
|
|
1643
|
+
// Fast-path: Skip if nothing would change
|
|
1644
1644
|
if (!shouldMinifyInnerHTML(options)) {
|
|
1645
1645
|
return attrValue;
|
|
1646
1646
|
}
|
|
@@ -2072,7 +2072,9 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
2072
2072
|
|
|
2073
2073
|
if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
|
|
2074
2074
|
~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
|
|
2075
|
+
// Determine the appropriate quote character
|
|
2075
2076
|
if (!options.preventAttributesEscaping) {
|
|
2077
|
+
// Normal mode: choose quotes and escape
|
|
2076
2078
|
if (typeof options.quoteCharacter === 'undefined') {
|
|
2077
2079
|
// Count quotes in a single pass instead of two regex operations
|
|
2078
2080
|
let apos = 0, quot = 0;
|
|
@@ -2089,6 +2091,50 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
2089
2091
|
} else {
|
|
2090
2092
|
attrValue = attrValue.replace(/'/g, ''');
|
|
2091
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
|
+
}
|
|
2092
2138
|
}
|
|
2093
2139
|
emittedAttrValue = attrQuote + attrValue + attrQuote;
|
|
2094
2140
|
if (!isLast && !options.removeTagWhitespace) {
|
|
@@ -2353,7 +2399,7 @@ function uniqueId(value) {
|
|
|
2353
2399
|
|
|
2354
2400
|
const specialContentTags = new Set(['script', 'style']);
|
|
2355
2401
|
|
|
2356
|
-
async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
2402
|
+
async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
|
|
2357
2403
|
const attrChains = options.sortAttributes && Object.create(null);
|
|
2358
2404
|
const classChain = options.sortClassName && new TokenChain();
|
|
2359
2405
|
|
|
@@ -2367,10 +2413,20 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
2367
2413
|
return !uid || token.indexOf(uid) === -1;
|
|
2368
2414
|
}
|
|
2369
2415
|
|
|
2370
|
-
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
|
+
}
|
|
2371
2422
|
return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
|
|
2372
2423
|
}
|
|
2373
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
|
+
|
|
2374
2430
|
async function scan(input) {
|
|
2375
2431
|
let currentTag, currentType;
|
|
2376
2432
|
const parser = new HTMLParser(input, {
|
|
@@ -2379,12 +2435,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
2379
2435
|
if (!attrChains[tag]) {
|
|
2380
2436
|
attrChains[tag] = new TokenChain();
|
|
2381
2437
|
}
|
|
2382
|
-
|
|
2438
|
+
const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
|
|
2439
|
+
attrChains[tag].add(attrNamesList);
|
|
2383
2440
|
}
|
|
2384
2441
|
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
2385
2442
|
const attr = attrs[i];
|
|
2386
2443
|
if (classChain && attr.value && options.name(attr.name) === 'class') {
|
|
2387
|
-
|
|
2444
|
+
const classes = trimWhitespace(attr.value).split(whitespaceSplitPatternScan).filter(shouldKeepToken);
|
|
2445
|
+
classChain.add(classes);
|
|
2388
2446
|
} else if (options.processScripts && attr.name.toLowerCase() === 'type') {
|
|
2389
2447
|
currentTag = tag;
|
|
2390
2448
|
currentType = attr.value;
|
|
@@ -2404,19 +2462,84 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
2404
2462
|
}
|
|
2405
2463
|
},
|
|
2406
2464
|
// We never need `nextTag` information in this scan
|
|
2407
|
-
wantsNextTag: false
|
|
2465
|
+
wantsNextTag: false,
|
|
2466
|
+
// Continue on parse errors during analysis pass
|
|
2467
|
+
continueOnParseError: options.continueOnParseError
|
|
2408
2468
|
});
|
|
2409
2469
|
|
|
2410
|
-
|
|
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
|
+
}
|
|
2411
2479
|
}
|
|
2412
2480
|
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
const
|
|
2418
|
-
|
|
2419
|
-
|
|
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
|
+
}
|
|
2420
2543
|
if (attrChains) {
|
|
2421
2544
|
const attrSorters = Object.create(null);
|
|
2422
2545
|
for (const tag in attrChains) {
|
|
@@ -2430,7 +2553,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
2430
2553
|
names.forEach(function (name, index) {
|
|
2431
2554
|
(attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
|
|
2432
2555
|
});
|
|
2433
|
-
sorter.sort(names)
|
|
2556
|
+
const sorted = sorter.sort(names);
|
|
2557
|
+
sorted.forEach(function (name, index) {
|
|
2434
2558
|
attrs[index] = attrMap[name].shift();
|
|
2435
2559
|
});
|
|
2436
2560
|
}
|
|
@@ -2439,7 +2563,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
2439
2563
|
if (classChain) {
|
|
2440
2564
|
const sorter = classChain.createSorter();
|
|
2441
2565
|
options.sortClassName = function (value) {
|
|
2442
|
-
|
|
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(' ');
|
|
2443
2581
|
};
|
|
2444
2582
|
}
|
|
2445
2583
|
}
|
|
@@ -2520,6 +2658,13 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
2520
2658
|
return token;
|
|
2521
2659
|
});
|
|
2522
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
|
+
|
|
2523
2668
|
const customFragments = options.ignoreCustomFragments.map(function (re) {
|
|
2524
2669
|
return re.source;
|
|
2525
2670
|
});
|
|
@@ -2578,11 +2723,6 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
2578
2723
|
});
|
|
2579
2724
|
}
|
|
2580
2725
|
|
|
2581
|
-
if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
|
|
2582
|
-
(options.sortClassName && typeof options.sortClassName !== 'function')) {
|
|
2583
|
-
await createSortFns(value, options, uidIgnore, uidAttr);
|
|
2584
|
-
}
|
|
2585
|
-
|
|
2586
2726
|
function _canCollapseWhitespace(tag, attrs) {
|
|
2587
2727
|
return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
|
|
2588
2728
|
}
|
|
@@ -6782,7 +6782,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
6782
6782
|
return options.minifyCSS(attrValue, 'media');
|
|
6783
6783
|
} else if (tag === 'iframe' && attrName === 'srcdoc') {
|
|
6784
6784
|
// Recursively minify HTML content within srcdoc attribute
|
|
6785
|
-
// Fast-path:
|
|
6785
|
+
// Fast-path: Skip if nothing would change
|
|
6786
6786
|
if (!shouldMinifyInnerHTML(options)) {
|
|
6787
6787
|
return attrValue;
|
|
6788
6788
|
}
|
|
@@ -7214,7 +7214,9 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
7214
7214
|
|
|
7215
7215
|
if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
|
|
7216
7216
|
~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
|
|
7217
|
+
// Determine the appropriate quote character
|
|
7217
7218
|
if (!options.preventAttributesEscaping) {
|
|
7219
|
+
// Normal mode: choose quotes and escape
|
|
7218
7220
|
if (typeof options.quoteCharacter === 'undefined') {
|
|
7219
7221
|
// Count quotes in a single pass instead of two regex operations
|
|
7220
7222
|
let apos = 0, quot = 0;
|
|
@@ -7231,6 +7233,50 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
7231
7233
|
} else {
|
|
7232
7234
|
attrValue = attrValue.replace(/'/g, ''');
|
|
7233
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
|
+
}
|
|
7234
7280
|
}
|
|
7235
7281
|
emittedAttrValue = attrQuote + attrValue + attrQuote;
|
|
7236
7282
|
if (!isLast && !options.removeTagWhitespace) {
|
|
@@ -7495,7 +7541,7 @@ function uniqueId(value) {
|
|
|
7495
7541
|
|
|
7496
7542
|
const specialContentTags = new Set(['script', 'style']);
|
|
7497
7543
|
|
|
7498
|
-
async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
7544
|
+
async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
|
|
7499
7545
|
const attrChains = options.sortAttributes && Object.create(null);
|
|
7500
7546
|
const classChain = options.sortClassName && new TokenChain();
|
|
7501
7547
|
|
|
@@ -7509,10 +7555,20 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
7509
7555
|
return !uid || token.indexOf(uid) === -1;
|
|
7510
7556
|
}
|
|
7511
7557
|
|
|
7512
|
-
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
|
+
}
|
|
7513
7564
|
return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
|
|
7514
7565
|
}
|
|
7515
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
|
+
|
|
7516
7572
|
async function scan(input) {
|
|
7517
7573
|
let currentTag, currentType;
|
|
7518
7574
|
const parser = new HTMLParser(input, {
|
|
@@ -7521,12 +7577,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
7521
7577
|
if (!attrChains[tag]) {
|
|
7522
7578
|
attrChains[tag] = new TokenChain();
|
|
7523
7579
|
}
|
|
7524
|
-
|
|
7580
|
+
const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
|
|
7581
|
+
attrChains[tag].add(attrNamesList);
|
|
7525
7582
|
}
|
|
7526
7583
|
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
7527
7584
|
const attr = attrs[i];
|
|
7528
7585
|
if (classChain && attr.value && options.name(attr.name) === 'class') {
|
|
7529
|
-
|
|
7586
|
+
const classes = trimWhitespace(attr.value).split(whitespaceSplitPatternScan).filter(shouldKeepToken);
|
|
7587
|
+
classChain.add(classes);
|
|
7530
7588
|
} else if (options.processScripts && attr.name.toLowerCase() === 'type') {
|
|
7531
7589
|
currentTag = tag;
|
|
7532
7590
|
currentType = attr.value;
|
|
@@ -7546,19 +7604,84 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
7546
7604
|
}
|
|
7547
7605
|
},
|
|
7548
7606
|
// We never need `nextTag` information in this scan
|
|
7549
|
-
wantsNextTag: false
|
|
7607
|
+
wantsNextTag: false,
|
|
7608
|
+
// Continue on parse errors during analysis pass
|
|
7609
|
+
continueOnParseError: options.continueOnParseError
|
|
7550
7610
|
});
|
|
7551
7611
|
|
|
7552
|
-
|
|
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
|
+
}
|
|
7553
7621
|
}
|
|
7554
7622
|
|
|
7555
|
-
|
|
7556
|
-
|
|
7557
|
-
|
|
7558
|
-
|
|
7559
|
-
const
|
|
7560
|
-
|
|
7561
|
-
|
|
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
|
+
}
|
|
7562
7685
|
if (attrChains) {
|
|
7563
7686
|
const attrSorters = Object.create(null);
|
|
7564
7687
|
for (const tag in attrChains) {
|
|
@@ -7572,7 +7695,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
7572
7695
|
names.forEach(function (name, index) {
|
|
7573
7696
|
(attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
|
|
7574
7697
|
});
|
|
7575
|
-
sorter.sort(names)
|
|
7698
|
+
const sorted = sorter.sort(names);
|
|
7699
|
+
sorted.forEach(function (name, index) {
|
|
7576
7700
|
attrs[index] = attrMap[name].shift();
|
|
7577
7701
|
});
|
|
7578
7702
|
}
|
|
@@ -7581,7 +7705,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
7581
7705
|
if (classChain) {
|
|
7582
7706
|
const sorter = classChain.createSorter();
|
|
7583
7707
|
options.sortClassName = function (value) {
|
|
7584
|
-
|
|
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(' ');
|
|
7585
7723
|
};
|
|
7586
7724
|
}
|
|
7587
7725
|
}
|
|
@@ -7662,6 +7800,13 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
7662
7800
|
return token;
|
|
7663
7801
|
});
|
|
7664
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
|
+
|
|
7665
7810
|
const customFragments = options.ignoreCustomFragments.map(function (re) {
|
|
7666
7811
|
return re.source;
|
|
7667
7812
|
});
|
|
@@ -7720,11 +7865,6 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
7720
7865
|
});
|
|
7721
7866
|
}
|
|
7722
7867
|
|
|
7723
|
-
if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
|
|
7724
|
-
(options.sortClassName && typeof options.sortClassName !== 'function')) {
|
|
7725
|
-
await createSortFns(value, options, uidIgnore, uidAttr);
|
|
7726
|
-
}
|
|
7727
|
-
|
|
7728
7868
|
function _canCollapseWhitespace(tag, attrs) {
|
|
7729
7869
|
return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
|
|
7730
7870
|
}
|
|
@@ -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":"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"}
|
package/package.json
CHANGED
package/src/htmlminifier.js
CHANGED
|
@@ -840,7 +840,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
840
840
|
return options.minifyCSS(attrValue, 'media');
|
|
841
841
|
} else if (tag === 'iframe' && attrName === 'srcdoc') {
|
|
842
842
|
// Recursively minify HTML content within srcdoc attribute
|
|
843
|
-
// Fast-path:
|
|
843
|
+
// Fast-path: Skip if nothing would change
|
|
844
844
|
if (!shouldMinifyInnerHTML(options)) {
|
|
845
845
|
return attrValue;
|
|
846
846
|
}
|
|
@@ -1272,7 +1272,9 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
1272
1272
|
|
|
1273
1273
|
if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
|
|
1274
1274
|
~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
|
|
1275
|
+
// Determine the appropriate quote character
|
|
1275
1276
|
if (!options.preventAttributesEscaping) {
|
|
1277
|
+
// Normal mode: choose quotes and escape
|
|
1276
1278
|
if (typeof options.quoteCharacter === 'undefined') {
|
|
1277
1279
|
// Count quotes in a single pass instead of two regex operations
|
|
1278
1280
|
let apos = 0, quot = 0;
|
|
@@ -1289,6 +1291,50 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
1289
1291
|
} else {
|
|
1290
1292
|
attrValue = attrValue.replace(/'/g, ''');
|
|
1291
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
|
+
}
|
|
1292
1338
|
}
|
|
1293
1339
|
emittedAttrValue = attrQuote + attrValue + attrQuote;
|
|
1294
1340
|
if (!isLast && !options.removeTagWhitespace) {
|
|
@@ -1553,7 +1599,7 @@ function uniqueId(value) {
|
|
|
1553
1599
|
|
|
1554
1600
|
const specialContentTags = new Set(['script', 'style']);
|
|
1555
1601
|
|
|
1556
|
-
async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
1602
|
+
async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
|
|
1557
1603
|
const attrChains = options.sortAttributes && Object.create(null);
|
|
1558
1604
|
const classChain = options.sortClassName && new TokenChain();
|
|
1559
1605
|
|
|
@@ -1567,10 +1613,20 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
1567
1613
|
return !uid || token.indexOf(uid) === -1;
|
|
1568
1614
|
}
|
|
1569
1615
|
|
|
1570
|
-
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
|
+
}
|
|
1571
1622
|
return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
|
|
1572
1623
|
}
|
|
1573
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
|
+
|
|
1574
1630
|
async function scan(input) {
|
|
1575
1631
|
let currentTag, currentType;
|
|
1576
1632
|
const parser = new HTMLParser(input, {
|
|
@@ -1579,12 +1635,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
1579
1635
|
if (!attrChains[tag]) {
|
|
1580
1636
|
attrChains[tag] = new TokenChain();
|
|
1581
1637
|
}
|
|
1582
|
-
|
|
1638
|
+
const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
|
|
1639
|
+
attrChains[tag].add(attrNamesList);
|
|
1583
1640
|
}
|
|
1584
1641
|
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
1585
1642
|
const attr = attrs[i];
|
|
1586
1643
|
if (classChain && attr.value && options.name(attr.name) === 'class') {
|
|
1587
|
-
|
|
1644
|
+
const classes = trimWhitespace(attr.value).split(whitespaceSplitPatternScan).filter(shouldKeepToken);
|
|
1645
|
+
classChain.add(classes);
|
|
1588
1646
|
} else if (options.processScripts && attr.name.toLowerCase() === 'type') {
|
|
1589
1647
|
currentTag = tag;
|
|
1590
1648
|
currentType = attr.value;
|
|
@@ -1604,19 +1662,84 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
1604
1662
|
}
|
|
1605
1663
|
},
|
|
1606
1664
|
// We never need `nextTag` information in this scan
|
|
1607
|
-
wantsNextTag: false
|
|
1665
|
+
wantsNextTag: false,
|
|
1666
|
+
// Continue on parse errors during analysis pass
|
|
1667
|
+
continueOnParseError: options.continueOnParseError
|
|
1608
1668
|
});
|
|
1609
1669
|
|
|
1610
|
-
|
|
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
|
+
}
|
|
1611
1679
|
}
|
|
1612
1680
|
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
const
|
|
1618
|
-
|
|
1619
|
-
|
|
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
|
+
}
|
|
1620
1743
|
if (attrChains) {
|
|
1621
1744
|
const attrSorters = Object.create(null);
|
|
1622
1745
|
for (const tag in attrChains) {
|
|
@@ -1630,7 +1753,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
1630
1753
|
names.forEach(function (name, index) {
|
|
1631
1754
|
(attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
|
|
1632
1755
|
});
|
|
1633
|
-
sorter.sort(names)
|
|
1756
|
+
const sorted = sorter.sort(names);
|
|
1757
|
+
sorted.forEach(function (name, index) {
|
|
1634
1758
|
attrs[index] = attrMap[name].shift();
|
|
1635
1759
|
});
|
|
1636
1760
|
}
|
|
@@ -1639,7 +1763,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
|
1639
1763
|
if (classChain) {
|
|
1640
1764
|
const sorter = classChain.createSorter();
|
|
1641
1765
|
options.sortClassName = function (value) {
|
|
1642
|
-
|
|
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(' ');
|
|
1643
1781
|
};
|
|
1644
1782
|
}
|
|
1645
1783
|
}
|
|
@@ -1720,6 +1858,13 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1720
1858
|
return token;
|
|
1721
1859
|
});
|
|
1722
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
|
+
|
|
1723
1868
|
const customFragments = options.ignoreCustomFragments.map(function (re) {
|
|
1724
1869
|
return re.source;
|
|
1725
1870
|
});
|
|
@@ -1778,11 +1923,6 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1778
1923
|
});
|
|
1779
1924
|
}
|
|
1780
1925
|
|
|
1781
|
-
if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
|
|
1782
|
-
(options.sortClassName && typeof options.sortClassName !== 'function')) {
|
|
1783
|
-
await createSortFns(value, options, uidIgnore, uidAttr);
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
1926
|
function _canCollapseWhitespace(tag, attrs) {
|
|
1787
1927
|
return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
|
|
1788
1928
|
}
|