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.
@@ -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: 'json'
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: 'json'
115
+ type: 'jsonObject'
112
116
  },
113
117
  minifySVG: {
114
118
  description: 'Minify SVG elements (uses SVGO)',
115
- type: 'json'
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'
@@ -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 `lib/` helpers; normalization guarantees that the function-valued options
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
- // The custom-fragment ReDoS warning is security-relevant, so it reaches the
83
- // console even without a `log` hook—once per process, like the warnings above
84
- let customFragmentQuantifierWarned = false;
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 “${key}” (see README for available options)`);
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
- // Cache key: Content + type + options signature; large inputs are hashed to avoid huge Map keys
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({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
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 {string | Promise<string> | undefined} */ (cssCache.get(cssKey));
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
- return await cached;
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
- return (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
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
- return resolved;
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