html-minifier-next 4.7.1 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/cli.js +4 -3
- package/dist/htmlminifier.cjs +169 -10
- package/dist/htmlminifier.esm.bundle.js +169 -10
- package/dist/types/htmlminifier.d.ts +28 -2
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/htmlminifier.js +169 -10
package/README.md
CHANGED
|
@@ -159,7 +159,7 @@ Options can be used in config files (camelCase) or via CLI flags (kebab-case wit
|
|
|
159
159
|
| `minifyURLs`<br>`--minify-urls` | Minify URLs in various attributes (uses [relateurl](https://github.com/stevenvachon/relateurl)) | `false` (could be `String`, `Object`, `Function(text)`, `async Function(text)`) |
|
|
160
160
|
| `noNewlinesBeforeTagClose`<br>`--no-newlines-before-tag-close` | Never add a newline before a tag that closes an element | `false` |
|
|
161
161
|
| `partialMarkup`<br>`--partial-markup` | Treat input as a partial HTML fragment, preserving stray end tags (closing tags without opening tags) and preventing auto-closing of unclosed tags at end of input | `false` |
|
|
162
|
-
| `preserveLineBreaks`<br>`--preserve-line-breaks` | Always collapse to
|
|
162
|
+
| `preserveLineBreaks`<br>`--preserve-line-breaks` | Always collapse to one line break (never remove it entirely) when whitespace between tags includes a line break—use with `collapseWhitespace: true` | `false` |
|
|
163
163
|
| `preventAttributesEscaping`<br>`--prevent-attributes-escaping` | Prevents the escaping of the values of attributes | `false` |
|
|
164
164
|
| `processConditionalComments`<br>`--process-conditional-comments` | Process contents of conditional comments through minifier | `false` |
|
|
165
165
|
| `processScripts`<br>`--process-scripts` | Array of strings corresponding to types of `script` elements to process through minifier (e.g., `text/ng-template`, `text/x-handlebars-template`, etc.) | `[]` |
|
|
@@ -168,6 +168,7 @@ Options can be used in config files (camelCase) or via CLI flags (kebab-case wit
|
|
|
168
168
|
| `removeComments`<br>`--remove-comments` | [Strip HTML comments](https://perfectionkills.com/experimenting-with-html-minifier#remove_comments) | `false` |
|
|
169
169
|
| `removeEmptyAttributes`<br>`--remove-empty-attributes` | [Remove all attributes with whitespace-only values](https://perfectionkills.com/experimenting-with-html-minifier#remove_empty_or_blank_attributes) | `false` (could be `true`, `Function(attrName, tag)`) |
|
|
170
170
|
| `removeEmptyElements`<br>`--remove-empty-elements` | [Remove all elements with empty contents](https://perfectionkills.com/experimenting-with-html-minifier#remove_empty_elements) | `false` |
|
|
171
|
+
| `removeEmptyElementsExcept`<br>`--remove-empty-elements-except` | Array of elements to preserve when `removeEmptyElements` is enabled; accepts simple tag names (e.g., `["td"]`) or HTML-like markup with attributes (e.g., `["<span aria-hidden='true'>"]`); supports double quotes, single quotes, and unquoted attribute values | `[]` |
|
|
171
172
|
| `removeOptionalTags`<br>`--remove-optional-tags` | [Remove optional tags](https://perfectionkills.com/experimenting-with-html-minifier#remove_optional_tags) | `false` |
|
|
172
173
|
| `removeRedundantAttributes`<br>`--remove-redundant-attributes` | [Remove attributes when value matches default](https://meiert.com/blog/optional-html/#toc-attribute-values) | `false` |
|
|
173
174
|
| `removeScriptTypeAttributes`<br>`--remove-script-type-attributes` | Remove `type="text/javascript"` from `script` elements; other `type` attribute values are left intact | `false` |
|
|
@@ -262,7 +263,7 @@ How does HTML Minifier Next compare to other minifiers? (All with the most aggre
|
|
|
262
263
|
**Sample command line:**
|
|
263
264
|
|
|
264
265
|
```bash
|
|
265
|
-
html-minifier-next --collapse-whitespace --remove-comments --minify-js
|
|
266
|
+
html-minifier-next --collapse-whitespace --remove-comments --minify-js --input-dir=. --output-dir=example
|
|
266
267
|
```
|
|
267
268
|
|
|
268
269
|
**Process specific files and directories:**
|
package/cli.js
CHANGED
|
@@ -119,9 +119,9 @@ const mainOptions = {
|
|
|
119
119
|
caseSensitive: 'Treat attributes in case-sensitive manner (useful for custom HTML elements)',
|
|
120
120
|
collapseBooleanAttributes: 'Omit attribute values from boolean attributes',
|
|
121
121
|
customFragmentQuantifierLimit: ['Set maximum quantifier limit for custom fragments to prevent ReDoS attacks (default: 200)', parseValidInt('customFragmentQuantifierLimit')],
|
|
122
|
-
collapseInlineTagWhitespace: 'Don’t leave any spaces between “display: inline;” elements when collapsing—use with
|
|
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 1 space (never remove it entirely)—use with
|
|
124
|
+
conservativeCollapse: 'Always collapse to 1 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],
|
|
@@ -142,7 +142,7 @@ const mainOptions = {
|
|
|
142
142
|
minifyURLs: ['Minify URLs in various attributes (uses relateurl)', parseJSON],
|
|
143
143
|
noNewlinesBeforeTagClose: 'Never add a newline before a tag that closes an element',
|
|
144
144
|
partialMarkup: 'Treat input as a partial HTML fragment, preserving stray end tags and unclosed tags',
|
|
145
|
-
preserveLineBreaks: 'Always collapse to
|
|
145
|
+
preserveLineBreaks: 'Always collapse to one line break (never remove it entirely) when whitespace between tags includes a line break—use with “--collapse-whitespace”',
|
|
146
146
|
preventAttributesEscaping: 'Prevents the escaping of the values of attributes',
|
|
147
147
|
processConditionalComments: 'Process contents of conditional comments through minifier',
|
|
148
148
|
processScripts: ['Array of strings corresponding to types of “script” elements to process through minifier (e.g., “text/ng-template”, “text/x-handlebars-template”, etc.)', parseJSONArray],
|
|
@@ -151,6 +151,7 @@ const mainOptions = {
|
|
|
151
151
|
removeComments: 'Strip HTML comments',
|
|
152
152
|
removeEmptyAttributes: 'Remove all attributes with whitespace-only values',
|
|
153
153
|
removeEmptyElements: 'Remove all elements with empty contents',
|
|
154
|
+
removeEmptyElementsExcept: ['Array of elements to preserve when “--remove-empty-elements” is enabled (e.g., “td”, “["td", "<span aria-hidden=\'true\'>"]”)', parseJSONArray],
|
|
154
155
|
removeOptionalTags: 'Remove unrequired tags',
|
|
155
156
|
removeRedundantAttributes: 'Remove attributes when value matches default',
|
|
156
157
|
removeScriptTypeAttributes: 'Remove “type="text/javascript"” from “script” elements; other “type” attribute values are left intact',
|
package/dist/htmlminifier.cjs
CHANGED
|
@@ -1117,9 +1117,9 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
1117
1117
|
}))).join(', ');
|
|
1118
1118
|
} else if (isMetaViewport(tag, attrs) && attrName === 'content') {
|
|
1119
1119
|
attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1120
|
+
// “0.90000” → “0.9”
|
|
1121
|
+
// “1.0” → “1”
|
|
1122
|
+
// “1.0001” → “1.0001” (unchanged)
|
|
1123
1123
|
return (+numString).toString();
|
|
1124
1124
|
});
|
|
1125
1125
|
} else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
|
|
@@ -1390,6 +1390,107 @@ function canRemoveElement(tag, attrs) {
|
|
|
1390
1390
|
return true;
|
|
1391
1391
|
}
|
|
1392
1392
|
|
|
1393
|
+
function parseElementSpec(str, options) {
|
|
1394
|
+
if (typeof str !== 'string') {
|
|
1395
|
+
return null;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
const trimmed = str.trim();
|
|
1399
|
+
if (!trimmed) {
|
|
1400
|
+
return null;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// Simple tag name: “td”
|
|
1404
|
+
if (!/[<>]/.test(trimmed)) {
|
|
1405
|
+
return { tag: options.name(trimmed), attrs: null };
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// HTML-like markup: “<span aria-hidden='true'>” or “<td></td>”
|
|
1409
|
+
// Extract opening tag using regex
|
|
1410
|
+
const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
|
|
1411
|
+
if (!match) {
|
|
1412
|
+
return null;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const tag = options.name(match[1]);
|
|
1416
|
+
const attrString = match[2];
|
|
1417
|
+
|
|
1418
|
+
if (!attrString.trim()) {
|
|
1419
|
+
return { tag, attrs: null };
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Parse attributes from string
|
|
1423
|
+
const attrs = {};
|
|
1424
|
+
const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
|
|
1425
|
+
let attrMatch;
|
|
1426
|
+
|
|
1427
|
+
while ((attrMatch = attrRegex.exec(attrString))) {
|
|
1428
|
+
const attrName = options.name(attrMatch[1]);
|
|
1429
|
+
const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
|
|
1430
|
+
// Boolean attributes have no value (undefined)
|
|
1431
|
+
attrs[attrName] = attrValue;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
return {
|
|
1435
|
+
tag,
|
|
1436
|
+
attrs: Object.keys(attrs).length > 0 ? attrs : null
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function parseRemoveEmptyElementsExcept(input, options) {
|
|
1441
|
+
if (!Array.isArray(input)) {
|
|
1442
|
+
return [];
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
return input.map(item => {
|
|
1446
|
+
if (typeof item === 'string') {
|
|
1447
|
+
const spec = parseElementSpec(item, options);
|
|
1448
|
+
if (!spec && options.log) {
|
|
1449
|
+
options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
|
|
1450
|
+
}
|
|
1451
|
+
return spec;
|
|
1452
|
+
}
|
|
1453
|
+
if (options.log) {
|
|
1454
|
+
options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
|
|
1455
|
+
}
|
|
1456
|
+
return null;
|
|
1457
|
+
}).filter(Boolean);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function shouldPreserveEmptyElement(tag, attrs, preserveList) {
|
|
1461
|
+
for (const spec of preserveList) {
|
|
1462
|
+
// Tag name must match
|
|
1463
|
+
if (spec.tag !== tag) {
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// If no attributes specified in spec, tag match is enough
|
|
1468
|
+
if (!spec.attrs) {
|
|
1469
|
+
return true;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Check if all specified attributes match
|
|
1473
|
+
const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
|
|
1474
|
+
const attr = attrs.find(a => a.name === name);
|
|
1475
|
+
if (!attr) {
|
|
1476
|
+
return false; // Attribute not present
|
|
1477
|
+
}
|
|
1478
|
+
// Boolean attribute in spec (undefined value) matches if attribute is present
|
|
1479
|
+
if (value === undefined) {
|
|
1480
|
+
return true;
|
|
1481
|
+
}
|
|
1482
|
+
// Valued attribute must match exactly
|
|
1483
|
+
return attr.value === value;
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
if (allAttrsMatch) {
|
|
1487
|
+
return true;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
return false;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1393
1494
|
function canCollapseWhitespace(tag) {
|
|
1394
1495
|
return !/^(?:script|style|pre|textarea)$/.test(tag);
|
|
1395
1496
|
}
|
|
@@ -1839,6 +1940,17 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1839
1940
|
const inlineTextSet = new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements]);
|
|
1840
1941
|
const inlineElements = new Set([...inlineElementsToKeepWhitespaceAround, ...normalizedCustomElements]);
|
|
1841
1942
|
|
|
1943
|
+
// Parse `removeEmptyElementsExcept` option
|
|
1944
|
+
let removeEmptyElementsExcept;
|
|
1945
|
+
if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
|
|
1946
|
+
if (options.log) {
|
|
1947
|
+
options.log('Warning: "removeEmptyElementsExcept" option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
|
|
1948
|
+
}
|
|
1949
|
+
removeEmptyElementsExcept = [];
|
|
1950
|
+
} else {
|
|
1951
|
+
removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1842
1954
|
// Temporarily replace ignored chunks with comments,
|
|
1843
1955
|
// so that we don’t have to worry what’s there.
|
|
1844
1956
|
// For all we care there might be
|
|
@@ -1866,7 +1978,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1866
1978
|
// Warn about potential ReDoS if custom fragments use unlimited quantifiers
|
|
1867
1979
|
for (let i = 0; i < customFragments.length; i++) {
|
|
1868
1980
|
if (/[*+]/.test(customFragments[i])) {
|
|
1869
|
-
options.log('Warning: Custom fragment contains unlimited quantifiers (
|
|
1981
|
+
options.log('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
|
|
1870
1982
|
break;
|
|
1871
1983
|
}
|
|
1872
1984
|
}
|
|
@@ -2109,10 +2221,32 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
2109
2221
|
}
|
|
2110
2222
|
|
|
2111
2223
|
if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2224
|
+
let preserve = false;
|
|
2225
|
+
if (removeEmptyElementsExcept.length) {
|
|
2226
|
+
// Normalize attribute names for comparison with specs
|
|
2227
|
+
const normalizedAttrs = attrs.map(attr => ({ ...attr, name: options.name(attr.name) }));
|
|
2228
|
+
preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
if (!preserve) {
|
|
2232
|
+
// Remove last “element” from buffer
|
|
2233
|
+
removeStartTag();
|
|
2234
|
+
optionalStartTag = '';
|
|
2235
|
+
optionalEndTag = '';
|
|
2236
|
+
} else {
|
|
2237
|
+
// Preserve the element - add closing tag
|
|
2238
|
+
if (autoGenerated && !options.includeAutoGeneratedTags) {
|
|
2239
|
+
optionalEndTag = '';
|
|
2240
|
+
} else {
|
|
2241
|
+
buffer.push('</' + tag + '>');
|
|
2242
|
+
}
|
|
2243
|
+
charsPrevTag = '/' + tag;
|
|
2244
|
+
if (!inlineElements.has(tag)) {
|
|
2245
|
+
currentChars = '';
|
|
2246
|
+
} else if (isElementEmpty) {
|
|
2247
|
+
currentChars += '|';
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2116
2250
|
} else {
|
|
2117
2251
|
if (autoGenerated && !options.includeAutoGeneratedTags) {
|
|
2118
2252
|
optionalEndTag = '';
|
|
@@ -2393,7 +2527,7 @@ var htmlminifier = { minify, presets, getPreset, getPresetNames };
|
|
|
2393
2527
|
*
|
|
2394
2528
|
* @prop {boolean} [collapseBooleanAttributes]
|
|
2395
2529
|
* Collapse boolean attributes to their name only (for example
|
|
2396
|
-
* `disabled="disabled"`
|
|
2530
|
+
* `disabled="disabled"` → `disabled`).
|
|
2397
2531
|
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes
|
|
2398
2532
|
*
|
|
2399
2533
|
* Default: `false`
|
|
@@ -2637,6 +2771,31 @@ var htmlminifier = { minify, presets, getPreset, getPresetNames };
|
|
|
2637
2771
|
*
|
|
2638
2772
|
* Default: `false`
|
|
2639
2773
|
*
|
|
2774
|
+
* @prop {string[]} [removeEmptyElementsExcept]
|
|
2775
|
+
* Specifies empty elements to preserve when `removeEmptyElements` is enabled.
|
|
2776
|
+
* Has no effect unless `removeEmptyElements: true`.
|
|
2777
|
+
*
|
|
2778
|
+
* Accepts tag names or HTML-like element specifications:
|
|
2779
|
+
*
|
|
2780
|
+
* * Tag name only: `["td", "span"]`—preserves all empty elements of these types
|
|
2781
|
+
* * With valued attributes: `["<span aria-hidden='true'>"]`—preserves only when attribute values match
|
|
2782
|
+
* * With boolean attributes: `["<input disabled>"]`—preserves only when boolean attribute is present
|
|
2783
|
+
* * Mixed: `["<button type='button' disabled>"]`—all specified attributes must match
|
|
2784
|
+
*
|
|
2785
|
+
* Attribute matching:
|
|
2786
|
+
*
|
|
2787
|
+
* * All specified attributes must be present and match (valued attributes must have exact values)
|
|
2788
|
+
* * Additional attributes on the element are allowed
|
|
2789
|
+
* * Attribute name matching respects the `caseSensitive` option
|
|
2790
|
+
* * Supports double quotes, single quotes, and unquoted attribute values in specifications
|
|
2791
|
+
*
|
|
2792
|
+
* Limitations:
|
|
2793
|
+
*
|
|
2794
|
+
* * Self-closing syntax (e.g., `["<span/>"]`) is not supported; use `["span"]` instead
|
|
2795
|
+
* * Definitions containing `>` within quoted attribute values (e.g., `["<span title='a>b'>"]`) are not supported
|
|
2796
|
+
*
|
|
2797
|
+
* Default: `[]`
|
|
2798
|
+
*
|
|
2640
2799
|
* @prop {boolean} [removeOptionalTags]
|
|
2641
2800
|
* Drop optional start/end tags where the HTML specification permits it
|
|
2642
2801
|
* (for example `</li>`, optional `<html>` etc.).
|
|
@@ -2664,7 +2823,7 @@ var htmlminifier = { minify, presets, getPreset, getPresetNames };
|
|
|
2664
2823
|
* Default: `false`
|
|
2665
2824
|
*
|
|
2666
2825
|
* @prop {boolean} [removeTagWhitespace]
|
|
2667
|
-
* **Note that this will
|
|
2826
|
+
* **Note that this will result in invalid HTML!**
|
|
2668
2827
|
*
|
|
2669
2828
|
* When true, extra whitespace between tag name and attributes (or before
|
|
2670
2829
|
* the closing bracket) will be removed where possible. Affects output spacing
|
|
@@ -40170,9 +40170,9 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
40170
40170
|
}))).join(', ');
|
|
40171
40171
|
} else if (isMetaViewport(tag, attrs) && attrName === 'content') {
|
|
40172
40172
|
attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
|
|
40173
|
-
//
|
|
40174
|
-
//
|
|
40175
|
-
//
|
|
40173
|
+
// “0.90000” → “0.9”
|
|
40174
|
+
// “1.0” → “1”
|
|
40175
|
+
// “1.0001” → “1.0001” (unchanged)
|
|
40176
40176
|
return (+numString).toString();
|
|
40177
40177
|
});
|
|
40178
40178
|
} else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
|
|
@@ -40443,6 +40443,107 @@ function canRemoveElement(tag, attrs) {
|
|
|
40443
40443
|
return true;
|
|
40444
40444
|
}
|
|
40445
40445
|
|
|
40446
|
+
function parseElementSpec(str, options) {
|
|
40447
|
+
if (typeof str !== 'string') {
|
|
40448
|
+
return null;
|
|
40449
|
+
}
|
|
40450
|
+
|
|
40451
|
+
const trimmed = str.trim();
|
|
40452
|
+
if (!trimmed) {
|
|
40453
|
+
return null;
|
|
40454
|
+
}
|
|
40455
|
+
|
|
40456
|
+
// Simple tag name: “td”
|
|
40457
|
+
if (!/[<>]/.test(trimmed)) {
|
|
40458
|
+
return { tag: options.name(trimmed), attrs: null };
|
|
40459
|
+
}
|
|
40460
|
+
|
|
40461
|
+
// HTML-like markup: “<span aria-hidden='true'>” or “<td></td>”
|
|
40462
|
+
// Extract opening tag using regex
|
|
40463
|
+
const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
|
|
40464
|
+
if (!match) {
|
|
40465
|
+
return null;
|
|
40466
|
+
}
|
|
40467
|
+
|
|
40468
|
+
const tag = options.name(match[1]);
|
|
40469
|
+
const attrString = match[2];
|
|
40470
|
+
|
|
40471
|
+
if (!attrString.trim()) {
|
|
40472
|
+
return { tag, attrs: null };
|
|
40473
|
+
}
|
|
40474
|
+
|
|
40475
|
+
// Parse attributes from string
|
|
40476
|
+
const attrs = {};
|
|
40477
|
+
const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
|
|
40478
|
+
let attrMatch;
|
|
40479
|
+
|
|
40480
|
+
while ((attrMatch = attrRegex.exec(attrString))) {
|
|
40481
|
+
const attrName = options.name(attrMatch[1]);
|
|
40482
|
+
const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
|
|
40483
|
+
// Boolean attributes have no value (undefined)
|
|
40484
|
+
attrs[attrName] = attrValue;
|
|
40485
|
+
}
|
|
40486
|
+
|
|
40487
|
+
return {
|
|
40488
|
+
tag,
|
|
40489
|
+
attrs: Object.keys(attrs).length > 0 ? attrs : null
|
|
40490
|
+
};
|
|
40491
|
+
}
|
|
40492
|
+
|
|
40493
|
+
function parseRemoveEmptyElementsExcept(input, options) {
|
|
40494
|
+
if (!Array.isArray(input)) {
|
|
40495
|
+
return [];
|
|
40496
|
+
}
|
|
40497
|
+
|
|
40498
|
+
return input.map(item => {
|
|
40499
|
+
if (typeof item === 'string') {
|
|
40500
|
+
const spec = parseElementSpec(item, options);
|
|
40501
|
+
if (!spec && options.log) {
|
|
40502
|
+
options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
|
|
40503
|
+
}
|
|
40504
|
+
return spec;
|
|
40505
|
+
}
|
|
40506
|
+
if (options.log) {
|
|
40507
|
+
options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
|
|
40508
|
+
}
|
|
40509
|
+
return null;
|
|
40510
|
+
}).filter(Boolean);
|
|
40511
|
+
}
|
|
40512
|
+
|
|
40513
|
+
function shouldPreserveEmptyElement(tag, attrs, preserveList) {
|
|
40514
|
+
for (const spec of preserveList) {
|
|
40515
|
+
// Tag name must match
|
|
40516
|
+
if (spec.tag !== tag) {
|
|
40517
|
+
continue;
|
|
40518
|
+
}
|
|
40519
|
+
|
|
40520
|
+
// If no attributes specified in spec, tag match is enough
|
|
40521
|
+
if (!spec.attrs) {
|
|
40522
|
+
return true;
|
|
40523
|
+
}
|
|
40524
|
+
|
|
40525
|
+
// Check if all specified attributes match
|
|
40526
|
+
const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
|
|
40527
|
+
const attr = attrs.find(a => a.name === name);
|
|
40528
|
+
if (!attr) {
|
|
40529
|
+
return false; // Attribute not present
|
|
40530
|
+
}
|
|
40531
|
+
// Boolean attribute in spec (undefined value) matches if attribute is present
|
|
40532
|
+
if (value === undefined) {
|
|
40533
|
+
return true;
|
|
40534
|
+
}
|
|
40535
|
+
// Valued attribute must match exactly
|
|
40536
|
+
return attr.value === value;
|
|
40537
|
+
});
|
|
40538
|
+
|
|
40539
|
+
if (allAttrsMatch) {
|
|
40540
|
+
return true;
|
|
40541
|
+
}
|
|
40542
|
+
}
|
|
40543
|
+
|
|
40544
|
+
return false;
|
|
40545
|
+
}
|
|
40546
|
+
|
|
40446
40547
|
function canCollapseWhitespace(tag) {
|
|
40447
40548
|
return !/^(?:script|style|pre|textarea)$/.test(tag);
|
|
40448
40549
|
}
|
|
@@ -40892,6 +40993,17 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
40892
40993
|
const inlineTextSet = new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements]);
|
|
40893
40994
|
const inlineElements = new Set([...inlineElementsToKeepWhitespaceAround, ...normalizedCustomElements]);
|
|
40894
40995
|
|
|
40996
|
+
// Parse `removeEmptyElementsExcept` option
|
|
40997
|
+
let removeEmptyElementsExcept;
|
|
40998
|
+
if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
|
|
40999
|
+
if (options.log) {
|
|
41000
|
+
options.log('Warning: "removeEmptyElementsExcept" option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
|
|
41001
|
+
}
|
|
41002
|
+
removeEmptyElementsExcept = [];
|
|
41003
|
+
} else {
|
|
41004
|
+
removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
|
|
41005
|
+
}
|
|
41006
|
+
|
|
40895
41007
|
// Temporarily replace ignored chunks with comments,
|
|
40896
41008
|
// so that we don’t have to worry what’s there.
|
|
40897
41009
|
// For all we care there might be
|
|
@@ -40919,7 +41031,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
40919
41031
|
// Warn about potential ReDoS if custom fragments use unlimited quantifiers
|
|
40920
41032
|
for (let i = 0; i < customFragments.length; i++) {
|
|
40921
41033
|
if (/[*+]/.test(customFragments[i])) {
|
|
40922
|
-
options.log('Warning: Custom fragment contains unlimited quantifiers (
|
|
41034
|
+
options.log('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
|
|
40923
41035
|
break;
|
|
40924
41036
|
}
|
|
40925
41037
|
}
|
|
@@ -41162,10 +41274,32 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
41162
41274
|
}
|
|
41163
41275
|
|
|
41164
41276
|
if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
|
|
41165
|
-
|
|
41166
|
-
|
|
41167
|
-
|
|
41168
|
-
|
|
41277
|
+
let preserve = false;
|
|
41278
|
+
if (removeEmptyElementsExcept.length) {
|
|
41279
|
+
// Normalize attribute names for comparison with specs
|
|
41280
|
+
const normalizedAttrs = attrs.map(attr => ({ ...attr, name: options.name(attr.name) }));
|
|
41281
|
+
preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
|
|
41282
|
+
}
|
|
41283
|
+
|
|
41284
|
+
if (!preserve) {
|
|
41285
|
+
// Remove last “element” from buffer
|
|
41286
|
+
removeStartTag();
|
|
41287
|
+
optionalStartTag = '';
|
|
41288
|
+
optionalEndTag = '';
|
|
41289
|
+
} else {
|
|
41290
|
+
// Preserve the element - add closing tag
|
|
41291
|
+
if (autoGenerated && !options.includeAutoGeneratedTags) {
|
|
41292
|
+
optionalEndTag = '';
|
|
41293
|
+
} else {
|
|
41294
|
+
buffer.push('</' + tag + '>');
|
|
41295
|
+
}
|
|
41296
|
+
charsPrevTag = '/' + tag;
|
|
41297
|
+
if (!inlineElements.has(tag)) {
|
|
41298
|
+
currentChars = '';
|
|
41299
|
+
} else if (isElementEmpty) {
|
|
41300
|
+
currentChars += '|';
|
|
41301
|
+
}
|
|
41302
|
+
}
|
|
41169
41303
|
} else {
|
|
41170
41304
|
if (autoGenerated && !options.includeAutoGeneratedTags) {
|
|
41171
41305
|
optionalEndTag = '';
|
|
@@ -41446,7 +41580,7 @@ var htmlminifier = { minify, presets, getPreset, getPresetNames };
|
|
|
41446
41580
|
*
|
|
41447
41581
|
* @prop {boolean} [collapseBooleanAttributes]
|
|
41448
41582
|
* Collapse boolean attributes to their name only (for example
|
|
41449
|
-
* `disabled="disabled"`
|
|
41583
|
+
* `disabled="disabled"` → `disabled`).
|
|
41450
41584
|
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes
|
|
41451
41585
|
*
|
|
41452
41586
|
* Default: `false`
|
|
@@ -41690,6 +41824,31 @@ var htmlminifier = { minify, presets, getPreset, getPresetNames };
|
|
|
41690
41824
|
*
|
|
41691
41825
|
* Default: `false`
|
|
41692
41826
|
*
|
|
41827
|
+
* @prop {string[]} [removeEmptyElementsExcept]
|
|
41828
|
+
* Specifies empty elements to preserve when `removeEmptyElements` is enabled.
|
|
41829
|
+
* Has no effect unless `removeEmptyElements: true`.
|
|
41830
|
+
*
|
|
41831
|
+
* Accepts tag names or HTML-like element specifications:
|
|
41832
|
+
*
|
|
41833
|
+
* * Tag name only: `["td", "span"]`—preserves all empty elements of these types
|
|
41834
|
+
* * With valued attributes: `["<span aria-hidden='true'>"]`—preserves only when attribute values match
|
|
41835
|
+
* * With boolean attributes: `["<input disabled>"]`—preserves only when boolean attribute is present
|
|
41836
|
+
* * Mixed: `["<button type='button' disabled>"]`—all specified attributes must match
|
|
41837
|
+
*
|
|
41838
|
+
* Attribute matching:
|
|
41839
|
+
*
|
|
41840
|
+
* * All specified attributes must be present and match (valued attributes must have exact values)
|
|
41841
|
+
* * Additional attributes on the element are allowed
|
|
41842
|
+
* * Attribute name matching respects the `caseSensitive` option
|
|
41843
|
+
* * Supports double quotes, single quotes, and unquoted attribute values in specifications
|
|
41844
|
+
*
|
|
41845
|
+
* Limitations:
|
|
41846
|
+
*
|
|
41847
|
+
* * Self-closing syntax (e.g., `["<span/>"]`) is not supported; use `["span"]` instead
|
|
41848
|
+
* * Definitions containing `>` within quoted attribute values (e.g., `["<span title='a>b'>"]`) are not supported
|
|
41849
|
+
*
|
|
41850
|
+
* Default: `[]`
|
|
41851
|
+
*
|
|
41693
41852
|
* @prop {boolean} [removeOptionalTags]
|
|
41694
41853
|
* Drop optional start/end tags where the HTML specification permits it
|
|
41695
41854
|
* (for example `</li>`, optional `<html>` etc.).
|
|
@@ -41717,7 +41876,7 @@ var htmlminifier = { minify, presets, getPreset, getPresetNames };
|
|
|
41717
41876
|
* Default: `false`
|
|
41718
41877
|
*
|
|
41719
41878
|
* @prop {boolean} [removeTagWhitespace]
|
|
41720
|
-
* **Note that this will
|
|
41879
|
+
* **Note that this will result in invalid HTML!**
|
|
41721
41880
|
*
|
|
41722
41881
|
* When true, extra whitespace between tag name and attributes (or before
|
|
41723
41882
|
* the closing bracket) will be removed where possible. Affects output spacing
|
|
@@ -46,7 +46,7 @@ export type MinifierOptions = {
|
|
|
46
46
|
caseSensitive?: boolean;
|
|
47
47
|
/**
|
|
48
48
|
* Collapse boolean attributes to their name only (for example
|
|
49
|
-
* `disabled="disabled"`
|
|
49
|
+
* `disabled="disabled"` → `disabled`).
|
|
50
50
|
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes
|
|
51
51
|
*
|
|
52
52
|
* Default: `false`
|
|
@@ -326,6 +326,32 @@ export type MinifierOptions = {
|
|
|
326
326
|
* Default: `false`
|
|
327
327
|
*/
|
|
328
328
|
removeEmptyElements?: boolean;
|
|
329
|
+
/**
|
|
330
|
+
* Specifies empty elements to preserve when `removeEmptyElements` is enabled.
|
|
331
|
+
* Has no effect unless `removeEmptyElements: true`.
|
|
332
|
+
*
|
|
333
|
+
* Accepts tag names or HTML-like element specifications:
|
|
334
|
+
*
|
|
335
|
+
* * Tag name only: `["td", "span"]`—preserves all empty elements of these types
|
|
336
|
+
* * With valued attributes: `["<span aria-hidden='true'>"]`—preserves only when attribute values match
|
|
337
|
+
* * With boolean attributes: `["<input disabled>"]`—preserves only when boolean attribute is present
|
|
338
|
+
* * Mixed: `["<button type='button' disabled>"]`—all specified attributes must match
|
|
339
|
+
*
|
|
340
|
+
* Attribute matching:
|
|
341
|
+
*
|
|
342
|
+
* * All specified attributes must be present and match (valued attributes must have exact values)
|
|
343
|
+
* * Additional attributes on the element are allowed
|
|
344
|
+
* * Attribute name matching respects the `caseSensitive` option
|
|
345
|
+
* * Supports double quotes, single quotes, and unquoted attribute values in specifications
|
|
346
|
+
*
|
|
347
|
+
* Limitations:
|
|
348
|
+
*
|
|
349
|
+
* * Self-closing syntax (e.g., `["<span/>"]`) is not supported; use `["span"]` instead
|
|
350
|
+
* * Definitions containing `>` within quoted attribute values (e.g., `["<span title='a>b'>"]`) are not supported
|
|
351
|
+
*
|
|
352
|
+
* Default: `[]`
|
|
353
|
+
*/
|
|
354
|
+
removeEmptyElementsExcept?: string[];
|
|
329
355
|
/**
|
|
330
356
|
* Drop optional start/end tags where the HTML specification permits it
|
|
331
357
|
* (for example `</li>`, optional `<html>` etc.).
|
|
@@ -357,7 +383,7 @@ export type MinifierOptions = {
|
|
|
357
383
|
*/
|
|
358
384
|
removeStyleLinkTypeAttributes?: boolean;
|
|
359
385
|
/**
|
|
360
|
-
* **Note that this will
|
|
386
|
+
* **Note that this will result in invalid HTML!**
|
|
361
387
|
*
|
|
362
388
|
* When true, extra whitespace between tag name and attributes (or before
|
|
363
389
|
* the closing bracket) will be removed where possible. Affects output spacing
|
|
@@ -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":"AAwvDO,8BAJI,MAAM,YACN,eAAe,GACb,OAAO,CAAC,MAAM,CAAC,CAQ3B;;;;;;;;;;;;UAUS,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;;;;;;;;gCAOP,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;;wBA1mEkC,cAAc;0BAAd,cAAc;+BAAd,cAAc"}
|
package/package.json
CHANGED
package/src/htmlminifier.js
CHANGED
|
@@ -420,9 +420,9 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
|
|
|
420
420
|
}))).join(', ');
|
|
421
421
|
} else if (isMetaViewport(tag, attrs) && attrName === 'content') {
|
|
422
422
|
attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
423
|
+
// “0.90000” → “0.9”
|
|
424
|
+
// “1.0” → “1”
|
|
425
|
+
// “1.0001” → “1.0001” (unchanged)
|
|
426
426
|
return (+numString).toString();
|
|
427
427
|
});
|
|
428
428
|
} else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
|
|
@@ -693,6 +693,107 @@ function canRemoveElement(tag, attrs) {
|
|
|
693
693
|
return true;
|
|
694
694
|
}
|
|
695
695
|
|
|
696
|
+
function parseElementSpec(str, options) {
|
|
697
|
+
if (typeof str !== 'string') {
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const trimmed = str.trim();
|
|
702
|
+
if (!trimmed) {
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Simple tag name: “td”
|
|
707
|
+
if (!/[<>]/.test(trimmed)) {
|
|
708
|
+
return { tag: options.name(trimmed), attrs: null };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// HTML-like markup: “<span aria-hidden='true'>” or “<td></td>”
|
|
712
|
+
// Extract opening tag using regex
|
|
713
|
+
const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
|
|
714
|
+
if (!match) {
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const tag = options.name(match[1]);
|
|
719
|
+
const attrString = match[2];
|
|
720
|
+
|
|
721
|
+
if (!attrString.trim()) {
|
|
722
|
+
return { tag, attrs: null };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Parse attributes from string
|
|
726
|
+
const attrs = {};
|
|
727
|
+
const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
|
|
728
|
+
let attrMatch;
|
|
729
|
+
|
|
730
|
+
while ((attrMatch = attrRegex.exec(attrString))) {
|
|
731
|
+
const attrName = options.name(attrMatch[1]);
|
|
732
|
+
const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
|
|
733
|
+
// Boolean attributes have no value (undefined)
|
|
734
|
+
attrs[attrName] = attrValue;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return {
|
|
738
|
+
tag,
|
|
739
|
+
attrs: Object.keys(attrs).length > 0 ? attrs : null
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function parseRemoveEmptyElementsExcept(input, options) {
|
|
744
|
+
if (!Array.isArray(input)) {
|
|
745
|
+
return [];
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return input.map(item => {
|
|
749
|
+
if (typeof item === 'string') {
|
|
750
|
+
const spec = parseElementSpec(item, options);
|
|
751
|
+
if (!spec && options.log) {
|
|
752
|
+
options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
|
|
753
|
+
}
|
|
754
|
+
return spec;
|
|
755
|
+
}
|
|
756
|
+
if (options.log) {
|
|
757
|
+
options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
|
|
758
|
+
}
|
|
759
|
+
return null;
|
|
760
|
+
}).filter(Boolean);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function shouldPreserveEmptyElement(tag, attrs, preserveList) {
|
|
764
|
+
for (const spec of preserveList) {
|
|
765
|
+
// Tag name must match
|
|
766
|
+
if (spec.tag !== tag) {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// If no attributes specified in spec, tag match is enough
|
|
771
|
+
if (!spec.attrs) {
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Check if all specified attributes match
|
|
776
|
+
const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
|
|
777
|
+
const attr = attrs.find(a => a.name === name);
|
|
778
|
+
if (!attr) {
|
|
779
|
+
return false; // Attribute not present
|
|
780
|
+
}
|
|
781
|
+
// Boolean attribute in spec (undefined value) matches if attribute is present
|
|
782
|
+
if (value === undefined) {
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
785
|
+
// Valued attribute must match exactly
|
|
786
|
+
return attr.value === value;
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
if (allAttrsMatch) {
|
|
790
|
+
return true;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
796
|
+
|
|
696
797
|
function canCollapseWhitespace(tag) {
|
|
697
798
|
return !/^(?:script|style|pre|textarea)$/.test(tag);
|
|
698
799
|
}
|
|
@@ -1142,6 +1243,17 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1142
1243
|
const inlineTextSet = new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements]);
|
|
1143
1244
|
const inlineElements = new Set([...inlineElementsToKeepWhitespaceAround, ...normalizedCustomElements]);
|
|
1144
1245
|
|
|
1246
|
+
// Parse `removeEmptyElementsExcept` option
|
|
1247
|
+
let removeEmptyElementsExcept;
|
|
1248
|
+
if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
|
|
1249
|
+
if (options.log) {
|
|
1250
|
+
options.log('Warning: "removeEmptyElementsExcept" option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
|
|
1251
|
+
}
|
|
1252
|
+
removeEmptyElementsExcept = [];
|
|
1253
|
+
} else {
|
|
1254
|
+
removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1145
1257
|
// Temporarily replace ignored chunks with comments,
|
|
1146
1258
|
// so that we don’t have to worry what’s there.
|
|
1147
1259
|
// For all we care there might be
|
|
@@ -1169,7 +1281,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1169
1281
|
// Warn about potential ReDoS if custom fragments use unlimited quantifiers
|
|
1170
1282
|
for (let i = 0; i < customFragments.length; i++) {
|
|
1171
1283
|
if (/[*+]/.test(customFragments[i])) {
|
|
1172
|
-
options.log('Warning: Custom fragment contains unlimited quantifiers (
|
|
1284
|
+
options.log('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
|
|
1173
1285
|
break;
|
|
1174
1286
|
}
|
|
1175
1287
|
}
|
|
@@ -1412,10 +1524,32 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1412
1524
|
}
|
|
1413
1525
|
|
|
1414
1526
|
if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1527
|
+
let preserve = false;
|
|
1528
|
+
if (removeEmptyElementsExcept.length) {
|
|
1529
|
+
// Normalize attribute names for comparison with specs
|
|
1530
|
+
const normalizedAttrs = attrs.map(attr => ({ ...attr, name: options.name(attr.name) }));
|
|
1531
|
+
preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
if (!preserve) {
|
|
1535
|
+
// Remove last “element” from buffer
|
|
1536
|
+
removeStartTag();
|
|
1537
|
+
optionalStartTag = '';
|
|
1538
|
+
optionalEndTag = '';
|
|
1539
|
+
} else {
|
|
1540
|
+
// Preserve the element - add closing tag
|
|
1541
|
+
if (autoGenerated && !options.includeAutoGeneratedTags) {
|
|
1542
|
+
optionalEndTag = '';
|
|
1543
|
+
} else {
|
|
1544
|
+
buffer.push('</' + tag + '>');
|
|
1545
|
+
}
|
|
1546
|
+
charsPrevTag = '/' + tag;
|
|
1547
|
+
if (!inlineElements.has(tag)) {
|
|
1548
|
+
currentChars = '';
|
|
1549
|
+
} else if (isElementEmpty) {
|
|
1550
|
+
currentChars += '|';
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1419
1553
|
} else {
|
|
1420
1554
|
if (autoGenerated && !options.includeAutoGeneratedTags) {
|
|
1421
1555
|
optionalEndTag = '';
|
|
@@ -1698,7 +1832,7 @@ export default { minify, presets, getPreset, getPresetNames };
|
|
|
1698
1832
|
*
|
|
1699
1833
|
* @prop {boolean} [collapseBooleanAttributes]
|
|
1700
1834
|
* Collapse boolean attributes to their name only (for example
|
|
1701
|
-
* `disabled="disabled"`
|
|
1835
|
+
* `disabled="disabled"` → `disabled`).
|
|
1702
1836
|
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes
|
|
1703
1837
|
*
|
|
1704
1838
|
* Default: `false`
|
|
@@ -1942,6 +2076,31 @@ export default { minify, presets, getPreset, getPresetNames };
|
|
|
1942
2076
|
*
|
|
1943
2077
|
* Default: `false`
|
|
1944
2078
|
*
|
|
2079
|
+
* @prop {string[]} [removeEmptyElementsExcept]
|
|
2080
|
+
* Specifies empty elements to preserve when `removeEmptyElements` is enabled.
|
|
2081
|
+
* Has no effect unless `removeEmptyElements: true`.
|
|
2082
|
+
*
|
|
2083
|
+
* Accepts tag names or HTML-like element specifications:
|
|
2084
|
+
*
|
|
2085
|
+
* * Tag name only: `["td", "span"]`—preserves all empty elements of these types
|
|
2086
|
+
* * With valued attributes: `["<span aria-hidden='true'>"]`—preserves only when attribute values match
|
|
2087
|
+
* * With boolean attributes: `["<input disabled>"]`—preserves only when boolean attribute is present
|
|
2088
|
+
* * Mixed: `["<button type='button' disabled>"]`—all specified attributes must match
|
|
2089
|
+
*
|
|
2090
|
+
* Attribute matching:
|
|
2091
|
+
*
|
|
2092
|
+
* * All specified attributes must be present and match (valued attributes must have exact values)
|
|
2093
|
+
* * Additional attributes on the element are allowed
|
|
2094
|
+
* * Attribute name matching respects the `caseSensitive` option
|
|
2095
|
+
* * Supports double quotes, single quotes, and unquoted attribute values in specifications
|
|
2096
|
+
*
|
|
2097
|
+
* Limitations:
|
|
2098
|
+
*
|
|
2099
|
+
* * Self-closing syntax (e.g., `["<span/>"]`) is not supported; use `["span"]` instead
|
|
2100
|
+
* * Definitions containing `>` within quoted attribute values (e.g., `["<span title='a>b'>"]`) are not supported
|
|
2101
|
+
*
|
|
2102
|
+
* Default: `[]`
|
|
2103
|
+
*
|
|
1945
2104
|
* @prop {boolean} [removeOptionalTags]
|
|
1946
2105
|
* Drop optional start/end tags where the HTML specification permits it
|
|
1947
2106
|
* (for example `</li>`, optional `<html>` etc.).
|
|
@@ -1969,7 +2128,7 @@ export default { minify, presets, getPreset, getPresetNames };
|
|
|
1969
2128
|
* Default: `false`
|
|
1970
2129
|
*
|
|
1971
2130
|
* @prop {boolean} [removeTagWhitespace]
|
|
1972
|
-
* **Note that this will
|
|
2131
|
+
* **Note that this will result in invalid HTML!**
|
|
1973
2132
|
*
|
|
1974
2133
|
* When true, extra whitespace between tag name and attributes (or before
|
|
1975
2134
|
* the closing bracket) will be removed where possible. Affects output spacing
|