html-minifier 4.0.0 → 5.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.
Files changed (55) hide show
  1. package/LICENSE.txt +23 -0
  2. package/README.md +851 -110
  3. package/cli.js +991 -207
  4. package/dist/types/htmlminifier.d.ts +629 -0
  5. package/dist/types/htmlminifier.d.ts.map +1 -0
  6. package/dist/types/htmlparser.d.ts +28 -0
  7. package/dist/types/htmlparser.d.ts.map +1 -0
  8. package/dist/types/lib/attributes.d.ts +87 -0
  9. package/dist/types/lib/attributes.d.ts.map +1 -0
  10. package/dist/types/lib/constants.d.ts +421 -0
  11. package/dist/types/lib/constants.d.ts.map +1 -0
  12. package/dist/types/lib/content.d.ts +44 -0
  13. package/dist/types/lib/content.d.ts.map +1 -0
  14. package/dist/types/lib/elements.d.ts +48 -0
  15. package/dist/types/lib/elements.d.ts.map +1 -0
  16. package/dist/types/lib/fragments.d.ts +47 -0
  17. package/dist/types/lib/fragments.d.ts.map +1 -0
  18. package/dist/types/lib/option-definitions.d.ts +33 -0
  19. package/dist/types/lib/option-definitions.d.ts.map +1 -0
  20. package/dist/types/lib/options.d.ts +93 -0
  21. package/dist/types/lib/options.d.ts.map +1 -0
  22. package/dist/types/lib/unused-css.d.ts +76 -0
  23. package/dist/types/lib/unused-css.d.ts.map +1 -0
  24. package/dist/types/lib/urls.d.ts +15 -0
  25. package/dist/types/lib/urls.d.ts.map +1 -0
  26. package/dist/types/lib/utils.d.ts +132 -0
  27. package/dist/types/lib/utils.d.ts.map +1 -0
  28. package/dist/types/lib/whitespace.d.ts +97 -0
  29. package/dist/types/lib/whitespace.d.ts.map +1 -0
  30. package/dist/types/presets.d.ts +50 -0
  31. package/dist/types/presets.d.ts.map +1 -0
  32. package/dist/types/tokenchain.d.ts +36 -0
  33. package/dist/types/tokenchain.d.ts.map +1 -0
  34. package/html-minifier.schema.json +322 -0
  35. package/package.json +70 -58
  36. package/src/htmlminifier.js +2845 -1107
  37. package/src/htmlparser.js +1163 -452
  38. package/src/lib/attributes.js +1196 -0
  39. package/src/lib/constants.js +521 -0
  40. package/src/lib/content.js +237 -0
  41. package/src/lib/elements.js +268 -0
  42. package/src/lib/file-pool.js +193 -0
  43. package/src/lib/file-write.js +87 -0
  44. package/src/lib/fragments.js +340 -0
  45. package/src/lib/option-definitions.js +246 -0
  46. package/src/lib/options.js +841 -0
  47. package/src/lib/unused-css.js +499 -0
  48. package/src/lib/urls.js +146 -0
  49. package/src/lib/utils.js +1026 -0
  50. package/src/lib/whitespace.js +431 -0
  51. package/src/presets.js +58 -0
  52. package/src/tokenchain.js +142 -56
  53. package/LICENSE +0 -22
  54. package/sample-cli-config-file.conf +0 -40
  55. package/src/utils.js +0 -18
@@ -0,0 +1,841 @@
1
+ /**
2
+ * Options processing and per-document minification state
3
+ */
4
+
5
+ import { createUrlMinifier } from './urls.js';
6
+ import { LRU, MAX_CACHE_ENTRY_SIZE, stableStringify, hashContent, identity, isThenable, lowercase, paramCase, replaceAsync, parseRegExp, describeQuantifierRisk, lostFlag, findTagEnd } from './utils.js';
7
+ import { RE_TRAILING_SEMICOLON, MISSING_DEPENDENCY, svgoPluginsInline, namespaceSVG, namespaceMathML, namespaceXLink } from './constants.js';
8
+ import { canCollapseWhitespace, canTrimWhitespace } from './whitespace.js';
9
+ import { wrapCSS, unwrapCSS } from './content.js';
10
+ import { createSelectorPruner, findUnusedSymbols, normalizeUnusedCSSOptions } from './unused-css.js';
11
+ import { getPreset, getPresetNames } from '../presets.js';
12
+ import { optionDefinitions, optionDefaults } from './option-definitions.js';
13
+
14
+ /** @import { MinifierOptions, HTMLAttribute } from '../htmlminifier.js' */
15
+
16
+ /**
17
+ * Per-document state handed to `minifyCSS`. Its closure hangs off the memoized
18
+ * options object that every `minify()` call with those options shares, so state
19
+ * belonging to one document has to be passed in rather than captured.
20
+ *
21
+ * @typedef {{usedSymbols?: Set<string>, usedElements?: Set<string>, usedElementsKey?: string, warned: Set<string>}} CSSContext
22
+ */
23
+
24
+ /**
25
+ * Minified style sheet plus the warnings its transform produced, cached together
26
+ * so that a cache hit can report what the transform reported
27
+ *
28
+ * @typedef {{css: string, warnings: string[]}} CSSResult
29
+ */
30
+
31
+ /**
32
+ * Options object produced by `processOptions` and consumed by `minifyHTML` and
33
+ * the lib/ helpers; normalization guarantees that the function-valued options
34
+ * below are always present (defaulting to identity/built-in functions), and
35
+ * minification adds writable internal state on top of the public options
36
+ * (set on prototype-chain forks during SVG/MathML namespace transitions)
37
+ *
38
+ * @typedef {Omit<MinifierOptions, 'preset' | 'canCollapseWhitespace' | 'canTrimWhitespace' | 'ignoreCustomComments' | 'log' | 'minifyCSS' | 'minifyJS' | 'minifyURLs' | 'minifySVG' | 'removeUnusedCSS'> & {
39
+ * name: (name: string) => string,
40
+ * log: (message: any) => unknown,
41
+ * ignoreCustomComments: RegExp[],
42
+ * canCollapseWhitespace: (tag: string, attrs: HTMLAttribute[], defaultFn: (tag: string) => boolean) => boolean,
43
+ * canTrimWhitespace: (tag: string, attrs: HTMLAttribute[], defaultFn: (tag: string) => boolean) => boolean,
44
+ * minifyCSS: (text: string, type?: string, context?: CSSContext) => string | Promise<string>,
45
+ * minifyJS: (text: string, inline?: boolean, isModule?: boolean) => string | Promise<string>,
46
+ * minifyURLs: (text: string) => string | Promise<string>,
47
+ * minifySVG: ((svgContent: string) => string | Promise<string>) | null,
48
+ * canMinifyCSS: ((text: string, type?: 'inline' | 'media') => boolean) | null,
49
+ * canMinifyJS: ((text: string, inline?: boolean) => boolean) | null,
50
+ * canMinifySVG: ((text: string) => boolean) | null,
51
+ * removeUnusedCSS: {safelist: Array<string | RegExp>, scripts: boolean} | null,
52
+ * cssContext?: CSSContext,
53
+ * documentState?: {hasMathML: boolean},
54
+ * baseState?: {hasBase: boolean | undefined, source: string},
55
+ * parallelJS?: boolean,
56
+ * nameParent?: (name: string) => string,
57
+ * namesAsWritten?: boolean,
58
+ * keepClosingSlashHTML?: boolean,
59
+ * insideSVG?: boolean,
60
+ * insideForeignContent?: boolean,
61
+ * namespaceForeign?: 'svg' | 'math'
62
+ * }} ProcessedOptions
63
+ */
64
+
65
+ const RE_SCOPE_AT_RULE = /@scope\b/i;
66
+ const RE_SVGO_EMPTY_ATTRIBUTE = / ([^\s"'<>/=]+)=""/g;
67
+ const RE_SVG_START = /^<svg(?=[\s/>])/;
68
+ const RE_QUOTED_VALUE = /"[^"]*"|'[^']*'/g;
69
+ const RE_XLINK_DECLARATION = /\sxmlns:xlink(?=[\s=/>]|$)/;
70
+ // The declaration as SVGO writes it, and where it may be, outside comments and
71
+ // CDATA sections, which SVGO writes as is (values and text escape `"`)
72
+ const XLINK_DECLARATION_SVGO = ' xmlns:xlink="' + namespaceXLink + '"';
73
+ const RE_XLINK_DECLARATION_SVGO = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>| xmlns:xlink="[^"]*"/g;
74
+
75
+ /**
76
+ * SVGO plugin dropping the `xmlns` declarations HTML reads anyway on `svg` and `math` elements
77
+ * (`xmlns:xlink` goes after SVGO, as each pass of `multipass` reads XML again, which needs it)
78
+ */
79
+ const pluginNamespacesHTML = {
80
+ name: 'removeNamespacesHTML',
81
+ fn: () => ({
82
+ element: {
83
+ enter: (/** @type {{name: string, attributes: Record<string, string>}} */ node) => {
84
+ const attrs = node.attributes;
85
+ if (attrs.xmlns === (node.name === 'math' ? namespaceMathML : node.name === 'svg' ? namespaceSVG : undefined)) {
86
+ delete attrs.xmlns;
87
+ }
88
+ }
89
+ }
90
+ })
91
+ };
92
+
93
+ /** @param {ProcessedOptions} options */
94
+ function shouldMinifyInnerHTML(options) {
95
+ return Boolean(
96
+ options.collapseWhitespace ||
97
+ options.removeComments ||
98
+ options.removeOptionalTags ||
99
+ options.minifyJS !== identity ||
100
+ options.minifyCSS !== identity ||
101
+ options.minifyJSON ||
102
+ options.minifyURLs !== identity ||
103
+ options.minifySVG
104
+ );
105
+ }
106
+
107
+ // Persistent per-site URL minification caches—results depend only on the `site`
108
+ // configuration, so entries can be shared across `minify()` calls; bounded so
109
+ // batch runs with many distinct sites can’t grow memory without limit
110
+ const MAX_URL_CACHE_SITES = 20;
111
+ /** @type {Map<string, LRU>} */
112
+ const urlMinifyCaches = new Map();
113
+
114
+ /** @param {string} site */
115
+ function getUrlMinifyCache(site) {
116
+ let cache = urlMinifyCaches.get(site);
117
+ if (!cache) {
118
+ if (urlMinifyCaches.size >= MAX_URL_CACHE_SITES) {
119
+ const oldestSite = urlMinifyCaches.keys().next().value;
120
+ if (oldestSite !== undefined) {
121
+ urlMinifyCaches.delete(oldestSite);
122
+ }
123
+ }
124
+ cache = new LRU(500);
125
+ urlMinifyCaches.set(site, cache);
126
+ }
127
+ return cache;
128
+ }
129
+
130
+ // User-facing option keys that are valid but not listed in `optionDefinitions`
131
+ const optionKeysExtra = new Set(['preset', 'log', 'canCollapseWhitespace', 'canTrimWhitespace', 'canMinifyCSS', 'canMinifyJS', 'canMinifySVG']);
132
+
133
+ // Unknown option keys and preset names already warned about—warn once per
134
+ // key per process, so repeated `minify` calls (e.g., batch runs) don’t flood STDERR
135
+ const optionKeysWarned = new Set();
136
+ const presetNamesWarned = new Set();
137
+ // Custom fragments whose shape risks ReDoS, warned about once per pattern per process
138
+ const customFragmentsWarned = new Set();
139
+ // Options set without the option they need, warned about once per message per process
140
+ const dependenciesWarned = new Set();
141
+ // Unused-CSS configuration that does not hold up, warned about once per message per process
142
+ const unusedCSSWarned = new Set();
143
+ // Object-valued options handed a string, warned about once per distinct value
144
+ const stringValuesWarned = new Set();
145
+
146
+ // Clause for options that only the built-in minifier behind `key` reads
147
+ const replacedByOwnFunction = (/** @type {string} */ key) =>
148
+ (/** @type {Record<string, any>} */ input) => typeof input[key] === 'function' && 'which a function of your own replaces';
149
+
150
+ /**
151
+ * Options that do nothing on their own, and the option each one needs to take effect
152
+ *
153
+ * Cache sizes are deliberately absent: They never change output, so they are
154
+ * configuration for a subsystem rather than a transform that silently fails.
155
+ *
156
+ * @typedef {object} OptionDependency
157
+ * @property {string} option
158
+ * @property {string} requires
159
+ * @property {(input: Record<string, any>) => string | false} [unusable] Clause saying why the prerequisite does not count even though it is set
160
+ * @property {boolean} [clear] Whether to null the option out, where leaving it set would cost work downstream
161
+ *
162
+ * @type {OptionDependency[]}
163
+ */
164
+ const optionDependencies = [
165
+ { option: 'canMinifyCSS', requires: 'minifyCSS', unusable: replacedByOwnFunction('minifyCSS'), clear: true },
166
+ { option: 'canMinifyJS', requires: 'minifyJS', unusable: replacedByOwnFunction('minifyJS'), clear: true },
167
+ { option: 'canMinifySVG', requires: 'minifySVG', unusable: replacedByOwnFunction('minifySVG'), clear: true },
168
+ { option: 'collapseInlineTagWhitespace', requires: 'collapseWhitespace' },
169
+ { option: 'collapseNoBreakSpaces', requires: 'collapseWhitespace' },
170
+ { option: 'conservativeCollapse', requires: 'collapseWhitespace' },
171
+ { option: 'customEventAttributes', requires: 'minifyJS' },
172
+ { option: 'inlineCustomElements', requires: 'collapseWhitespace' },
173
+ { option: 'noNewlinesBeforeTagClose', requires: 'maxLineLength' },
174
+ { option: 'preserveLineBreaks', requires: 'collapseWhitespace' },
175
+ { option: 'removeEmptyElementsExcept', requires: 'removeEmptyElements' },
176
+ {
177
+ option: 'removeUnusedCSS',
178
+ requires: 'minifyCSS',
179
+ // Removal rides along with Lightning CSS, which a function of one’s own replaces
180
+ unusable: replacedByOwnFunction('minifyCSS'),
181
+ clear: true
182
+ },
183
+ { option: 'trimCustomFragments', requires: 'collapseWhitespace' }
184
+ ];
185
+
186
+ // An empty array is a value the user supplied but did not populate, and so asks for
187
+ // nothing; `identity` is what a minifier left off resolves to
188
+ /** @param {unknown} value */
189
+ function isRequested(value) {
190
+ if (value === identity) return false;
191
+ return Array.isArray(value) ? value.length > 0 : Boolean(value);
192
+ }
193
+
194
+ // A `canMinify…` hook that returns false leaves the content as is, as does one that
195
+ // throws under `continueOnMinifyError`; hooks are synchronous, so a promise counts as an error
196
+ /**
197
+ * @param {ProcessedOptions} options - Processed options
198
+ * @param {'canMinifyCSS' | 'canMinifyJS' | 'canMinifySVG'} name - Hook to consult
199
+ * @param {...any} args - Arguments for the hook
200
+ * @returns {boolean}
201
+ */
202
+ function declinesMinify(options, name, ...args) {
203
+ const hook = /** @type {((...args: any[]) => unknown) | null} */ (options[name]);
204
+ if (!hook) return false;
205
+ try {
206
+ const result = hook(...args);
207
+ if (isThenable(result)) {
208
+ // Keep a later rejection from surfacing as unhandled
209
+ /** @type {Promise<unknown>} */ (result).catch(() => {});
210
+ throw new TypeError(`\`${name}\` must return a boolean, not a promise`);
211
+ }
212
+ return !result;
213
+ } catch (err) {
214
+ if (!options.continueOnMinifyError) {
215
+ throw err;
216
+ }
217
+ options.log(err);
218
+ return true;
219
+ }
220
+ }
221
+
222
+ // Cache key for minifier output: the content, hashed where too large for a `Map` key, then
223
+ // what else the output depends on; `undefined` where the content is too large to cache
224
+ /**
225
+ * @param {string} content
226
+ * @param {string} suffix
227
+ */
228
+ function minifyCacheKey(content, suffix) {
229
+ if (content.length > MAX_CACHE_ENTRY_SIZE) return undefined;
230
+ return (content.length > 2048 ? hashContent(content) : content) + '|' + suffix;
231
+ }
232
+
233
+ // Runs `compute` once per key: Concurrent callers share the in-flight promise, which the
234
+ // result replaces once settled; a failure leaves no entry behind
235
+ /**
236
+ * @template T
237
+ * @param {LRU} cache
238
+ * @param {string | undefined} key
239
+ * @param {() => Promise<T>} compute
240
+ * @returns {Promise<T>}
241
+ */
242
+ async function cachedMinify(cache, key, compute) {
243
+ if (key === undefined) return compute();
244
+ const cached = /** @type {T | Promise<T> | undefined} */ (cache.get(key));
245
+ if (cached !== undefined) return cached;
246
+ const inFlight = compute();
247
+ cache.set(key, inFlight);
248
+ try {
249
+ const resolved = await inFlight;
250
+ cache.set(key, resolved);
251
+ return resolved;
252
+ } catch (err) {
253
+ cache.delete(key);
254
+ throw err;
255
+ }
256
+ }
257
+
258
+ /** @param {unknown} err - Error raised while minifying */
259
+ function isMissingDependency(err) {
260
+ return Boolean(err) && /** @type {any} */ (err).code === MISSING_DEPENDENCY;
261
+ }
262
+
263
+ // An unset (falsy) engine takes the fallback and names match lowercase; anything else
264
+ // passes through untouched so that validation reports it instead of a type error
265
+ /**
266
+ * @param {unknown} engine - Configured engine name
267
+ * @param {string} fallback - Engine to use when none is configured
268
+ */
269
+ function normalizeEngine(engine, fallback) {
270
+ if (!engine) return fallback;
271
+ return typeof engine === 'string' ? engine.toLowerCase() : engine;
272
+ }
273
+
274
+ /** @param {unknown} engine - Engine name as it should read in an error message */
275
+ function describeEngine(engine) {
276
+ if (typeof engine === 'string') return engine;
277
+ try {
278
+ return JSON.stringify(engine) ?? String(engine);
279
+ } catch {
280
+ // BigInt and cyclic values do not serialize
281
+ return String(engine);
282
+ }
283
+ }
284
+
285
+ /**
286
+ * @param {MinifierOptions} inputOptions - User-provided options
287
+ * @param {{getLightningCSS: Function, getTerser: Function, getSwc: Function, getSvgo: Function, cssMinifyCache: LRU, jsMinifyCache: LRU, svgMinifyCache: LRU}} deps - Dependencies from htmlminifier.js
288
+ * @returns {ProcessedOptions} Normalized options with defaults applied
289
+ */
290
+ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getSvgo, cssMinifyCache, jsMinifyCache, svgMinifyCache }) => {
291
+ /** @type {ProcessedOptions} */
292
+ const options = {
293
+ name: lowercase,
294
+ namesAsWritten: false,
295
+ canCollapseWhitespace,
296
+ canTrimWhitespace,
297
+ ...optionDefaults,
298
+ log: identity,
299
+ minifyCSS: identity,
300
+ minifyJS: identity,
301
+ minifyURLs: identity,
302
+ minifySVG: null,
303
+ canMinifyCSS: null,
304
+ canMinifyJS: null,
305
+ canMinifySVG: null,
306
+ removeUnusedCSS: null
307
+ };
308
+
309
+ const parseRegExpArray = (/** @type {unknown} */ arr) => {
310
+ return Array.isArray(arr) ? arr.map(parseRegExp) : [];
311
+ };
312
+
313
+ // Helper for nested arrays (e.g., `customAttrSurround: [[start, end], …]`)
314
+ const parseNestedRegExpArray = (/** @type {unknown} */ arr) => {
315
+ if (!Array.isArray(arr)) return [];
316
+ return arr.map(item => {
317
+ if (Array.isArray(item)) {
318
+ return item.map(parseRegExp);
319
+ }
320
+ return parseRegExp(item);
321
+ });
322
+ };
323
+
324
+ // Route warnings through the user-provided `log` hook so API consumers can
325
+ // capture or suppress them consistently; fall back to `console.warn`.
326
+ //
327
+ // Message convention: Strings that may reach the console unsolicited (via
328
+ // this fallback) carry the “HTML Minifier: ” prefix for attribution and
329
+ // no “Warning:” label—the channel already conveys severity. Strings sent
330
+ // only to an explicitly provided `log` hook carry “Warning: ” instead, since
331
+ // the hook mixes severities (info strings, warnings, `Error` objects) and
332
+ // its consumer already knows the source.
333
+ const warn = typeof inputOptions.log === 'function' ? inputOptions.log : console.warn;
334
+
335
+ // Warn about unrecognized options—catches typos and options removed in earlier versions
336
+ Object.keys(inputOptions).forEach(function (key) {
337
+ if (!Object.hasOwn(optionDefinitions, key) && !optionKeysExtra.has(key) && !optionKeysWarned.has(key)) {
338
+ optionKeysWarned.add(key);
339
+ warn(`HTML Minifier: Ignoring unknown or deprecated option \`${key}\` (see README for available options)`);
340
+ }
341
+ });
342
+
343
+ // Merge preset with user options (which take precedence), so all values go through normalization
344
+ let effectiveInput = inputOptions;
345
+ if (inputOptions.preset) {
346
+ const preset = getPreset(inputOptions.preset);
347
+ if (preset) {
348
+ effectiveInput = { ...preset, ...inputOptions };
349
+ } else if (!presetNamesWarned.has(inputOptions.preset)) {
350
+ presetNamesWarned.add(inputOptions.preset);
351
+ const available = getPresetNames().join(', ');
352
+ warn(`HTML Minifier: Unknown preset “${inputOptions.preset}”; available presets: ${available}`);
353
+ }
354
+ }
355
+
356
+ // Escape hatch for the loop below, which reads and assigns user-provided values by dynamic key
357
+ const optionsDynamic = /** @type {Record<string, any>} */ (options);
358
+
359
+ Object.keys(effectiveInput).forEach(function (key) {
360
+ const option = /** @type {Record<string, any>} */ (effectiveInput)[key];
361
+
362
+ // Skip `preset` (already processed) and unrecognized keys (warned about above)—
363
+ // the latter also keeps internal keys from being overridden
364
+ if (key === 'preset' || (!Object.hasOwn(optionDefinitions, key) && !optionKeysExtra.has(key))) {
365
+ return;
366
+ }
367
+
368
+ // A string carries no configuration for these options. The CLI parses config
369
+ // values as JSON first, so only a value that is not JSON reaches this from
370
+ // there. (`minifyURLs` is deliberately excluded—there, a string names the site.)
371
+ const definition = optionDefinitions[key];
372
+ if (typeof option === 'string' && definition?.type === 'jsonObject') {
373
+ const message = `HTML Minifier: Ignoring \`${key}\`—it takes a boolean or an object, not a string (“${option}”)`;
374
+ if (!stringValuesWarned.has(message)) {
375
+ stringValuesWarned.add(message);
376
+ warn(message);
377
+ }
378
+ return;
379
+ }
380
+
381
+ if (key === 'caseSensitive') {
382
+ // HTML names are read in lowercase, like browsers do, and only written as in the source
383
+ if (option) {
384
+ options.namesAsWritten = true;
385
+ }
386
+ } else if (key === 'removeUnusedCSS') {
387
+ optionsDynamic.removeUnusedCSS = normalizeUnusedCSSOptions(option, message => {
388
+ if (!unusedCSSWarned.has(message)) {
389
+ unusedCSSWarned.add(message);
390
+ warn(`HTML Minifier: ${message}`);
391
+ }
392
+ });
393
+ } else if (key === 'log') {
394
+ if (typeof option === 'function') {
395
+ options.log = option;
396
+ }
397
+ } else if (key === 'canMinifyCSS' || key === 'canMinifyJS' || key === 'canMinifySVG') {
398
+ if (typeof option === 'function') {
399
+ optionsDynamic[key] = option;
400
+ }
401
+ } else if (key === 'minifyCSS' && typeof option !== 'function') {
402
+ if (!option) {
403
+ return;
404
+ }
405
+
406
+ const lightningCssOptions = typeof option === 'object' ? option : {};
407
+
408
+ options.minifyCSS = async function (/** @type {string} */ text, /** @type {string | undefined} */ type, /** @type {CSSContext | undefined} */ context) {
409
+ // Fast path: Nothing to minify
410
+ if (!text || !text.trim()) {
411
+ return text;
412
+ }
413
+
414
+ if (declinesMinify(options, 'canMinifyCSS', text, type)) {
415
+ return text;
416
+ }
417
+
418
+ // Warnings are cached with the result and replayed on every hit, so a second document with
419
+ // the same defect hears about it, too (`context.warned` keeps one document from repeating
420
+ // itself), and the output doesn’t depend on cache size or eviction. They are cached even
421
+ // with the no-op `log`, so a later document with a `log` hook still gets them.
422
+ const report = (/** @type {string[]} */ messages) => {
423
+ if (!messages.length || options.log === identity) {
424
+ return;
425
+ }
426
+ const warned = context?.warned;
427
+ for (const message of messages) {
428
+ if (warned) {
429
+ if (warned.has(message)) {
430
+ continue;
431
+ }
432
+ warned.add(message);
433
+ }
434
+ options.log(message);
435
+ }
436
+ };
437
+
438
+ // Skip the costly `replaceAsync` when URL minification is off
439
+ if (options.minifyURLs !== identity) {
440
+ text = await replaceAsync(
441
+ text,
442
+ /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
443
+ async function (/** @type {string} */ match, /** @type {string} */ prefix, /** @type {string | undefined} */ dq, /** @type {string | undefined} */ sq, /** @type {string | undefined} */ unq, /** @type {string} */ suffix) {
444
+ const quote = dq != null ? '"' : (sq != null ? "'" : '');
445
+ const url = dq ?? sq ?? unq ?? '';
446
+ try {
447
+ const out = await options.minifyURLs(url);
448
+ return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
449
+ } catch (err) {
450
+ if (!options.continueOnMinifyError) {
451
+ throw err;
452
+ }
453
+ options.log(err);
454
+ return match;
455
+ }
456
+ }
457
+ );
458
+ }
459
+
460
+ // Unused-symbol removal applies to style sheets only
461
+ const unusedCSSConfig = type === undefined ? options.removeUnusedCSS : undefined;
462
+ const unusedSymbols = (unusedCSSConfig && context?.usedSymbols)
463
+ ? findUnusedSymbols(text, context.usedSymbols, unusedCSSConfig.safelist)
464
+ : undefined;
465
+ // Selectors are pruned one by one, too—unless a `visitor` of the caller’s own is
466
+ // set, or the sheet uses `@scope`, whose preludes the visitor reaches as well and
467
+ // where a dropped limit would widen the scope
468
+ const pruneSelectors = Boolean(unusedSymbols && context?.usedElements) &&
469
+ !('visitor' in lightningCssOptions) && !RE_SCOPE_AT_RULE.test(text);
470
+
471
+ // Cache key: Content + type + options signature; large inputs are hashed to avoid
472
+ // huge Map keys. The symbol list belongs in the signature: The cache outlives a
473
+ // single `minify()` call, so identical style sheets in differently marked-up
474
+ // documents must not share an entry; so do the element set and the safelist
475
+ // where the selector visitor reads them.
476
+ const inputCSS = wrapCSS(text, type);
477
+ const cssSig = stableStringify({
478
+ type,
479
+ opts: lightningCssOptions,
480
+ cont: !!options.continueOnMinifyError,
481
+ unused: unusedSymbols && unusedSymbols.length ? unusedSymbols.slice().sort() : undefined,
482
+ elements: pruneSelectors ? context?.usedElementsKey : undefined,
483
+ safelist: pruneSelectors ? unusedCSSConfig?.safelist : undefined
484
+ });
485
+ const cssKey = minifyCacheKey(inputCSS, type + '|' + cssSig);
486
+
487
+ try {
488
+ const resolved = await cachedMinify(cssMinifyCache, cssKey, async () => {
489
+ const transformCSS = await getLightningCSS();
490
+ // `Buffer.from()` is required—the Lightning CSS API expects a `Uint8Array`
491
+ const result = transformCSS({
492
+ filename: 'input.css',
493
+ code: Buffer.from(inputCSS),
494
+ minify: true,
495
+ errorRecovery: !!options.continueOnMinifyError,
496
+ ...lightningCssOptions,
497
+ // Union, so that a manually supplied `unusedSymbols` list survives
498
+ ...(unusedSymbols && unusedSymbols.length
499
+ ? { unusedSymbols: lightningCssOptions.unusedSymbols ? [...new Set([...lightningCssOptions.unusedSymbols, ...unusedSymbols])] : unusedSymbols }
500
+ : {}),
501
+ ...(pruneSelectors && unusedCSSConfig && context?.usedElements
502
+ ? { visitor: { Selector: createSelectorPruner(unusedSymbols ?? [], context.usedElements, unusedCSSConfig.safelist) } }
503
+ : {})
504
+ });
505
+
506
+ // With `errorRecovery` enabled, Lightning CSS reports what it takes issue
507
+ // with instead of throwing—dropping the rule in some cases (`@property`
508
+ // with a bad `syntax`) and passing it through in others (an unknown
509
+ // at-rule), which is why the wording stops at “reported”
510
+ /** @type {string[]} */
511
+ const warnings = [];
512
+ if (result.warnings) {
513
+ for (const warning of result.warnings) {
514
+ const at = warning.loc ? ` (line ${warning.loc.line}, column ${warning.loc.column})` : '';
515
+ warnings.push(`Warning: Lightning CSS reported invalid CSS${at}: ${warning.message}`);
516
+ }
517
+ }
518
+
519
+ const outputCSS = unwrapCSS(result.code.toString(), type);
520
+
521
+ // Where Lightning CSS emptied template syntax (`<?php ?>`, `{{ }}`: `<` or `>` without
522
+ // `CDATA`) or custom-fragment UIDs (long lowercase alphanumeric runs), keep the
523
+ // original; CDATA sections, entities, and other invalid CSS may go
524
+ const isCDATA = text.includes('<![CDATA[');
525
+ const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
526
+ const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
527
+ const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
528
+
529
+ const css = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
530
+ return /** @type {CSSResult} */ ({ css, warnings });
531
+ });
532
+ report(resolved.warnings);
533
+ return resolved.css;
534
+ } catch (err) {
535
+ if (!options.continueOnMinifyError) {
536
+ throw err;
537
+ }
538
+ options.log(err);
539
+ return text;
540
+ }
541
+ };
542
+ } else if (key === 'minifyJS' && typeof option !== 'function') {
543
+ if (!option) {
544
+ return;
545
+ }
546
+
547
+ const config = typeof option === 'object' ? option : {};
548
+ const engine = normalizeEngine(config.engine, 'terser');
549
+
550
+ const supportedEngines = ['terser', 'swc'];
551
+ if (!supportedEngines.includes(/** @type {string} */ (engine))) {
552
+ throw new Error(`Unsupported JS minifier engine: \`${describeEngine(engine)}\`. Supported engines: ${supportedEngines.join(', ')}`);
553
+ }
554
+
555
+ const engineOptions = { ...config };
556
+ delete engineOptions.engine;
557
+
558
+ // Terser options (when engine is `terser`)
559
+ const terserOptions = engine === 'terser' ? engineOptions : {};
560
+ terserOptions.parse = {
561
+ ...terserOptions.parse,
562
+ bare_returns: false
563
+ };
564
+
565
+ // SWC options (when engine is `swc`)
566
+ const swcOptions = engine === 'swc' ? engineOptions : {};
567
+
568
+ // Option signatures, computed once rather than per call
569
+ const terserSig = stableStringify({
570
+ ...terserOptions,
571
+ cont: !!options.continueOnMinifyError
572
+ });
573
+ const swcSig = stableStringify({
574
+ ...swcOptions,
575
+ cont: !!options.continueOnMinifyError
576
+ });
577
+
578
+ options.minifyJS = async function (/** @type {string} */ text, /** @type {boolean | undefined} */ inline, /** @type {boolean | undefined} */ isModule) {
579
+ const start = text.match(/^\s*<!--.*/);
580
+ const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
581
+
582
+ // Fast path: Avoid invoking minifier for empty/whitespace-only content
583
+ if (!code || !code.trim()) {
584
+ return '';
585
+ }
586
+
587
+ if (declinesMinify(options, 'canMinifyJS', text, inline)) {
588
+ return text;
589
+ }
590
+
591
+ const optsSig = engine === 'terser' ? terserSig : swcSig;
592
+ const jsKey = minifyCacheKey(code, (inline ? '1' : '0') + '|' + (isModule ? 'm' : '') + '|' + engine + '|' + optsSig);
593
+
594
+ try {
595
+ return await cachedMinify(jsMinifyCache, jsKey, async () => {
596
+ if (engine === 'terser') {
597
+ // Create a copy to avoid mutating shared `terserOptions` (race condition)
598
+ const terserCallOptions = {
599
+ ...terserOptions,
600
+ parse: {
601
+ ...terserOptions.parse,
602
+ bare_returns: inline
603
+ },
604
+ ...(isModule ? { module: true } : {}) // Overrides user options: module detection takes precedence for `<script type=module>`
605
+ };
606
+ const terser = await getTerser();
607
+ const result = await terser(code, terserCallOptions);
608
+ return result.code.replace(RE_TRAILING_SEMICOLON, '');
609
+ } else {
610
+ const swc = await getSwc();
611
+ if (inline) {
612
+ // `swc.minify()` rejects bare returns, which `transform()` can parse;
613
+ // no .swcrc may apply, and `esnext` keeps the syntax as written
614
+ const result = await swc.transform(code, {
615
+ swcrc: false,
616
+ configFile: false,
617
+ isModule: false,
618
+ minify: true,
619
+ jsc: {
620
+ parser: { syntax: 'ecmascript', allowReturnOutsideFunction: true },
621
+ target: 'esnext',
622
+ minify: { compress: true, mangle: true, ...swcOptions }
623
+ }
624
+ });
625
+ return result.code.replace(RE_TRAILING_SEMICOLON, '');
626
+ }
627
+ // `swc.minify()` takes compress and mangle directly as options
628
+ const result = await swc.minify(code, {
629
+ compress: true,
630
+ mangle: true,
631
+ ...swcOptions,
632
+ ...(isModule ? { module: true } : {}) // Overrides user options: module detection takes precedence for `<script type=module>`
633
+ });
634
+ return result.code.replace(RE_TRAILING_SEMICOLON, '');
635
+ }
636
+ });
637
+ } catch (err) {
638
+ if (!options.continueOnMinifyError || isMissingDependency(err)) {
639
+ throw err;
640
+ }
641
+ options.log(err);
642
+ return text;
643
+ }
644
+ };
645
+
646
+ // Whether dispatching script bodies ahead of the parse pays off. It needs a minifier
647
+ // that leaves the main thread—SWC hands work to its own threadpool, where overlapping
648
+ // calls genuinely run at once, while Terser would only compete with the parse for the
649
+ // one thread both share. A user-supplied `minifyJS` is excluded either way—only this
650
+ // wrapper is content-keyed and free of side effects.
651
+ options.parallelJS = engine === 'swc';
652
+ } else if (key === 'minifyURLs' && typeof option !== 'function') {
653
+ if (!option) {
654
+ return;
655
+ }
656
+
657
+ let urlOptions = option;
658
+
659
+ if (typeof option === 'string') {
660
+ urlOptions = { site: option };
661
+ } else if (typeof option !== 'object') {
662
+ urlOptions = {};
663
+ }
664
+
665
+ const relate = createUrlMinifier(urlOptions.site || '');
666
+
667
+ // Reuse the persistent cache for this site configuration
668
+ const instanceCache = getUrlMinifyCache(urlOptions.site || '');
669
+
670
+ options.minifyURLs = function (/** @type {string} */ text) {
671
+ // Fast path: Skip text without URL-like characters (`/`, `:`, `#`, `?`) or spaces
672
+ if (!/[/:?#\s]/.test(text)) {
673
+ return text;
674
+ }
675
+
676
+ const cached = /** @type {string | undefined} */ (instanceCache.get(text));
677
+ if (cached !== undefined) {
678
+ return cached;
679
+ }
680
+
681
+ try {
682
+ const result = relate(text);
683
+ instanceCache.set(text, result);
684
+ return result;
685
+ } catch (err) {
686
+ // Don’t cache errors
687
+ if (!options.continueOnMinifyError) {
688
+ throw err;
689
+ }
690
+ options.log(err);
691
+ return text;
692
+ }
693
+ };
694
+ } else if (key === 'minifySVG' && typeof option !== 'function') {
695
+ if (!option) {
696
+ return;
697
+ }
698
+
699
+ // A config without `plugins` of its own keeps the plugins fit for inline SVG
700
+ const svgoConfig = typeof option === 'object' ? /** @type {{plugins?: unknown[]}} */ (option) : {};
701
+ const svgoOptions = { ...svgoConfig, plugins: svgoConfig.plugins ?? svgoPluginsInline };
702
+
703
+ // Option signature for cache keys
704
+ const svgSig = stableStringify({
705
+ ...svgoOptions,
706
+ cont: !!options.continueOnMinifyError
707
+ });
708
+
709
+ options.minifySVG = async function (/** @type {string} */ svgContent) {
710
+ if (!svgContent || !svgContent.trim()) {
711
+ return svgContent;
712
+ }
713
+
714
+ if (declinesMinify(options, 'canMinifySVG', svgContent)) {
715
+ return svgContent;
716
+ }
717
+
718
+ // SVGO needs every attribute to carry a value, but its output is HTML again,
719
+ // where an empty value can go; it escapes quotes in values and text, so `=""`
720
+ // is always one. Read here, as options are processed in the order they are given.
721
+ const collapseEmpty = Boolean(options.collapseEmptyAttributes);
722
+ const removeNamespaces = Boolean(options.removeRedundantAttributes);
723
+
724
+ // XML reads `xlink:` names only where their prefix is declared, which HTML needs not be;
725
+ // a declaration added to the root for SVGO goes after it (one on a nested `svg` covers
726
+ // that one only)
727
+ const rootEnd = svgContent.indexOf('xlink:') !== -1 && RE_SVG_START.test(svgContent) ? findTagEnd(svgContent, 4) : -1;
728
+ // Values are quoted where SVGO reads, so what is left is names
729
+ const addsXLink = rootEnd !== -1 &&
730
+ !RE_XLINK_DECLARATION.test(svgContent.slice(4, rootEnd).replace(RE_QUOTED_VALUE, ''));
731
+ const svgInput = addsXLink
732
+ ? '<svg xmlns:xlink="' + namespaceXLink + '"' + svgContent.slice(4)
733
+ : svgContent;
734
+
735
+ const svgKey = minifyCacheKey(svgContent, svgSig + (collapseEmpty ? '|c' : '') + (removeNamespaces ? '|n' : ''));
736
+
737
+ try {
738
+ return await cachedMinify(svgMinifyCache, svgKey, async () => {
739
+ const optimize = await getSvgo();
740
+ const result = optimize(svgInput, removeNamespaces
741
+ ? { ...svgoOptions, plugins: [...svgoOptions.plugins, pluginNamespacesHTML] }
742
+ : svgoOptions);
743
+ let data = result.data;
744
+ // The root’s declaration was added for SVGO, unless all of them go
745
+ if (removeNamespaces) {
746
+ data = data.replace(RE_XLINK_DECLARATION_SVGO, (/** @type {string} */ match) => match === XLINK_DECLARATION_SVGO ? '' : match);
747
+ } else if (addsXLink) {
748
+ // SVGO escapes `>` in values, so the root’s start tag ends at the first one
749
+ const at = data.indexOf(XLINK_DECLARATION_SVGO);
750
+ if (at !== -1 && at < data.indexOf('>')) {
751
+ data = data.slice(0, at) + data.slice(at + XLINK_DECLARATION_SVGO.length);
752
+ }
753
+ }
754
+ return /** @type {string} */ (collapseEmpty ? data.replace(RE_SVGO_EMPTY_ATTRIBUTE, ' $1') : data);
755
+ });
756
+ } catch (err) {
757
+ if (!options.continueOnMinifyError) {
758
+ throw err;
759
+ }
760
+ options.log(err);
761
+ return svgContent;
762
+ }
763
+ };
764
+ } else if (key === 'customAttrCollapse') {
765
+ // Single regex pattern
766
+ optionsDynamic[key] = parseRegExp(option);
767
+ } else if (key === 'customAttrSurround') {
768
+ // Nested array of RegExp pairs: `[[openRegExp, closeRegExp], …]`
769
+ optionsDynamic[key] = parseNestedRegExpArray(option);
770
+ } else if (['customAttrAssign', 'customEventAttributes', 'ignoreCustomComments', 'ignoreCustomFragments'].includes(key)) {
771
+ // Array of regex patterns
772
+ optionsDynamic[key] = parseRegExpArray(option);
773
+ } else {
774
+ optionsDynamic[key] = option;
775
+ }
776
+ });
777
+
778
+ // The parser merges these into one attribute pattern, and a merged pattern
779
+ // carries no flags of its own. `i` and `s` are written into the source instead,
780
+ // but `u` and `v` change how a source reads and cannot be: dropped, `\p{L}`
781
+ // stops being a property escape and matches the literal text `p{L}`, silently
782
+ // and without failing to compile. Refusing them beats matching the wrong thing.
783
+ /** @type {[string, RegExp[]][]} */
784
+ const merged = [
785
+ ['customAttrAssign', options.customAttrAssign || []],
786
+ ['customAttrSurround', (options.customAttrSurround || []).flat()]
787
+ ];
788
+ for (const [key, patterns] of merged) {
789
+ for (const re of patterns) {
790
+ if (!(re instanceof RegExp)) continue;
791
+ const flag = lostFlag(re);
792
+ if (!flag) continue;
793
+ throw new Error(`HTML Minifier: \`${key}\` pattern \`/${re.source}/${re.flags}\` carries \`${flag}\`, which the merged attribute pattern cannot carry—rewrite it without \`${flag}\``);
794
+ }
795
+ }
796
+
797
+ // Quantifiers or alternation compounded under unbounded repetition backtrack catastrophically,
798
+ // and a linear scan cannot stand in for them; patterns too long or too deeply nested to read are
799
+ // refused for that alone. A lone `[\s\S]*?` up to a literal terminator is linear and passes, as
800
+ // HM’s default fragments have exactly that shape.
801
+ for (const re of options.ignoreCustomFragments || []) {
802
+ const risk = describeQuantifierRisk(re);
803
+ if (!risk) continue;
804
+ const problem = `Custom fragment \`/${re.source}/\` ${risk}`;
805
+ if (options.strictCustomFragments) {
806
+ throw new Error(`HTML Minifier: ${problem}`);
807
+ }
808
+ if (!customFragmentsWarned.has(re.source)) {
809
+ customFragmentsWarned.add(re.source);
810
+ warn(`HTML Minifier: ${problem}`);
811
+ }
812
+ }
813
+
814
+ // Options that do nothing without the option they build on say so. The option is read from
815
+ // `effectiveInput` (user options over preset), so a default filled in above doesn’t count as
816
+ // asked for; the one it needs comes from the settled options, so an unloadable minifier is absent
817
+ const requestedOptions = /** @type {Record<string, any>} */ (effectiveInput);
818
+ const settledOptions = /** @type {Record<string, any>} */ (options);
819
+ for (const { option, requires, unusable, clear } of optionDependencies) {
820
+ if (!isRequested(requestedOptions[option])) continue;
821
+ const aside = unusable && unusable(requestedOptions);
822
+ if (!aside && isRequested(settledOptions[requires])) continue;
823
+ const message = `HTML Minifier: Ignoring \`${option}\`—use with \`${requires}\` (\`--${paramCase(requires)}\`)${aside ? `, ${aside}` : ''}`;
824
+ if (!dependenciesWarned.has(message)) {
825
+ dependenciesWarned.add(message);
826
+ warn(message);
827
+ }
828
+ if (clear) {
829
+ settledOptions[option] = null;
830
+ }
831
+ }
832
+
833
+ return options;
834
+ };
835
+
836
+ export {
837
+ optionDependencies,
838
+ optionKeysExtra,
839
+ shouldMinifyInnerHTML,
840
+ processOptions
841
+ };