html-minifier-next 7.5.3 → 8.0.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 +60 -24
- package/cli.js +178 -55
- package/dist/types/htmlminifier.d.ts +24 -4
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/lib/attributes.d.ts +2 -2
- package/dist/types/lib/attributes.d.ts.map +1 -1
- package/dist/types/lib/constants.d.ts +3 -2
- package/dist/types/lib/constants.d.ts.map +1 -1
- package/dist/types/lib/elements.d.ts +1 -1
- package/dist/types/lib/fragments.d.ts +46 -0
- package/dist/types/lib/fragments.d.ts.map +1 -0
- package/dist/types/lib/option-definitions.d.ts +21 -194
- package/dist/types/lib/option-definitions.d.ts.map +1 -1
- package/dist/types/lib/options.d.ts +33 -5
- package/dist/types/lib/options.d.ts.map +1 -1
- package/dist/types/lib/unused-css.d.ts +43 -0
- package/dist/types/lib/unused-css.d.ts.map +1 -0
- package/dist/types/lib/utils.d.ts +26 -1
- package/dist/types/lib/utils.d.ts.map +1 -1
- package/dist/types/tokenchain.d.ts +2 -2
- package/dist/types/tokenchain.d.ts.map +1 -1
- package/html-minifier-next.schema.json +11 -8
- package/package.json +2 -2
- package/src/htmlminifier.js +86 -88
- package/src/htmlparser.js +4 -4
- package/src/lib/attributes.js +65 -8
- package/src/lib/constants.js +8 -8
- package/src/lib/elements.js +3 -3
- package/src/lib/fragments.js +274 -0
- package/src/lib/option-definitions.js +19 -7
- package/src/lib/options.js +151 -28
- package/src/lib/unused-css.js +377 -0
- package/src/lib/utils.js +330 -2
- package/src/tokenchain.js +37 -30
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom fragment matching
|
|
3
|
+
*
|
|
4
|
+
* `ignoreCustomFragments` patterns nearly always describe the same shape: a literal
|
|
5
|
+
* opening delimiter, an any-character or negated-class body, and a literal closing
|
|
6
|
+
* delimiter, as in `<%[\s\S]*?%>` or `\{\{[^}]*?\}\}`. Such a fragment can be found
|
|
7
|
+
* with `indexOf` in linear time, where running the pattern as a regex costs O(n²)
|
|
8
|
+
* on input that opens fragments it never closes—and can cost far more than that when
|
|
9
|
+
* the pattern itself backtracks. Patterns of other shapes keep running as regexes,
|
|
10
|
+
* one per pattern, so each keeps its own flags.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const RE_WHITESPACE = /\s/;
|
|
14
|
+
|
|
15
|
+
// Any-character and negated-class bodies; without the `s` flag, `.` is itself a
|
|
16
|
+
// negated class, excluding line terminators
|
|
17
|
+
const RE_DELIMITED = /^(.*?)(?:\[\\s\\S\]|\[\\S\\s\]|\[\^\]|(\.)|\[\^((?:\\[^]|[^\]\\])+)\])(?:([*+])|\{(\d+)(?:,(\d*))?\})\?(.*)$/;
|
|
18
|
+
|
|
19
|
+
// Flags that leave literal matching alone, so a pattern carrying them can still be
|
|
20
|
+
// scanned for; `i` in particular cannot, since case folding moves character indexes
|
|
21
|
+
const RE_LITERAL_FLAGS = /^[gds]*$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {{open: string, close: string, min: number, max: number, excluded: RegExp | null}} DelimitedFragment
|
|
25
|
+
* Literal delimiters around a body, found by scanning; `excluded` holds the
|
|
26
|
+
* characters a negated-class body cannot cross, null when the body spans every
|
|
27
|
+
* character
|
|
28
|
+
* @typedef {{search: RegExp, anchored: RegExp}} PatternFragment
|
|
29
|
+
* Everything else, found by running the pattern itself
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a regex source as a literal string, so it can be matched with `indexOf`
|
|
34
|
+
* @param {string} source
|
|
35
|
+
* @returns {string | null} Null when the source is more than literal characters
|
|
36
|
+
*/
|
|
37
|
+
function toLiteral(source) {
|
|
38
|
+
let literal = '';
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < source.length; i++) {
|
|
41
|
+
const char = source[i] ?? '';
|
|
42
|
+
if (char === '\\') {
|
|
43
|
+
const escaped = source[++i];
|
|
44
|
+
// A trailing backslash is half an escape the body token was split out of,
|
|
45
|
+
// as in `<%\.*?%>`, where the `.` is literal and not a body
|
|
46
|
+
if (escaped === undefined) return null;
|
|
47
|
+
// `\n` and friends are literal characters, `\s` and `\1` are not
|
|
48
|
+
if (escaped === 'n') literal += '\n';
|
|
49
|
+
else if (escaped === 't') literal += '\t';
|
|
50
|
+
else if (escaped === 'r') literal += '\r';
|
|
51
|
+
else if (escaped === 'f') literal += '\f';
|
|
52
|
+
else if (escaped === 'v') literal += '\v';
|
|
53
|
+
else if (/[A-Za-z0-9]/.test(escaped)) return null;
|
|
54
|
+
else literal += escaped;
|
|
55
|
+
} else if ('.*+?()[]{}|^$'.includes(char)) {
|
|
56
|
+
return null;
|
|
57
|
+
} else {
|
|
58
|
+
literal += char;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return literal;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Describe a fragment pattern as literal delimiters around an any-character body
|
|
67
|
+
* @param {RegExp} pattern
|
|
68
|
+
* @returns {DelimitedFragment | null} Null when the pattern has another shape, which
|
|
69
|
+
* the caller answers by running it as a regex
|
|
70
|
+
*/
|
|
71
|
+
function toDelimitedFragment(pattern) {
|
|
72
|
+
if (!RE_LITERAL_FLAGS.test(pattern.flags)) return null;
|
|
73
|
+
|
|
74
|
+
const match = RE_DELIMITED.exec(pattern.source);
|
|
75
|
+
if (!match) return null;
|
|
76
|
+
|
|
77
|
+
const [, rawOpen, dot, negated, simple, exact, upper, rawClose] = match;
|
|
78
|
+
const open = toLiteral(rawOpen ?? '');
|
|
79
|
+
const close = toLiteral(rawClose ?? '');
|
|
80
|
+
// Both delimiters have to be there: without them a match has no boundary to scan to
|
|
81
|
+
if (!open || !close) return null;
|
|
82
|
+
|
|
83
|
+
// The class content doubles as the search for characters the body cannot cross;
|
|
84
|
+
// a `.` body spans every character under the `s` flag, and excludes line
|
|
85
|
+
// terminators without it
|
|
86
|
+
const inner = dot ? (pattern.flags.includes('s') ? null : '\\n\\r\\u2028\\u2029') : negated ?? null;
|
|
87
|
+
|
|
88
|
+
const min = simple ? (simple === '+' ? 1 : 0) : Number(exact);
|
|
89
|
+
const max = simple || upper === '' ? Infinity : Number(upper ?? exact);
|
|
90
|
+
|
|
91
|
+
return { open, close, min, max, excluded: inner === null ? null : new RegExp('[' + inner + ']', 'g') };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Prepare a fragment pattern for matching, by scanning where the shape allows it
|
|
96
|
+
* @param {RegExp} pattern
|
|
97
|
+
* @returns {DelimitedFragment | PatternFragment}
|
|
98
|
+
*/
|
|
99
|
+
function toFragment(pattern) {
|
|
100
|
+
const delimited = toDelimitedFragment(pattern);
|
|
101
|
+
if (delimited) return delimited;
|
|
102
|
+
|
|
103
|
+
// `g` and `y` are ours to set, the rest belong to the pattern
|
|
104
|
+
const flags = pattern.flags.replace(/[gy]/g, '');
|
|
105
|
+
return { search: new RegExp(pattern.source, flags + 'g'), anchored: new RegExp(pattern.source, flags + 'y') };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Replace runs of custom fragments, and the whitespace padding them
|
|
110
|
+
* @param {string} value - Document to scan
|
|
111
|
+
* @param {(DelimitedFragment | PatternFragment)[]} fragments - In the order the patterns
|
|
112
|
+
* were given, since the earliest match wins and ties go to the pattern listed first
|
|
113
|
+
* @param {(match: string) => string} replacer - Called with each match, as `replace` would
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function replaceCustomFragments(value, fragments, replacer) {
|
|
117
|
+
// Where each fragment's next match may start, its next closing delimiter may be
|
|
118
|
+
// found, and its next excluded character sits; all only ever move forward, which
|
|
119
|
+
// is what keeps the whole scan linear
|
|
120
|
+
const found = fragments.map(() => /** @type {{start: number, end: number} | null} */ (null));
|
|
121
|
+
const closesAt = fragments.map(() => -1);
|
|
122
|
+
const excludedAt = fragments.map(() => -1);
|
|
123
|
+
const exhausted = fragments.map(() => false);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether a body region holds a character its class excludes
|
|
127
|
+
* @param {DelimitedFragment} fragment
|
|
128
|
+
* @param {number} index - Which fragment, for the forward-only bookkeeping
|
|
129
|
+
* @param {number} bodyStart
|
|
130
|
+
* @param {number} close
|
|
131
|
+
* @returns {boolean}
|
|
132
|
+
*/
|
|
133
|
+
const bodyBlocked = (fragment, index, bodyStart, close) => {
|
|
134
|
+
if (!fragment.excluded) return false;
|
|
135
|
+
if (/** @type {number} */ (excludedAt[index]) < bodyStart) {
|
|
136
|
+
fragment.excluded.lastIndex = bodyStart;
|
|
137
|
+
const blocked = fragment.excluded.exec(value);
|
|
138
|
+
excludedAt[index] = blocked ? blocked.index : Infinity;
|
|
139
|
+
}
|
|
140
|
+
return /** @type {number} */ (excludedAt[index]) < close;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Earliest match of one delimited fragment at or after `from`
|
|
145
|
+
* @param {DelimitedFragment} fragment
|
|
146
|
+
* @param {number} index - Which fragment, for the forward-only bookkeeping
|
|
147
|
+
* @param {number} from
|
|
148
|
+
* @param {boolean} anchored - Whether the match has to start exactly at `from`
|
|
149
|
+
* @returns {{start: number, end: number} | null}
|
|
150
|
+
*/
|
|
151
|
+
const scan = (fragment, index, from, anchored) => {
|
|
152
|
+
let openFrom = from;
|
|
153
|
+
|
|
154
|
+
for (;;) {
|
|
155
|
+
const open = anchored
|
|
156
|
+
? (value.startsWith(fragment.open, from) ? from : -1)
|
|
157
|
+
: value.indexOf(fragment.open, openFrom);
|
|
158
|
+
if (open === -1) {
|
|
159
|
+
if (!anchored) exhausted[index] = true;
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const bodyStart = open + fragment.open.length;
|
|
164
|
+
if (/** @type {number} */ (closesAt[index]) < bodyStart + fragment.min) {
|
|
165
|
+
closesAt[index] = value.indexOf(fragment.close, bodyStart + fragment.min);
|
|
166
|
+
}
|
|
167
|
+
const close = /** @type {number} */ (closesAt[index]);
|
|
168
|
+
if (close === -1) {
|
|
169
|
+
exhausted[index] = true;
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (close - bodyStart <= fragment.max && !bodyBlocked(fragment, index, bodyStart, close)) {
|
|
174
|
+
return { start: open, end: close + fragment.close.length };
|
|
175
|
+
}
|
|
176
|
+
// The body is longer than the pattern allows, or holds a character its class
|
|
177
|
+
// excludes, so the match has to start later
|
|
178
|
+
if (anchored) return null;
|
|
179
|
+
openFrom = open + 1;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Earliest match of one regex fragment at or after `from`
|
|
185
|
+
* @param {PatternFragment} fragment
|
|
186
|
+
* @param {number} from
|
|
187
|
+
* @param {boolean} anchored
|
|
188
|
+
* @returns {{start: number, end: number} | null}
|
|
189
|
+
*/
|
|
190
|
+
const run = (fragment, from, anchored) => {
|
|
191
|
+
const pattern = anchored ? fragment.anchored : fragment.search;
|
|
192
|
+
pattern.lastIndex = from;
|
|
193
|
+
|
|
194
|
+
for (;;) {
|
|
195
|
+
const match = pattern.exec(value);
|
|
196
|
+
if (!match) return null;
|
|
197
|
+
// A pattern that matches nothing would leave the run in place forever
|
|
198
|
+
if (match[0].length > 0) return { start: match.index, end: match.index + match[0].length };
|
|
199
|
+
if (anchored) return null;
|
|
200
|
+
pattern.lastIndex = match.index + 1;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {number} from
|
|
206
|
+
* @param {boolean} anchored
|
|
207
|
+
* @returns {{start: number, end: number} | null}
|
|
208
|
+
*/
|
|
209
|
+
const find = (from, anchored) => {
|
|
210
|
+
/** @type {{start: number, end: number} | null} */
|
|
211
|
+
let earliest = null;
|
|
212
|
+
|
|
213
|
+
for (let i = 0; i < fragments.length; i++) {
|
|
214
|
+
if (exhausted[i]) continue;
|
|
215
|
+
const fragment = /** @type {DelimitedFragment | PatternFragment} */ (fragments[i]);
|
|
216
|
+
|
|
217
|
+
/** @type {{start: number, end: number} | null} */
|
|
218
|
+
let match;
|
|
219
|
+
if (anchored) {
|
|
220
|
+
match = 'open' in fragment ? scan(fragment, i, from, true) : run(fragment, from, true);
|
|
221
|
+
} else {
|
|
222
|
+
// Matches only ever move forward, so the last one found still stands
|
|
223
|
+
const previous = found[i];
|
|
224
|
+
match = previous && previous.start >= from
|
|
225
|
+
? previous
|
|
226
|
+
: ('open' in fragment ? scan(fragment, i, from, false) : run(fragment, from, false));
|
|
227
|
+
found[i] = match;
|
|
228
|
+
// Nothing ahead now means nothing ahead later either
|
|
229
|
+
if (!match) exhausted[i] = true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Ties go to the fragment listed first, the way alternation would resolve them
|
|
233
|
+
if (match && (!earliest || match.start < earliest.start)) earliest = match;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return earliest;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
let out = '';
|
|
240
|
+
let copied = 0;
|
|
241
|
+
let search = 0;
|
|
242
|
+
|
|
243
|
+
while (search <= value.length) {
|
|
244
|
+
const first = find(search, false);
|
|
245
|
+
if (!first) break;
|
|
246
|
+
|
|
247
|
+
// Fragments running straight into each other are one match
|
|
248
|
+
let end = first.end;
|
|
249
|
+
for (;;) {
|
|
250
|
+
const next = find(end, true);
|
|
251
|
+
if (!next) break;
|
|
252
|
+
end = next.end;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// …as is the whitespace on either side, back to where the last match left off
|
|
256
|
+
let start = first.start;
|
|
257
|
+
while (start > copied && RE_WHITESPACE.test(value[start - 1] ?? '')) start--;
|
|
258
|
+
while (end < value.length && RE_WHITESPACE.test(value[end] ?? '')) end++;
|
|
259
|
+
|
|
260
|
+
out += value.slice(copied, start) + replacer(value.slice(start, end));
|
|
261
|
+
copied = end;
|
|
262
|
+
search = end;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return copied === 0 ? value : out + value.slice(copied);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Exports
|
|
269
|
+
|
|
270
|
+
export {
|
|
271
|
+
toDelimitedFragment,
|
|
272
|
+
toFragment,
|
|
273
|
+
replaceCustomFragments
|
|
274
|
+
};
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
// Single source of truth for minifier option names, descriptions, types, and shared defaults
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {object} OptionDefinition
|
|
5
|
+
* @property {string} description Help text, phrased for the option’s primary CLI form
|
|
6
|
+
* @property {string} [descriptionAffirmative] Help text for what enabling the option does, where `description` describes the negated form
|
|
7
|
+
* @property {string} type Key into the parser and JSON Schema type maps in cli.js and scripts/build-schema.js
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** @type {Record<string, OptionDefinition>} */
|
|
3
11
|
const optionDefinitions = {
|
|
4
12
|
cacheCSS: {
|
|
5
13
|
description: 'Set CSS minification cache size (number of entries, default: 500)',
|
|
@@ -62,10 +70,6 @@ const optionDefinitions = {
|
|
|
62
70
|
description: 'Array of regexes that allow to support custom event attributes for minifyJS (e.g., `ng-click`)',
|
|
63
71
|
type: 'regexpArray'
|
|
64
72
|
},
|
|
65
|
-
customFragmentQuantifierLimit: {
|
|
66
|
-
description: 'Set maximum quantifier limit for custom fragments to prevent ReDoS attacks (default: 200)',
|
|
67
|
-
type: 'int'
|
|
68
|
-
},
|
|
69
73
|
decodeEntities: {
|
|
70
74
|
description: 'Use direct Unicode characters whenever possible',
|
|
71
75
|
type: 'boolean'
|
|
@@ -104,15 +108,15 @@ const optionDefinitions = {
|
|
|
104
108
|
},
|
|
105
109
|
minifyCSS: {
|
|
106
110
|
description: 'Minify CSS in `style` elements and attributes (uses Lightning CSS)',
|
|
107
|
-
type: '
|
|
111
|
+
type: 'jsonObject'
|
|
108
112
|
},
|
|
109
113
|
minifyJS: {
|
|
110
114
|
description: 'Minify JavaScript in `script` elements and event attributes (uses Terser or SWC; pass `{"engine": "swc"}` for SWC)',
|
|
111
|
-
type: '
|
|
115
|
+
type: 'jsonObject'
|
|
112
116
|
},
|
|
113
117
|
minifySVG: {
|
|
114
118
|
description: 'Minify SVG elements (uses SVGO)',
|
|
115
|
-
type: '
|
|
119
|
+
type: 'jsonObject'
|
|
116
120
|
},
|
|
117
121
|
minifyURLs: {
|
|
118
122
|
description: 'Minify URLs in various attributes',
|
|
@@ -178,6 +182,10 @@ const optionDefinitions = {
|
|
|
178
182
|
description: 'Remove space between attributes whenever possible; note that this will result in invalid HTML',
|
|
179
183
|
type: 'boolean'
|
|
180
184
|
},
|
|
185
|
+
removeUnusedCSS: {
|
|
186
|
+
description: 'Remove rules from `style` elements whose class or ID selectors the document never references (requires `--minify-css`); note that class names only applied by external scripts cannot be detected—use `{"safelist": […]}` for those',
|
|
187
|
+
type: 'jsonObject'
|
|
188
|
+
},
|
|
181
189
|
sortAttributes: {
|
|
182
190
|
description: 'Sort attributes by frequency',
|
|
183
191
|
type: 'boolean'
|
|
@@ -190,6 +198,10 @@ const optionDefinitions = {
|
|
|
190
198
|
description: 'Trim whitespace around custom fragments (`--ignore-custom-fragments`)',
|
|
191
199
|
type: 'boolean'
|
|
192
200
|
},
|
|
201
|
+
strictCustomFragments: {
|
|
202
|
+
description: 'Reject `ignoreCustomFragments` patterns that risk catastrophic backtracking (rather than warning about them)',
|
|
203
|
+
type: 'boolean'
|
|
204
|
+
},
|
|
193
205
|
useShortDoctype: {
|
|
194
206
|
description: 'Replaces the doctype with the short HTML doctype',
|
|
195
207
|
type: 'boolean'
|
package/src/lib/options.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createUrlMinifier } from './urls.js';
|
|
2
|
-
import { LRU, MAX_CACHE_ENTRY_SIZE, stableStringify, hashContent, identity, lowercase, replaceAsync, parseRegExp } from './utils.js';
|
|
2
|
+
import { LRU, MAX_CACHE_ENTRY_SIZE, stableStringify, hashContent, identity, lowercase, replaceAsync, parseRegExp, describeQuantifierRisk } from './utils.js';
|
|
3
3
|
import { RE_TRAILING_SEMICOLON } from './constants.js';
|
|
4
4
|
import { canCollapseWhitespace, canTrimWhitespace } from './whitespace.js';
|
|
5
5
|
import { wrapCSS, unwrapCSS } from './content.js';
|
|
6
|
+
import { findUnusedSymbols, normalizeUnusedCSSOptions } from './unused-css.js';
|
|
6
7
|
import { getPreset, getPresetNames } from '../presets.js';
|
|
7
8
|
import { optionDefinitions, optionDefaults } from './option-definitions.js';
|
|
8
9
|
|
|
@@ -10,23 +11,40 @@ import { optionDefinitions, optionDefaults } from './option-definitions.js';
|
|
|
10
11
|
|
|
11
12
|
// Type definitions
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Per-document state handed to `minifyCSS`. Its closure hangs off the memoized
|
|
16
|
+
* options object that every `minify()` call with those options shares, so state
|
|
17
|
+
* belonging to one document has to be passed in rather than captured.
|
|
18
|
+
*
|
|
19
|
+
* @typedef {{usedSymbols?: Set<string>, warned: Set<string>}} CSSContext
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Minified style sheet plus the warnings its transform produced, cached together
|
|
24
|
+
* so that a cache hit can report what the transform reported
|
|
25
|
+
*
|
|
26
|
+
* @typedef {{css: string, warnings: string[]}} CSSResult
|
|
27
|
+
*/
|
|
28
|
+
|
|
13
29
|
/**
|
|
14
30
|
* Options object produced by `processOptions` and consumed by `minifyHTML` and
|
|
15
|
-
* the
|
|
31
|
+
* the lib/ helpers; normalization guarantees that the function-valued options
|
|
16
32
|
* below are always present (defaulting to identity/built-in functions), and
|
|
17
33
|
* minification adds writable internal state on top of the public options
|
|
18
34
|
* (set on prototype-chain forks during SVG/MathML namespace transitions)
|
|
19
35
|
*
|
|
20
|
-
* @typedef {Omit<MinifierOptions, 'preset' | 'canCollapseWhitespace' | 'canTrimWhitespace' | 'ignoreCustomComments' | 'log' | 'minifyCSS' | 'minifyJS' | 'minifyURLs' | 'minifySVG'> & {
|
|
36
|
+
* @typedef {Omit<MinifierOptions, 'preset' | 'canCollapseWhitespace' | 'canTrimWhitespace' | 'ignoreCustomComments' | 'log' | 'minifyCSS' | 'minifyJS' | 'minifyURLs' | 'minifySVG' | 'removeUnusedCSS'> & {
|
|
21
37
|
* name: (name: string) => string,
|
|
22
38
|
* log: (message: any) => unknown,
|
|
23
39
|
* ignoreCustomComments: RegExp[],
|
|
24
40
|
* canCollapseWhitespace: (tag: string, attrs: HTMLAttribute[], defaultFn: (tag: string) => boolean) => boolean,
|
|
25
41
|
* canTrimWhitespace: (tag: string, attrs: HTMLAttribute[], defaultFn: (tag: string) => boolean) => boolean,
|
|
26
|
-
* minifyCSS: (text: string, type?: string) => string | Promise<string>,
|
|
42
|
+
* minifyCSS: (text: string, type?: string, context?: CSSContext) => string | Promise<string>,
|
|
27
43
|
* minifyJS: (text: string, inline?: boolean, isModule?: boolean) => string | Promise<string>,
|
|
28
44
|
* minifyURLs: (text: string) => string | Promise<string>,
|
|
29
45
|
* minifySVG: ((svgContent: string) => string | Promise<string>) | null,
|
|
46
|
+
* removeUnusedCSS: {safelist: Array<string | RegExp>, scripts: boolean} | null,
|
|
47
|
+
* cssContext?: CSSContext,
|
|
30
48
|
* nameParent?: (name: string) => string,
|
|
31
49
|
* nameHTML?: (name: string) => string,
|
|
32
50
|
* insideSVG?: boolean,
|
|
@@ -79,9 +97,11 @@ const optionKeysExtra = new Set(['preset', 'log', 'canCollapseWhitespace', 'canT
|
|
|
79
97
|
// key per process, so repeated `minify` calls (e.g., batch runs) don’t flood STDERR
|
|
80
98
|
const optionKeysWarned = new Set();
|
|
81
99
|
const presetNamesWarned = new Set();
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
100
|
+
// Custom fragments whose shape risks ReDoS, warned about once per pattern per process
|
|
101
|
+
const customFragmentsWarned = new Set();
|
|
102
|
+
const unusedCSSWarned = new Set();
|
|
103
|
+
// Object-valued options handed a string, warned about once per distinct value
|
|
104
|
+
const stringValuesWarned = new Set();
|
|
85
105
|
|
|
86
106
|
// Main options processor
|
|
87
107
|
|
|
@@ -101,7 +121,8 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
101
121
|
minifyCSS: identity,
|
|
102
122
|
minifyJS: identity,
|
|
103
123
|
minifyURLs: identity,
|
|
104
|
-
minifySVG: null
|
|
124
|
+
minifySVG: null,
|
|
125
|
+
removeUnusedCSS: null
|
|
105
126
|
};
|
|
106
127
|
|
|
107
128
|
const parseRegExpArray = (/** @type {unknown} */ arr) => {
|
|
@@ -136,7 +157,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
136
157
|
Object.keys(inputOptions).forEach(function (key) {
|
|
137
158
|
if (!Object.hasOwn(optionDefinitions, key) && !optionKeysExtra.has(key) && !optionKeysWarned.has(key)) {
|
|
138
159
|
optionKeysWarned.add(key);
|
|
139
|
-
warn(`HTML Minifier Next: Ignoring unknown or deprecated option
|
|
160
|
+
warn(`HTML Minifier Next: Ignoring unknown or deprecated option \`${key}\` (see README for available options)`);
|
|
140
161
|
}
|
|
141
162
|
});
|
|
142
163
|
|
|
@@ -166,10 +187,30 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
166
187
|
return;
|
|
167
188
|
}
|
|
168
189
|
|
|
190
|
+
// A string carries no configuration for these options. The CLI parses config
|
|
191
|
+
// values as JSON first, so only a value that is not JSON reaches this from
|
|
192
|
+
// there. (`minifyURLs` is deliberately excluded—there, a string names the site.)
|
|
193
|
+
const definition = optionDefinitions[key];
|
|
194
|
+
if (typeof option === 'string' && definition?.type === 'jsonObject') {
|
|
195
|
+
const message = `HTML Minifier Next: Ignoring \`${key}\`—it takes a boolean or an object, not a string (“${option}”)`;
|
|
196
|
+
if (!stringValuesWarned.has(message)) {
|
|
197
|
+
stringValuesWarned.add(message);
|
|
198
|
+
warn(message);
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
169
203
|
if (key === 'caseSensitive') {
|
|
170
204
|
if (option) {
|
|
171
205
|
options.name = identity;
|
|
172
206
|
}
|
|
207
|
+
} else if (key === 'removeUnusedCSS') {
|
|
208
|
+
optionsDynamic.removeUnusedCSS = normalizeUnusedCSSOptions(option, message => {
|
|
209
|
+
if (!unusedCSSWarned.has(message)) {
|
|
210
|
+
unusedCSSWarned.add(message);
|
|
211
|
+
warn(`HTML Minifier Next: ${message}`);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
173
214
|
} else if (key === 'log') {
|
|
174
215
|
if (typeof option === 'function') {
|
|
175
216
|
options.log = option;
|
|
@@ -184,12 +225,34 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
184
225
|
const cssLoader = getLightningCSS;
|
|
185
226
|
const cssCache = cssMinifyCache;
|
|
186
227
|
|
|
187
|
-
options.minifyCSS = async function (/** @type {string} */ text, /** @type {string | undefined} */ type) {
|
|
228
|
+
options.minifyCSS = async function (/** @type {string} */ text, /** @type {string | undefined} */ type, /** @type {CSSContext | undefined} */ context) {
|
|
188
229
|
// Fast path: Nothing to minify
|
|
189
230
|
if (!text || !text.trim()) {
|
|
190
231
|
return text;
|
|
191
232
|
}
|
|
192
233
|
|
|
234
|
+
// Warnings are stored with the minified result and replayed on every hit, so a
|
|
235
|
+
// second document with the same defect hears about it, too; `context.warned`
|
|
236
|
+
// then keeps one document from repeating itself. Reporting from the cache
|
|
237
|
+
// rather than only from the transform keeps the output independent of cache
|
|
238
|
+
// size and eviction. They are built and cached even when `log` is the default
|
|
239
|
+
// no-op, so a later document that does pass a `log` hook still gets them.
|
|
240
|
+
const report = (/** @type {string[]} */ messages) => {
|
|
241
|
+
if (!messages.length || options.log === identity) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const warned = context?.warned;
|
|
245
|
+
for (const message of messages) {
|
|
246
|
+
if (warned) {
|
|
247
|
+
if (warned.has(message)) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
warned.add(message);
|
|
251
|
+
}
|
|
252
|
+
options.log(message);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
193
256
|
// Optimization: Only process URLs if minification is enabled (not identity function)
|
|
194
257
|
// This avoids expensive `replaceAsync` when URL minification is disabled
|
|
195
258
|
if (options.minifyURLs !== identity) {
|
|
@@ -213,9 +276,22 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
213
276
|
);
|
|
214
277
|
}
|
|
215
278
|
|
|
216
|
-
//
|
|
279
|
+
// Unused-symbol removal applies to style sheets only
|
|
280
|
+
const unusedCSSConfig = type === undefined ? options.removeUnusedCSS : undefined;
|
|
281
|
+
const unusedSymbols = (unusedCSSConfig && context?.usedSymbols)
|
|
282
|
+
? findUnusedSymbols(text, context.usedSymbols, unusedCSSConfig.safelist)
|
|
283
|
+
: undefined;
|
|
284
|
+
|
|
285
|
+
// Cache key: Content + type + options signature; large inputs are hashed to avoid huge Map keys.
|
|
286
|
+
// The symbol list belongs in the signature: The cache outlives a single `minify()` call, so
|
|
287
|
+
// identical style sheets in differently marked-up documents must not share an entry.
|
|
217
288
|
const inputCSS = wrapCSS(text, type);
|
|
218
|
-
const cssSig = stableStringify({
|
|
289
|
+
const cssSig = stableStringify({
|
|
290
|
+
type,
|
|
291
|
+
opts: lightningCssOptions,
|
|
292
|
+
cont: !!options.continueOnMinifyError,
|
|
293
|
+
unused: unusedSymbols && unusedSymbols.length ? unusedSymbols.slice().sort() : undefined
|
|
294
|
+
});
|
|
219
295
|
const isCacheable = inputCSS.length <= MAX_CACHE_ENTRY_SIZE;
|
|
220
296
|
const cssKey = isCacheable
|
|
221
297
|
? (inputCSS.length > 2048
|
|
@@ -225,10 +301,12 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
225
301
|
|
|
226
302
|
try {
|
|
227
303
|
if (cssKey !== undefined) {
|
|
228
|
-
const cached = /** @type {
|
|
304
|
+
const cached = /** @type {CSSResult | Promise<CSSResult> | undefined} */ (cssCache.get(cssKey));
|
|
229
305
|
if (cached !== undefined) {
|
|
230
306
|
// Support both resolved values and in-flight promises
|
|
231
|
-
|
|
307
|
+
const settled = await cached;
|
|
308
|
+
report(settled.warnings);
|
|
309
|
+
return settled.css;
|
|
232
310
|
}
|
|
233
311
|
}
|
|
234
312
|
|
|
@@ -242,9 +320,26 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
242
320
|
code: Buffer.from(inputCSS),
|
|
243
321
|
minify: true,
|
|
244
322
|
errorRecovery: !!options.continueOnMinifyError,
|
|
245
|
-
...lightningCssOptions
|
|
323
|
+
...lightningCssOptions,
|
|
324
|
+
// Union, so that a manually supplied `unusedSymbols` list survives
|
|
325
|
+
...(unusedSymbols && unusedSymbols.length
|
|
326
|
+
? { unusedSymbols: lightningCssOptions.unusedSymbols ? [...new Set([...lightningCssOptions.unusedSymbols, ...unusedSymbols])] : unusedSymbols }
|
|
327
|
+
: {})
|
|
246
328
|
});
|
|
247
329
|
|
|
330
|
+
// With `errorRecovery` enabled, Lightning CSS reports what it takes issue
|
|
331
|
+
// with instead of throwing—dropping the rule in some cases (`@property`
|
|
332
|
+
// with a bad `syntax`) and passing it through in others (an unknown
|
|
333
|
+
// at-rule), which is why the wording stops at “reported”
|
|
334
|
+
/** @type {string[]} */
|
|
335
|
+
const warnings = [];
|
|
336
|
+
if (result.warnings) {
|
|
337
|
+
for (const warning of result.warnings) {
|
|
338
|
+
const at = warning.loc ? ` (line ${warning.loc.line}, column ${warning.loc.column})` : '';
|
|
339
|
+
warnings.push(`Warning: Lightning CSS reported invalid CSS${at}: ${warning.message}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
248
343
|
const outputCSS = unwrapCSS(result.code.toString(), type);
|
|
249
344
|
|
|
250
345
|
// If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
|
|
@@ -259,13 +354,15 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
259
354
|
|
|
260
355
|
// Preserve if output is empty and input had template syntax or UIDs
|
|
261
356
|
// This catches cases where Lightning CSS removed content that should be preserved
|
|
262
|
-
|
|
357
|
+
const css = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
|
|
358
|
+
return { css, warnings };
|
|
263
359
|
})();
|
|
264
360
|
|
|
265
361
|
if (cssKey !== undefined) cssCache.set(cssKey, inFlight);
|
|
266
362
|
const resolved = await inFlight;
|
|
267
363
|
if (cssKey !== undefined) cssCache.set(cssKey, resolved);
|
|
268
|
-
|
|
364
|
+
report(resolved.warnings);
|
|
365
|
+
return resolved.css;
|
|
269
366
|
} catch (err) {
|
|
270
367
|
if (cssKey !== undefined) cssCache.delete(cssKey);
|
|
271
368
|
if (!options.continueOnMinifyError) {
|
|
@@ -500,21 +597,47 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
500
597
|
} else if (['customAttrAssign', 'customEventAttributes', 'ignoreCustomComments', 'ignoreCustomFragments'].includes(key)) {
|
|
501
598
|
// Array of regex patterns
|
|
502
599
|
optionsDynamic[key] = parseRegExpArray(option);
|
|
503
|
-
// Warn about potential ReDoS when user-provided fragments use unlimited
|
|
504
|
-
// quantifiers; only explicitly passed fragments are checked
|
|
505
|
-
if (key === 'ignoreCustomFragments' && !customFragmentQuantifierWarned) {
|
|
506
|
-
for (const re of /** @type {RegExp[]} */ (optionsDynamic[key])) {
|
|
507
|
-
if (/[*+]/.test(re.source)) {
|
|
508
|
-
customFragmentQuantifierWarned = true;
|
|
509
|
-
warn('HTML Minifier Next: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
|
|
510
|
-
break;
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
600
|
} else {
|
|
515
601
|
optionsDynamic[key] = option;
|
|
516
602
|
}
|
|
517
603
|
});
|
|
604
|
+
|
|
605
|
+
// Fragments that compound quantifiers or alternation under unbounded repetition
|
|
606
|
+
// are the shapes that backtrack catastrophically, and they are also the ones a
|
|
607
|
+
// linear scan cannot stand in for; so are patterns too long or too deeply
|
|
608
|
+
// nested to read, which are refused for that rather than for a shape. A lone
|
|
609
|
+
// `[\s\S]*?` up to a literal terminator is linear and passes; HMN’s default
|
|
610
|
+
// fragments have exactly that shape, so the check flagging it would mean
|
|
611
|
+
// warning about the defaults themselves.
|
|
612
|
+
for (const re of options.ignoreCustomFragments || []) {
|
|
613
|
+
const risk = describeQuantifierRisk(re.source);
|
|
614
|
+
if (!risk) continue;
|
|
615
|
+
const problem = `Custom fragment \`/${re.source}/\` ${risk}`;
|
|
616
|
+
if (options.strictCustomFragments) {
|
|
617
|
+
throw new Error(`HTML Minifier Next: ${problem}`);
|
|
618
|
+
}
|
|
619
|
+
if (!customFragmentsWarned.has(re.source)) {
|
|
620
|
+
customFragmentsWarned.add(re.source);
|
|
621
|
+
warn(`HTML Minifier Next: ${problem}`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Unused-CSS removal rides along with Lightning CSS, so it silently does nothing
|
|
626
|
+
// when `minifyCSS` is off or replaced by a function—say so rather than let it pass
|
|
627
|
+
if (options.removeUnusedCSS) {
|
|
628
|
+
const cssOption = /** @type {Record<string, any>} */ (effectiveInput).minifyCSS;
|
|
629
|
+
const reason = typeof cssOption === 'function'
|
|
630
|
+
? 'it does not apply when `minifyCSS` is a function'
|
|
631
|
+
: (options.minifyCSS === identity ? 'it requires `minifyCSS` (`--minify-css`)' : '');
|
|
632
|
+
if (reason) {
|
|
633
|
+
if (!unusedCSSWarned.has(reason)) {
|
|
634
|
+
unusedCSSWarned.add(reason);
|
|
635
|
+
warn(`HTML Minifier Next: Ignoring \`removeUnusedCSS\`—${reason}`);
|
|
636
|
+
}
|
|
637
|
+
options.removeUnusedCSS = null;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
518
641
|
return options;
|
|
519
642
|
};
|
|
520
643
|
|