html-minifier-next 7.5.3 → 7.6.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.
@@ -470,7 +470,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
470
470
  attrValue = attrValue.replace(/\s*;$/, ';');
471
471
  }
472
472
  const originalAttrValue = attrValue;
473
- const cssResult = options.minifyCSS(attrValue, 'inline');
473
+ const cssResult = options.minifyCSS(attrValue, 'inline', options.cssContext);
474
474
  if (isThenable(cssResult)) {
475
475
  return cssResult
476
476
  .then((/** @type {string} */ minified) => {
@@ -581,7 +581,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
581
581
  return attrValue;
582
582
  }
583
583
  const originalAttrValue = attrValue;
584
- const cssResult = options.minifyCSS(attrValue, 'media');
584
+ const cssResult = options.minifyCSS(attrValue, 'media', options.cssContext);
585
585
  if (isThenable(cssResult)) {
586
586
  return cssResult.catch((/** @type {Error} */ err) => {
587
587
  if (!options.continueOnMinifyError) throw err;
@@ -16,6 +16,7 @@ const RE_ESCAPE_LT = /</g;
16
16
  const RE_ATTR_WS_CHECK = /[ \n\r\t\f]/;
17
17
  const RE_ATTR_WS_COLLAPSE = /[ \n\r\t\f]+/g;
18
18
  const RE_ATTR_WS_TRIM = /^[ \n\r\t\f]+|[ \n\r\t\f]+$/g;
19
+ const RE_STYLE_ELEMENT = /<style[\s/>]/i;
19
20
 
20
21
  // Inline element sets for whitespace handling
21
22
 
@@ -192,6 +193,7 @@ export {
192
193
  RE_ATTR_WS_CHECK,
193
194
  RE_ATTR_WS_COLLAPSE,
194
195
  RE_ATTR_WS_TRIM,
196
+ RE_STYLE_ELEMENT,
195
197
  // Inline element sets
196
198
  inlineElementsToKeepWhitespaceAround,
197
199
  inlineElementsToKeepWhitespaceWithin,
@@ -137,7 +137,7 @@ function canRemoveElement(tag, attrs) {
137
137
  }
138
138
 
139
139
  /**
140
- * @param {string} str - Tag name or HTML-like element spec (e.g., “td” or “<span aria-hidden='true'>”)
140
+ * @param {string} str - Tag name or HTML-like element spec (e.g., `td` or `<span aria-hidden='true'>`)
141
141
  * @param {ProcessedOptions} options - Options object for name normalization
142
142
  * @returns {{tag: string, attrs: Object.<string, string|undefined>|null}|null} Parsed spec or null if invalid
143
143
  */
@@ -203,12 +203,12 @@ function parseRemoveEmptyElementsExcept(input, options) {
203
203
  if (typeof item === 'string') {
204
204
  const spec = parseElementSpec(item, options);
205
205
  if (!spec && options.log) {
206
- options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: “' + item + '”');
206
+ options.log('Warning: Unable to parse `removeEmptyElementsExcept` specification: “' + item + '”');
207
207
  }
208
208
  return spec;
209
209
  }
210
210
  if (options.log) {
211
- options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
211
+ options.log('Warning: `removeEmptyElementsExcept` specification must be a string, received: ' + typeof item);
212
212
  }
213
213
  return null;
214
214
  }).filter(Boolean));
@@ -104,15 +104,15 @@ const optionDefinitions = {
104
104
  },
105
105
  minifyCSS: {
106
106
  description: 'Minify CSS in `style` elements and attributes (uses Lightning CSS)',
107
- type: 'json'
107
+ type: 'jsonObject'
108
108
  },
109
109
  minifyJS: {
110
110
  description: 'Minify JavaScript in `script` elements and event attributes (uses Terser or SWC; pass `{"engine": "swc"}` for SWC)',
111
- type: 'json'
111
+ type: 'jsonObject'
112
112
  },
113
113
  minifySVG: {
114
114
  description: 'Minify SVG elements (uses SVGO)',
115
- type: 'json'
115
+ type: 'jsonObject'
116
116
  },
117
117
  minifyURLs: {
118
118
  description: 'Minify URLs in various attributes',
@@ -178,6 +178,10 @@ const optionDefinitions = {
178
178
  description: 'Remove space between attributes whenever possible; note that this will result in invalid HTML',
179
179
  type: 'boolean'
180
180
  },
181
+ removeUnusedCSS: {
182
+ 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',
183
+ type: 'jsonObject'
184
+ },
181
185
  sortAttributes: {
182
186
  description: 'Sort attributes by frequency',
183
187
  type: 'boolean'
@@ -3,6 +3,7 @@ import { LRU, MAX_CACHE_ENTRY_SIZE, stableStringify, hashContent, identity, lowe
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,6 +11,21 @@ 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
31
  * the `lib/` helpers; normalization guarantees that the function-valued options
@@ -17,16 +33,18 @@ import { optionDefinitions, optionDefaults } from './option-definitions.js';
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,
@@ -82,6 +100,9 @@ const presetNamesWarned = new Set();
82
100
  // The custom-fragment ReDoS warning is security-relevant, so it reaches the
83
101
  // console even without a `log` hook—once per process, like the warnings above
84
102
  let customFragmentQuantifierWarned = false;
103
+ const unusedCSSWarned = new Set();
104
+ // Object-valued options handed a string, warned about once per distinct value
105
+ const stringValuesWarned = new Set();
85
106
 
86
107
  // Main options processor
87
108
 
@@ -101,7 +122,8 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
101
122
  minifyCSS: identity,
102
123
  minifyJS: identity,
103
124
  minifyURLs: identity,
104
- minifySVG: null
125
+ minifySVG: null,
126
+ removeUnusedCSS: null
105
127
  };
106
128
 
107
129
  const parseRegExpArray = (/** @type {unknown} */ arr) => {
@@ -136,7 +158,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
136
158
  Object.keys(inputOptions).forEach(function (key) {
137
159
  if (!Object.hasOwn(optionDefinitions, key) && !optionKeysExtra.has(key) && !optionKeysWarned.has(key)) {
138
160
  optionKeysWarned.add(key);
139
- warn(`HTML Minifier Next: Ignoring unknown or deprecated option “${key}” (see README for available options)`);
161
+ warn(`HTML Minifier Next: Ignoring unknown or deprecated option \`${key}\` (see README for available options)`);
140
162
  }
141
163
  });
142
164
 
@@ -166,10 +188,30 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
166
188
  return;
167
189
  }
168
190
 
191
+ // A string carries no configuration for these options. The CLI parses config
192
+ // values as JSON first, so only a value that is not JSON reaches this from
193
+ // there. (`minifyURLs` is deliberately excluded—there, a string names the site.)
194
+ const definition = /** @type {Record<string, {type?: string}>} */ (optionDefinitions)[key];
195
+ if (typeof option === 'string' && definition?.type === 'jsonObject') {
196
+ const message = `HTML Minifier Next: Ignoring \`${key}\`—it takes a boolean or an object, not a string (“${option}”)`;
197
+ if (!stringValuesWarned.has(message)) {
198
+ stringValuesWarned.add(message);
199
+ warn(message);
200
+ }
201
+ return;
202
+ }
203
+
169
204
  if (key === 'caseSensitive') {
170
205
  if (option) {
171
206
  options.name = identity;
172
207
  }
208
+ } else if (key === 'removeUnusedCSS') {
209
+ optionsDynamic.removeUnusedCSS = normalizeUnusedCSSOptions(option, message => {
210
+ if (!unusedCSSWarned.has(message)) {
211
+ unusedCSSWarned.add(message);
212
+ warn(`HTML Minifier Next: ${message}`);
213
+ }
214
+ });
173
215
  } else if (key === 'log') {
174
216
  if (typeof option === 'function') {
175
217
  options.log = option;
@@ -184,12 +226,34 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
184
226
  const cssLoader = getLightningCSS;
185
227
  const cssCache = cssMinifyCache;
186
228
 
187
- options.minifyCSS = async function (/** @type {string} */ text, /** @type {string | undefined} */ type) {
229
+ options.minifyCSS = async function (/** @type {string} */ text, /** @type {string | undefined} */ type, /** @type {CSSContext | undefined} */ context) {
188
230
  // Fast path: Nothing to minify
189
231
  if (!text || !text.trim()) {
190
232
  return text;
191
233
  }
192
234
 
235
+ // Warnings are stored with the minified result and replayed on every hit, so a
236
+ // second document with the same defect hears about it, too; `context.warned`
237
+ // then keeps one document from repeating itself. Reporting from the cache
238
+ // rather than only from the transform keeps the output independent of cache
239
+ // size and eviction. They are built and cached even when `log` is the default
240
+ // no-op, so a later document that does pass a `log` hook still gets them.
241
+ const report = (/** @type {string[]} */ messages) => {
242
+ if (!messages.length || options.log === identity) {
243
+ return;
244
+ }
245
+ const warned = context?.warned;
246
+ for (const message of messages) {
247
+ if (warned) {
248
+ if (warned.has(message)) {
249
+ continue;
250
+ }
251
+ warned.add(message);
252
+ }
253
+ options.log(message);
254
+ }
255
+ };
256
+
193
257
  // Optimization: Only process URLs if minification is enabled (not identity function)
194
258
  // This avoids expensive `replaceAsync` when URL minification is disabled
195
259
  if (options.minifyURLs !== identity) {
@@ -213,9 +277,22 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
213
277
  );
214
278
  }
215
279
 
216
- // Cache key: Content + type + options signature; large inputs are hashed to avoid huge Map keys
280
+ // Unused-symbol removal applies to style sheets only
281
+ const unusedCSSConfig = type === undefined ? options.removeUnusedCSS : undefined;
282
+ const unusedSymbols = (unusedCSSConfig && context?.usedSymbols)
283
+ ? findUnusedSymbols(text, context.usedSymbols, unusedCSSConfig.safelist)
284
+ : undefined;
285
+
286
+ // Cache key: Content + type + options signature; large inputs are hashed to avoid huge Map keys.
287
+ // The symbol list belongs in the signature: The cache outlives a single `minify()` call, so
288
+ // identical style sheets in differently marked-up documents must not share an entry.
217
289
  const inputCSS = wrapCSS(text, type);
218
- const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
290
+ const cssSig = stableStringify({
291
+ type,
292
+ opts: lightningCssOptions,
293
+ cont: !!options.continueOnMinifyError,
294
+ unused: unusedSymbols && unusedSymbols.length ? unusedSymbols.slice().sort() : undefined
295
+ });
219
296
  const isCacheable = inputCSS.length <= MAX_CACHE_ENTRY_SIZE;
220
297
  const cssKey = isCacheable
221
298
  ? (inputCSS.length > 2048
@@ -225,10 +302,12 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
225
302
 
226
303
  try {
227
304
  if (cssKey !== undefined) {
228
- const cached = /** @type {string | Promise<string> | undefined} */ (cssCache.get(cssKey));
305
+ const cached = /** @type {CSSResult | Promise<CSSResult> | undefined} */ (cssCache.get(cssKey));
229
306
  if (cached !== undefined) {
230
307
  // Support both resolved values and in-flight promises
231
- return await cached;
308
+ const settled = await cached;
309
+ report(settled.warnings);
310
+ return settled.css;
232
311
  }
233
312
  }
234
313
 
@@ -242,9 +321,26 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
242
321
  code: Buffer.from(inputCSS),
243
322
  minify: true,
244
323
  errorRecovery: !!options.continueOnMinifyError,
245
- ...lightningCssOptions
324
+ ...lightningCssOptions,
325
+ // Union, so that a manually supplied `unusedSymbols` list survives
326
+ ...(unusedSymbols && unusedSymbols.length
327
+ ? { unusedSymbols: lightningCssOptions.unusedSymbols ? [...new Set([...lightningCssOptions.unusedSymbols, ...unusedSymbols])] : unusedSymbols }
328
+ : {})
246
329
  });
247
330
 
331
+ // With `errorRecovery` enabled, Lightning CSS reports what it takes issue
332
+ // with instead of throwing—dropping the rule in some cases (`@property`
333
+ // with a bad `syntax`) and passing it through in others (an unknown
334
+ // at-rule), which is why the wording stops at “reported”
335
+ /** @type {string[]} */
336
+ const warnings = [];
337
+ if (result.warnings) {
338
+ for (const warning of result.warnings) {
339
+ const at = warning.loc ? ` (line ${warning.loc.line}, column ${warning.loc.column})` : '';
340
+ warnings.push(`Warning: Lightning CSS reported invalid CSS${at}: ${warning.message}`);
341
+ }
342
+ }
343
+
248
344
  const outputCSS = unwrapCSS(result.code.toString(), type);
249
345
 
250
346
  // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
@@ -259,13 +355,15 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
259
355
 
260
356
  // Preserve if output is empty and input had template syntax or UIDs
261
357
  // This catches cases where Lightning CSS removed content that should be preserved
262
- return (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
358
+ const css = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
359
+ return { css, warnings };
263
360
  })();
264
361
 
265
362
  if (cssKey !== undefined) cssCache.set(cssKey, inFlight);
266
363
  const resolved = await inFlight;
267
364
  if (cssKey !== undefined) cssCache.set(cssKey, resolved);
268
- return resolved;
365
+ report(resolved.warnings);
366
+ return resolved.css;
269
367
  } catch (err) {
270
368
  if (cssKey !== undefined) cssCache.delete(cssKey);
271
369
  if (!options.continueOnMinifyError) {
@@ -515,6 +613,23 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
515
613
  optionsDynamic[key] = option;
516
614
  }
517
615
  });
616
+
617
+ // Unused-CSS removal rides along with Lightning CSS, so it silently does nothing
618
+ // when `minifyCSS` is off or replaced by a function—say so rather than let it pass
619
+ if (options.removeUnusedCSS) {
620
+ const cssOption = /** @type {Record<string, any>} */ (effectiveInput).minifyCSS;
621
+ const reason = typeof cssOption === 'function'
622
+ ? 'it does not apply when `minifyCSS` is a function'
623
+ : (options.minifyCSS === identity ? 'it requires `minifyCSS` (`--minify-css`)' : '');
624
+ if (reason) {
625
+ if (!unusedCSSWarned.has(reason)) {
626
+ unusedCSSWarned.add(reason);
627
+ warn(`HTML Minifier Next: Ignoring \`removeUnusedCSS\`—${reason}`);
628
+ }
629
+ options.removeUnusedCSS = null;
630
+ }
631
+ }
632
+
518
633
  return options;
519
634
  };
520
635