html-minifier 3.5.21 → 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 -109
  3. package/cli.js +991 -206
  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 +2848 -1095
  37. package/src/htmlparser.js +1165 -450
  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 -39
  55. package/src/utils.js +0 -18
@@ -1,1329 +1,3082 @@
1
- 'use strict';
2
-
3
- var CleanCSS = require('clean-css');
4
- var decode = require('he').decode;
5
- var HTMLParser = require('./htmlparser').HTMLParser;
6
- var RelateUrl = require('relateurl');
7
- var TokenChain = require('./tokenchain');
8
- var UglifyJS = require('uglify-js');
9
- var utils = require('./utils');
10
-
11
- function trimWhitespace(str) {
12
- return str && str.replace(/^[ \n\r\t\f]+/, '').replace(/[ \n\r\t\f]+$/, '');
1
+ import { HTMLParser } from './htmlparser.js';
2
+ import TokenChain from './tokenchain.js';
3
+ import { presets, getPreset, getPresetNames } from './presets.js';
4
+
5
+ import { LRU, decodeForeignText, describeDependencyFailure, escapeXML, findTagEnd, identity, isThenable, lowercase, uniqueId } from './lib/utils.js';
6
+ import { collectUsage } from './lib/unused-css.js';
7
+
8
+ import {
9
+ RE_CR,
10
+ RE_LEGACY_ENTITIES,
11
+ RE_LINE_BREAK_CR,
12
+ RE_ESCAPE_LT,
13
+ RE_ESCAPE_LT_RAW_TEXT,
14
+ RE_HTML_ENCODING,
15
+ RE_WS_START,
16
+ RE_WS_END,
17
+ RE_WS_ONLY,
18
+ RE_WS_NBSP_END,
19
+ RE_STYLE_ELEMENT,
20
+ formattingElements,
21
+ inlineElementsToKeepWhitespaceAround,
22
+ inlineElementsToKeepWhitespaceWithin,
23
+ inlineElementsTransparent,
24
+ inlineElementsTransparentAfter,
25
+ specialContentElements,
26
+ rawTextElements,
27
+ htmlElements,
28
+ optionalStartTags,
29
+ optionalEndTags,
30
+ topLevelElements,
31
+ compactElements,
32
+ looseElements,
33
+ trailingElements,
34
+ pClosingParents,
35
+ namesForeignMixedCase,
36
+ integrationPointsForeign,
37
+ MISSING_DEPENDENCY
38
+ } from './lib/constants.js';
39
+
40
+ import {
41
+ buildInlineSets,
42
+ defaultInlineSets,
43
+ endsWithWhitespace,
44
+ trimWhitespace,
45
+ collapseWhitespaceAll,
46
+ collapseNoBreakSpaces,
47
+ collapseWhitespace,
48
+ collapseWhitespaceSmart,
49
+ canCollapseWhitespace as defaultCanCollapseWhitespace,
50
+ canTrimWhitespace as defaultCanTrimWhitespace,
51
+ isAllWhitespace
52
+ } from './lib/whitespace.js';
53
+
54
+ import {
55
+ isIgnoredComment,
56
+ isExecutableScript,
57
+ isStyleElement,
58
+ isMetaWithoutEffect,
59
+ normalizeAttr,
60
+ buildAttr,
61
+ deduplicateAttributes,
62
+ replaceContentTypePragma
63
+ } from './lib/attributes.js';
64
+
65
+ import {
66
+ canRemoveParentTag,
67
+ isStartTagMandatory,
68
+ canRemovePrecedingTag,
69
+ canRemoveElement,
70
+ parseRemoveEmptyElementsExcept,
71
+ shouldPreserveEmptyElement
72
+ } from './lib/elements.js';
73
+
74
+ import {
75
+ hasJsonScriptType,
76
+ extractScriptBodies,
77
+ processScript
78
+ } from './lib/content.js';
79
+
80
+ import { processOptions } from './lib/options.js';
81
+ import { toFragment, replaceCustomFragments } from './lib/fragments.js';
82
+
83
+ /** @import { ProcessedOptions } from './lib/options.js' */
84
+
85
+ /**
86
+ * @typedef {Object} HTMLAttribute
87
+ * Representation of an attribute from the HTML parser.
88
+ * Internal counterpart: `HTMLAttribute` in lib/attributes.js—keep in sync.
89
+ *
90
+ * @prop {string} name
91
+ * @prop {string} [value]
92
+ * @prop {string} [quote]
93
+ * @prop {string} [customAssign]
94
+ * @prop {string} [customOpen]
95
+ * @prop {string} [customClose]
96
+ */
97
+
98
+ /**
99
+ * @typedef {Object} MinifierOptions
100
+ * Options that control how HTML is minified. All of these are optional
101
+ * and usually default to a disabled/safe value unless noted.
102
+ *
103
+ * @prop {(tag: string, attrs: HTMLAttribute[], canCollapseWhitespace: (tag: string) => boolean) => boolean} [canCollapseWhitespace]
104
+ * Predicate that determines whether runs of whitespace inside a given element
105
+ * can be collapsed (whether its content may be trimmed is up to `canTrimWhitespace`).
106
+ *
107
+ * Default: Built-in `canCollapseWhitespace` function
108
+ *
109
+ * @prop {((text: string, type?: 'inline' | 'media') => boolean) | null} [canMinifyCSS]
110
+ * Synchronous predicate that determines whether the built-in `minifyCSS` may
111
+ * process a given piece of CSS—`type` is `undefined` for `style` elements,
112
+ * `'inline'` for `style` attributes, and `'media'` for `media` attributes.
113
+ * Returning false leaves that CSS as if `minifyCSS` were off, including its URLs
114
+ * and unused rules (within `svg` elements, SVGO still minifies it under `minifySVG`).
115
+ *
116
+ * Default: `null` (all CSS is minified)
117
+ *
118
+ * @prop {((text: string, inline?: boolean) => boolean) | null} [canMinifyJS]
119
+ * Synchronous predicate that determines whether the built-in `minifyJS` may
120
+ * process a given piece of JavaScript—`inline` is true for event handler
121
+ * attributes. Returning false leaves that JavaScript unminified (`mergeScripts`
122
+ * still applies).
123
+ *
124
+ * Default: `null` (all JavaScript is minified)
125
+ *
126
+ * @prop {((text: string) => boolean) | null} [canMinifySVG]
127
+ * Synchronous predicate that determines whether the built-in `minifySVG` may
128
+ * pass a given outermost `svg` element, nested ones included, to SVGO. It
129
+ * receives the element as serialized by the HTML pass (e.g., without comments
130
+ * under `removeComments`); returning false leaves it as that pass wrote it.
131
+ *
132
+ * Default: `null` (all SVG is minified)
133
+ *
134
+ * @prop {(tag: string | null, attrs: HTMLAttribute[] | undefined, canTrimWhitespace: (tag: string) => boolean) => boolean} [canTrimWhitespace]
135
+ * Predicate that determines whether whitespace inside the element may be
136
+ * trimmed; where not, it stays as written, as in `pre`.
137
+ *
138
+ * Default: Built-in `canTrimWhitespace` function
139
+ *
140
+ * @prop {number} [cacheCSS]
141
+ * The maximum number of entries for the CSS minification cache. Higher values
142
+ * improve performance for inputs with repeated CSS (e.g., batch processing).
143
+ * - Cache is created on first `minify()` call and persists for the process lifetime
144
+ * - Cache size is locked after first call—subsequent calls reuse the same cache
145
+ * - `0` switches the cache off; negative and non-finite values fall back to the default size
146
+ *
147
+ * Default: `500`
148
+ *
149
+ * @prop {number} [cacheJS]
150
+ * The maximum number of entries for the JavaScript minification cache. Higher
151
+ * values improve performance for inputs with repeated JavaScript.
152
+ * - Cache is created on first `minify()` call and persists for the process lifetime
153
+ * - Cache size is locked after first call—subsequent calls reuse the same cache
154
+ * - `0` switches the cache off; negative and non-finite values fall back to the default size
155
+ *
156
+ * Default: `500`
157
+ *
158
+ * @prop {number} [cacheSVG]
159
+ * The maximum number of entries for the SVG minification cache. Higher
160
+ * values improve performance for inputs with repeated SVG content.
161
+ * - Cache is created on first `minify()` call and persists for the process lifetime
162
+ * - Cache size is locked after first call—subsequent calls reuse the same cache
163
+ * - `0` switches the cache off; negative and non-finite values fall back to the default size
164
+ *
165
+ * Default: `500`
166
+ *
167
+ * @prop {boolean} [caseSensitive]
168
+ * When true, tag and attribute names are written as in the source rather than in lowercase.
169
+ * Useful for framework components like `<MyButton>`. HTML names are still read in any case,
170
+ * so every option applies to them as without it; hooks get names as written.
171
+ *
172
+ * Default: `false`
173
+ *
174
+ * @prop {boolean} [collapseAttributeWhitespace]
175
+ * Collapse multiple whitespace characters within attribute values into a
176
+ * single space. Also trims leading and trailing whitespace from attribute
177
+ * values. Applied as an early normalization step before special attribute
178
+ * handlers (CSS minification, class sorting, etc.) run.
179
+ *
180
+ * Default: `false`
181
+ *
182
+ * @prop {boolean} [collapseBooleanAttributes]
183
+ * Collapse boolean attributes to their name only (for example
184
+ * `disabled="disabled"` → `disabled`).
185
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes
186
+ *
187
+ * Default: `false`
188
+ *
189
+ * @prop {boolean} [collapseEmptyAttributes]
190
+ * Collapse attributes with empty values to their name only (for example
191
+ * `alt=""` → `alt`), which the HTML parser reads the same way.
192
+ *
193
+ * Default: `false`
194
+ *
195
+ * @prop {boolean} [collapseInlineTagWhitespace]
196
+ * When false (default) whitespace around `inline` tags is preserved in
197
+ * more cases. When true, whitespace between inline tags may be collapsed,
198
+ * except around text-level elements (like `a`, `code`, or `em`); whitespace
199
+ * next to text is kept as without the option.
200
+ * Must also enable `collapseWhitespace` to have effect.
201
+ *
202
+ * Default: `false`
203
+ *
204
+ * @prop {boolean} [collapseNoBreakSpaces]
205
+ * Remove whitespace next to a no-break space (`U+00A0`), narrow no-break
206
+ * space (`U+202F`), or figure space (`U+2007`) in text (for example
207
+ * `a &nbsp; b` → `a&nbsp;b`), which changes how the text renders. Runs of
208
+ * no-break spaces are kept.
209
+ * Must also enable `collapseWhitespace` to have effect.
210
+ *
211
+ * Default: `false`
212
+ *
213
+ * @prop {boolean} [collapseWhitespace]
214
+ * Collapse multiple whitespace characters into one where allowed. Also
215
+ * controls trimming behaviour in several code paths.
216
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_whitespace
217
+ *
218
+ * Default: `false`
219
+ *
220
+ * @prop {boolean} [conservativeCollapse]
221
+ * If true, be conservative when collapsing whitespace (preserve more
222
+ * whitespace in edge cases). Affects collapse algorithms.
223
+ * Must also enable `collapseWhitespace` to have effect.
224
+ *
225
+ * Default: `false`
226
+ *
227
+ * @prop {boolean} [continueOnMinifyError]
228
+ * When set to `false`, minification errors may throw.
229
+ * By default, the minifier will attempt to recover from minification
230
+ * errors, or ignore them and preserve the original content.
231
+ *
232
+ * Default: `true`
233
+ *
234
+ * @prop {boolean} [continueOnParseError]
235
+ * When true, the parser will attempt to continue on recoverable parse
236
+ * errors. Otherwise, parsing errors may throw.
237
+ *
238
+ * Default: `false`
239
+ *
240
+ * @prop {RegExp[]} [customAttrAssign]
241
+ * Array of regexes used to recognise custom attribute assignment
242
+ * operators (e.g., `'<div flex?="{{mode != cover}}"></div>'`).
243
+ * These are concatenated with the built-in assignment patterns.
244
+ *
245
+ * Default: `[]`
246
+ *
247
+ * @prop {RegExp} [customAttrCollapse]
248
+ * Regex matching attribute names whose values should be collapsed.
249
+ * Basically used to remove newlines and excess spaces inside attribute values,
250
+ * e.g., `/ng-class/`.
251
+ *
252
+ * @prop {[RegExp, RegExp][]} [customAttrSurround]
253
+ * Array of `[openRegExp, closeRegExp]` pairs used by the parser to
254
+ * detect custom attribute surround patterns (for non-standard syntaxes,
255
+ * e.g., `<input {{#if value}}checked="checked"{{/if}}>`).
256
+ *
257
+ * @prop {RegExp[]} [customEventAttributes]
258
+ * Array of regexes used to detect event handler attributes for `minifyJS`
259
+ * (e.g., `ng-click`). The default matches standard `on…` event attributes.
260
+ *
261
+ * Default: `[/^on[a-z]{3,}$/]`
262
+ *
263
+ * @prop {boolean} [strictCustomFragments]
264
+ * Rejects `ignoreCustomFragments` patterns that risk catastrophic backtracking,
265
+ * rather than warning about them: those that compound quantifiers or alternation
266
+ * (`(a+)+`, `(a|b)*`), those that repeat the same unbounded atom twice in a row
267
+ * (`.*.*`), and those too long or too deeply nested to analyze at all.
268
+ *
269
+ * Default: `false`
270
+ *
271
+ * @prop {boolean} [decodeEntities]
272
+ * When true, decodes HTML entities in text and attributes before
273
+ * processing, and re-encodes ambiguous ampersands when outputting.
274
+ *
275
+ * Default: `false`
276
+ *
277
+ *
278
+ * @prop {RegExp[]} [ignoreCustomComments]
279
+ * Comments matching any pattern in this array of regexes will be
280
+ * preserved when `removeComments` is enabled. The default preserves
281
+ * “bang” comments and comments starting with `#`.
282
+ *
283
+ * Default: `[/^!/, /^\s*#/]`
284
+ *
285
+ * @prop {RegExp[]} [ignoreCustomFragments]
286
+ * Array of regexes used to identify fragments that should be
287
+ * preserved (for example server templates). These fragments are temporarily
288
+ * replaced during minification to avoid corrupting template code.
289
+ * The default preserves ASP/PHP-style tags and XML declarations.
290
+ *
291
+ * Default: `[/<%[\s\S]*?%>/, /<\?(?:php[\t\n\r ]|=|\$|xml(?:-stylesheet)?(?![\w-])|\s)[\s\S]*?\?>/i]`
292
+ *
293
+ * @prop {boolean} [includeAutoGeneratedTags]
294
+ * If false, tags marked as auto-generated by the parser will be omitted
295
+ * from output. Useful to skip injected tags.
296
+ *
297
+ * Default: `false`
298
+ *
299
+ * @prop {ArrayLike<string>} [inlineCustomElements]
300
+ * Collection of custom element tag names that should be treated as inline
301
+ * elements for white-space handling, alongside the built-in inline elements.
302
+ *
303
+ * Default: `[]`
304
+ *
305
+ * @prop {boolean} [keepClosingSlash]
306
+ * Keep the trailing slash on void elements and other start tags that carry one, and read it
307
+ * as closing the element. The default drops the slash and ignores it, as HTML does; in SVG
308
+ * and MathML a slash closes the element either way.
309
+ *
310
+ * Default: `false`
311
+ *
312
+ * @prop {(message: unknown) => void} [log]
313
+ * Logging function used by the minifier for warnings/errors/info.
314
+ * You can directly provide `console.log`, but `message` may also be an `Error`
315
+ * object or other non-string value.
316
+ *
317
+ * Default: `() => {}` (no-op function)
318
+ *
319
+ * @prop {number} [maxInputLength]
320
+ * The maximum allowed input length. Used as a guard against ReDoS via
321
+ * pathological inputs. If the input exceeds this length an error is
322
+ * thrown.
323
+ *
324
+ * Default: No limit
325
+ *
326
+ * @prop {number} [maxLineLength]
327
+ * Maximum line length for the output. When set the minifier will wrap
328
+ * output to the given number of characters where possible, splitting
329
+ * lines only in tags—between attributes or before a tag’s `>`.
330
+ *
331
+ * Default: No limit
332
+ *
333
+ * @prop {boolean} [mergeScripts]
334
+ * When true, consecutive inline `<script>` elements are merged into one.
335
+ * Only merges compatible scripts (same `type`, matching `async`/`defer`/
336
+ * `nomodule`/`nonce` attributes). Does not merge external scripts (with `src`).
337
+ *
338
+ * Default: `false`
339
+ *
340
+ * @prop {boolean | Partial<import("lightningcss").TransformOptions<import("lightningcss").CustomAtRules>> | ((text: string, type?: string) => Promise<string> | string)} [minifyCSS]
341
+ * When true, enables CSS minification for inline `<style>` tags or
342
+ * `style` attributes. If an object is provided, it is passed to
343
+ * [Lightning CSS](https://www.npmjs.com/package/lightningcss)
344
+ * as transform options. If a function is provided, it will be used to perform
345
+ * custom CSS minification. If disabled, CSS is not minified.
346
+ *
347
+ * Default: `false`
348
+ *
349
+ * @prop {boolean | import("terser").MinifyOptions | {engine?: 'terser' | 'swc', [key: string]: any} | ((text: string, inline?: boolean) => Promise<string> | string)} [minifyJS]
350
+ * When true, enables JS minification for `<script>` contents and
351
+ * event handler attributes. If an object is provided, it can include:
352
+ * - `engine`: The minifier to use (`terser` or `swc`). Default: `terser`.
353
+ * - Engine-specific options (e.g., Terser options if `engine: 'terser'`,
354
+ * SWC options if `engine: 'swc'`).
355
+ * If a function is provided, it will be used to perform
356
+ * custom JS minification. If disabled, JS is not minified.
357
+ *
358
+ * Default: `false`
359
+ *
360
+ * @prop {boolean} [minifyJSON]
361
+ * When true, removes whitespace from the content of JSON `<script>` types
362
+ * (e.g., `application/ld+json`, `importmap`, `speculationrules`), leaving
363
+ * values as written. Malformed JSON is left as is.
364
+ *
365
+ * Default: `false`
366
+ *
367
+ * @prop {boolean | string | {site?: string} | ((text: string) => Promise<string> | string)} [minifyURLs]
368
+ * When true, enables URL rewriting/minification. If an object is provided,
369
+ * the `site` property sets the base URL for computing relative paths.
370
+ * If a string is provided, it is treated as an `{ site: string }` options
371
+ * object. If a function is provided, it will be used to perform custom URL
372
+ * minification. If disabled, URLs are not minified.
373
+ *
374
+ * Default: `false`
375
+ *
376
+ * @prop {boolean | Object} [minifySVG]
377
+ * When true, enables SVG minification using [SVGO](https://github.com/svg/svgo).
378
+ * Complete SVG subtrees are extracted and optimized as a block, with the SVGO
379
+ * plugins that assume a standalone SVG file turned off.
380
+ * If an object is provided, it is passed to SVGO as configuration options; unless
381
+ * it sets `plugins`, it keeps that plugin set.
382
+ * If disabled, SVG content is minified using standard HTML rules only.
383
+ *
384
+ * Default: `false`
385
+ *
386
+ * @prop {boolean} [noNewlinesBeforeTagClose]
387
+ * When wrapping lines, never split a line in an end tag (e.g., keeps
388
+ * `</a>` from becoming `</a` and `>` on two lines).
389
+ *
390
+ * Default: `false`
391
+ *
392
+ * @prop {boolean} [partialMarkup]
393
+ * When true, treat input as a partial HTML fragment rather than a complete
394
+ * document. This preserves stray end tags (closing tags without corresponding
395
+ * opening tags) and prevents auto-closing of unclosed tags at the end of input.
396
+ * Useful for minifying template fragments, SSI includes, or other partial HTML
397
+ * that will be combined with other fragments.
398
+ *
399
+ * Default: `false`
400
+ *
401
+ * @prop {boolean} [preserveLineBreaks]
402
+ * Preserve a single line break at the start/end of text nodes when
403
+ * collapsing/trimming whitespace.
404
+ * Must also enable `collapseWhitespace` to have effect.
405
+ *
406
+ * Default: `false`
407
+ *
408
+ * @prop {string} [preset]
409
+ * Name of a preset configuration to use as a base (`conservative` or
410
+ * `comprehensive`). Explicitly provided options take precedence over
411
+ * preset values. Unknown preset names are ignored (with a warning).
412
+ *
413
+ * Default: No preset
414
+ *
415
+ * @prop {boolean} [preventAttributesEscaping]
416
+ * When true, attribute values will not be HTML-escaped (dangerous for
417
+ * untrusted input). By default, attributes are escaped.
418
+ *
419
+ * Default: `false`
420
+ *
421
+ * @prop {string[]} [processScripts]
422
+ * Array of `type` attribute values for `<script>` elements whose contents
423
+ * should be processed as HTML
424
+ * (e.g., `text/ng-template`, `text/x-handlebars-template`, etc.).
425
+ * When present, the contents of matching script tags are recursively minified,
426
+ * like normal HTML content.
427
+ *
428
+ * Default: `[]`
429
+ *
430
+ * @prop {"\"" | "'"} [quoteCharacter]
431
+ * Preferred quote character for attribute values. If unspecified the
432
+ * minifier picks the safest quote based on the attribute value.
433
+ *
434
+ * Default: Auto-detected
435
+ *
436
+ * @prop {boolean} [removeAttributeQuotes]
437
+ * Remove quotes around attribute values where it is safe to do so.
438
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_attribute_quotes
439
+ *
440
+ * Default: `false`
441
+ *
442
+ * @prop {boolean} [removeComments]
443
+ * Remove HTML comments. Comments that match `ignoreCustomComments` will
444
+ * still be preserved.
445
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_comments
446
+ *
447
+ * Default: `false`
448
+ *
449
+ * @prop {boolean} [removeDefaultTypeAttributes]
450
+ * Remove default `type` attributes from `<style>`/`<link>` (e.g., `type="text/css"`)
451
+ * and `<script>` (e.g., `type="text/javascript"`) elements.
452
+ *
453
+ * Default: `false`
454
+ *
455
+ * @prop {boolean | ((attrName: string, tag: string) => boolean)} [removeEmptyAttributes]
456
+ * If true, removes attributes whose values are empty (some attributes
457
+ * are excluded by name). Can also be a function to customise which empty
458
+ * attributes are removed.
459
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_empty_or_blank_attributes
460
+ *
461
+ * Default: `false`
462
+ *
463
+ * @prop {boolean} [removeEmptyElements]
464
+ * Remove elements that are empty and safe to remove (for example
465
+ * `<script />` without `src`), including elements left empty that way.
466
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_empty_elements
467
+ *
468
+ * Default: `false`
469
+ *
470
+ * @prop {string[]} [removeEmptyElementsExcept]
471
+ * Specifies empty elements to preserve when `removeEmptyElements` is enabled.
472
+ * Has no effect unless `removeEmptyElements: true`.
473
+ *
474
+ * Accepts tag names or HTML-like element specifications:
475
+ *
476
+ * * Tag name only: `["td", "span"]`—preserves all empty elements of these types
477
+ * * With valued attributes: `["<i class='test'>"]`—preserves only when attribute values match
478
+ * * With boolean attributes: `["<input disabled>"]`—preserves only when the
479
+ * boolean attribute is present
480
+ * * Mixed: `["<button type='button' disabled>"]`—all specified attributes must match
481
+ *
482
+ * Attribute matching:
483
+ *
484
+ * * All specified attributes must be present and match (valued attributes must have exact values)
485
+ * * Additional attributes on the element are allowed
486
+ * * Element and attribute names match in any case
487
+ * * Supports double quotes, single quotes, and unquoted attribute values in specifications
488
+ *
489
+ * Limitations:
490
+ *
491
+ * * Self-closing syntax (e.g., `["<span/>"]`) is not supported; use `["span"]` instead
492
+ * * Definitions containing `>` within quoted attribute values (e.g.,
493
+ * `["<span title='a>b'>"]`) are not supported
494
+ *
495
+ * Default: `[]`
496
+ *
497
+ * @prop {boolean} [removeOptionalTags]
498
+ * Drop optional start/end tags where the HTML specification permits it
499
+ * (for example `</li>`, optional `<html>` etc.).
500
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_optional_tags
501
+ *
502
+ * Default: `false`
503
+ *
504
+ * @prop {boolean} [removeRedundantAttributes]
505
+ * Remove attributes that are redundant because they match the element’s
506
+ * default values (for example `<button type="submit">`) or have no effect
507
+ * (for example `xml:lang`), along with `meta` elements and `rel` keywords
508
+ * that have no effect.
509
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_redundant_attributes
510
+ *
511
+ * Default: `false`
512
+ *
513
+ * @prop {boolean} [removeTagWhitespace]
514
+ * **Note that this will result in invalid HTML!**
515
+ *
516
+ * When true, extra whitespace between tag name and attributes (or before
517
+ * the closing bracket) will be removed where possible. Affects output spacing
518
+ * such as the space used in the short doctype representation.
519
+ *
520
+ * Default: `false`
521
+ *
522
+ * @prop {boolean | {safelist?: Array<string | RegExp>, scripts?: boolean}} [removeUnusedCSS]
523
+ * Remove selectors from `style` elements that name an element, class, or ID the
524
+ * document doesn’t reference, as well as rules that are left without selectors.
525
+ * Requires `minifyCSS` (removal runs through Lightning CSS) and has no effect on
526
+ * `style` or `media` attributes.
527
+ *
528
+ * Element, class, and ID names are collected from the markup and—unless `scripts`
529
+ * is set to `false`—from inline `script` elements; class names and IDs also from
530
+ * `data-*` attributes. Names that only ever appear in external scripts cannot
531
+ * be detected; list those under `safelist` (strings or regular expressions) to
532
+ * keep them.
533
+ *
534
+ * Default: `false`
535
+ *
536
+ * @prop {boolean} [selfCloseForeignElements]
537
+ * When true, writes empty SVG and MathML elements as self-closing tags (for
538
+ * example `<path d="…"/>` for `<path d="…"></path>`), which HTML reads as the
539
+ * same element in foreign content; HTML elements, including those in
540
+ * `foreignObject` and `annotation-xml`, keep their end tags.
541
+ *
542
+ * Default: `false`
543
+ *
544
+ * @prop {boolean | ((tag: string, attrs: HTMLAttribute[]) => void)} [sortAttributes]
545
+ * When true, enables sorting of attributes. If a function is provided it
546
+ * will be used as a custom attribute sorter, which should mutate `attrs`
547
+ * in-place to the desired order. If disabled, the minifier will attempt to
548
+ * preserve the order from the input.
549
+ *
550
+ * Default: `false`
551
+ *
552
+ * @prop {boolean | ((value: string) => string)} [sortClassNames]
553
+ * When true, enables sorting of class names inside `class` attributes.
554
+ * If a function is provided, it will be used to transform/sort the class
555
+ * name string. If disabled, the minifier will attempt to preserve the
556
+ * class-name order from the input.
557
+ *
558
+ * Default: `false`
559
+ *
560
+ * @prop {boolean} [trimCustomFragments]
561
+ * When true, whitespace around ignored custom fragments is removed rather
562
+ * than collapsed where the minifier removes whitespace anyway (e.g., between
563
+ * block-level elements). Whitespace in tags, attribute values, CSS, and
564
+ * JavaScript is kept.
565
+ *
566
+ * Default: `false`
567
+ *
568
+ * @prop {boolean} [useShortDoctype]
569
+ * Replace the HTML doctype with the short `<!doctype html>` form.
570
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#use_short_doctype
571
+ *
572
+ * Default: `false`
573
+ */
574
+
575
+ // Lazy-load heavy dependencies only when needed
576
+
577
+ /** @type {Promise<Function> | undefined} */
578
+ let lightningCSSPromise;
579
+ async function getLightningCSS() {
580
+ if (!lightningCSSPromise) {
581
+ lightningCSSPromise = import('lightningcss').then(m => m.transform);
582
+ }
583
+ return lightningCSSPromise;
13
584
  }
14
585
 
15
- function collapseWhitespaceAll(str) {
16
- // Non-breaking space is specifically handled inside the replacer function here:
17
- return str && str.replace(/[ \n\r\t\f\xA0]+/g, function(spaces) {
18
- return spaces === '\t' ? '\t' : spaces.replace(/(^|\xA0+)[^\xA0]+/g, '$1 ');
19
- });
20
- }
21
-
22
- function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
23
- var lineBreakBefore = '', lineBreakAfter = '';
24
-
25
- if (options.preserveLineBreaks) {
26
- str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function() {
27
- lineBreakBefore = '\n';
28
- return '';
29
- }).replace(/[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*$/, function() {
30
- lineBreakAfter = '\n';
31
- return '';
32
- });
33
- }
34
-
35
- if (trimLeft) {
36
- // Non-breaking space is specifically handled inside the replacer function here:
37
- str = str.replace(/^[ \n\r\t\f\xA0]+/, function(spaces) {
38
- var conservative = !lineBreakBefore && options.conservativeCollapse;
39
- if (conservative && spaces === '\t') {
40
- return '\t';
41
- }
42
- return spaces.replace(/^[^\xA0]+/, '').replace(/(\xA0+)[^\xA0]+/g, '$1 ') || (conservative ? ' ' : '');
43
- });
44
- }
45
-
46
- if (trimRight) {
47
- // Non-breaking space is specifically handled inside the replacer function here:
48
- str = str.replace(/[ \n\r\t\f\xA0]+$/, function(spaces) {
49
- var conservative = !lineBreakAfter && options.conservativeCollapse;
50
- if (conservative && spaces === '\t') {
51
- return '\t';
52
- }
53
- return spaces.replace(/[^\xA0]+(\xA0+)/g, ' $1').replace(/[^\xA0]+$/, '') || (conservative ? ' ' : '');
54
- });
55
- }
56
-
57
- if (collapseAll) {
58
- // strip non space whitespace then compress spaces to one
59
- str = collapseWhitespaceAll(str);
586
+ /** @type {Promise<Function> | undefined} */
587
+ let terserPromise;
588
+ async function getTerser() {
589
+ if (!terserPromise) {
590
+ terserPromise = import('terser').then(m => m.minify);
60
591
  }
61
-
62
- return lineBreakBefore + str + lineBreakAfter;
592
+ return terserPromise;
63
593
  }
64
594
 
65
- var createMapFromString = utils.createMapFromString;
66
- // non-empty tags that will maintain whitespace around them
67
- var inlineTags = createMapFromString('a,abbr,acronym,b,bdi,bdo,big,button,cite,code,del,dfn,em,font,i,ins,kbd,label,mark,math,nobr,object,q,rp,rt,rtc,ruby,s,samp,select,small,span,strike,strong,sub,sup,svg,textarea,time,tt,u,var');
68
- // non-empty tags that will maintain whitespace within them
69
- var inlineTextTags = createMapFromString('a,abbr,acronym,b,big,del,em,font,i,ins,kbd,mark,nobr,rp,s,samp,small,span,strike,strong,sub,sup,time,tt,u,var');
70
- // self-closing tags that will maintain whitespace around them
71
- var selfClosingInlineTags = createMapFromString('comment,img,input,wbr');
72
-
73
- function collapseWhitespaceSmart(str, prevTag, nextTag, options) {
74
- var trimLeft = prevTag && !selfClosingInlineTags(prevTag);
75
- if (trimLeft && !options.collapseInlineTagWhitespace) {
76
- trimLeft = prevTag.charAt(0) === '/' ? !inlineTags(prevTag.slice(1)) : !inlineTextTags(prevTag);
77
- }
78
- var trimRight = nextTag && !selfClosingInlineTags(nextTag);
79
- if (trimRight && !options.collapseInlineTagWhitespace) {
80
- trimRight = nextTag.charAt(0) === '/' ? !inlineTextTags(nextTag.slice(1)) : !inlineTags(nextTag);
81
- }
82
- return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag);
595
+ /**
596
+ * @param {string} label - Minifier name as it should read in the message
597
+ * @param {string} specifier - Package that failed to load
598
+ * @param {unknown} cause - Error the import rejected with
599
+ * @returns {Error} Error marked as an unavailable dependency
600
+ */
601
+ function unavailableDependency(label, specifier, cause) {
602
+ const err = new Error(describeDependencyFailure(label, specifier, cause), { cause });
603
+ /** @type {any} */ (err).code = MISSING_DEPENDENCY;
604
+ return err;
83
605
  }
84
606
 
85
- function isConditionalComment(text) {
86
- return /^\[if\s[^\]]+]|\[endif]$/.test(text);
607
+ /** @type {Promise<any> | undefined} */
608
+ let swcPromise;
609
+ async function getSwc() {
610
+ if (!swcPromise) {
611
+ swcPromise = import('@swc/core')
612
+ .then(m => m.default || m)
613
+ .catch(err => {
614
+ throw unavailableDependency('swc', '@swc/core', err);
615
+ });
616
+ }
617
+ return swcPromise;
87
618
  }
88
619
 
89
- function isIgnoredComment(text, options) {
90
- for (var i = 0, len = options.ignoreCustomComments.length; i < len; i++) {
91
- if (options.ignoreCustomComments[i].test(text)) {
92
- return true;
93
- }
620
+ /** @type {Promise<Function> | undefined} */
621
+ let svgoPromise;
622
+ async function getSvgo() {
623
+ if (!svgoPromise) {
624
+ svgoPromise = import('svgo').then(m => m.optimize);
94
625
  }
95
- return false;
626
+ return svgoPromise;
96
627
  }
97
628
 
98
- function isEventAttribute(attrName, options) {
99
- var patterns = options.customEventAttributes;
100
- if (patterns) {
101
- for (var i = patterns.length; i--;) {
102
- if (patterns[i].test(attrName)) {
103
- return true;
104
- }
105
- }
106
- return false;
629
+ /** @type {Promise<Function> | undefined} */
630
+ let decodeHTMLPromise;
631
+ /** @type {((text: string) => string) | undefined} The resolved `decodeHTML`, once the lazy import above has settled */
632
+ let decodeHTMLFn;
633
+ async function getDecodeHTML() {
634
+ if (!decodeHTMLPromise) {
635
+ decodeHTMLPromise = import('entities').then(m => (decodeHTMLFn = m.decodeHTML));
107
636
  }
108
- return /^on[a-z]{3,}$/.test(attrName);
637
+ return decodeHTMLPromise;
109
638
  }
110
639
 
111
- function canRemoveAttributeQuotes(value) {
112
- // https://mathiasbynens.be/notes/unquoted-attribute-values
113
- return /^[^ \t\n\f\r"'`=<>]+$/.test(value);
640
+ // Minification caches (initialized on first use with configurable sizes)
641
+ /** @type {LRU | null} */
642
+ let cssMinifyCache = null;
643
+ /** @type {LRU | null} */
644
+ let jsMinifyCache = null;
645
+ /** @type {LRU | null} */
646
+ let svgMinifyCache = null;
647
+
648
+ /**
649
+ * @typedef {Object} CacheStats
650
+ * Hit/miss statistics and current occupancy for a single minification cache.
651
+ *
652
+ * @prop {number} gets Number of lookups attempted against the cache.
653
+ * @prop {number} hits Number of lookups that found a cached entry.
654
+ * @prop {number} size Number of entries currently held in the cache.
655
+ * @prop {number} limit Maximum number of entries the cache can hold.
656
+ */
657
+
658
+ /**
659
+ * @param {LRU | null} cache
660
+ * @returns {CacheStats}
661
+ */
662
+ const cacheStatsOrEmpty = (cache) => cache ? cache.stats() : { gets: 0, hits: 0, size: 0, limit: 0 };
663
+
664
+ // Patterns for script merging
665
+ const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
666
+ const RE_SCRIPT_OPEN = /<script(?=[\s>])/gi; // Finds tag start; use `findTagEnd()` for the actual closing `>`
667
+ const RE_SCRIPT_CLOSE = /<\/script\s*>/gi;
668
+ const RE_MATH_TAG = /<(\/?)math(?=[\s/>])/gi;
669
+ const SCRIPT_BOOL_ATTRS = new Set(['async', 'defer', 'nomodule']);
670
+ const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript']);
671
+
672
+ // Patterns for buffer scanning
673
+ const RE_START_TAG = /^<[^/!]/;
674
+ const RE_UNQUOTED_VALUE_END = /=[^\s"'=<>`]+$/;
675
+ const RE_START_TAG_NAME = /^<([^\s/>]+)/;
676
+ const RE_LEADING_WHITESPACE = /^[ \t\n\r\f]/;
677
+ const RE_ASCII_WHITESPACE = /[ \t\n\r\f]/;
678
+ const RE_END_TAG = /^<\//;
679
+ const RE_WHITESPACE_ONLY = /^\s+$/;
680
+ const RE_TABS = /\t/g;
681
+ const RE_TRAILING_WHITESPACE = /\s$/;
682
+ const RE_LINE_BREAK_BLANKS_END = /(?:\r\n?|\n)[ \t\f]*$/;
683
+ const RE_BLANKS_LINE_BREAK_START = /^[ \t\f]*(?:\r\n?|\n)/;
684
+ const RE_BLANKS_START = /^[ \t\f]+/;
685
+ const RE_BLANKS_ONLY = /^[ \t\f]+$/;
686
+
687
+ // Patterns for `htmlmin:ignore` block content analysis
688
+ const RE_HTML_COMMENT_START = /^\s*<!--/;
689
+ const RE_CLOSING_TAG_START = /^\s*<\/([a-zA-Z][\w:-]*)/;
690
+ const RE_FIRST_HTML_TAG = /^\s*<([a-zA-Z][\w:-]*)/;
691
+ const RE_LAST_HTML_TAG = /[\s\S]*<(\/?[a-zA-Z][\w:-]*)/;
692
+
693
+
694
+ // Probe for SVG/MathML elements—when the input has none (the common case),
695
+ // the per-tag foreign-content machinery can be skipped entirely
696
+ const RE_FOREIGN_ELEMENT_PROBE = /<(?:svg|math)[\s/>]/i;
697
+
698
+ // A name in foreign content as the HTML parser reads it
699
+ /** @param {string} name */
700
+ function nameForeign(name) {
701
+ const nameLower = name.toLowerCase();
702
+ return namesForeignMixedCase.get(nameLower) ?? nameLower;
114
703
  }
115
704
 
116
- function attributesInclude(attributes, attribute) {
117
- for (var i = attributes.length; i--;) {
118
- if (attributes[i].name.toLowerCase() === attribute) {
119
- return true;
705
+ /**
706
+ * Merge consecutive inline script tags into one.
707
+ * Only merges scripts that are compatible:
708
+ * - Both inline (no `src` attribute)
709
+ * - Same `type` (or both default JavaScript)
710
+ * - No conflicting attributes (`async`, `defer`, `nomodule`, different `nonce`)
711
+ *
712
+ * Uses a scanner rather than a regex to locate script boundaries, so literal
713
+ * `</script>` strings inside script content are handled correctly per the HTML
714
+ * spec (raw text ends at the first `</script>`).
715
+ *
716
+ * Single forward pass: Each script absorbs all directly following compatible
717
+ * scripts, and the output is assembled from segments—this avoids rebuilding
718
+ * the full string and restarting the scan after every merge (O(n²) on inputs
719
+ * with many merges). Merges can’t cascade backwards: compatibility depends
720
+ * only on the attributes of the first script in a chain, which merging
721
+ * preserves, so one pass finds every merge the iterated rescan found.
722
+ *
723
+ * @param {string} html
724
+ * @param {boolean} [hasMathML] - Whether the HTML holds MathML, whose `script` elements stay apart
725
+ * @returns {string} HTML with consecutive scripts merged
726
+ */
727
+ function mergeConsecutiveScripts(html, hasMathML = false) {
728
+ const parseAttrs = (/** @type {string} */ attrStr) => {
729
+ /** @type {Record<string, string>} */
730
+ const attrs = {};
731
+ RE_SCRIPT_ATTRS.lastIndex = 0;
732
+ let m;
733
+ while ((m = RE_SCRIPT_ATTRS.exec(attrStr)) !== null) {
734
+ const name = (m[1] ?? '').toLowerCase();
735
+ const value = m[2] ?? m[3] ?? m[4] ?? '';
736
+ attrs[name] = value;
120
737
  }
121
- }
122
- return false;
123
- }
124
-
125
- function isAttributeRedundant(tag, attrName, attrValue, attrs) {
126
- attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
127
-
128
- return (
129
- tag === 'script' &&
130
- attrName === 'language' &&
131
- attrValue === 'javascript' ||
738
+ return attrs;
739
+ };
132
740
 
133
- tag === 'form' &&
134
- attrName === 'method' &&
135
- attrValue === 'get' ||
741
+ // In MathML, a `script` neither runs nor is hidden, so its text is shown as it is;
742
+ // scripts are visited in order, so the MathML tags before each are counted once
743
+ let mathDepth = 0;
744
+ let mathPos = 0;
745
+ const insideMath = (/** @type {number} */ index) => {
746
+ if (!hasMathML) return false;
747
+ RE_MATH_TAG.lastIndex = mathPos;
748
+ let tag;
749
+ while ((tag = RE_MATH_TAG.exec(html)) !== null && tag.index < index) {
750
+ mathDepth = tag[1] ? Math.max(0, mathDepth - 1) : mathDepth + 1;
751
+ mathPos = RE_MATH_TAG.lastIndex;
752
+ }
753
+ return mathDepth > 0;
754
+ };
136
755
 
137
- tag === 'input' &&
138
- attrName === 'type' &&
139
- attrValue === 'text' ||
756
+ /** @type {string[]} */
757
+ const segments = [];
758
+ let consumedPos = 0; // Start of the input region not yet copied to `segments`
759
+
760
+ RE_SCRIPT_OPEN.lastIndex = 0;
761
+ let m1;
762
+
763
+ while ((m1 = RE_SCRIPT_OPEN.exec(html)) !== null) {
764
+ // `findTagEnd` skips quoted attribute values to find the real closing `>`
765
+ const tagEnd1 = findTagEnd(html, m1.index + 7);
766
+ if (tagEnd1 === -1) break;
767
+
768
+ const attrs1Str = html.slice(m1.index + 7, tagEnd1);
769
+
770
+ // Find end of this script’s content (first `</script>`—per HTML spec, raw text ends here)
771
+ RE_SCRIPT_CLOSE.lastIndex = tagEnd1 + 1;
772
+ const close1 = RE_SCRIPT_CLOSE.exec(html);
773
+ if (!close1) break;
774
+
775
+ let mergedContent = html.slice(tagEnd1 + 1, close1.index);
776
+ let afterClose = close1.index + close1[0].length;
777
+
778
+ const a1 = parseAttrs(attrs1Str);
779
+ // `src` (external) and non-default types (modules, JSON, etc.) must not be merged:
780
+ // module scripts have per-script lexical scope, and non-JS content (e.g., JSON)
781
+ // is not concatenable; even identical non-JS types are incompatible
782
+ const mergeable = !('src' in a1) && DEFAULT_JS_TYPES.has((a1.type || '').toLowerCase());
783
+ let mergedAny = false;
784
+ // Whitespace between the scripts, which may separate text around them
785
+ let gap = '';
786
+
787
+ // Absorb all directly following compatible scripts
788
+ while (mergeable) {
789
+ let i = afterClose;
790
+ while (i < html.length && (html[i] === ' ' || html[i] === '\t' || html[i] === '\n' || html[i] === '\r' || html[i] === '\f')) i++;
791
+ const gapNext = html.slice(afterClose, i);
792
+ if (html.slice(i, i + 7).toLowerCase() !== '<script' || (html.charAt(i + 7) !== '>' && !/\s/.test(html.charAt(i + 7)))) break;
793
+
794
+ const tagEnd2 = findTagEnd(html, i + 7);
795
+ if (tagEnd2 === -1) break;
796
+
797
+ const attrs2Str = html.slice(i + 7, tagEnd2);
798
+
799
+ RE_SCRIPT_CLOSE.lastIndex = tagEnd2 + 1;
800
+ const close2 = RE_SCRIPT_CLOSE.exec(html);
801
+ if (!close2) break;
802
+
803
+ const a2 = parseAttrs(attrs2Str);
804
+
805
+ // Check compatibility: No `src`, default JS type, no conflicting boolean
806
+ // attributes, and `nonce` must be same or both absent
807
+ if ('src' in a2 || !DEFAULT_JS_TYPES.has((a2.type || '').toLowerCase())) break;
808
+ let boolConflict = false;
809
+ for (const attr of SCRIPT_BOOL_ATTRS) {
810
+ if ((attr in a1) !== (attr in a2)) { boolConflict = true; break; }
811
+ }
812
+ if (boolConflict || a1.nonce !== a2.nonce || (!mergedAny && insideMath(m1.index))) break;
813
+
814
+ // Join with a semicolon, or with a newline where the first ends in a `//` comment
815
+ const content2 = html.slice(tagEnd2 + 1, close2.index);
816
+ const c1 = mergedContent.trim();
817
+ const c2 = content2.trim();
818
+ if (c1 && c2) {
819
+ const lastLine = c1.slice(c1.lastIndexOf('\n') + 1);
820
+ const separator = lastLine.includes('//') ? '\n' : (c1.endsWith(';') ? '' : ';');
821
+ mergedContent = c1 + separator + c2;
822
+ } else {
823
+ mergedContent = c1 || c2;
824
+ }
140
825
 
141
- tag === 'script' &&
142
- attrName === 'charset' &&
143
- !attributesInclude(attrs, 'src') ||
826
+ afterClose = close2.index + close2[0].length;
827
+ mergedAny = true;
828
+ gap ||= gapNext;
829
+ }
144
830
 
145
- tag === 'a' &&
146
- attrName === 'name' &&
147
- attributesInclude(attrs, 'id') ||
831
+ if (mergedAny) {
832
+ // Whitespace goes after the merged script, unless nothing or
833
+ // whitespace is on either side anyway
834
+ if (gap && (!m1.index || afterClose === html.length || RE_ASCII_WHITESPACE.test(html.charAt(m1.index - 1) + html.charAt(afterClose)))) {
835
+ gap = '';
836
+ }
837
+ // Use first script’s attributes (they are compatible)
838
+ segments.push(html.slice(consumedPos, m1.index), `<script${attrs1Str}>${mergedContent}</script>${gap}`);
839
+ consumedPos = afterClose;
840
+ }
148
841
 
149
- tag === 'area' &&
150
- attrName === 'shape' &&
151
- attrValue === 'rect'
152
- );
153
- }
842
+ // Continue scanning after this script (skips `<script` occurrences inside content)
843
+ RE_SCRIPT_OPEN.lastIndex = afterClose;
844
+ }
154
845
 
155
- // https://mathiasbynens.be/demo/javascript-mime-type
156
- // https://developer.mozilla.org/en/docs/Web/HTML/Element/script#attr-type
157
- var executableScriptsMimetypes = utils.createMap([
158
- 'text/javascript',
159
- 'text/ecmascript',
160
- 'text/jscript',
161
- 'application/javascript',
162
- 'application/x-javascript',
163
- 'application/ecmascript'
164
- ]);
165
-
166
- function isScriptTypeAttribute(attrValue) {
167
- attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
168
- return attrValue === '' || executableScriptsMimetypes(attrValue);
846
+ if (!segments.length) return html;
847
+ segments.push(html.slice(consumedPos));
848
+ return segments.join('');
169
849
  }
170
850
 
171
- function isExecutableScript(tag, attrs) {
172
- if (tag !== 'script') {
173
- return false;
174
- }
175
- for (var i = 0, len = attrs.length; i < len; i++) {
176
- var attrName = attrs[i].name.toLowerCase();
177
- if (attrName === 'type') {
178
- return isScriptTypeAttribute(attrs[i].value);
179
- }
851
+ /**
852
+ * @param {string} value
853
+ * @param {ProcessedOptions} options
854
+ * @param {string} uidIgnore
855
+ * @param {string[]} ignoredMarkupChunks
856
+ */
857
+ async function createSortFns(value, options, uidIgnore, ignoredMarkupChunks) {
858
+ const attrChains = options.sortAttributes && typeof options.sortAttributes !== 'function' && Object.create(null);
859
+ const classChain = options.sortClassNames && typeof options.sortClassNames !== 'function' && new TokenChain();
860
+ const resolveName = options.name;
861
+
862
+ function attrNames(/** @type {HTMLAttribute[]} */ attrs) {
863
+ return attrs.map(function (attr) {
864
+ return resolveName(attr.name);
865
+ });
180
866
  }
181
- return true;
182
- }
183
867
 
184
- function isStyleLinkTypeAttribute(attrValue) {
185
- attrValue = trimWhitespace(attrValue).toLowerCase();
186
- return attrValue === '' || attrValue === 'text/css';
187
- }
868
+ const whitespaceSplitPatternScan = /[ \t\n\f\r]+/;
869
+ const whitespaceSplitPatternSort = /[ \n\f\r]+/;
188
870
 
189
- function isStyleSheet(tag, attrs) {
190
- if (tag !== 'style') {
191
- return false;
192
- }
193
- for (var i = 0, len = attrs.length; i < len; i++) {
194
- var attrName = attrs[i].name.toLowerCase();
195
- if (attrName === 'type') {
196
- return isStyleLinkTypeAttribute(attrs[i].value);
871
+ async function scan(/** @type {string} */ input) {
872
+ /** @type {string} */
873
+ let currentTag;
874
+ /** @type {string | undefined} */
875
+ let currentType;
876
+ const parser = new HTMLParser(input, {
877
+ start: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ unary) {
878
+ if (attrChains) {
879
+ // Lowercase, as the first pass writes names as in the source under `caseSensitive`
880
+ const tagKey = resolveName(tag);
881
+ if (!attrChains[tagKey]) {
882
+ attrChains[tagKey] = new TokenChain();
883
+ }
884
+ attrChains[tagKey].add(attrNames(attrs));
885
+ }
886
+ for (const attr of attrs) {
887
+ if (classChain && attr.value && resolveName(attr.name) === 'class') {
888
+ const classes = trimWhitespace(attr.value).split(whitespaceSplitPatternScan);
889
+ classChain.add(classes);
890
+ } else if (options.processScripts && attr.name.toLowerCase() === 'type') {
891
+ currentTag = tag;
892
+ currentType = attr.value;
893
+ }
894
+ }
895
+ // As on the main path: What a unary tag holds ends with the tag,
896
+ // so no text can belong to it
897
+ if (unary) {
898
+ currentTag = '';
899
+ currentType = undefined;
900
+ }
901
+ },
902
+ end: function () {
903
+ currentTag = '';
904
+ },
905
+ chars: async function (/** @type {string} */ text) {
906
+ // Only HTML content is scanned recursively, not JSON-LD or other non-HTML script types
907
+ if (options.processScripts && specialContentElements.has(currentTag) &&
908
+ options.processScripts.indexOf(currentType ?? '') > -1 &&
909
+ currentType === 'text/html') {
910
+ await scan(text);
911
+ }
912
+ },
913
+ wantsNextTag: false,
914
+ continueOnParseError: options.continueOnParseError,
915
+ selfClosingSlash: options.keepClosingSlash
916
+ });
917
+
918
+ try {
919
+ await parser.parse();
920
+ } catch (err) {
921
+ // If parsing fails during analysis pass, skip it—there’s partial frequency
922
+ // data from what can be parsed
923
+ if (!options.continueOnParseError) {
924
+ throw err;
925
+ }
197
926
  }
198
927
  }
199
- return true;
200
- }
201
928
 
202
- var isSimpleBoolean = createMapFromString('allowfullscreen,async,autofocus,autoplay,checked,compact,controls,declare,default,defaultchecked,defaultmuted,defaultselected,defer,disabled,enabled,formnovalidate,hidden,indeterminate,inert,ismap,itemscope,loop,multiple,muted,nohref,noresize,noshade,novalidate,nowrap,open,pauseonexit,readonly,required,reversed,scoped,seamless,selected,sortable,truespeed,typemustmatch,visible');
203
- var isBooleanValue = createMapFromString('true,false');
929
+ // First-pass options: Aggressive minification is off, attribute transformations (like
930
+ // `removeDefaultTypeAttributes`) stay. Custom-fragment UID markers (`uidAttr`) don’t
931
+ // exist yet; `htmlmin:ignore` ones (`uidIgnore`) do and are expanded for analysis.
932
+ const firstPassOptions = Object.assign({}, options, {
933
+ sortAttributes: false,
934
+ sortClassNames: false,
935
+ // Disable aggressive minification that doesn’t affect attribute analysis
936
+ collapseWhitespace: false,
937
+ removeAttributeQuotes: false,
938
+ removeTagWhitespace: false,
939
+ decodeEntities: false,
940
+ processScripts: false,
941
+ // `ignoreCustomFragments` stays, and template syntax may not parse cleanly
942
+ continueOnParseError: true,
943
+ log: identity
944
+ });
204
945
 
205
- function isBooleanAttribute(attrName, attrValue) {
206
- return isSimpleBoolean(attrName) || attrName === 'draggable' && !isBooleanValue(attrValue);
207
- }
946
+ // `scan()` reads `options` rather than `firstPassOptions`, so it needs the same leniency
947
+ const originalContinueOnParseError = options.continueOnParseError;
948
+ options.continueOnParseError = true;
949
+
950
+ // Pre-compile the UID replacement pattern, and read the fragments the way the
951
+ // main path does, so each keeps its own flags
952
+ const uidReplacePattern = uidIgnore && ignoredMarkupChunks
953
+ ? new RegExp('<!--' + uidIgnore + '(\\d+)-->', 'g')
954
+ : null;
955
+ const customFragments = (options.ignoreCustomFragments || []).map(toFragment);
956
+
957
+ try {
958
+ // Expand UID tokens back to the original content for frequency analysis
959
+ let expandedValue = value;
960
+ if (uidReplacePattern) {
961
+ expandedValue = value.replace(uidReplacePattern, function (/** @type {string} */ _match, /** @type {string} */ index) {
962
+ return ignoredMarkupChunks[+index] || '';
963
+ });
964
+ uidReplacePattern.lastIndex = 0;
965
+ }
208
966
 
209
- function isUriTypeAttribute(attrName, tag) {
210
- return (
211
- /^(?:a|area|link|base)$/.test(tag) && attrName === 'href' ||
212
- tag === 'img' && /^(?:src|longdesc|usemap)$/.test(attrName) ||
213
- tag === 'object' && /^(?:classid|codebase|data|usemap)$/.test(attrName) ||
214
- tag === 'q' && attrName === 'cite' ||
215
- tag === 'blockquote' && attrName === 'cite' ||
216
- (tag === 'ins' || tag === 'del') && attrName === 'cite' ||
217
- tag === 'form' && attrName === 'action' ||
218
- tag === 'input' && (attrName === 'src' || attrName === 'usemap') ||
219
- tag === 'head' && attrName === 'profile' ||
220
- tag === 'script' && (attrName === 'src' || attrName === 'for')
221
- );
222
- }
967
+ const firstPassOutput = await minifyHTML(expandedValue, firstPassOptions);
223
968
 
224
- function isNumberTypeAttribute(attrName, tag) {
225
- return (
226
- /^(?:a|area|object|button)$/.test(tag) && attrName === 'tabindex' ||
227
- tag === 'input' && (attrName === 'maxlength' || attrName === 'tabindex') ||
228
- tag === 'select' && (attrName === 'size' || attrName === 'tabindex') ||
229
- tag === 'textarea' && /^(?:rows|cols|tabindex)$/.test(attrName) ||
230
- tag === 'colgroup' && attrName === 'span' ||
231
- tag === 'col' && attrName === 'span' ||
232
- (tag === 'th' || tag === 'td') && (attrName === 'rowspan' || attrName === 'colspan')
233
- );
234
- }
969
+ // Custom fragments are removed for frequency analysis, as HTML comments in start tags keep
970
+ // attributes from parsing; a space in their place preserves attribute boundaries
971
+ let scanValue = firstPassOutput;
972
+ if (customFragments.length) {
973
+ scanValue = replaceCustomFragments(firstPassOutput, customFragments, () => ' ');
974
+ }
235
975
 
236
- function isLinkType(tag, attrs, value) {
237
- if (tag !== 'link') {
238
- return false;
239
- }
240
- for (var i = 0, len = attrs.length; i < len; i++) {
241
- if (attrs[i].name === 'rel' && attrs[i].value === value) {
242
- return true;
976
+ await scan(scanValue);
977
+ } finally {
978
+ if (originalContinueOnParseError !== undefined) {
979
+ options.continueOnParseError = originalContinueOnParseError;
980
+ } else {
981
+ delete options.continueOnParseError;
243
982
  }
244
983
  }
245
- }
246
-
247
- function isMediaQuery(tag, attrs, attrName) {
248
- return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleSheet(tag, attrs));
249
- }
984
+ if (attrChains) {
985
+ const attrSorters = Object.create(null);
986
+ for (const tag in attrChains) {
987
+ attrSorters[tag] = attrChains[tag].createSorter();
988
+ }
989
+ // Memoize sorted attribute orders—attribute sets often repeat in templates
990
+ const attrOrderCache = new LRU(500);
250
991
 
251
- var srcsetTags = createMapFromString('img,source');
992
+ options.sortAttributes = function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
993
+ if (attrs.length < 2) {
994
+ return;
995
+ }
996
+ // Called with the name as written, as any `sortAttributes` function is
997
+ const sorter = attrSorters[resolveName(tag)];
998
+ if (sorter) {
999
+ const names = attrNames(attrs);
252
1000
 
253
- function isSrcset(attrName, tag) {
254
- return attrName === 'srcset' && srcsetTags(tag);
255
- }
1001
+ // Order-independent cache key
1002
+ const cacheKey = tag + ':' + names.slice().sort().join(',');
1003
+ let sortedNames = /** @type {string[] | undefined} */ (attrOrderCache.get(cacheKey));
256
1004
 
257
- function cleanAttributeValue(tag, attrName, attrValue, options, attrs) {
258
- if (isEventAttribute(attrName, options)) {
259
- attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
260
- return options.minifyJS(attrValue, true);
261
- }
262
- else if (attrName === 'class') {
263
- attrValue = trimWhitespace(attrValue);
264
- if (options.sortClassName) {
265
- attrValue = options.sortClassName(attrValue);
266
- }
267
- else {
268
- attrValue = collapseWhitespaceAll(attrValue);
269
- }
270
- return attrValue;
271
- }
272
- else if (isUriTypeAttribute(attrName, tag)) {
273
- attrValue = trimWhitespace(attrValue);
274
- return isLinkType(tag, attrs, 'canonical') ? attrValue : options.minifyURLs(attrValue);
275
- }
276
- else if (isNumberTypeAttribute(attrName, tag)) {
277
- return trimWhitespace(attrValue);
278
- }
279
- else if (attrName === 'style') {
280
- attrValue = trimWhitespace(attrValue);
281
- if (attrValue) {
282
- if (/;$/.test(attrValue) && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
283
- attrValue = attrValue.replace(/\s*;$/, ';');
284
- }
285
- attrValue = options.minifyCSS(attrValue, 'inline');
286
- }
287
- return attrValue;
288
- }
289
- else if (isSrcset(attrName, tag)) {
290
- // https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
291
- attrValue = trimWhitespace(attrValue).split(/\s+,\s*|\s*,\s+/).map(function(candidate) {
292
- var url = candidate;
293
- var descriptor = '';
294
- var match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
295
- if (match) {
296
- url = url.slice(0, -match[0].length);
297
- var num = +match[1].slice(0, -1);
298
- var suffix = match[1].slice(-1);
299
- if (num !== 1 || suffix !== 'x') {
300
- descriptor = ' ' + num + suffix;
1005
+ if (sortedNames === undefined) {
1006
+ // `sort` works in place, hence the copy
1007
+ sortedNames = sorter.sort(names.slice());
1008
+ attrOrderCache.set(cacheKey, sortedNames);
301
1009
  }
1010
+
1011
+ // Apply the sorted order to `attrs`
1012
+ const attrMap = Object.create(null);
1013
+ names.forEach(function (/** @type {string} */ name, /** @type {number} */ index) {
1014
+ (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
1015
+ });
1016
+ /** @type {string[]} */ (sortedNames).forEach(function (/** @type {string} */ name, /** @type {number} */ index) {
1017
+ attrs[index] = attrMap[name].shift();
1018
+ });
302
1019
  }
303
- return options.minifyURLs(url) + descriptor;
304
- }).join(', ');
305
- }
306
- else if (isMetaViewport(tag, attrs) && attrName === 'content') {
307
- attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function(numString) {
308
- // "0.90000" -> "0.9"
309
- // "1.0" -> "1"
310
- // "1.0001" -> "1.0001" (unchanged)
311
- return (+numString).toString();
312
- });
313
- }
314
- else if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
315
- attrValue = attrValue.replace(/\n+|\r+|\s{2,}/g, '');
316
- }
317
- else if (tag === 'script' && attrName === 'type') {
318
- attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
1020
+ };
319
1021
  }
320
- else if (isMediaQuery(tag, attrs, attrName)) {
321
- attrValue = trimWhitespace(attrValue);
322
- return options.minifyCSS(attrValue, 'media');
1022
+ if (classChain) {
1023
+ const sorter = classChain.createSorter();
1024
+ // Memoize `sortClassNames` results—class lists often repeat in templates
1025
+ const classNameCache = new LRU(500);
1026
+
1027
+ options.sortClassNames = function (/** @type {string} */ classNames) {
1028
+ // Fast path: Single class (no spaces) needs no sorting
1029
+ if (classNames.indexOf(' ') === -1) {
1030
+ return classNames;
1031
+ }
1032
+
1033
+ const cached = /** @type {string | undefined} */ (classNameCache.get(classNames));
1034
+ if (cached !== undefined) {
1035
+ return cached;
1036
+ }
1037
+
1038
+ // Expand UID tokens back to original content before sorting
1039
+ // Fast path: Skip if no HTML comments (UID markers) present
1040
+ let expandedValue = classNames;
1041
+ if (uidReplacePattern && classNames.indexOf('<!--') !== -1) {
1042
+ expandedValue = classNames.replace(uidReplacePattern, function (/** @type {string} */ _match, /** @type {string} */ index) {
1043
+ return ignoredMarkupChunks[+index] || '';
1044
+ });
1045
+ uidReplacePattern.lastIndex = 0;
1046
+ }
1047
+ const classes = expandedValue.split(whitespaceSplitPatternSort).filter(function(/** @type {string} */ cls) {
1048
+ return cls !== '';
1049
+ });
1050
+ const sorted = sorter.sort(classes);
1051
+ const result = sorted.join(' ');
1052
+
1053
+ classNameCache.set(classNames, result);
1054
+ return result;
1055
+ };
323
1056
  }
324
- return attrValue;
325
1057
  }
326
1058
 
327
- function isMetaViewport(tag, attrs) {
328
- if (tag !== 'meta') {
329
- return false;
330
- }
331
- for (var i = 0, len = attrs.length; i < len; i++) {
332
- if (attrs[i].name === 'name' && attrs[i].value === 'viewport') {
333
- return true;
1059
+ // Match `/^<\/([\w:-]+)>$/` by hand—the regex engine’s per-call setup costs more
1060
+ // than the scan on these short buffer entries
1061
+ /**
1062
+ * @param {string} str
1063
+ * @returns {string | null} The tag name, or `null` where the string is not a plain end tag
1064
+ */
1065
+ function matchPlainEndTag(str) {
1066
+ const end = str.length;
1067
+ if (end < 4 || str.charCodeAt(0) !== 60 /* < */ || str.charCodeAt(1) !== 47 /* / */ || str.charCodeAt(end - 1) !== 62 /* > */) {
1068
+ return null;
1069
+ }
1070
+ for (let i = 2; i < end - 1; i++) {
1071
+ const code = str.charCodeAt(i);
1072
+ // `[\w:-]`: Letters, digits, `_`, `:`, `-`
1073
+ if (!((code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 95 || code === 58 || code === 45)) {
1074
+ return null;
334
1075
  }
335
1076
  }
1077
+ return str.slice(2, end - 1);
336
1078
  }
337
1079
 
338
- function ignoreCSS(id) {
339
- return '/* clean-css ignore:start */' + id + '/* clean-css ignore:end */';
340
- }
341
-
342
- // Wrap CSS declarations for CleanCSS > 3.x
343
- // See https://github.com/jakubpawlowicz/clean-css/issues/418
344
- function wrapCSS(text, type) {
345
- switch (type) {
346
- case 'inline':
347
- return '*{' + text + '}';
348
- case 'media':
349
- return '@media ' + text + '{a{top:0}}';
350
- default:
351
- return text;
1080
+ /**
1081
+ * @param {string} value
1082
+ * @param {ProcessedOptions} options
1083
+ * @param {boolean} [partialMarkup] - Whether treating input as partial markup
1084
+ * @returns {Promise<string>}
1085
+ */
1086
+ async function minifyHTML(value, options, partialMarkup) {
1087
+ // Input length limit, against ReDoS
1088
+ if (options.maxInputLength && value.length > options.maxInputLength) {
1089
+ throw new Error(`Input length (${value.length}) exceeds maximum allowed length (${options.maxInputLength})`);
352
1090
  }
353
- }
354
-
355
- function unwrapCSS(text, type) {
356
- var matches;
357
- switch (type) {
358
- case 'inline':
359
- matches = text.match(/^\*\{([\s\S]*)\}$/);
360
- break;
361
- case 'media':
362
- matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/);
363
- break;
364
- }
365
- return matches ? matches[1] : text;
366
- }
367
1091
 
368
- function cleanConditionalComment(comment, options) {
369
- return options.processConditionalComments ? comment.replace(/^(\[if\s[^\]]+]>)([\s\S]*?)(<!\[endif])$/, function(match, prefix, text, suffix) {
370
- return prefix + minify(text, options, true) + suffix;
371
- }) : comment;
372
- }
1092
+ if (options.collapseWhitespace) {
1093
+ value = collapseWhitespace(value, options, true, true);
1094
+ }
373
1095
 
374
- function processScript(text, options, currentAttrs) {
375
- for (var i = 0, len = currentAttrs.length; i < len; i++) {
376
- if (currentAttrs[i].name.toLowerCase() === 'type' &&
377
- options.processScripts.indexOf(currentAttrs[i].value) > -1) {
378
- return minify(text, options);
1096
+ const resolveName = options.name;
1097
+ /** @type {HTMLAttribute[]} */
1098
+ const emptyAttrs = [];
1099
+
1100
+ /** @type {string[]} */
1101
+ const buffer = [];
1102
+ // Parallel to `buffer`: whether the entry is a tag—the only place lines may break
1103
+ const bufferTags = options.maxLineLength ? /** @type {boolean[]} */ ([]) : null;
1104
+ /** @type {string} */
1105
+ let charsPrevTag = '';
1106
+ let currentChars = '';
1107
+ /** @type {boolean} */
1108
+ let hasChars = false;
1109
+ // Open elements under `removeEmptyElements`, so an element whose content
1110
+ // went is removed, too: where its start tag sits in `buffer`, and whether
1111
+ // anything kept went in since
1112
+ /** @type {Array<{tag: string, index: number, empty: boolean}>} */
1113
+ const openElements = [];
1114
+ // Under `selfCloseForeignElements`, the open `svg` and `math` elements and the integration points
1115
+ // in them, whose children are HTML, which a slash does not close
1116
+ /** @type {string[]} */
1117
+ const foreignScopes = [];
1118
+ // A start tag the parser implied without writing it, whose end tag, if it comes next, is a stray
1119
+ // one that browsers read as an empty element (as `</p>` is), so it must not be omitted
1120
+ let impliedStartTag = '';
1121
+ // Whether the parser found the document to be in no-quirks mode, where `table` closes a `p`
1122
+ let tableClosesP = false;
1123
+ // Under `collapseWhitespace`, the text whose trailing whitespace sits before or
1124
+ // between elements that don’t render, kept until what follows them shows whether
1125
+ // it separates anything; whether the text being processed is such text; and how
1126
+ // deep inside such elements parsing is
1127
+ let transparentGapIndex = -1;
1128
+ let transparentGapPending = false;
1129
+ let transparentDepth = 0;
1130
+ let currentTag = '';
1131
+ /** @type {Array<{name: string, value?: string, quote?: string, customAssign?: string, customOpen?: string, customClose?: string}>} */
1132
+ let currentAttrs = [];
1133
+ /** @type {string[]} */
1134
+ const stackNoTrimWhitespace = [];
1135
+ /** @type {string[]} */
1136
+ const stackNoCollapseWhitespace = [];
1137
+ let preTextareaDepth = 0; // Count of `pre`/`textarea` entries in `stackNoTrimWhitespace`
1138
+ // Where the last element whose whitespace may not be trimmed ended in `buffer`;
1139
+ // what lies up to there stays as written, also where `canTrimWhitespace` decides
1140
+ // by attributes the trim walk lacks
1141
+ let noTrimEndIndex = -1;
1142
+ // The same for the last element whose whitespace may not be collapsed
1143
+ let noCollapseEndIndex = -1;
1144
+ let optionalStartTag = '';
1145
+ let optionalEndTag = '';
1146
+ let optionalEndTagEmitted = false;
1147
+ // Whether a node was removed since the last output
1148
+ let removed = false;
1149
+ // Whether the latest text was kept verbatim, as in `pre` or `script`
1150
+ let textVerbatim = false;
1151
+ /** @type {string[]} */
1152
+ const ignoredMarkupChunks = [];
1153
+ /** @type {Array<RegExpExecArray | null>} */
1154
+ const ignoredCustomMarkupChunks = [];
1155
+ // Custom fragments whose surrounding whitespace is never trimmed: in tags, where it
1156
+ // separates attributes, and in CSS and JavaScript, where minifiers can’t judge it
1157
+ /** @type {Set<number>} */
1158
+ const customMarkupInTags = new Set();
1159
+ /** @type {Set<number>} */
1160
+ const customMarkupInCode = new Set();
1161
+ // Attributes joined with the custom fragments they run into, to be written as they are
1162
+ /** @type {WeakSet<HTMLAttribute>} */
1163
+ const opaqueAttrs = new WeakSet();
1164
+ /** @type {string | undefined} */
1165
+ let uidIgnore;
1166
+ /** @type {RegExp | undefined} */
1167
+ let uidIgnorePlaceholderPattern;
1168
+ /** @type {string | undefined} */
1169
+ let uidAttr;
1170
+ /** @type {RegExp | undefined} */
1171
+ let uidPattern;
1172
+ /** @type {RegExp | undefined} */
1173
+ let uidAttrLeadingPattern;
1174
+ /** @type {RegExp | undefined} */
1175
+ let uidAttrExactPattern;
1176
+ // Create inline tags/text sets with custom elements
1177
+ const customElementsInput = options.inlineCustomElements ?? [];
1178
+ const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
1179
+ const normalizedCustomElements = customElementsArr.map(name => resolveName(name));
1180
+ // Fast path: Reuse base sets if no custom elements
1181
+ const inlineTextSet = normalizedCustomElements.length
1182
+ ? new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements])
1183
+ : inlineElementsToKeepWhitespaceWithin;
1184
+ const inlineElements = normalizedCustomElements.length
1185
+ ? new Set([...inlineElementsToKeepWhitespaceAround, ...normalizedCustomElements])
1186
+ : inlineElementsToKeepWhitespaceAround;
1187
+ const inlineSets = normalizedCustomElements.length
1188
+ ? buildInlineSets(inlineElements, inlineTextSet)
1189
+ : defaultInlineSets;
1190
+
1191
+ /** @type {Array<{tag: string, attrs: {[x: string]: string | undefined} | null}>} */
1192
+ let removeEmptyElementsExcept;
1193
+ if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
1194
+ options.log('Warning: `removeEmptyElementsExcept` option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
1195
+ removeEmptyElementsExcept = [];
1196
+ } else {
1197
+ removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept || [], options);
1198
+ }
1199
+
1200
+ // Temporarily replace ignored chunks with comments, so whatever they hold needn’t be parsed
1201
+ if (value.indexOf('<!-- htmlmin:ignore -->') !== -1) {
1202
+ // Use `indexOf`-based O(n) loop instead of a global regex with [\s\S]*? to avoid O(n²)
1203
+ // backtracking on adversarial HTML with many `<!--` prefixes but no closing marker
1204
+ const ignoreMarker = '<!-- htmlmin:ignore -->';
1205
+ const ignoreMarkerLen = ignoreMarker.length;
1206
+ let ignoreResult = '';
1207
+ let ignorePos = 0;
1208
+ while (ignorePos < value.length) {
1209
+ const ignoreStart = value.indexOf(ignoreMarker, ignorePos);
1210
+ if (ignoreStart === -1) { ignoreResult += value.slice(ignorePos); break; }
1211
+ ignoreResult += value.slice(ignorePos, ignoreStart);
1212
+ const ignoreEnd = value.indexOf(ignoreMarker, ignoreStart + ignoreMarkerLen);
1213
+ if (ignoreEnd === -1) { ignoreResult += value.slice(ignoreStart); break; }
1214
+ const group1 = value.slice(ignoreStart + ignoreMarkerLen, ignoreEnd);
1215
+ if (!uidIgnore) {
1216
+ uidIgnore = uniqueId(value);
1217
+ const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
1218
+ uidIgnorePlaceholderPattern = new RegExp('^<!--' + uidIgnore + '(\\d+)-->$');
1219
+ if (options.ignoreCustomComments) {
1220
+ options.ignoreCustomComments = options.ignoreCustomComments.slice();
1221
+ } else {
1222
+ options.ignoreCustomComments = [];
1223
+ }
1224
+ options.ignoreCustomComments.push(pattern);
1225
+ }
1226
+ const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
1227
+ ignoredMarkupChunks.push(group1);
1228
+ ignoreResult += token;
1229
+ ignorePos = ignoreEnd + ignoreMarkerLen;
379
1230
  }
1231
+ value = ignoreResult;
380
1232
  }
381
- return text;
382
- }
383
-
384
- // Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags
385
- // with the following deviations:
386
- // - retain <body> if followed by <noscript>
387
- // - </rb>, </rt>, </rtc>, </rp> & </tfoot> follow https://www.w3.org/TR/html5/syntax.html#optional-tags
388
- // - retain all tags which are adjacent to non-standard HTML tags
389
- var optionalStartTags = createMapFromString('html,head,body,colgroup,tbody');
390
- var optionalEndTags = createMapFromString('html,head,body,li,dt,dd,p,rb,rt,rtc,rp,optgroup,option,colgroup,caption,thead,tbody,tfoot,tr,td,th');
391
- var headerTags = createMapFromString('meta,link,script,style,template,noscript');
392
- var descriptionTags = createMapFromString('dt,dd');
393
- var pBlockTags = createMapFromString('address,article,aside,blockquote,details,div,dl,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,hr,main,menu,nav,ol,p,pre,section,table,ul');
394
- var pInlineTags = createMapFromString('a,audio,del,ins,map,noscript,video');
395
- var rubyTags = createMapFromString('rb,rt,rtc,rp');
396
- var rtcTag = createMapFromString('rb,rtc,rp');
397
- var optionTag = createMapFromString('option,optgroup');
398
- var tableContentTags = createMapFromString('tbody,tfoot');
399
- var tableSectionTags = createMapFromString('thead,tbody,tfoot');
400
- var cellTags = createMapFromString('td,th');
401
- var topLevelTags = createMapFromString('html,head,body');
402
- var compactTags = createMapFromString('html,body');
403
- var looseTags = createMapFromString('head,colgroup,caption');
404
- var trailingTags = createMapFromString('dt,thead');
405
- var htmlTags = createMapFromString('a,abbr,acronym,address,applet,area,article,aside,audio,b,base,basefont,bdi,bdo,bgsound,big,blink,blockquote,body,br,button,canvas,caption,center,cite,code,col,colgroup,command,content,data,datalist,dd,del,details,dfn,dialog,dir,div,dl,dt,element,em,embed,fieldset,figcaption,figure,font,footer,form,frame,frameset,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,i,iframe,image,img,input,ins,isindex,kbd,keygen,label,legend,li,link,listing,main,map,mark,marquee,menu,menuitem,meta,meter,multicol,nav,nobr,noembed,noframes,noscript,object,ol,optgroup,option,output,p,param,picture,plaintext,pre,progress,q,rb,rp,rt,rtc,ruby,s,samp,script,section,select,shadow,small,source,spacer,span,strike,strong,style,sub,summary,sup,table,tbody,td,template,textarea,tfoot,th,thead,time,title,tr,track,tt,u,ul,var,video,wbr,xmp');
406
-
407
- function canRemoveParentTag(optionalStartTag, tag) {
408
- switch (optionalStartTag) {
409
- case 'html':
410
- case 'head':
411
- return true;
412
- case 'body':
413
- return !headerTags(tag);
414
- case 'colgroup':
415
- return tag === 'col';
416
- case 'tbody':
417
- return tag === 'tr';
418
- }
419
- return false;
420
- }
421
1233
 
422
- function isStartTagMandatory(optionalEndTag, tag) {
423
- switch (tag) {
424
- case 'colgroup':
425
- return optionalEndTag === 'colgroup';
426
- case 'tbody':
427
- return tableSectionTags(optionalEndTag);
1234
+ // Sort functions are created after `htmlmin:ignore` processing but before custom fragment UID
1235
+ // markers, so frequency analysis can reach ignored content via UID tokens
1236
+ if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
1237
+ (options.sortClassNames && typeof options.sortClassNames !== 'function')) {
1238
+ await createSortFns(value, options, uidIgnore ?? '', ignoredMarkupChunks);
428
1239
  }
429
- return false;
430
- }
431
1240
 
432
- function canRemovePrecedingTag(optionalEndTag, tag) {
433
- switch (optionalEndTag) {
434
- case 'html':
435
- case 'head':
436
- case 'body':
437
- case 'colgroup':
438
- case 'caption':
439
- return true;
440
- case 'li':
441
- case 'optgroup':
442
- case 'tr':
443
- return tag === optionalEndTag;
444
- case 'dt':
445
- case 'dd':
446
- return descriptionTags(tag);
447
- case 'p':
448
- return pBlockTags(tag);
449
- case 'rb':
450
- case 'rt':
451
- case 'rp':
452
- return rubyTags(tag);
453
- case 'rtc':
454
- return rtcTag(tag);
455
- case 'option':
456
- return optionTag(tag);
457
- case 'thead':
458
- case 'tbody':
459
- return tableContentTags(tag);
460
- case 'tfoot':
461
- return tag === 'tbody';
462
- case 'td':
463
- case 'th':
464
- return cellTags(tag);
465
- }
466
- return false;
467
- }
1241
+ const customFragments = (options.ignoreCustomFragments || []).map(toFragment);
1242
+ if (customFragments.length) {
1243
+ // Temporarily replace custom ignored fragments with unique attributes
1244
+ const replaceFragment = function (/** @type {string} */ match) {
1245
+ if (!uidAttr) {
1246
+ uidAttr = uniqueId(value);
1247
+ uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
1248
+ uidAttrLeadingPattern = new RegExp('^\\s*' + uidAttr + '(\\d+)' + uidAttr);
1249
+ uidAttrExactPattern = new RegExp('^' + uidAttr + '(\\d+)' + uidAttr + '$');
1250
+
1251
+ if (options.minifyCSS !== identity) {
1252
+ options.minifyCSS = (function (/** @type {ProcessedOptions['minifyCSS']} */ fn) {
1253
+ return function (/** @type {string} */ text, /** @type {string | undefined} */ type, /** @type {ProcessedOptions['cssContext']} */ context) {
1254
+ text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1255
+ const chunks = ignoredCustomMarkupChunks[+index];
1256
+ customMarkupInCode.add(+index);
1257
+ return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1258
+ });
468
1259
 
469
- var reEmptyAttribute = new RegExp(
470
- '^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
471
- '?:down|up|over|move|out)|key(?:press|down|up)))$');
1260
+ return fn(text, type, context);
1261
+ };
1262
+ })(options.minifyCSS);
1263
+ }
472
1264
 
473
- function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
474
- var isValueEmpty = !attrValue || /^\s*$/.test(attrValue);
475
- if (!isValueEmpty) {
476
- return false;
1265
+ if (options.minifyJS !== identity) {
1266
+ options.minifyJS = (function (/** @type {ProcessedOptions['minifyJS']} */ fn) {
1267
+ return function (/** @type {string} */ text, /** @type {boolean | undefined} */ inline, /** @type {boolean | undefined} */ isModule) {
1268
+ return fn(text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1269
+ const chunks = ignoredCustomMarkupChunks[+index];
1270
+ customMarkupInCode.add(+index);
1271
+ return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1272
+ }), inline, isModule);
1273
+ };
1274
+ })(options.minifyJS);
1275
+ }
1276
+ }
1277
+
1278
+ const token = uidAttr + ignoredCustomMarkupChunks.length + uidAttr;
1279
+ ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
1280
+ return '\t' + token + '\t';
1281
+ };
1282
+
1283
+ value = replaceCustomFragments(value, customFragments, replaceFragment);
477
1284
  }
478
- if (typeof options.removeEmptyAttributes === 'function') {
479
- return options.removeEmptyAttributes(attrName, tag);
1285
+
1286
+ // HTML reads every line break as a line feed, while ignored markup and custom fragments,
1287
+ // now placeholders, stay as written
1288
+ if (value.indexOf('\r') !== -1) {
1289
+ value = value.replace(RE_LINE_BREAK_CR, '\n');
480
1290
  }
481
- return tag === 'input' && attrName === 'value' || reEmptyAttribute.test(attrName);
482
- }
483
1291
 
484
- function hasAttrName(name, attrs) {
485
- for (var i = attrs.length - 1; i >= 0; i--) {
486
- if (attrs[i].name === name) {
487
- return true;
1292
+ // Custom fragment and `htmlmin:ignore` placeholders hold these
1293
+ const markers = [uidAttr, uidIgnore].filter(marker => marker !== undefined);
1294
+
1295
+ // Record the custom fragments in (part of) a tag
1296
+ function markTagFragments(/** @type {string} */ str) {
1297
+ for (const uidMatch of str.matchAll(/** @type {RegExp} */ (uidPattern))) {
1298
+ customMarkupInTags.add(+(uidMatch[2] ?? 0));
488
1299
  }
489
1300
  }
490
- return false;
491
- }
492
1301
 
493
- function canRemoveElement(tag, attrs) {
494
- switch (tag) {
495
- case 'textarea':
496
- return false;
497
- case 'audio':
498
- case 'script':
499
- case 'video':
500
- if (hasAttrName('src', attrs)) {
501
- return false;
502
- }
503
- break;
504
- case 'iframe':
505
- if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
506
- return false;
507
- }
508
- break;
509
- case 'object':
510
- if (hasAttrName('data', attrs)) {
511
- return false;
1302
+ // Source text of an attribute, as far as it matters
1303
+ function rawAttr(/** @type {HTMLAttribute} */ attr) {
1304
+ if (opaqueAttrs.has(attr)) {
1305
+ return attr.name;
1306
+ }
1307
+ let raw = attr.name;
1308
+ // Skip values the parser filled in for bare boolean attributes
1309
+ if (attr.value !== undefined && !(attr.quote === '' && attr.value === attr.name)) {
1310
+ raw += (attr.customAssign ?? '=') + (attr.quote ?? '') + attr.value + (attr.quote ?? '');
1311
+ }
1312
+ return (attr.customOpen ?? '') + raw + (attr.customClose ?? '');
1313
+ }
1314
+
1315
+ /**
1316
+ * Join each attribute a custom fragment runs into without whitespace with that fragment,
1317
+ * so that no later step separates, drops, changes, or reorders them
1318
+ * @param {HTMLAttribute[]} attrs - Joined in place
1319
+ * @returns {boolean} Whether the first attribute runs into the tag name
1320
+ */
1321
+ function joinFragmentAttrs(attrs) {
1322
+ const pattern = /** @type {RegExp} */ (uidAttrExactPattern);
1323
+ let joinsTag = false;
1324
+ let writeIndex = 0;
1325
+ // Padding of the previous attribute if that is a fragment without value
1326
+ /** @type {RegExpExecArray | null | undefined} */
1327
+ let prevChunks = null;
1328
+ for (const attr of attrs) {
1329
+ const uidMatch = pattern.exec(attr.name);
1330
+ const chunks = uidMatch ? ignoredCustomMarkupChunks[+(uidMatch[1] ?? 0)] : null;
1331
+ const joins = prevChunks ? prevChunks[2] === '' : chunks?.[1] === '';
1332
+ prevChunks = attr.value === undefined ? chunks : null;
1333
+ if (!joins) {
1334
+ attrs[writeIndex++] = attr;
1335
+ continue;
512
1336
  }
513
- break;
514
- case 'applet':
515
- if (hasAttrName('code', attrs)) {
516
- return false;
1337
+ const prev = writeIndex ? attrs[writeIndex - 1] : undefined;
1338
+ if (!prev) {
1339
+ joinsTag = true;
517
1340
  }
518
- break;
519
- }
520
- return true;
521
- }
522
-
523
- function canCollapseWhitespace(tag) {
524
- return !/^(?:script|style|pre|textarea)$/.test(tag);
525
- }
526
-
527
- function canTrimWhitespace(tag) {
528
- return !/^(?:pre|textarea)$/.test(tag);
529
- }
530
-
531
- function normalizeAttr(attr, attrs, tag, options) {
532
- var attrName = options.name(attr.name),
533
- attrValue = attr.value;
534
-
535
- if (options.decodeEntities && attrValue) {
536
- attrValue = decode(attrValue, { isAttributeValue: true });
1341
+ /** @type {HTMLAttribute} */
1342
+ const joined = { name: (prev ? rawAttr(prev) : '') + rawAttr(attr), customAssign: '=', customOpen: '', customClose: '', quote: '' };
1343
+ opaqueAttrs.add(joined);
1344
+ attrs[prev ? writeIndex - 1 : writeIndex++] = joined;
1345
+ }
1346
+ attrs.length = writeIndex;
1347
+ return joinsTag;
1348
+ }
1349
+
1350
+ /**
1351
+ * Restore the whitespace on one side of a custom fragment
1352
+ * @param {string} str - Whitespace the minifier left there and the fragment’s own padding
1353
+ * @param {boolean} trim
1354
+ * @param {boolean} leading
1355
+ */
1356
+ function restoreFragmentSide(str, trim, leading) {
1357
+ if (!str) {
1358
+ return str;
1359
+ }
1360
+ return collapseWhitespace(str, {
1361
+ preserveLineBreaks: options.preserveLineBreaks,
1362
+ conservativeCollapse: !trim
1363
+ }, leading, !leading);
1364
+ }
1365
+
1366
+ // Under `caseSensitive`, names are read in lowercase all the same, while `buffer` and hooks
1367
+ // get them as written, so the defaults a hook may call take any spelling then
1368
+ const namesAsWritten = Boolean(options.namesAsWritten);
1369
+ const defaultCanCollapse = namesAsWritten
1370
+ ? (/** @type {string} */ tag) => defaultCanCollapseWhitespace(tag.toLowerCase())
1371
+ : defaultCanCollapseWhitespace;
1372
+ const defaultCanTrim = namesAsWritten
1373
+ ? (/** @type {string} */ tag) => defaultCanTrimWhitespace(tag.toLowerCase())
1374
+ : defaultCanTrimWhitespace;
1375
+
1376
+ // The defaults decide by `tag`, a hook gets `tagOut`; without a hook, the
1377
+ // option holds the default itself
1378
+ function canCollapseWhitespace(/** @type {string} */ tag, /** @type {string} */ tagOut, /** @type {HTMLAttribute[]} */ attrs) {
1379
+ const hook = options.canCollapseWhitespace;
1380
+ return hook === defaultCanCollapseWhitespace ? defaultCanCollapseWhitespace(tag) : hook(tagOut, attrs, defaultCanCollapse);
1381
+ }
1382
+
1383
+ function canTrimWhitespace(/** @type {string} */ tag, /** @type {string} */ tagOut, /** @type {HTMLAttribute[]} */ attrs) {
1384
+ const hook = options.canTrimWhitespace;
1385
+ return hook === defaultCanTrimWhitespace ? defaultCanTrimWhitespace(tag) : hook(tagOut, attrs, defaultCanTrim);
1386
+ }
1387
+
1388
+ function bufferPush(/** @type {string} */ entry, /** @type {boolean} */ isTag) {
1389
+ buffer.push(entry);
1390
+ bufferTags?.push(isTag);
1391
+ if (entry) removed = false;
1392
+ }
1393
+
1394
+ function bufferTruncate(/** @type {number} */ length) {
1395
+ buffer.length = Math.max(0, length);
1396
+ if (bufferTags) bufferTags.length = buffer.length;
1397
+ if (transparentGapIndex >= buffer.length) transparentGapIndex = -1;
1398
+ if (noTrimEndIndex >= buffer.length) noTrimEndIndex = -1;
1399
+ if (noCollapseEndIndex >= buffer.length) noCollapseEndIndex = -1;
1400
+ removed = true;
1401
+ }
1402
+
1403
+ // The nearest entries before and from `index` that aren’t empty, as removed nodes leave them
1404
+ /** @returns {[number, number]} */
1405
+ function findNeighbors(/** @type {number} */ index) {
1406
+ let before = index - 1;
1407
+ while (before >= 0 && buffer[before] === '') {
1408
+ before--;
1409
+ }
1410
+ let after = index;
1411
+ while (after < buffer.length && buffer[after] === '') {
1412
+ after++;
1413
+ }
1414
+ return [before, after];
537
1415
  }
538
1416
 
539
- if (options.removeRedundantAttributes &&
540
- isAttributeRedundant(tag, attrName, attrValue, attrs) ||
541
- options.removeScriptTypeAttributes && tag === 'script' &&
542
- attrName === 'type' && isScriptTypeAttribute(attrValue) ||
543
- options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
544
- attrName === 'type' && isStyleLinkTypeAttribute(attrValue)) {
545
- return;
1417
+ // With whitespace kept as is, a removed node must not leave a line of nothing but
1418
+ // whitespace behind: The text around the removal (at `index`) drops that line. The start
1419
+ // of the output counts as a line break, and blank lines of the source stay.
1420
+ function removeWhitespaceLine(/** @type {number} */ index) {
1421
+ const [first, after] = findNeighbors(index);
1422
+ // The end of the output is left to `trimOutputWhitespace`
1423
+ if (after === buffer.length) {
1424
+ return;
1425
+ }
1426
+ const next = buffer[after] ?? '';
1427
+ if (!RE_BLANKS_LINE_BREAK_START.test(next)) {
1428
+ return;
1429
+ }
1430
+ // Blanks between nodes removed from the same line belong to that line
1431
+ let before = first;
1432
+ while (before >= 0 && (buffer[before] === '' || RE_BLANKS_ONLY.test(buffer[before] ?? ''))) {
1433
+ before--;
1434
+ }
1435
+ const prev = buffer[before] ?? '';
1436
+ if (before >= 0 && !RE_LINE_BREAK_BLANKS_END.test(prev)) {
1437
+ return;
1438
+ }
1439
+ for (let i = before + 1; i <= first; i++) {
1440
+ buffer[i] = '';
1441
+ }
1442
+ // The line break goes on the side of the text before the removal, so that the text after
1443
+ // it keeps its own for a removal that may follow
1444
+ if (before < 0) {
1445
+ buffer[after] = next.replace(RE_BLANKS_LINE_BREAK_START, '');
1446
+ } else {
1447
+ buffer[before] = prev.replace(RE_LINE_BREAK_BLANKS_END, '');
1448
+ buffer[after] = next.replace(RE_BLANKS_START, '');
1449
+ }
546
1450
  }
547
1451
 
548
- if (attrValue) {
549
- attrValue = cleanAttributeValue(tag, attrName, attrValue, options, attrs);
1452
+ // Whitespace at either end of the output goes, but not verbatim whitespace, nor whitespace
1453
+ // that `conservativeCollapse` or `preserveLineBreaks` promise to keep, nor that of partial
1454
+ // markup, whose ends join other markup
1455
+ function trimOutputWhitespace() {
1456
+ if (options.partialMarkup || (options.collapseWhitespace && (options.conservativeCollapse || options.preserveLineBreaks))) {
1457
+ return;
1458
+ }
1459
+ // Tags start with `<` and end with `>`, so whitespace at either end belongs to text
1460
+ if (!textVerbatim) {
1461
+ let index = buffer.length - 1;
1462
+ while (index >= 0 && (buffer[index] === '' || RE_WS_ONLY.test(buffer[index] ?? ''))) {
1463
+ buffer[index--] = '';
1464
+ }
1465
+ if (index >= 0) {
1466
+ buffer[index] = (buffer[index] ?? '').replace(RE_WS_END, '');
1467
+ }
1468
+ }
1469
+ let index = 0;
1470
+ while (index < buffer.length && (buffer[index] === '' || RE_WS_ONLY.test(buffer[index] ?? ''))) {
1471
+ buffer[index++] = '';
1472
+ }
1473
+ if (index < buffer.length) {
1474
+ buffer[index] = (buffer[index] ?? '').replace(RE_WS_START, '');
1475
+ }
550
1476
  }
551
1477
 
552
- if (options.removeEmptyAttributes &&
553
- canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
554
- return;
1478
+ function lastStartTagIndex() {
1479
+ let index = buffer.length - 1;
1480
+ while (index > 0 && !RE_START_TAG.test(buffer[index] ?? '')) {
1481
+ index--;
1482
+ }
1483
+ return index;
555
1484
  }
556
1485
 
557
- if (options.decodeEntities && attrValue) {
558
- attrValue = attrValue.replace(/&(#?[0-9a-zA-Z]+;)/g, '&amp;$1');
1486
+ function removeStartTag() {
1487
+ bufferTruncate(lastStartTagIndex());
559
1488
  }
560
1489
 
561
- return {
562
- attr: attr,
563
- name: attrName,
564
- value: attrValue
565
- };
566
- }
567
-
568
- function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
569
- var attrName = normalized.name,
570
- attrValue = normalized.value,
571
- attr = normalized.attr,
572
- attrQuote = attr.quote,
573
- attrFragment,
574
- emittedAttrValue;
575
-
576
- if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
577
- ~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
578
- if (!options.preventAttributesEscaping) {
579
- if (typeof options.quoteCharacter === 'undefined') {
580
- var apos = (attrValue.match(/'/g) || []).length;
581
- var quot = (attrValue.match(/"/g) || []).length;
582
- attrQuote = apos < quot ? '\'' : '"';
583
- }
584
- else {
585
- attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
586
- }
587
- if (attrQuote === '"') {
588
- attrValue = attrValue.replace(/"/g, '&#34;');
589
- }
590
- else {
591
- attrValue = attrValue.replace(/'/g, '&#39;');
1490
+ // Write the last start tag as self-closing, where it is the last thing written and holds no
1491
+ // custom fragment, which may write anything; returns whether it did
1492
+ function closeLastStartTag() {
1493
+ const start = lastStartTagIndex();
1494
+ let last = buffer.length - 1;
1495
+ while (last > start && buffer[last] === '') {
1496
+ last--;
1497
+ }
1498
+ for (let i = start; i <= last; i++) {
1499
+ const entry = buffer[i] ?? '';
1500
+ if ((i > start && entry[0] === '<') || (uidAttr && entry.indexOf(uidAttr) !== -1)) {
1501
+ return false;
592
1502
  }
593
1503
  }
594
- emittedAttrValue = attrQuote + attrValue + attrQuote;
595
- if (!isLast && !options.removeTagWhitespace) {
596
- emittedAttrValue += ' ';
1504
+ const tagText = buffer[last] ?? '';
1505
+ if (!tagText.endsWith('>') || tagText.endsWith('/>')) {
1506
+ return false;
597
1507
  }
1508
+ const tagOpen = tagText.slice(0, -1);
1509
+ // A slash right after an unquoted value would belong to the value
1510
+ buffer[last] = tagOpen + (RE_UNQUOTED_VALUE_END.test(tagOpen) ? ' />' : '/>');
1511
+ return true;
598
1512
  }
599
- // make sure trailing slash is not interpreted as HTML self-closing tag
600
- else if (isLast && !hasUnarySlash && !/\/$/.test(attrValue)) {
601
- emittedAttrValue = attrValue;
1513
+
1514
+ /** @param {string} lowerTag */
1515
+ function enterForeignScope(lowerTag) {
1516
+ if (lowerTag === 'svg' || lowerTag === 'math' ||
1517
+ (options.namespaceForeign && integrationPointsForeign[options.namespaceForeign].has(lowerTag))) {
1518
+ foreignScopes.push(lowerTag);
1519
+ }
602
1520
  }
603
- else {
604
- emittedAttrValue = attrValue + ' ';
1521
+
1522
+ // Leaves the scope the element opened, if any; returns whether a slash would close the element
1523
+ /** @param {string} lowerTag */
1524
+ function leaveForeignScope(lowerTag) {
1525
+ if (foreignScopes[foreignScopes.length - 1] === lowerTag) {
1526
+ foreignScopes.pop();
1527
+ }
1528
+ const scope = foreignScopes[foreignScopes.length - 1];
1529
+ return lowerTag === 'svg' || lowerTag === 'math' || scope === undefined || scope === 'svg' || scope === 'math';
605
1530
  }
606
1531
 
607
- if (typeof attrValue === 'undefined' || options.collapseBooleanAttributes &&
608
- isBooleanAttribute(attrName.toLowerCase(), attrValue.toLowerCase())) {
609
- attrFragment = attrName;
610
- if (!isLast) {
611
- attrFragment += ' ';
1532
+ function markOpenElementFilled() {
1533
+ // Guarded, as reading index -1 is a slow property lookup, and this runs for every text node
1534
+ if (openElements.length) {
1535
+ /** @type {{empty: boolean}} */ (openElements[openElements.length - 1]).empty = false;
612
1536
  }
613
1537
  }
614
- else {
615
- attrFragment = attrName + attr.customAssign + emittedAttrValue;
1538
+
1539
+ // Whether the element being parsed holds text rather than markup. The namespace decides
1540
+ // this, not the name alone: Only HTML elements do—an SVG or MathML `script` or `title`
1541
+ // holds markup like any foreign element
1542
+ function holdsRawText() {
1543
+ return rawTextElements.has(currentTag) && !options.insideForeignContent;
616
1544
  }
617
1545
 
618
- return attr.customOpen + attrFragment + attr.customClose;
619
- }
1546
+ // Whether whitespace-only text between tags survives minification
1547
+ function keepsWhitespace() {
1548
+ return !options.collapseWhitespace || Boolean(options.conservativeCollapse) || Boolean(options.preserveLineBreaks);
1549
+ }
620
1550
 
621
- function identity(value) {
622
- return value;
623
- }
1551
+ // The two whitespace runs an omitted tag separated become one run, which collapses to the
1552
+ // single character it is due: a line break under `preserveLineBreaks`, a no-break space
1553
+ // where the run holds one, a space under `conservativeCollapse`
1554
+ function mergeWhitespaceRuns(/** @type {number} */ index) {
1555
+ const [before, after] = findNeighbors(index);
1556
+ if (before < 0 || after >= buffer.length) {
1557
+ return;
1558
+ }
1559
+ const prev = buffer[before] ?? '';
1560
+ const next = buffer[after] ?? '';
1561
+ if (!RE_WS_NBSP_END.test(prev) || !RE_WS_START.test(next)) {
1562
+ return;
1563
+ }
1564
+ // A line break the run already carries outranks the whitespace ahead of it; trimming
1565
+ // the other side takes ASCII whitespace only, so a no-break space stays as is
1566
+ if (options.preserveLineBreaks && /[\n\r]/.test((next.match(RE_WS_START) ?? [''])[0])) {
1567
+ buffer[before] = prev.replace(RE_WS_END, '');
1568
+ } else {
1569
+ buffer[after] = next.replace(RE_WS_START, '');
1570
+ }
1571
+ }
624
1572
 
625
- function processOptions(values) {
626
- var options = {
627
- name: function(name) {
628
- return name.toLowerCase();
629
- },
630
- canCollapseWhitespace: canCollapseWhitespace,
631
- canTrimWhitespace: canTrimWhitespace,
632
- html5: true,
633
- ignoreCustomComments: [/^!/],
634
- ignoreCustomFragments: [
635
- /<%[\s\S]*?%>/,
636
- /<\?[\s\S]*?\?>/
637
- ],
638
- includeAutoGeneratedTags: true,
639
- log: identity,
640
- minifyCSS: identity,
641
- minifyJS: identity,
642
- minifyURLs: identity
643
- };
644
- Object.keys(values).forEach(function(key) {
645
- var value = values[key];
646
- if (key === 'caseSensitive') {
647
- if (value) {
648
- options.name = identity;
649
- }
1573
+ // `insideNoTrim` stands in for the stack once the end tag that omits this one has left it
1574
+ function removeEndTag(/** @type {boolean} */ insideNoTrim = false) {
1575
+ let index = buffer.length - 1;
1576
+ while (index > 0 && !RE_END_TAG.test(buffer[index] ?? '')) {
1577
+ index--;
650
1578
  }
651
- else if (key === 'log') {
652
- if (typeof value === 'function') {
653
- options.log = value;
1579
+ // Text that follows the end tag is kept as is, so drop the tag alone. Whitespace kept
1580
+ // verbatim—in `pre`, in `textarea`, or wherever `canTrimWhitespace` says so—counts as
1581
+ // kept text too, no matter how whitespace is collapsed elsewhere.
1582
+ const noTrim = insideNoTrim || stackNoTrimWhitespace.length > 0;
1583
+ if (index < buffer.length - 1 && (keepsWhitespace() || noTrim) && RE_END_TAG.test(buffer[index] ?? '')) {
1584
+ buffer.splice(index, 1);
1585
+ bufferTags?.splice(index, 1);
1586
+ // Collapsed whitespace can merge, kept whitespace can leave a line of only whitespace
1587
+ if (!noTrim) {
1588
+ if (options.collapseWhitespace) {
1589
+ mergeWhitespaceRuns(index);
1590
+ } else {
1591
+ removeWhitespaceLine(index);
1592
+ }
654
1593
  }
1594
+ return;
655
1595
  }
656
- else if (key === 'minifyCSS' && typeof value !== 'function') {
657
- if (!value) {
658
- return;
1596
+ bufferTruncate(index);
1597
+ }
1598
+
1599
+ // Look for trailing whitespace, bypassing inline tags
1600
+ function trimTrailingWhitespace(/** @type {number} */ index, /** @type {string} */ nextTag) {
1601
+ // Nothing to the left of the text is in play here, so only its trailing run can
1602
+ // change—unless preserved line breaks bring the leading run into play, too
1603
+ const trailingOnly = !options.preserveLineBreaks;
1604
+ for (let prevTag = ''; index > noTrimEndIndex && canTrimWhitespace(namesAsWritten ? prevTag.toLowerCase() : prevTag, prevTag, emptyAttrs); index--) {
1605
+ const str = buffer[index] ?? '';
1606
+ const endTagName = matchPlainEndTag(str);
1607
+ if (endTagName !== null) {
1608
+ prevTag = endTagName;
1609
+ continue;
1610
+ }
1611
+ // A tag ends the search, and so does text that survives collapsing. Text with no
1612
+ // trailing run comes back from the call unchanged, so it can end the search on
1613
+ // the spot; empty text cannot, as the search has to carry on past it
1614
+ if (str.charCodeAt(str.length - 1) === 62 /* > */) {
1615
+ break;
659
1616
  }
660
- if (typeof value !== 'object') {
661
- value = {};
1617
+ if (trailingOnly && str && !endsWithWhitespace(str)) {
1618
+ break;
662
1619
  }
663
- options.minifyCSS = function(text, type) {
664
- text = text.replace(/(url\s*\(\s*)("|'|)(.*?)\2(\s*\))/ig, function(match, prefix, quote, url, suffix) {
665
- return prefix + quote + options.minifyURLs(url) + quote + suffix;
666
- });
667
- var cleanCssOutput = new CleanCSS(value).minify(wrapCSS(text, type));
668
- if (cleanCssOutput.errors.length > 0) {
669
- cleanCssOutput.errors.forEach(options.log);
670
- return text;
671
- }
672
- return unwrapCSS(cleanCssOutput.styles, type);
673
- };
674
- }
675
- else if (key === 'minifyJS' && typeof value !== 'function') {
676
- if (!value) {
677
- return;
1620
+ buffer[index] = collapseWhitespaceSmart(str, '', nextTag, emptyAttrs, emptyAttrs, options, inlineSets, !stackNoCollapseWhitespace.length && index > noCollapseEndIndex);
1621
+ if (buffer[index]) {
1622
+ break;
678
1623
  }
679
- if (typeof value !== 'object') {
680
- value = {};
681
- }
682
- (value.parse || (value.parse = {})).bare_returns = false;
683
- options.minifyJS = function(text, inline) {
684
- var start = text.match(/^\s*<!--.*/);
685
- var code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
686
- value.parse.bare_returns = inline;
687
- var result = UglifyJS.minify(code, value);
688
- if (result.error) {
689
- options.log(result.error);
690
- return text;
691
- }
692
- return result.code.replace(/;$/, '');
693
- };
694
1624
  }
695
- else if (key === 'minifyURLs' && typeof value !== 'function') {
696
- if (!value) {
697
- return;
698
- }
699
- if (typeof value === 'string') {
700
- value = { site: value };
1625
+ }
1626
+
1627
+ // Look for trailing whitespace in earlier text that a following comment, or an empty element
1628
+ // removed since, kept from being trimmed
1629
+ function squashTrailingWhitespace(/** @type {string} */ nextTag) {
1630
+ // Fast path: With no transparent gap pending, a start tag or text without trailing
1631
+ // whitespace last in the buffer ends the walk below on that entry. That leaves text
1632
+ // alone as well where `preserveLineBreaks` has the walk collapse it again, as the
1633
+ // text’s own pass collapsed it already.
1634
+ if (transparentGapIndex === -1) {
1635
+ const last = buffer[buffer.length - 1] ?? '';
1636
+ if (last) {
1637
+ if (last.charCodeAt(0) !== 60 /* < */) {
1638
+ if (!endsWithWhitespace(last)) {
1639
+ return;
1640
+ }
1641
+ } else {
1642
+ const second = last.charCodeAt(1);
1643
+ if (second !== 47 /* / */ && second !== 33 /* ! */ && last.charCodeAt(last.length - 1) === 62 /* > */) {
1644
+ return;
1645
+ }
1646
+ }
701
1647
  }
702
- else if (typeof value !== 'object') {
703
- value = {};
1648
+ }
1649
+ if (transparentGapIndex !== -1 && !transparentDepth && !inlineElementsTransparentAfter.has(nextTag) && !inlineElementsTransparent.has(nextTag)) {
1650
+ settleTransparentGap(collapseWhitespaceSmart(' ', '', nextTag, emptyAttrs, emptyAttrs, options, inlineSets) !== '');
1651
+ }
1652
+ let charsIndex = buffer.length - 1;
1653
+ // Step back over every trailing comment and empty entry, not just one—a single step
1654
+ // stops the search at the comment itself, leaving whitespace behind that a second
1655
+ // minification run then removes
1656
+ while (charsIndex > 0) {
1657
+ const item = buffer[charsIndex] ?? '';
1658
+ // Comments and empty entries only, as `<!…` or nothing at all
1659
+ const skippable = item === '' || (item.charCodeAt(0) === 60 /* < */ && item.charCodeAt(1) === 33 /* ! */);
1660
+ if (!skippable || (uidIgnore && item.indexOf(uidIgnore) !== -1)) {
1661
+ break;
704
1662
  }
705
- options.minifyURLs = function(text) {
706
- try {
707
- return RelateUrl.relate(text, value);
708
- }
709
- catch (err) {
710
- options.log(err);
711
- return text;
712
- }
713
- };
1663
+ charsIndex--;
714
1664
  }
715
- else {
716
- options[key] = value;
1665
+ // Right before an element that doesn’t render, the text’s own pass has
1666
+ // weighed both sides already
1667
+ if (charsIndex === buffer.length - 1 && inlineElementsTransparent.has(nextTag)) {
1668
+ return;
717
1669
  }
718
- });
719
- return options;
720
- }
721
-
722
- function uniqueId(value) {
723
- var id;
724
- do {
725
- id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
726
- } while (~value.indexOf(id));
727
- return id;
728
- }
729
-
730
- var specialContentTags = createMapFromString('script,style');
731
-
732
- function createSortFns(value, options, uidIgnore, uidAttr) {
733
- var attrChains = options.sortAttributes && Object.create(null);
734
- var classChain = options.sortClassName && new TokenChain();
735
-
736
- function attrNames(attrs) {
737
- return attrs.map(function(attr) {
738
- return options.name(attr.name);
739
- });
1670
+ trimTrailingWhitespace(charsIndex, nextTag);
740
1671
  }
741
1672
 
742
- function shouldSkipUID(token, uid) {
743
- return !uid || token.indexOf(uid) === -1;
1673
+ // Keep or trim the whitespace pending before or between elements that don’t render
1674
+ function settleTransparentGap(/** @type {boolean} */ keep) {
1675
+ if (!keep) {
1676
+ buffer[transparentGapIndex] = collapseWhitespace(buffer[transparentGapIndex] ?? '', options, false, true);
1677
+ }
1678
+ transparentGapIndex = -1;
744
1679
  }
745
1680
 
746
- function shouldSkipUIDs(token) {
747
- return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
1681
+ // The index of the start tag whose attributes, which follow it as entries
1682
+ // of their own, end at `index`, or -1 for text
1683
+ function findAttributesStartTag(/** @type {number} */ index) {
1684
+ if ((buffer[index] ?? '').charCodeAt((buffer[index] ?? '').length - 1) !== 62 /* > */) return -1;
1685
+ for (let tagIndex = index - 1; tagIndex >= 0; tagIndex--) {
1686
+ const str = buffer[tagIndex] ?? '';
1687
+ if (str.charCodeAt(0) === 60 /* < */) {
1688
+ return RE_START_TAG.test(str) && str.charCodeAt(str.length - 1) !== 62 /* > */ ? tagIndex : -1;
1689
+ }
1690
+ if (!str || str.charCodeAt(str.length - 1) === 62 /* > */) return -1;
1691
+ }
1692
+ return -1;
1693
+ }
1694
+
1695
+ // Whether what comes before the elements that don’t render at the end of `buffer`
1696
+ // would run into what follows them; without `skipAttributes`, the scan stops at
1697
+ // a start tag’s attributes and says yes, as stepping over them on every
1698
+ // call makes the scan quadratic over long runs
1699
+ function precedesTransparentRun(/** @type {boolean} */ skipAttributes = false) {
1700
+ for (let index = buffer.length - 1; index >= 0; index--) {
1701
+ let str = buffer[index] ?? '';
1702
+ if (!str) continue;
1703
+ if (str.charCodeAt(0) !== 60 /* < */) {
1704
+ const tagIndex = skipAttributes ? findAttributesStartTag(index) : -1;
1705
+ if (tagIndex === -1) {
1706
+ return !endsWithWhitespace(str);
1707
+ }
1708
+ index = tagIndex;
1709
+ str = buffer[index] ?? '';
1710
+ }
1711
+ // Comments don’t render, whereas what an `htmlmin:ignore` block
1712
+ // or a processing instruction stands for may
1713
+ if (str.charCodeAt(1) === 33 /* ! */ || str.charCodeAt(1) === 63 /* ? */) {
1714
+ if (str.charCodeAt(1) === 63 || (uidIgnore && str.indexOf(uidIgnore) !== -1)) return true;
1715
+ continue;
1716
+ }
1717
+ const endTagName = matchPlainEndTag(str);
1718
+ const tag = (endTagName === null ? (RE_START_TAG_NAME.exec(str)?.[1] ?? '') : '/' + endTagName).toLowerCase();
1719
+ if (!inlineElementsTransparentAfter.has(tag)) {
1720
+ return collapseWhitespaceSmart(' ', tag, '', emptyAttrs, emptyAttrs, options, inlineSets) !== '';
1721
+ }
1722
+ // Skip what an element that doesn’t render holds
1723
+ if (endTagName !== null) {
1724
+ const startTag = '<' + endTagName.toLowerCase();
1725
+ // Only the start of each entry is compared, as what such an element holds can be large
1726
+ while (index > 0 && (buffer[index - 1] ?? '').slice(0, startTag.length).toLowerCase() !== startTag) {
1727
+ index--;
1728
+ }
1729
+ index--;
1730
+ }
1731
+ }
1732
+ return false;
748
1733
  }
749
1734
 
750
- function scan(input) {
751
- var currentTag, currentType;
752
- new HTMLParser(input, {
753
- start: function(tag, attrs) {
754
- if (attrChains) {
755
- if (!attrChains[tag]) {
756
- attrChains[tag] = new TokenChain();
1735
+ // Per-text-node context for `charsCollapse`/`charsFinalize`, set by the `chars`
1736
+ // handler before either phase runs; shared variables (rather than parameters)
1737
+ // let both phases live at this scope—`charsCollapse` reassigns `textPrevTag`
1738
+ // and `charsFinalize` must see that change. Defining the phases inside the
1739
+ // `chars` handler instead would allocate two closures per text node.
1740
+ /** @type {string} */
1741
+ let textPrevTag = '';
1742
+ /** @type {string} */
1743
+ let textNextTag = '';
1744
+ /** @type {HTMLAttribute[]} */
1745
+ let textPrevAttrs = emptyAttrs;
1746
+ /** @type {HTMLAttribute[]} */
1747
+ let textNextAttrs = emptyAttrs;
1748
+ // Whether the text is in foreign content, and if so, whether it was read as HTML reads it
1749
+ // and so is to be escaped again
1750
+ let textForeign = false;
1751
+ let textForeignDecoded = false;
1752
+
1753
+ // Whitespace collapsing phase (sync)
1754
+ function charsCollapse(/** @type {string} */ text) {
1755
+ // Trim a single trailing newline inside `pre`/`textarea`, often a template engine artifact
1756
+ // (several are likely intentional formatting)
1757
+ if (options.collapseWhitespace && stackNoTrimWhitespace.length) {
1758
+ const topTag = stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1];
1759
+ if (preTextareaDepth > 0) {
1760
+ // Treat CRLF (`\r\n`), CR (`\r`), and LF (`\n`) as single line-ending units
1761
+ if (textNextTag && textNextTag === '/' + topTag && /[^\r\n](?:\r\n|\r|\n)[ \t]*$/.test(text)) {
1762
+ text = text.replace(RE_LINE_BREAK_BLANKS_END, '');
1763
+ }
1764
+ }
1765
+ }
1766
+ if (options.collapseWhitespace) {
1767
+ if (!stackNoTrimWhitespace.length) {
1768
+ // When the prev item is a UID placeholder, compute its effective tag name for whitespace
1769
+ // decisions; this is only used in `collapseWhitespaceSmart`—`textPrevTag` itself is not
1770
+ // modified, to avoid side effects on the `inlineTextSet` branch below
1771
+ let effectivePrevTag = textPrevTag;
1772
+ if (textPrevTag === 'comment') {
1773
+ const prevComment = buffer[buffer.length - 1] ?? '';
1774
+ if (!uidIgnore || prevComment.indexOf(uidIgnore) === -1) {
1775
+ if (!prevComment) {
1776
+ textPrevTag = charsPrevTag;
1777
+ effectivePrevTag = textPrevTag;
1778
+ }
1779
+ if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
1780
+ const charsIndex = buffer.length - 2;
1781
+ buffer[charsIndex] = (buffer[charsIndex] ?? '').replace(/\s+$/, function (trailingSpaces) {
1782
+ text = trailingSpaces + text;
1783
+ return '';
1784
+ });
1785
+ }
1786
+ } else if (uidIgnorePlaceholderPattern && textNextTag !== 'comment') {
1787
+ // UID placeholder followed by a real element—derive the effective `prevTag` from the
1788
+ // placeholder’s last HTML tag so `collapseWhitespaceSmart` can make the right call;
1789
+ // `commentFinalize` handles a `textNextTag` of `comment` (another UID placeholder)
1790
+ const match = prevComment.match(uidIgnorePlaceholderPattern);
1791
+ if (match) {
1792
+ const idx = +(match[1] ?? '');
1793
+ if (idx < ignoredMarkupChunks.length) {
1794
+ const content = ignoredMarkupChunks[idx];
1795
+ const lastTagMatch = content && RE_LAST_HTML_TAG.exec(content);
1796
+ if (lastTagMatch) {
1797
+ const group = lastTagMatch[1] ?? '';
1798
+ const isClose = group.charAt(0) === '/';
1799
+ const tagName = resolveName(isClose ? group.slice(1) : group);
1800
+ effectivePrevTag = isClose ? '/' + tagName : tagName;
1801
+ }
1802
+ }
1803
+ }
757
1804
  }
758
- attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
759
1805
  }
760
- for (var i = 0, len = attrs.length; i < len; i++) {
761
- var attr = attrs[i];
762
- if (classChain && attr.value && options.name(attr.name) === 'class') {
763
- classChain.add(trimWhitespace(attr.value).split(/[ \t\n\f\r]+/).filter(shouldSkipUIDs));
1806
+ if (textPrevTag) {
1807
+ if (textPrevTag === '/nobr' || textPrevTag === 'wbr') {
1808
+ if (/^\s/.test(text)) {
1809
+ let tagIndex = buffer.length - 1;
1810
+ while (tagIndex > 0 && (namesAsWritten ? (buffer[tagIndex] ?? '').toLowerCase() : buffer[tagIndex] ?? '').lastIndexOf('<' + textPrevTag) !== 0) {
1811
+ tagIndex--;
1812
+ }
1813
+ trimTrailingWhitespace(tagIndex - 1, 'br');
1814
+ }
1815
+ } else if (inlineSets.withinEither.has(textPrevTag)) {
1816
+ text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars), false, false, stackNoCollapseWhitespace.length > 0);
764
1817
  }
765
- else if (options.processScripts && attr.name.toLowerCase() === 'type') {
766
- currentTag = tag;
767
- currentType = attr.value;
1818
+ }
1819
+ if (textPrevTag || textNextTag) {
1820
+ let collapsed = collapseWhitespaceSmart(text, effectivePrevTag, textNextTag, textPrevAttrs, textNextAttrs, options, inlineSets, !stackNoCollapseWhitespace.length);
1821
+ if (!transparentDepth && inlineElementsTransparentAfter.has(textPrevTag)) {
1822
+ // Between elements that don’t render, whitespace stays pending when it may
1823
+ // separate text, and one space does
1824
+ if (!collapsed && text && inlineElementsTransparent.has(textNextTag)) {
1825
+ if (transparentGapIndex === -1 && precedesTransparentRun()) {
1826
+ transparentGapPending = true;
1827
+ text = ' ';
1828
+ } else {
1829
+ text = '';
1830
+ }
1831
+ } else {
1832
+ // Text that follows decides, while a tag that follows does so in
1833
+ // `squashTrailingWhitespace`
1834
+ if (RE_LEADING_WHITESPACE.test(collapsed)) {
1835
+ if (transparentGapIndex !== -1) settleTransparentGap(false);
1836
+ // Where nothing before the elements renders a space, the text’s own goes, too
1837
+ if (!options.conservativeCollapse && !precedesTransparentRun(true)) {
1838
+ collapsed = collapseWhitespace(collapsed, options, true, false);
1839
+ }
1840
+ } else if (transparentGapIndex !== -1 && collapsed) {
1841
+ settleTransparentGap(true);
1842
+ }
1843
+ text = collapsed;
1844
+ }
1845
+ } else {
1846
+ text = collapsed;
1847
+ // Whitespace before an element that doesn’t render waits for what follows it, too
1848
+ if (!transparentDepth && inlineElementsTransparent.has(textNextTag) && endsWithWhitespace(text)) {
1849
+ transparentGapPending = true;
1850
+ }
768
1851
  }
1852
+ } else {
1853
+ text = collapseWhitespace(text, options, true, true);
769
1854
  }
770
- },
771
- end: function() {
772
- currentTag = '';
773
- },
774
- chars: function(text) {
775
- if (options.processScripts && specialContentTags(currentTag) &&
776
- options.processScripts.indexOf(currentType) > -1) {
777
- scan(text);
1855
+ if (!text && RE_TRAILING_WHITESPACE.test(currentChars) && textPrevTag && textPrevTag.charAt(0) === '/') {
1856
+ trimTrailingWhitespace(buffer.length - 1, textNextTag);
778
1857
  }
779
1858
  }
780
- });
781
- }
782
-
783
- var log = options.log;
784
- options.log = identity;
785
- options.sortAttributes = false;
786
- options.sortClassName = false;
787
- scan(minify(value, options));
788
- options.log = log;
789
- if (attrChains) {
790
- var attrSorters = Object.create(null);
791
- for (var tag in attrChains) {
792
- attrSorters[tag] = attrChains[tag].createSorter();
1859
+ // Whitespace that may not be trimmed stays verbatim, as in `pre`
1860
+ if (!stackNoCollapseWhitespace.length && !stackNoTrimWhitespace.length && textNextTag !== 'html' && !(textPrevTag && textNextTag)) {
1861
+ text = collapseWhitespace(text, options, false, false, true);
1862
+ }
1863
+ if (options.collapseNoBreakSpaces && !stackNoCollapseWhitespace.length && !holdsRawText()) {
1864
+ text = collapseNoBreakSpaces(text, options);
1865
+ }
793
1866
  }
794
- options.sortAttributes = function(tag, attrs) {
795
- var sorter = attrSorters[tag];
796
- if (sorter) {
797
- var attrMap = Object.create(null);
798
- var names = attrNames(attrs);
799
- names.forEach(function(name, index) {
800
- (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
801
- });
802
- sorter.sort(names).forEach(function(name, index) {
803
- attrs[index] = attrMap[name].shift();
804
- });
1867
+ return text;
1868
+ }
1869
+
1870
+ // Finalization phase (sync): Optional tag handling, entity re-encoding, buffer push
1871
+ function charsFinalize(/** @type {string} */ text) {
1872
+ if (options.removeOptionalTags && text) {
1873
+ // UID-attr tokens are padded with `\t`, which would falsely look like leading whitespace;
1874
+ // resolve single-token text to its actual content for the space/comment checks below
1875
+ let effectiveText = text;
1876
+ if (uidAttrLeadingPattern && uidAttr && text.includes(uidAttr)) {
1877
+ const uidMatch = uidAttrLeadingPattern.exec(text);
1878
+ if (uidMatch) {
1879
+ const idx = +(uidMatch[1] ?? 0);
1880
+ const chunks = idx < ignoredCustomMarkupChunks.length ? ignoredCustomMarkupChunks[idx] : null;
1881
+ if (chunks != null) {
1882
+ effectiveText = chunks[0] ?? '';
1883
+ }
1884
+ }
805
1885
  }
806
- };
807
- }
808
- if (classChain) {
809
- var sorter = classChain.createSorter();
810
- options.sortClassName = function(value) {
811
- return sorter.sort(value.split(/[ \n\f\r]+/)).join(' ');
812
- };
1886
+ // `<html>` may be omitted if first thing inside is not a comment
1887
+ // `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`,
1888
+ // `<script>`, `<style>`, or `<template>`
1889
+ if (optionalStartTag === 'html' || (optionalStartTag === 'body' && !/^\s/.test(effectiveText))) {
1890
+ removeStartTag();
1891
+ }
1892
+ optionalStartTag = '';
1893
+ // `</html>` or `</body>` may be omitted if not followed by comment
1894
+ // `</head>`, `</colgroup>`, or `</caption>` may be omitted if not followed by space or
1895
+ // comment
1896
+ if (optionalEndTagEmitted && (compactElements.has(optionalEndTag) || (looseElements.has(optionalEndTag) && !/^\s/.test(effectiveText)))) {
1897
+ removeEndTag();
1898
+ // The tag is gone from the buffer, so nothing is left to omit later
1899
+ optionalEndTag = '';
1900
+ optionalEndTagEmitted = false;
1901
+ }
1902
+ // Whitespace-only text doesn’t prevent omission of the pending end tag: It’s either
1903
+ // collapsed away, or kept in place while the tag alone is dropped—except for
1904
+ // `</head>`, `</colgroup>`, and `</caption>`, which may not be followed by space.
1905
+ // Only ASCII whitespace counts, as no-break space and friends are content
1906
+ // https://html.spec.whatwg.org/multipage/syntax.html#optional-tags
1907
+ if (!RE_WS_ONLY.test(text) || (keepsWhitespace() && looseElements.has(optionalEndTag))) {
1908
+ optionalEndTag = '';
1909
+ optionalEndTagEmitted = false;
1910
+ }
1911
+ }
1912
+ charsPrevTag = isAllWhitespace(text) ? textPrevTag : 'comment';
1913
+ const rawText = Boolean(text) && holdsRawText();
1914
+ if (text) {
1915
+ textVerbatim = rawText || stackNoTrimWhitespace.length > 0;
1916
+ }
1917
+ if (text && options.insideSVG && options.minifySVG) {
1918
+ // SVGO reads XML, so text is escaped for it, while raw text, which XML cannot read as
1919
+ // HTML does (as in `foreignObject`), passes SVGO as a placeholder
1920
+ if (!rawText) {
1921
+ text = escapeXML(text);
1922
+ } else if (text.indexOf('&') !== -1 || text.indexOf('<') !== -1 || text.indexOf(']]>') !== -1) {
1923
+ uidSVGRawText ||= uniqueId(value);
1924
+ svgRawTexts.push(text);
1925
+ text = '/*!' + uidSVGRawText + (svgRawTexts.length - 1) + '*/';
1926
+ }
1927
+ } else if (text && (textForeign ? textForeignDecoded : options.decodeEntities && !rawText)) {
1928
+ // Escape every `&` that starts a character reference, legacy-named (without `;`) or not;
1929
+ // `&amp` needs no semicolon either
1930
+ // https://mathiasbynens.be/notes/ambiguous-ampersands
1931
+ if (text.indexOf('&') !== -1) {
1932
+ text = text.replace(RE_LEGACY_ENTITIES, '&amp$1');
1933
+ }
1934
+ if (text.indexOf('<') !== -1) {
1935
+ // Escapable raw text ends at its own end tag alone, so only that one needs escaping;
1936
+ // in foreign content the same element holds markup, where every `<` does
1937
+ const escapeLt = (textForeign ? undefined : RE_ESCAPE_LT_RAW_TEXT[currentTag]) ?? RE_ESCAPE_LT;
1938
+ text = text.replace(escapeLt, '&lt;');
1939
+ }
1940
+ if (text.indexOf('\r') !== -1) {
1941
+ text = text.replace(RE_CR, '&#13;');
1942
+ }
1943
+ } else if (text && textForeign && text.lastIndexOf('<![CDATA[') > text.lastIndexOf(']]>')) {
1944
+ // A CDATA section left open at the end of the input closes before the
1945
+ // end tags written after it
1946
+ text += ']]>';
1947
+ }
1948
+ if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
1949
+ text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1950
+ return ignoredCustomMarkupChunks[+index]?.[0] ?? match;
1951
+ });
1952
+ }
1953
+ currentChars += text;
1954
+ if (text) {
1955
+ hasChars = true;
1956
+ markOpenElementFilled();
1957
+ }
1958
+ const followsRemoval = removed && Boolean(text) && !options.collapseWhitespace && !stackNoTrimWhitespace.length;
1959
+ if (transparentGapPending) {
1960
+ transparentGapPending = false;
1961
+ if (text) transparentGapIndex = buffer.length;
1962
+ }
1963
+ bufferPush(text, false);
1964
+ if (followsRemoval) {
1965
+ removeWhitespaceLine(buffer.length - 1);
1966
+ }
813
1967
  }
814
- }
815
1968
 
816
- function minify(value, options, partialMarkup) {
817
- if (options.collapseWhitespace) {
818
- value = collapseWhitespace(value, options, true, true);
819
- }
1969
+ // Comment finalization (sync): Optional tags, `htmlmin:ignore` whitespace, buffer push
1970
+ function commentFinalize(/** @type {string} */ comment) {
1971
+ if (options.removeOptionalTags && comment) {
1972
+ if (uidIgnorePlaceholderPattern) {
1973
+ const match = uidIgnorePlaceholderPattern.exec(comment);
1974
+ if (match) {
1975
+ // UID placeholders represent real HTML content, not true HTML comments;
1976
+ // if there’s a pending optional end tag and the ignored content isn’t itself
1977
+ // a comment (which per the HTML spec prevents omission), resolve it now,
1978
+ // before the UID is pushed to the buffer
1979
+ const idx = +(match[1] ?? 0);
1980
+ const content = idx < ignoredMarkupChunks.length ? ignoredMarkupChunks[idx] : null;
1981
+ if (optionalEndTag && optionalEndTagEmitted && content != null && !/^\s*<!--/.test(content)) {
1982
+ const firstTagMatch = content.match(/^\s*<([a-zA-Z][^\s/>]*)/);
1983
+ const firstTagGroup = firstTagMatch?.[1] ?? '';
1984
+ const firstTag = firstTagGroup ? resolveName(firstTagGroup) : '';
1985
+ if (canRemovePrecedingTag(optionalEndTag, firstTag)) {
1986
+ removeEndTag();
1987
+ }
1988
+ }
1989
+ }
1990
+ }
1991
+ // Comments (real or placeholder) always suppress optional start tag omissions
1992
+ optionalStartTag = '';
1993
+ optionalEndTag = '';
1994
+ optionalEndTagEmitted = false;
1995
+ }
820
1996
 
821
- var buffer = [],
822
- charsPrevTag,
823
- currentChars = '',
824
- hasChars,
825
- currentTag = '',
826
- currentAttrs = [],
827
- stackNoTrimWhitespace = [],
828
- stackNoCollapseWhitespace = [],
829
- optionalStartTag = '',
830
- optionalEndTag = '',
831
- ignoredMarkupChunks = [],
832
- ignoredCustomMarkupChunks = [],
833
- uidIgnore,
834
- uidAttr,
835
- uidPattern;
836
-
837
- // temporarily replace ignored chunks with comments,
838
- // so that we don't have to worry what's there.
839
- // for all we care there might be
840
- // completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
841
- value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function(match, group1) {
842
- if (!uidIgnore) {
843
- uidIgnore = uniqueId(value);
844
- var pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
845
- if (options.ignoreCustomComments) {
846
- options.ignoreCustomComments = options.ignoreCustomComments.slice();
847
- }
848
- else {
849
- options.ignoreCustomComments = [];
850
- }
851
- options.ignoreCustomComments.push(pattern);
852
- }
853
- var token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
854
- ignoredMarkupChunks.push(group1);
855
- return token;
856
- });
1997
+ // Whitespace between an element that doesn’t render and an `htmlmin:ignore` block
1998
+ // that starts with one depends on what lies before both
1999
+ if (options.collapseWhitespace && comment && uidIgnorePlaceholderPattern && !transparentDepth && !stackNoTrimWhitespace.length && buffer.length >= 2) {
2000
+ const prevText = buffer[buffer.length - 1] ?? '';
2001
+ const match = prevText && RE_WS_ONLY.test(prevText) && uidIgnorePlaceholderPattern.exec(comment);
2002
+ const firstTag = match ? RE_FIRST_HTML_TAG.exec(ignoredMarkupChunks[+(match[1] ?? 0)] ?? '')?.[1] : undefined;
2003
+ if (firstTag && inlineElementsTransparent.has(resolveName(firstTag)) && inlineElementsTransparentAfter.has(charsPrevTag)) {
2004
+ // Emptied first, as `precedesTransparentRun` stops at text
2005
+ buffer[buffer.length - 1] = '';
2006
+ if (transparentGapIndex === buffer.length - 1) transparentGapIndex = -1;
2007
+ buffer[buffer.length - 1] = options.conservativeCollapse || precedesTransparentRun(true) ? ' ' : '';
2008
+ }
2009
+ }
2010
+ // What an `htmlmin:ignore` block holds is unknown, so whitespace pending before it stays
2011
+ if (transparentGapIndex !== -1 && !transparentDepth && comment && uidIgnorePlaceholderPattern?.test(comment)) {
2012
+ settleTransparentGap(true);
2013
+ }
857
2014
 
858
- var customFragments = options.ignoreCustomFragments.map(function(re) {
859
- return re.source;
860
- });
861
- if (customFragments.length) {
862
- var reCustomIgnore = new RegExp('\\s*(?:' + customFragments.join('|') + ')+\\s*', 'g');
863
- // temporarily replace custom ignored fragments with unique attributes
864
- value = value.replace(reCustomIgnore, function(match) {
865
- if (!uidAttr) {
866
- uidAttr = uniqueId(value);
867
- uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)(\\s*)', 'g');
868
- if (options.minifyCSS) {
869
- options.minifyCSS = (function(fn) {
870
- return function(text, type) {
871
- text = text.replace(uidPattern, function(match, prefix, index) {
872
- var chunks = ignoredCustomMarkupChunks[+index];
873
- return chunks[1] + uidAttr + index + chunks[2];
874
- });
875
- var ids = [];
876
- new CleanCSS().minify(wrapCSS(text, type)).warnings.forEach(function(warning) {
877
- var match = uidPattern.exec(warning);
878
- if (match) {
879
- var id = uidAttr + match[2];
880
- text = text.replace(id, ignoreCSS(id));
881
- ids.push(id);
2015
+ // Optimize whitespace collapsing between consecutive `htmlmin:ignore` placeholder comments
2016
+ if (options.collapseWhitespace && comment && uidIgnorePlaceholderPattern) {
2017
+ if (uidIgnorePlaceholderPattern.test(comment)) {
2018
+ // Check if previous buffer items are: [ignore-placeholder, whitespace-only text]
2019
+ if (buffer.length >= 2) {
2020
+ const prevText = buffer[buffer.length - 1];
2021
+ const prevComment = buffer[buffer.length - 2];
2022
+
2023
+ if (prevText && RE_WHITESPACE_ONLY.test(prevText) && prevComment && uidIgnorePlaceholderPattern.test(prevComment)) {
2024
+ const currentMatch = comment.match(uidIgnorePlaceholderPattern);
2025
+ const prevMatch = prevComment.match(uidIgnorePlaceholderPattern);
2026
+
2027
+ if (currentMatch && prevMatch) {
2028
+ const currentIndex = +(currentMatch[1] ?? 0);
2029
+ const prevIndex = +(prevMatch[1] ?? 0);
2030
+
2031
+ if (currentIndex < ignoredMarkupChunks.length && prevIndex < ignoredMarkupChunks.length) {
2032
+ const currentContent = ignoredMarkupChunks[currentIndex];
2033
+ const prevContent = ignoredMarkupChunks[prevIndex];
2034
+
2035
+ // Only collapse whitespace if both blocks contain HTML (start with `<`)
2036
+ // Don’t collapse if either contains plain text, as that would change meaning
2037
+ if (currentContent && prevContent && /^\s*</.test(currentContent) && /^\s*</.test(prevContent)) {
2038
+ const currentTagMatch = currentContent.match(RE_FIRST_HTML_TAG);
2039
+ const prevTagMatch = prevContent.match(RE_FIRST_HTML_TAG);
2040
+ // HTML comments are invisible (no block/inline nature), treat as non-inline
2041
+ const prevIsHtmlComment = !prevTagMatch && RE_HTML_COMMENT_START.test(prevContent);
2042
+ const currentIsHtmlComment = !currentTagMatch && RE_HTML_COMMENT_START.test(currentContent);
2043
+ // Closing tags (e.g., `</div>`)—inline-ness determines whether to collapse
2044
+ const prevClosingTagMatch = !prevTagMatch && RE_CLOSING_TAG_START.exec(prevContent);
2045
+ const currentClosingTagMatch = !currentTagMatch && RE_CLOSING_TAG_START.exec(currentContent);
2046
+
2047
+ // Collapse if both sides are element/closing tags or HTML
2048
+ // comments, and neither is inline
2049
+ if ((currentTagMatch || currentIsHtmlComment || currentClosingTagMatch) &&
2050
+ (prevTagMatch || prevIsHtmlComment || prevClosingTagMatch)) {
2051
+ const currentTagName = currentTagMatch ? resolveName(currentTagMatch[1] ?? '')
2052
+ : currentClosingTagMatch ? resolveName(currentClosingTagMatch[1] ?? '') : null;
2053
+ const prevTagName = prevTagMatch ? resolveName(prevTagMatch[1] ?? '')
2054
+ : prevClosingTagMatch ? resolveName(prevClosingTagMatch[1] ?? '') : null;
2055
+
2056
+ // Don’t collapse between inline elements (HTML comments count as non-inline)
2057
+ if (!inlineElements.has(currentTagName ?? '') && !inlineElements.has(prevTagName ?? '')) {
2058
+ let collapsedText = prevText;
2059
+
2060
+ if (!stackNoTrimWhitespace.length && !stackNoCollapseWhitespace.length) {
2061
+ // Not in pre or other no-collapse context
2062
+ if (prevText.includes('\xA0')) {
2063
+ // No-break spaces are content, so every one of them survives in place;
2064
+ // the ASCII whitespace around them collapses as it would anywhere else
2065
+ collapsedText = collapseWhitespace(prevText, options, true, true, true);
2066
+ } else if (options.preserveLineBreaks && /[\n\r]/.test(prevText)) {
2067
+ // Preserve line break as single newline
2068
+ collapsedText = '\n';
2069
+ } else if (options.conservativeCollapse) {
2070
+ // Conservative mode: Keep single space
2071
+ collapsedText = ' ';
2072
+ } else {
2073
+ // Aggressive mode: Remove all whitespace
2074
+ collapsedText = '';
2075
+ }
2076
+ }
2077
+
2078
+ buffer[buffer.length - 1] = collapsedText;
2079
+ }
2080
+ }
882
2081
  }
883
- });
884
- text = fn(text, type);
885
- ids.forEach(function(id) {
886
- text = text.replace(ignoreCSS(id), id);
887
- });
888
- return text;
889
- };
890
- })(options.minifyCSS);
891
- }
892
- if (options.minifyJS) {
893
- options.minifyJS = (function(fn) {
894
- return function(text, type) {
895
- return fn(text.replace(uidPattern, function(match, prefix, index) {
896
- var chunks = ignoredCustomMarkupChunks[+index];
897
- return chunks[1] + uidAttr + index + chunks[2];
898
- }), type);
899
- };
900
- })(options.minifyJS);
2082
+ }
2083
+ }
2084
+ }
901
2085
  }
902
2086
  }
903
- var token = uidAttr + ignoredCustomMarkupChunks.length;
904
- ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
905
- return '\t' + token + '\t';
906
- });
907
- }
2087
+ }
908
2088
 
909
- if (options.sortAttributes && typeof options.sortAttributes !== 'function' ||
910
- options.sortClassName && typeof options.sortClassName !== 'function') {
911
- createSortFns(value, options, uidIgnore, uidAttr);
2089
+ bufferPush(comment, false);
2090
+ }
2091
+
2092
+ // Inline scripts minified as the parse reaches them run one at a time; dispatching them all up
2093
+ // front lets the minifier overlap the work. Bodies are matched by exact content, so one the parse
2094
+ // asks for in another form misses and takes the original path.
2095
+ /** @type {Map<string, Promise<string>> | null} */
2096
+ let jsPrewarmed = null;
2097
+ if (options.parallelJS && options.minifyJS !== identity && !options.processScripts?.length) {
2098
+ const bodies = extractScriptBodies(value);
2099
+ if (bodies.length > 1) {
2100
+ jsPrewarmed = new Map();
2101
+ // `collapseWhitespace` reaches script bodies through the whole-document pass above,
2102
+ // so the parse sees them trimmed; other whitespace modes leave them as they are
2103
+ const trims = Boolean(options.collapseWhitespace) &&
2104
+ !options.conservativeCollapse && !options.preserveLineBreaks;
2105
+ for (const { code, isModule } of bodies) {
2106
+ const body = trims ? code.trim() : code;
2107
+ const key = (isModule ? 'm|' : '|') + body;
2108
+ if (jsPrewarmed.has(key)) {
2109
+ continue;
2110
+ }
2111
+ const pending = Promise.resolve(options.minifyJS(body, false, isModule));
2112
+ // The awaiting consumer reports the failure; a body the parse never asks for
2113
+ // would not have been minified at all, so its failure is not ours to raise
2114
+ pending.catch(() => {});
2115
+ jsPrewarmed.set(key, pending);
2116
+ }
2117
+ }
912
2118
  }
913
2119
 
914
- function _canCollapseWhitespace(tag, attrs) {
915
- return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
916
- }
2120
+ // Writes out a start tag for the `start` handler—right away, or once attribute values
2121
+ // that need asynchronous work (lazy module imports, minified URLs) resolve
2122
+ /**
2123
+ * @param {Array<{name: string, value: string | undefined, attr: HTMLAttribute} | undefined>} normalizedAttrs
2124
+ * @param {string} tag
2125
+ * @param {boolean} unary
2126
+ * @param {boolean} autoGenerated
2127
+ * @param {boolean | undefined} fragmentJoinsTag
2128
+ * @param {string | boolean | undefined} hasUnarySlash
2129
+ * @param {boolean | undefined} optional
2130
+ * @param {ProcessedOptions | null} restoreOptions - Context a unary tag pushed, or null
2131
+ */
2132
+ function emitStartTag(normalizedAttrs, tag, unary, autoGenerated, fragmentJoinsTag, hasUnarySlash, optional, restoreOptions) {
2133
+ // Find the last kept attribute, then emit in order, with no intermediate array
2134
+ let lastKeptIndex = -1;
2135
+ for (let i = normalizedAttrs.length - 1; i >= 0; i--) {
2136
+ if (normalizedAttrs[i]) {
2137
+ lastKeptIndex = i;
2138
+ break;
2139
+ }
2140
+ }
2141
+ // Without `maxLineLength`, a start tag is one buffer entry rather than one per attribute,
2142
+ // which keeps the buffer and the final join small (lines only break between entries);
2143
+ // the loop is written out per case, as a check per attribute measurably slows it down
2144
+ let tagText = '';
2145
+ if (lastKeptIndex !== -1) {
2146
+ if (!bufferTags) {
2147
+ tagText = fragmentJoinsTag ? '' : ' ';
2148
+ for (let i = 0; i <= lastKeptIndex; i++) {
2149
+ const normalizedAttr = normalizedAttrs[i];
2150
+ if (normalizedAttr) {
2151
+ if (uidAttr && normalizedAttr.name.includes(uidAttr)) {
2152
+ markTagFragments(normalizedAttr.name);
2153
+ }
2154
+ tagText += buildAttr(normalizedAttr, hasUnarySlash, options, i === lastKeptIndex, uidAttr);
2155
+ }
2156
+ }
2157
+ } else {
2158
+ if (!fragmentJoinsTag) {
2159
+ bufferPush(' ', true);
2160
+ }
2161
+ for (let i = 0; i <= lastKeptIndex; i++) {
2162
+ const normalizedAttr = normalizedAttrs[i];
2163
+ if (normalizedAttr) {
2164
+ if (uidAttr && normalizedAttr.name.includes(uidAttr)) {
2165
+ markTagFragments(normalizedAttr.name);
2166
+ }
2167
+ const builtAttr = buildAttr(normalizedAttr, hasUnarySlash, options, i === lastKeptIndex, uidAttr);
2168
+ // One entry with the tag name, so that no line break can come between them
2169
+ if (i === 0 && fragmentJoinsTag) {
2170
+ buffer[buffer.length - 1] += builtAttr;
2171
+ } else {
2172
+ bufferPush(builtAttr, true);
2173
+ }
2174
+ }
2175
+ }
2176
+ }
2177
+ } else if (optional && optionalStartTags.has(tag)) {
2178
+ // Start tag must never be omitted if it has any attributes
2179
+ optionalStartTag = tag;
2180
+ }
917
2181
 
918
- function _canTrimWhitespace(tag, attrs) {
919
- return options.canTrimWhitespace(tag, attrs, canTrimWhitespace);
920
- }
2182
+ buffer[buffer.length - 1] += tagText + (hasUnarySlash ? '/' : '') + '>';
921
2183
 
922
- function removeStartTag() {
923
- var index = buffer.length - 1;
924
- while (index > 0 && !/^<[^/!]/.test(buffer[index])) {
925
- index--;
2184
+ impliedStartTag = autoGenerated && !options.includeAutoGeneratedTags ? tag : '';
2185
+ if (impliedStartTag) {
2186
+ removeStartTag();
2187
+ optionalStartTag = '';
2188
+ currentTag = '';
926
2189
  }
927
- buffer.length = Math.max(0, index);
928
- }
929
2190
 
930
- function removeEndTag() {
931
- var index = buffer.length - 1;
932
- while (index > 0 && !/^<\//.test(buffer[index])) {
933
- index--;
2191
+ // `end` restores what a start tag pushed, and a unary tag never reaches it
2192
+ if (restoreOptions) {
2193
+ options = restoreOptions;
934
2194
  }
935
- buffer.length = Math.max(0, index);
936
- }
937
2195
 
938
- // look for trailing whitespaces, bypass any inline tags
939
- function trimTrailingWhitespace(index, nextTag) {
940
- for (var endTag = null; index >= 0 && _canTrimWhitespace(endTag); index--) {
941
- var str = buffer[index];
942
- var match = str.match(/^<\/([\w:-]+)>$/);
943
- if (match) {
944
- endTag = match[1];
945
- }
946
- else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, null, nextTag, options))) {
947
- break;
948
- }
2196
+ // What a unary tag holds ends with the tag, so no text can belong to it
2197
+ if (unary) {
2198
+ currentTag = '';
949
2199
  }
950
2200
  }
951
2201
 
952
- // look for trailing whitespaces from previously processed text
953
- // which may not be trimmed due to a following comment or an empty
954
- // element which has now been removed
955
- function squashTrailingWhitespace(nextTag) {
956
- var charsIndex = buffer.length - 1;
957
- if (buffer.length > 1) {
958
- var item = buffer[buffer.length - 1];
959
- if (/^(?:<!|$)/.test(item) && item.indexOf(uidIgnore) === -1) {
960
- charsIndex--;
2202
+ // SVG subtree capture: When SVGO is active, record buffer positions for post-processing
2203
+ /** @type {Array<{start: number, end: number}>} */
2204
+ const svgBlocks = [];
2205
+ let svgBufferStartIndex = -1;
2206
+ let svgDepth = 0;
2207
+ // HTML raw text in an SVG block, which XML cannot read as HTML does,
2208
+ // passes SVGO as a placeholder—a comment, as SVGO minifies `style` elements
2209
+ // wherever they are
2210
+ /** @type {string[]} */
2211
+ const svgRawTexts = [];
2212
+ let uidSVGRawText = '';
2213
+
2214
+ // One-time probe: If the input contains no SVG/MathML elements and the call
2215
+ // isn’t already inside foreign content (recursive calls inherit options),
2216
+ // the per-tag foreign-content handling in `start`/`end` can be skipped
2217
+ const hasForeignContext = Boolean(options.insideForeignContent) || RE_FOREIGN_ELEMENT_PROBE.test(value);
2218
+
2219
+ const parser = new HTMLParser(value, {
2220
+ partialMarkup: partialMarkup ?? options.partialMarkup,
2221
+ // The short doctype makes the output no-quirks, so the input is read as such
2222
+ noQuirksMode: options.useShortDoctype,
2223
+ continueOnParseError: options.continueOnParseError,
2224
+ // Recovered errors would have aborted without the option, so they are worth knowing about
2225
+ parseError: options.continueOnParseError && options.log !== identity
2226
+ ? (/** @type {string} */ message) => options.log('Warning: ' + message)
2227
+ : undefined,
2228
+ customAttrAssign: options.customAttrAssign,
2229
+ customAttrSurround: options.customAttrSurround,
2230
+ // Compute `nextTag` only when whitespace collapse features require it
2231
+ wantsNextTag: !!(options.collapseWhitespace || options.collapseInlineTagWhitespace || options.conservativeCollapse),
2232
+ // `keepClosingSlash` calls the slash meaningful, which decides how the input is read as well
2233
+ selfClosingSlash: options.keepClosingSlash,
2234
+
2235
+ start: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ unary, /** @type {string} */ unarySlash, /** @type {boolean} */ autoGenerated) {
2236
+ // `lowerTag` stays '' when no foreign content is around—the SVG/MathML
2237
+ // checks below can then never match, and no per-tag lowercasing is needed
2238
+ let lowerTag = '';
2239
+ let useNameParentForTag = false;
2240
+ // The context the start tag sits in, which the branches below leave behind
2241
+ const optionsParent = options;
2242
+ let pushedContext = false;
2243
+ if (hasForeignContext) {
2244
+ lowerTag = tag.toLowerCase();
2245
+ if (lowerTag === 'svg' || lowerTag === 'math') {
2246
+ if (lowerTag === 'math' && options.documentState) {
2247
+ options.documentState.hasMathML = true;
2248
+ }
2249
+ // Preserve the surrounding HTML context’s slash handling so
2250
+ // `foreignObject`/`annotation-xml` can restore it
2251
+ const keepClosingSlashHTML = Boolean(options.keepClosingSlash);
2252
+ options = Object.create(options);
2253
+ // SVGO reads an SVG block whole, nested MathML included, so what the
2254
+ // block is stays true inside it
2255
+ options.insideSVG = lowerTag === 'svg' || Boolean(options.insideSVG);
2256
+ // XML reads names as written, so wherever SVGO reads, they are written the way HTML reads
2257
+ // them (`viewbox` as `viewBox`), and attributes differing only in case are one attribute
2258
+ const readBySVGO = options.insideSVG && Boolean(options.minifySVG);
2259
+ options.caseSensitive = !readBySVGO;
2260
+ options.keepClosingSlash = true;
2261
+ options.name = readBySVGO ? nameForeign : identity;
2262
+ options.keepClosingSlashHTML = keepClosingSlashHTML;
2263
+ options.insideForeignContent = true;
2264
+ options.namespaceForeign = lowerTag;
2265
+ // Disable HTML-specific options that produce invalid XML:
2266
+ // SVG with `minifySVG` enabled is passed to SVGO, which requires valid XML input;
2267
+ // MathML is never processed by SVGO, so these restrictions never apply to it
2268
+ if (lowerTag === 'svg' && options.minifySVG) {
2269
+ options.removeAttributeQuotes = false;
2270
+ // Text and attribute values are decoded as HTML reads them, for transforms to read
2271
+ // them so, too, and then escaped for XML (see `escapeXML`) rather than for HTML
2272
+ options.decodeEntities = true;
2273
+ // Omitting a start and end tag both would keep the block well-formed, which this misses
2274
+ options.removeOptionalTags = false;
2275
+ options.collapseBooleanAttributes = false;
2276
+ options.collapseEmptyAttributes = false;
2277
+ options.includeAutoGeneratedTags = true;
2278
+ }
2279
+ options.removeTagWhitespace = false;
2280
+ pushedContext = true;
2281
+ }
2282
+ // `foreignObject` in SVG and `annotation-xml` in MathML contain HTML content
2283
+ // (the element itself is in SVG/MathML namespace, only its children are HTML);
2284
+ // a repeated attribute is dropped after the first, so the first `encoding` decides
2285
+ if (options.insideForeignContent && (lowerTag === 'foreignobject' ||
2286
+ (lowerTag === 'annotation-xml' && RE_HTML_ENCODING.test(
2287
+ attrs.find((/** @type {HTMLAttribute} */ a) => a.name.toLowerCase() === 'encoding')?.value ?? '')))) {
2288
+ const nameParent = options.name;
2289
+ options = Object.create(options);
2290
+ options.caseSensitive = false;
2291
+ // What the parser read the slash as, so that the two agree here as they do in HTML
2292
+ options.keepClosingSlash = options.keepClosingSlashHTML ?? false;
2293
+ options.nameParent = nameParent; // Preserve for the element tag itself
2294
+ options.name = lowercase;
2295
+ options.insideForeignContent = false;
2296
+ // Note: `removeAttributeQuotes` and `removeTagWhitespace` stay disabled,
2297
+ // and values stay escaped for XML (inherited from SVG context), because
2298
+ // the entire SVG block must be valid XML for SVGO processing
2299
+ useNameParentForTag = true;
2300
+ pushedContext = true;
2301
+ }
2302
+ if (options.selfCloseForeignElements && options.insideForeignContent && !unary) {
2303
+ enterForeignScope(lowerTag);
2304
+ }
961
2305
  }
962
- }
963
- trimTrailingWhitespace(charsIndex, nextTag);
964
- }
965
-
966
- new HTMLParser(value, {
967
- partialMarkup: partialMarkup,
968
- html5: options.html5,
969
-
970
- start: function(tag, attrs, unary, unarySlash, autoGenerated) {
971
- if (tag.toLowerCase() === 'svg') {
972
- options = Object.create(options);
973
- options.caseSensitive = true;
974
- options.keepClosingSlash = true;
975
- options.name = identity;
2306
+ // `nameParent` is set whenever `useNameParentForTag` is; the extra check narrows the type
2307
+ const nameTag = useNameParentForTag && options.nameParent ? options.nameParent : options.name;
2308
+ const tagSource = tag;
2309
+ tag = nameTag(tag);
2310
+ // HTML names are written as in the source under `caseSensitive`, foreign ones as named anyway
2311
+ const tagOut = namesAsWritten && nameTag === lowercase ? tagSource : tag;
2312
+ if (tag === 'meta' && options.removeRedundantAttributes) {
2313
+ deduplicateAttributes(attrs, Boolean(options.caseSensitive));
2314
+ // As if not written
2315
+ if (isMetaWithoutEffect(attrs, markers)) {
2316
+ removed = true;
2317
+ return undefined;
2318
+ }
2319
+ replaceContentTypePragma(attrs, markers);
976
2320
  }
977
- tag = options.name(tag);
978
2321
  currentTag = tag;
979
2322
  charsPrevTag = tag;
980
- if (!inlineTextTags(tag)) {
2323
+ if (!inlineTextSet.has(tag)) {
981
2324
  currentChars = '';
982
2325
  }
983
2326
  hasChars = false;
984
2327
  currentAttrs = attrs;
985
2328
 
986
- var optional = options.removeOptionalTags;
2329
+ let optional = options.removeOptionalTags;
987
2330
  if (optional) {
988
- var htmlTag = htmlTags(tag);
989
- // <html> may be omitted if first thing inside is not comment
990
- // <head> may be omitted if first thing inside is an element
991
- // <body> may be omitted if first thing inside is not space, comment, <meta>, <link>, <script>, <style> or <template>
992
- // <colgroup> may be omitted if first thing inside is <col>
993
- // <tbody> may be omitted if first thing inside is <tr>
2331
+ const htmlTag = htmlElements.has(tag);
2332
+ // `<html>` may be omitted if first thing inside is not a comment
2333
+ // `<head>` may be omitted if first thing inside is an element
2334
+ // `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`,
2335
+ // `<script>`, `<style>`, or `<template>`
2336
+ // `<colgroup>` may be omitted if first thing inside is `<col>`
2337
+ // `<tbody>` may be omitted if first thing inside is `<tr>`
994
2338
  if (htmlTag && canRemoveParentTag(optionalStartTag, tag)) {
995
2339
  removeStartTag();
996
2340
  }
997
2341
  optionalStartTag = '';
998
- // end-tag-followed-by-start-tag omission rules
999
- if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag)) {
1000
- removeEndTag();
1001
- // <colgroup> cannot be omitted if preceding </colgroup> is omitted
1002
- // <tbody> cannot be omitted if preceding </tbody>, </thead> or </tfoot> is omitted
2342
+ // End-tag-followed-by-start-tag omission rules
2343
+ // Outside no-quirks mode, `table` doesn’t (or may not) close a `p`
2344
+ if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag) && (tableClosesP || optionalEndTag !== 'p' || tag !== 'table')) {
2345
+ // Only a start tag that gets written can stand in for the end tag
2346
+ if (optionalEndTagEmitted && (!autoGenerated || options.includeAutoGeneratedTags)) {
2347
+ removeEndTag();
2348
+ }
2349
+ // `<colgroup>` cannot be omitted if preceding `</colgroup>` is omitted
2350
+ // `<tbody>` cannot be omitted if preceding `</tbody>`, `</thead>`, or `</tfoot>` is
2351
+ // omitted
1003
2352
  optional = !isStartTagMandatory(optionalEndTag, tag);
1004
2353
  }
1005
2354
  optionalEndTag = '';
2355
+ optionalEndTagEmitted = false;
1006
2356
  }
1007
2357
 
1008
- // set whitespace flags for nested tags (eg. <code> within a <pre>)
1009
- if (options.collapseWhitespace) {
1010
- if (!stackNoTrimWhitespace.length) {
1011
- squashTrailingWhitespace(tag);
2358
+ // Set whitespace flags for nested tags (e.g., `<code>` within a `<pre>`); verbatim
2359
+ // whitespace is tracked without `collapseWhitespace` too, as `removeWhitespaceLine` spares it
2360
+ if (options.collapseWhitespace && !stackNoTrimWhitespace.length) {
2361
+ squashTrailingWhitespace(tag);
2362
+ }
2363
+ if (options.collapseWhitespace && !unary && inlineElementsTransparent.has(tag)) {
2364
+ transparentDepth++;
2365
+ }
2366
+ if (!unary) {
2367
+ if (!canTrimWhitespace(tag, tagOut, attrs) || stackNoTrimWhitespace.length) {
2368
+ stackNoTrimWhitespace.push(tag);
2369
+ if (tag === 'pre' || tag === 'textarea') preTextareaDepth++;
1012
2370
  }
1013
- if (!unary) {
1014
- if (!_canTrimWhitespace(tag, attrs) || stackNoTrimWhitespace.length) {
1015
- stackNoTrimWhitespace.push(tag);
1016
- }
1017
- if (!_canCollapseWhitespace(tag, attrs) || stackNoCollapseWhitespace.length) {
1018
- stackNoCollapseWhitespace.push(tag);
1019
- }
2371
+ if (options.collapseWhitespace && (!canCollapseWhitespace(tag, tagOut, attrs) || stackNoCollapseWhitespace.length)) {
2372
+ stackNoCollapseWhitespace.push(tag);
1020
2373
  }
1021
2374
  }
1022
2375
 
1023
- var openTag = '<' + tag;
1024
- var hasUnarySlash = unarySlash && options.keepClosingSlash;
2376
+ // Track SVG subtree for SVGO block processing
2377
+ if (lowerTag === 'svg' && options.minifySVG && !unary) {
2378
+ if (svgDepth === 0) {
2379
+ svgBufferStartIndex = buffer.length; // Record position before <svg> is pushed
2380
+ }
2381
+ svgDepth++;
2382
+ }
1025
2383
 
1026
- buffer.push(openTag);
2384
+ const openTag = '<' + tagOut;
2385
+ // SVGO reads the whole SVG block as XML, where an element closes itself or not, so
2386
+ // every unary element in there is written with a slash
2387
+ const needsXMLSlash = unary && Boolean(options.insideSVG) && Boolean(options.minifySVG);
2388
+ const hasUnarySlash = needsXMLSlash || (unarySlash && (useNameParentForTag ? optionsParent : options).keepClosingSlash);
1027
2389
 
1028
- if (options.sortAttributes) {
1029
- options.sortAttributes(tag, attrs);
1030
- }
2390
+ bufferPush(openTag, true);
1031
2391
 
1032
- var parts = [];
1033
- for (var i = attrs.length, isLast = true; --i >= 0;) {
1034
- var normalized = normalizeAttr(attrs[i], attrs, tag, options);
1035
- if (normalized) {
1036
- parts.unshift(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
1037
- isLast = false;
2392
+ if (options.removeEmptyElements) {
2393
+ if (unary) {
2394
+ markOpenElementFilled();
2395
+ } else {
2396
+ // A start tag not written can’t be removed with its element
2397
+ openElements.push({ tag, index: buffer.length - 1, empty: !autoGenerated || Boolean(options.includeAutoGeneratedTags) });
1038
2398
  }
1039
2399
  }
1040
- if (parts.length > 0) {
1041
- buffer.push(' ');
1042
- buffer.push.apply(buffer, parts);
2400
+
2401
+ const fragmentJoinsTag = uidAttrExactPattern ? joinFragmentAttrs(attrs) : false;
2402
+ // Whatever runs into the tag name has to stay first
2403
+ const tagFragment = fragmentJoinsTag ? attrs.shift() : undefined;
2404
+
2405
+ // Duplicate attributes are invalid; per spec, the first one wins
2406
+ // https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state
2407
+ deduplicateAttributes(attrs, Boolean(options.caseSensitive));
2408
+
2409
+ if (options.sortAttributes) {
2410
+ // By the time tags are processed, `createSortFns` has replaced any
2411
+ // truthy non-function value
2412
+ /** @type {(tag: string, attrs: HTMLAttribute[]) => void} */ (options.sortAttributes)(tagOut, attrs);
1043
2413
  }
1044
- // start tag must never be omitted if it has any attributes
1045
- else if (optional && optionalStartTags(tag)) {
1046
- optionalStartTag = tag;
2414
+ if (tagFragment) {
2415
+ attrs.unshift(tagFragment);
1047
2416
  }
1048
2417
 
1049
- buffer.push(buffer.pop() + (hasUnarySlash ? '/' : '') + '>');
1050
-
1051
- if (autoGenerated && !options.includeAutoGeneratedTags) {
1052
- removeStartTag();
1053
- optionalStartTag = '';
2418
+ const attrResults = new Array(attrs.length);
2419
+ let anyThenable = false;
2420
+ for (let i = 0; i < attrs.length; i++) {
2421
+ const attr = /** @type {HTMLAttribute} */ (attrs[i]);
2422
+ const result = opaqueAttrs.has(attr)
2423
+ ? { name: attr.name, value: undefined, attr }
2424
+ : normalizeAttr(attr, attrs, tag, tagOut, options, minifyHTML, markers);
2425
+ if (!anyThenable && isThenable(result)) {
2426
+ anyThenable = true;
2427
+ }
2428
+ attrResults[i] = result;
2429
+ }
2430
+ // Only an attribute value that needs asynchronous work defers the write-out;
2431
+ // with none of those, the tag is written before the handler returns
2432
+ const restoreOptions = unary && pushedContext ? optionsParent : null;
2433
+ if (anyThenable) {
2434
+ return Promise.all(attrResults).then(resolved => {
2435
+ emitStartTag(resolved, tag, unary, autoGenerated, fragmentJoinsTag, hasUnarySlash, optional, restoreOptions);
2436
+ });
1054
2437
  }
2438
+ emitStartTag(/** @type {Array<{name: string, value: string | undefined, attr: HTMLAttribute} | undefined>} */ (attrResults), tag, unary, autoGenerated, fragmentJoinsTag, hasUnarySlash, optional, restoreOptions);
2439
+ return undefined;
1055
2440
  },
1056
- end: function(tag, attrs, autoGenerated) {
1057
- if (tag.toLowerCase() === 'svg') {
1058
- options = Object.getPrototypeOf(options);
2441
+ end: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ autoGenerated, /** @type {string | undefined} */ rest, /** @type {boolean | undefined} */ holdsTable) {
2442
+ // As in `start`: `lowerTag` stays '' when no foreign content is around
2443
+ let lowerTag = '';
2444
+ // Named as the start tag was: an `svg` or `math` element in its own context,
2445
+ // a `foreignObject` or `annotation-xml` element in its parent’s
2446
+ let name = options.name;
2447
+ // Whether the element is SVG or MathML, which a slash can close, and SVGO, if it
2448
+ // reads the element, writes so anyway
2449
+ let selfCloses = Boolean(options.selfCloseForeignElements) && hasForeignContext &&
2450
+ !(options.insideSVG && options.minifySVG);
2451
+ if (hasForeignContext) {
2452
+ lowerTag = tag.toLowerCase();
2453
+ // An integration point is named in its parent’s context, which it opened one inside of
2454
+ selfCloses &&= Boolean(options.insideForeignContent) || ((lowerTag === 'foreignobject' || lowerTag === 'annotation-xml') &&
2455
+ Boolean(Object.getPrototypeOf(options).insideForeignContent));
2456
+ if (foreignScopes.length && !leaveForeignScope(lowerTag)) {
2457
+ selfCloses = false;
2458
+ }
2459
+ // Restore parent context when exiting SVG/MathML or HTML-in-foreign-content elements
2460
+ if (lowerTag === 'svg' || lowerTag === 'math') {
2461
+ options = Object.getPrototypeOf(options);
2462
+ } else if ((lowerTag === 'foreignobject' || lowerTag === 'annotation-xml') &&
2463
+ !options.insideForeignContent && Object.getPrototypeOf(options).insideForeignContent) {
2464
+ options = Object.getPrototypeOf(options);
2465
+ name = options.name;
2466
+ }
2467
+ }
2468
+ const tagSource = tag;
2469
+ tag = name(tag);
2470
+ // As in `start`
2471
+ const tagOut = namesAsWritten && name === lowercase ? tagSource : tag;
2472
+ let endTagText = '</' + tagOut + '>';
2473
+ // Custom fragments after the name are kept, next to it if they were in the source
2474
+ if (rest && uidAttr && rest.includes(uidAttr)) {
2475
+ markTagFragments(rest);
2476
+ const uidMatch = /** @type {RegExp} */ (uidAttrLeadingPattern).exec(rest);
2477
+ const joinsTag = uidMatch !== null && rest.startsWith('\t' + uidAttr) &&
2478
+ ignoredCustomMarkupChunks[+(uidMatch[1] ?? 0)]?.[1] === '';
2479
+ // Anything else an end tag holds is dropped, as without fragments
2480
+ const marker = uidAttr;
2481
+ const fragments = Array.from(rest.matchAll(/** @type {RegExp} */ (uidPattern)), uid => marker + (uid[2] ?? '') + marker);
2482
+ endTagText = '</' + tagOut + (joinsTag ? '' : ' ') + fragments.join(' ') + '>';
2483
+ // Which a slash would drop
2484
+ selfCloses = false;
1059
2485
  }
1060
- tag = options.name(tag);
1061
2486
 
1062
- // check if current tag is in a whitespace stack
1063
- if (options.collapseWhitespace) {
1064
- if (stackNoTrimWhitespace.length) {
1065
- if (tag === stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1]) {
1066
- stackNoTrimWhitespace.pop();
1067
- }
2487
+ const insideNoTrim = stackNoTrimWhitespace.length > 0;
2488
+ let closesNoTrim = false;
2489
+ if (stackNoTrimWhitespace.length) {
2490
+ if (tag === stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1]) {
2491
+ if (tag === 'pre' || tag === 'textarea') preTextareaDepth--;
2492
+ stackNoTrimWhitespace.pop();
2493
+ closesNoTrim = !stackNoTrimWhitespace.length;
1068
2494
  }
1069
- else {
1070
- squashTrailingWhitespace('/' + tag);
1071
- }
1072
- if (stackNoCollapseWhitespace.length &&
2495
+ } else if (options.collapseWhitespace) {
2496
+ squashTrailingWhitespace('/' + tag);
2497
+ }
2498
+ if (transparentDepth && inlineElementsTransparent.has(tag)) {
2499
+ transparentDepth--;
2500
+ }
2501
+ let closesNoCollapse = false;
2502
+ if (stackNoCollapseWhitespace.length &&
1073
2503
  tag === stackNoCollapseWhitespace[stackNoCollapseWhitespace.length - 1]) {
1074
- stackNoCollapseWhitespace.pop();
1075
- }
2504
+ stackNoCollapseWhitespace.pop();
2505
+ closesNoCollapse = !stackNoCollapseWhitespace.length;
1076
2506
  }
1077
2507
 
1078
- var isElementEmpty = false;
2508
+ const endsImpliedStart = !autoGenerated && impliedStartTag === tag;
2509
+ // A `p` that holds a `table` while the mode is unknown needs its end tag as written: One
2510
+ // after the `table` that the source lacks would be stray in no-quirks mode, and one that it
2511
+ // has, closing the `p` in quirks mode, would be missed there
2512
+ const skipsEndTag = autoGenerated && (!options.includeAutoGeneratedTags || Boolean(holdsTable));
2513
+ impliedStartTag = '';
2514
+
2515
+ let isElementEmpty = false;
1079
2516
  if (tag === currentTag) {
1080
2517
  currentTag = '';
1081
2518
  isElementEmpty = !hasChars;
1082
2519
  }
2520
+ selfCloses &&= isElementEmpty;
2521
+ // A stray end tag has no open element of its own
2522
+ const openElement = options.removeEmptyElements && openElements[openElements.length - 1]?.tag === tag ? openElements.pop() : undefined;
2523
+ // Emptied by removing what it held, as long as its start tag is still the last one written
2524
+ const isElementEmptied = !isElementEmpty && Boolean(openElement?.empty) && lastStartTagIndex() === openElement?.index;
1083
2525
 
1084
2526
  if (options.removeOptionalTags) {
1085
- // <html>, <head> or <body> may be omitted if the element is empty
1086
- if (isElementEmpty && topLevelTags(optionalStartTag)) {
2527
+ // `<html>`, `<head>`, or `<body>` may be omitted if the element is empty
2528
+ if (isElementEmpty && topLevelElements.has(optionalStartTag)) {
1087
2529
  removeStartTag();
1088
2530
  }
1089
2531
  optionalStartTag = '';
1090
- // </html> or </body> may be omitted if not followed by comment
1091
- // </head> may be omitted if not followed by space or comment
1092
- // </p> may be omitted if no more content in non-</a> parent
1093
- // except for </dt> or </thead>, end tags may be omitted if no more content in parent element
1094
- if (htmlTags(tag) && optionalEndTag && !trailingTags(optionalEndTag) && (optionalEndTag !== 'p' || !pInlineTags(tag))) {
1095
- removeEndTag();
2532
+ // `</html>` or `</body>` may be omitted if not followed by comment
2533
+ // `</head>` may be omitted if not followed by space or comment
2534
+ // `</p>` may be omitted if no more content in parent, as long as the parent’s end tag
2535
+ // closes the `p` in browsers (`pClosingParents`)
2536
+ // Except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent
2537
+ // https://html.spec.whatwg.org/multipage/syntax.html#optional-tags
2538
+ if (tag && optionalEndTag && optionalEndTagEmitted && !trailingElements.has(optionalEndTag) && (optionalEndTag !== 'p' || pClosingParents.has(tag))) {
2539
+ removeEndTag(insideNoTrim);
1096
2540
  }
1097
- optionalEndTag = optionalEndTags(tag) ? tag : '';
2541
+ optionalEndTag = optionalEndTags.has(tag) && !endsImpliedStart && !holdsTable ? tag : '';
2542
+ optionalEndTagEmitted = true;
1098
2543
  }
1099
2544
 
1100
- if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
1101
- // remove last "element" from buffer
1102
- removeStartTag();
1103
- optionalStartTag = '';
1104
- optionalEndTag = '';
1105
- }
1106
- else {
1107
- if (autoGenerated && !options.includeAutoGeneratedTags) {
2545
+ let isElementRemoved = false;
2546
+ // A formatting element closed by a block is reopened after it by browsers, so it has to stay
2547
+ if (options.removeEmptyElements && (isElementEmpty || isElementEmptied) && !options.insideForeignContent && canRemoveElement(tag, attrs) &&
2548
+ !(autoGenerated && formattingElements.has(tag))) {
2549
+ let preserve = false;
2550
+ if (removeEmptyElementsExcept.length) {
2551
+ const normalizedAttrs = attrs.map((/** @type {HTMLAttribute} */ attr) => ({ ...attr, name: resolveName(attr.name) }));
2552
+ preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
2553
+ }
2554
+
2555
+ if (!preserve) {
2556
+ removeStartTag();
2557
+ isElementRemoved = true;
2558
+ optionalStartTag = '';
1108
2559
  optionalEndTag = '';
2560
+ optionalEndTagEmitted = false;
2561
+ } else {
2562
+ // Preserve the element—add closing tag
2563
+ if (skipsEndTag) {
2564
+ optionalEndTagEmitted = false;
2565
+ } else {
2566
+ bufferPush(endTagText, true);
2567
+ }
2568
+ charsPrevTag = '/' + tag;
2569
+ if (!inlineElements.has(tag)) {
2570
+ currentChars = '';
2571
+ } else if (isElementEmpty) {
2572
+ currentChars += '|';
2573
+ }
1109
2574
  }
1110
- else {
1111
- buffer.push('</' + tag + '>');
2575
+ } else {
2576
+ if (skipsEndTag) {
2577
+ optionalEndTagEmitted = false;
2578
+ } else if (!selfCloses || !closeLastStartTag()) {
2579
+ bufferPush(endTagText, true);
1112
2580
  }
1113
2581
  charsPrevTag = '/' + tag;
1114
- if (!inlineTags(tag)) {
2582
+ if (!inlineElements.has(tag)) {
1115
2583
  currentChars = '';
1116
- }
1117
- else if (isElementEmpty) {
2584
+ } else if (isElementEmpty) {
1118
2585
  currentChars += '|';
1119
2586
  }
1120
2587
  }
1121
- },
1122
- chars: function(text, prevTag, nextTag) {
1123
- prevTag = prevTag === '' ? 'comment' : prevTag;
1124
- nextTag = nextTag === '' ? 'comment' : nextTag;
1125
- if (options.decodeEntities && text && !specialContentTags(currentTag)) {
1126
- text = decode(text);
1127
- }
1128
- if (options.collapseWhitespace) {
1129
- if (!stackNoTrimWhitespace.length) {
1130
- if (prevTag === 'comment') {
1131
- var prevComment = buffer[buffer.length - 1];
1132
- if (prevComment.indexOf(uidIgnore) === -1) {
1133
- if (!prevComment) {
1134
- prevTag = charsPrevTag;
1135
- }
1136
- if (buffer.length > 1 && (!prevComment || !options.conservativeCollapse && / $/.test(currentChars))) {
1137
- var charsIndex = buffer.length - 2;
1138
- buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function(trailingSpaces) {
1139
- text = trailingSpaces + text;
1140
- return '';
1141
- });
1142
- }
1143
- }
1144
- }
1145
- if (prevTag) {
1146
- if (prevTag === '/nobr' || prevTag === 'wbr') {
1147
- if (/^\s/.test(text)) {
1148
- var tagIndex = buffer.length - 1;
1149
- while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
1150
- tagIndex--;
1151
- }
1152
- trimTrailingWhitespace(tagIndex - 1, 'br');
1153
- }
1154
- }
1155
- else if (inlineTextTags(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
1156
- text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
1157
- }
1158
- }
1159
- if (prevTag || nextTag) {
1160
- text = collapseWhitespaceSmart(text, prevTag, nextTag, options);
1161
- }
1162
- else {
1163
- text = collapseWhitespace(text, options, true, true);
1164
- }
1165
- if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
1166
- trimTrailingWhitespace(buffer.length - 1, nextTag);
1167
- }
1168
- }
1169
- if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
1170
- text = collapseWhitespace(text, options, false, false, true);
1171
- }
1172
- }
1173
- if (options.processScripts && specialContentTags(currentTag)) {
1174
- text = processScript(text, options, currentAttrs);
2588
+
2589
+ if (closesNoTrim && !isElementRemoved) {
2590
+ noTrimEndIndex = buffer.length - 1;
1175
2591
  }
1176
- if (isExecutableScript(currentTag, currentAttrs)) {
1177
- text = options.minifyJS(text);
2592
+ if (closesNoCollapse && !isElementRemoved) {
2593
+ noCollapseEndIndex = buffer.length - 1;
1178
2594
  }
1179
- if (isStyleSheet(currentTag, currentAttrs)) {
1180
- text = options.minifyCSS(text);
2595
+
2596
+ // A kept element fills the one it sits in
2597
+ if (openElement && !isElementRemoved) {
2598
+ markOpenElementFilled();
1181
2599
  }
1182
- if (options.removeOptionalTags && text) {
1183
- // <html> may be omitted if first thing inside is not comment
1184
- // <body> may be omitted if first thing inside is not space, comment, <meta>, <link>, <script>, <style> or <template>
1185
- if (optionalStartTag === 'html' || optionalStartTag === 'body' && !/^\s/.test(text)) {
1186
- removeStartTag();
1187
- }
1188
- optionalStartTag = '';
1189
- // </html> or </body> may be omitted if not followed by comment
1190
- // </head>, </colgroup> or </caption> may be omitted if not followed by space or comment
1191
- if (compactTags(optionalEndTag) || looseTags(optionalEndTag) && !/^\s/.test(text)) {
1192
- removeEndTag();
2600
+
2601
+ // SVG subtree capture: Record end position for post-processing with SVGO
2602
+ if (lowerTag === 'svg' && options.minifySVG && svgDepth > 0) {
2603
+ svgDepth--;
2604
+ if (svgDepth === 0 && svgBufferStartIndex >= 0) {
2605
+ svgBlocks.push({ start: svgBufferStartIndex, end: buffer.length });
2606
+ svgBufferStartIndex = -1;
1193
2607
  }
1194
- optionalEndTag = '';
1195
- }
1196
- charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
1197
- if (options.decodeEntities && text && !specialContentTags(currentTag)) {
1198
- // Escape any `&` symbols that start either:
1199
- // 1) a legacy named character reference (i.e. one that doesn't end with `;`)
1200
- // 2) or any other character reference (i.e. one that does end with `;`)
1201
- // Note that `&` can be escaped as `&amp`, without the semi-colon.
1202
- // https://mathiasbynens.be/notes/ambiguous-ampersands
1203
- text = text.replace(/&((?:Iacute|aacute|uacute|plusmn|Otilde|otilde|agrave|Agrave|Yacute|yacute|Oslash|oslash|atilde|Atilde|brvbar|ccedil|Ccedil|Ograve|curren|divide|eacute|Eacute|ograve|Oacute|egrave|Egrave|Ugrave|frac12|frac14|frac34|ugrave|oacute|iacute|Ntilde|ntilde|Uacute|middot|igrave|Igrave|iquest|Aacute|cedil|laquo|micro|iexcl|Icirc|icirc|acirc|Ucirc|Ecirc|ocirc|Ocirc|ecirc|ucirc|Aring|aring|AElig|aelig|acute|pound|raquo|Acirc|times|THORN|szlig|thorn|COPY|auml|ordf|ordm|Uuml|macr|uuml|Auml|ouml|Ouml|para|nbsp|euml|quot|QUOT|Euml|yuml|cent|sect|copy|sup1|sup2|sup3|iuml|Iuml|ETH|shy|reg|not|yen|amp|AMP|REG|uml|eth|deg|gt|GT|LT|lt)(?!;)|(?:#?[0-9a-zA-Z]+;))/g, '&amp$1').replace(/</g, '&lt;');
1204
- }
1205
- if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
1206
- text = text.replace(uidPattern, function(match, prefix, index) {
1207
- return ignoredCustomMarkupChunks[+index][0];
1208
- });
1209
2608
  }
1210
- currentChars += text;
1211
- if (text) {
1212
- hasChars = true;
1213
- }
1214
- buffer.push(text);
1215
2609
  },
1216
- comment: function(text, nonStandard) {
1217
- var prefix = nonStandard ? '<!' : '<!--';
1218
- var suffix = nonStandard ? '>' : '-->';
1219
- if (isConditionalComment(text)) {
1220
- text = prefix + cleanConditionalComment(text, options) + suffix;
1221
- }
1222
- else if (options.removeComments) {
1223
- if (isIgnoredComment(text, options)) {
1224
- text = '<!--' + text + '-->';
1225
- }
1226
- else {
1227
- text = '';
2610
+ chars: function (/** @type {string} */ text, /** @type {string} */ prevTag, /** @type {string} */ nextTag, /** @type {HTMLAttribute[]} */ prevAttrs, /** @type {HTMLAttribute[]} */ nextAttrs, /** @type {boolean} */ source) {
2611
+ // Publish this node’s context for the `charsCollapse`/`charsFinalize` phases
2612
+ textPrevTag = prevTag === '' ? 'comment' : prevTag;
2613
+ textNextTag = nextTag === '' ? 'comment' : nextTag;
2614
+ textPrevAttrs = prevAttrs || [];
2615
+ textNextAttrs = nextAttrs || [];
2616
+
2617
+ // Whether this text node needs any async work. In foreign content, text reads references
2618
+ // and CDATA sections, and `script` and `style` hold markup, so their text is a source only
2619
+ // where it is all they hold (and in SVG, where they run and apply)
2620
+ const foreign = Boolean(options.insideForeignContent);
2621
+ textForeign = foreign;
2622
+ const holdsSource = !foreign || Boolean(source);
2623
+ const needsProcessScript = holdsSource && specialContentElements.has(currentTag) && (options.processScripts || (options.minifyJSON && currentTag === 'script' && hasJsonScriptType(currentAttrs)));
2624
+ const needsMinifyJS = holdsSource && options.minifyJS !== identity && isExecutableScript(currentTag, currentAttrs);
2625
+ const isModuleScript = needsMinifyJS && currentAttrs.some(
2626
+ a => a.name.toLowerCase() === 'type' && (a.value ?? '').trim().toLowerCase() === 'module'
2627
+ );
2628
+ const needsMinifyCSS = holdsSource && options.minifyCSS !== identity && isStyleElement(currentTag, currentAttrs);
2629
+ textForeignDecoded = foreign && Boolean(options.decodeEntities || needsProcessScript || needsMinifyJS || needsMinifyCSS);
2630
+ const needsDecode = Boolean(text) && (foreign
2631
+ ? textForeignDecoded && (text.indexOf('&') !== -1 || text.indexOf('<![CDATA[') !== -1)
2632
+ : options.decodeEntities && text.indexOf('&') !== -1 && !holdsRawText());
2633
+
2634
+ // Fast path: All work is sync—skip async machinery (decoding counts
2635
+ // once `entities` has loaded)
2636
+ if ((!needsDecode || decodeHTMLFn) && !needsProcessScript && !needsMinifyJS && !needsMinifyCSS) {
2637
+ if (needsDecode && decodeHTMLFn) {
2638
+ text = foreign ? decodeForeignText(text, decodeHTMLFn) : decodeHTMLFn(text);
1228
2639
  }
2640
+ charsFinalize(charsCollapse(text));
2641
+ return;
1229
2642
  }
1230
- else {
1231
- text = prefix + text + suffix;
1232
- }
1233
- if (options.removeOptionalTags && text) {
1234
- // preceding comments suppress tag omissions
1235
- optionalStartTag = '';
1236
- optionalEndTag = '';
2643
+
2644
+ // Slow path: At least one async step required
2645
+ return (async () => {
2646
+ if (needsDecode) {
2647
+ const decode = /** @type {(text: string) => string} */ (await getDecodeHTML());
2648
+ text = foreign ? decodeForeignText(text, decode) : decode(text);
2649
+ }
2650
+ text = charsCollapse(text);
2651
+ if (needsProcessScript) {
2652
+ text = await processScript(text, options, currentTag, currentAttrs, minifyHTML);
2653
+ }
2654
+ if (needsMinifyJS) {
2655
+ const prewarmed = jsPrewarmed?.get((isModuleScript ? 'm|' : '|') + text);
2656
+ text = prewarmed ? await prewarmed : await options.minifyJS(text, false, isModuleScript);
2657
+ }
2658
+ if (needsMinifyCSS) {
2659
+ text = await options.minifyCSS(text, undefined, options.cssContext);
2660
+ }
2661
+ charsFinalize(text);
2662
+ })();
2663
+ },
2664
+ // `markup` is the comment as written, where it is to stay that way (`<? x>`)
2665
+ comment: function (/** @type {string} */ text, /** @type {boolean} */ nonStandard, /** @type {string | undefined} */ markup) {
2666
+ const prefix = nonStandard ? '<!' : '<!--';
2667
+ const suffix = nonStandard ? '>' : '-->';
2668
+
2669
+ if (options.removeComments && !isIgnoredComment(text, options)) {
2670
+ text = '';
2671
+ removed = true;
2672
+ } else {
2673
+ text = markup ?? prefix + text + suffix;
1237
2674
  }
1238
- buffer.push(text);
2675
+ commentFinalize(text);
1239
2676
  },
1240
- doctype: function(doctype) {
1241
- buffer.push(options.useShortDoctype ? '<!doctype' +
1242
- (options.removeTagWhitespace ? '' : ' ') + 'html>' :
1243
- collapseWhitespaceAll(doctype));
2677
+ // Kept as written, as content may be inserted at one (`<template for>`)
2678
+ processingInstruction: function (/** @type {string} */ text) {
2679
+ hasChars = true;
2680
+ markOpenElementFilled();
2681
+ commentFinalize(text);
1244
2682
  },
1245
- customAttrAssign: options.customAttrAssign,
1246
- customAttrSurround: options.customAttrSurround
2683
+ strayEnd: function () {
2684
+ removed = true;
2685
+ },
2686
+ doctype: function (/** @type {string} */ doctype, /** @type {boolean} */ noQuirksMode) {
2687
+ tableClosesP = noQuirksMode;
2688
+ bufferPush(options.useShortDoctype
2689
+ ? '<!doctype' +
2690
+ (options.removeTagWhitespace ? '' : ' ') + 'html>'
2691
+ : collapseWhitespaceAll(doctype), false);
2692
+ }
1247
2693
  });
1248
2694
 
2695
+ await parser.parse();
2696
+
2697
+ // Nothing follows at the end, so pending whitespace goes (before SVG blocks shift the buffer)
2698
+ if (transparentGapIndex !== -1) {
2699
+ settleTransparentGap(Boolean(options.conservativeCollapse));
2700
+ }
2701
+
2702
+ // Post-processing: Optimize SVG blocks with SVGO
2703
+ // Run all SVGO calls in parallel, then splice results in reverse to preserve indices
2704
+ const minifySVG = options.minifySVG;
2705
+ if (minifySVG && svgBlocks.length) {
2706
+ const optimized = await Promise.all(
2707
+ svgBlocks.map(({ start, end }) =>
2708
+ minifySVG(buffer.slice(start, end).join(''))
2709
+ )
2710
+ );
2711
+ for (let i = svgBlocks.length - 1; i >= 0; i--) {
2712
+ const block = svgBlocks[i];
2713
+ if (block) {
2714
+ buffer.splice(block.start, block.end - block.start, optimized[i] ?? '');
2715
+ bufferTags?.splice(block.start, block.end - block.start, false);
2716
+ }
2717
+ }
2718
+ }
2719
+ // Also where an SVG left open never reached SVGO
2720
+ if (svgRawTexts.length) {
2721
+ const pattern = new RegExp('/\\*!' + uidSVGRawText + '([0-9]+)\\*/', 'g');
2722
+ for (let i = 0; i < buffer.length; i++) {
2723
+ const segment = /** @type {string} */ (buffer[i]);
2724
+ if (segment.indexOf(uidSVGRawText) !== -1) {
2725
+ buffer[i] = segment.replace(pattern, (/** @type {string} */ match, /** @type {string} */ index) => svgRawTexts[+index] ?? match);
2726
+ }
2727
+ }
2728
+ }
2729
+
1249
2730
  if (options.removeOptionalTags) {
1250
- // <html> may be omitted if first thing inside is not comment
1251
- // <head> or <body> may be omitted if empty
1252
- if (topLevelTags(optionalStartTag)) {
2731
+ // `<html>` may be omitted if first thing inside is not a comment
2732
+ // `<head>` or `<body>` may be omitted if empty
2733
+ if (topLevelElements.has(optionalStartTag)) {
1253
2734
  removeStartTag();
1254
2735
  }
1255
- // except for </dt> or </thead>, end tags may be omitted if no more content in parent element
1256
- if (optionalEndTag && !trailingTags(optionalEndTag)) {
2736
+ // Except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent
2737
+ if (optionalEndTag && optionalEndTagEmitted && !trailingElements.has(optionalEndTag)) {
1257
2738
  removeEndTag();
1258
2739
  }
1259
2740
  }
2741
+ // Collapsing also trims whitespace before trailing inline end tags; trimming the output
2742
+ // catches what it leaves at either end, like a space after a removed leading comment
1260
2743
  if (options.collapseWhitespace) {
1261
2744
  squashTrailingWhitespace('br');
1262
2745
  }
2746
+ trimOutputWhitespace();
1263
2747
 
1264
- return joinResultSegments(buffer, options, uidPattern ? function(str) {
1265
- return str.replace(uidPattern, function(match, prefix, index, suffix) {
1266
- var chunk = ignoredCustomMarkupChunks[+index][0];
1267
- if (options.collapseWhitespace) {
1268
- if (prefix !== '\t') {
1269
- chunk = prefix + chunk;
2748
+ return joinResultSegments(buffer, bufferTags ?? [], markers, options, uidPattern
2749
+ ? function (/** @type {string} */ str) {
2750
+ return str.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ match, /** @type {string} */ prefix, /** @type {string} */ index, /** @type {string} */ suffix, /** @type {number} */ offset, /** @type {string} */ string) {
2751
+ const chunks = ignoredCustomMarkupChunks[+index];
2752
+ if (!chunks) {
2753
+ return match;
1270
2754
  }
1271
- if (suffix !== '\t') {
1272
- chunk += suffix;
2755
+ if (!options.collapseWhitespace) {
2756
+ // The fragment took the whitespace around it along, so other whitespace here was
2757
+ // written by the minifier (e.g., by sorting) and is needed where the fragment has none
2758
+ return (chunks[1] ? '' : prefix.replace(RE_TABS, '')) + chunks[0] + (chunks[2] ? '' : suffix.replace(RE_TABS, ''));
1273
2759
  }
1274
- return collapseWhitespace(chunk, {
1275
- preserveLineBreaks: options.preserveLineBreaks,
1276
- conservativeCollapse: !options.trimCustomFragments
1277
- }, /^[ \n\r\t\f]/.test(chunk), /[ \n\r\t\f]$/.test(chunk));
1278
- }
1279
- return chunk;
1280
- });
1281
- } : identity, uidIgnore ? function(str) {
1282
- return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function(match, index) {
1283
- return ignoredMarkupChunks[+index];
1284
- });
1285
- } : identity);
2760
+ const leading = chunks[1] ?? '';
2761
+ const trailing = chunks[2] ?? '';
2762
+ const fragment = chunks[0].slice(leading.length, chunks[0].length - trailing.length);
2763
+ const inTag = customMarkupInTags.has(+index);
2764
+ const keep = inTag || customMarkupInCode.has(+index) || !options.trimCustomFragments;
2765
+ // The padding tabs are whitespace to the minifier, so where one is gone, the
2766
+ // minifier removed the whitespace, which makes it safe to trim
2767
+ const trimLeading = !keep && prefix === '';
2768
+ const trimTrailing = inTag ? string.charCodeAt(offset + match.length) === 62 /* > */ : !keep && suffix === '';
2769
+ return restoreFragmentSide(prefix.replace(RE_TABS, '') + leading, trimLeading, true) +
2770
+ fragment +
2771
+ restoreFragmentSide(suffix.replace(RE_TABS, '') + trailing, trimTrailing, false);
2772
+ });
2773
+ }
2774
+ : identity, uidIgnore
2775
+ ? function (/** @type {string} */ str) {
2776
+ return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (/** @type {string} */ _match, /** @type {string} */ index) {
2777
+ return ignoredMarkupChunks[+index] ?? '';
2778
+ });
2779
+ }
2780
+ : identity);
1286
2781
  }
1287
2782
 
1288
- function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
1289
- var str;
1290
- var maxLineLength = options.maxLineLength;
2783
+ /**
2784
+ * @param {string[]} results
2785
+ * @param {boolean[]} resultTags Whether each result belongs to a start or end tag
2786
+ * @param {string[]} markers Custom fragment and `htmlmin:ignore` placeholder markers
2787
+ * @param {ProcessedOptions} options
2788
+ * @param {Function} restoreCustom
2789
+ * @param {Function} restoreIgnore
2790
+ */
2791
+ function joinResultSegments(results, resultTags, markers, options, restoreCustom, restoreIgnore) {
2792
+ let str;
2793
+ const maxLineLength = options.maxLineLength;
2794
+
1291
2795
  if (maxLineLength) {
1292
- var line = '', lines = [];
1293
- while (results.length) {
1294
- var len = line.length;
1295
- var end = results[0].indexOf('\n');
1296
- if (end < 0) {
1297
- line += restoreIgnore(restoreCustom(results.shift()));
1298
- }
1299
- else {
1300
- line += restoreIgnore(restoreCustom(results[0].slice(0, end)));
1301
- results[0] = results[0].slice(end + 1);
1302
- }
1303
- if (len > 0 && line.length > maxLineLength) {
1304
- lines.push(line.slice(0, len));
1305
- line = line.slice(len);
2796
+ // Whitespace in tags is the only whitespace that can’t change the output, so lines break
2797
+ // there alone—in place of the space between attributes, or right before a tag’s `>`
2798
+ const noNewlinesBeforeTagClose = options.noNewlinesBeforeTagClose;
2799
+ /** @type {string[]} */
2800
+ const lines = [];
2801
+ let lineLength = 0;
2802
+ // Output since the last break opportunity, and what that opportunity is when not taken;
2803
+ // fragments are restored by chunk, as whitespace around them decides how they come back
2804
+ let chunk = '';
2805
+ /** @type {string | null} */
2806
+ let separator = null;
2807
+ const flush = (/** @type {string | null} */ nextSeparator) => {
2808
+ chunk = restoreIgnore(restoreCustom(chunk));
2809
+ const newline = chunk.indexOf('\n');
2810
+ const head = newline < 0 ? chunk.length : newline;
2811
+ // A line already too long gains nothing from moving a `>` that ends a line of its own
2812
+ const isUseless = separator === '' && head === 1 && lineLength > maxLineLength;
2813
+ if (separator !== null) {
2814
+ if (lineLength > 0 && lineLength + separator.length + head > maxLineLength && !isUseless) {
2815
+ lines.push('\n');
2816
+ lineLength = 0;
2817
+ } else if (separator) {
2818
+ lines.push(separator);
2819
+ lineLength += separator.length;
2820
+ }
1306
2821
  }
1307
- else if (end >= 0) {
1308
- lines.push(line);
1309
- line = '';
2822
+ lines.push(chunk);
2823
+ lineLength = newline < 0 ? lineLength + chunk.length : chunk.length - chunk.lastIndexOf('\n') - 1;
2824
+ chunk = '';
2825
+ separator = nextSeparator;
2826
+ };
2827
+ for (let index = 0; index < results.length; index++) {
2828
+ const result = results[index] ?? '';
2829
+ if (!resultTags[index]) {
2830
+ chunk += result;
2831
+ } else if (markers.length && markers.some(marker => result.includes(marker))) {
2832
+ // Whitespace next to a fragment stays as is, as it may belong to it
2833
+ if (!chunk && separator !== null) {
2834
+ chunk = separator;
2835
+ separator = null;
2836
+ }
2837
+ chunk += result;
2838
+ } else if (result.charCodeAt(result.length - 1) === 32 /* space */) {
2839
+ chunk += result.slice(0, -1);
2840
+ flush(' ');
2841
+ } else if (result.charCodeAt(result.length - 1) === 62 /* > */ &&
2842
+ result.charCodeAt(result.length - 2) !== 47 /* / */ &&
2843
+ !(noNewlinesBeforeTagClose && result.startsWith('</'))) {
2844
+ chunk += result.slice(0, -1);
2845
+ flush('');
2846
+ chunk = '>';
2847
+ } else {
2848
+ chunk += result;
1310
2849
  }
1311
2850
  }
1312
- if (line) {
1313
- lines.push(line);
1314
- }
1315
- str = lines.join('\n');
1316
- }
1317
- else {
2851
+ flush(null);
2852
+ str = lines.join('');
2853
+ } else {
1318
2854
  str = restoreIgnore(restoreCustom(results.join('')));
1319
2855
  }
1320
2856
  return options.collapseWhitespace ? collapseWhitespace(str, options, true, true) : str;
1321
2857
  }
1322
2858
 
1323
- exports.minify = function(value, options) {
1324
- var start = Date.now();
1325
- options = processOptions(options || {});
1326
- var result = minify(value, options);
1327
- options.log('minified in: ' + (Date.now() - start) + 'ms');
2859
+ /**
2860
+ * Initialize minification caches with configurable sizes.
2861
+ *
2862
+ * Important behavior notes:
2863
+ * - Caches are created on the first `minify()` call and persist for the lifetime of the process
2864
+ * - Cache sizes are locked after first initialization—subsequent calls use the same caches
2865
+ * even if different `cacheCSS`/`cacheJS`/`cacheSVG` options are provided
2866
+ * - Invalid values (NaN, Infinity, negative) fall back to the default size (500)
2867
+ */
2868
+ /**
2869
+ * @param {MinifierOptions} options
2870
+ * @returns {{cssMinifyCache: LRU, jsMinifyCache: LRU, svgMinifyCache: LRU}}
2871
+ */
2872
+ function initCaches(options) {
2873
+ if (!cssMinifyCache || !jsMinifyCache || !svgMinifyCache) {
2874
+ const defaultSize = 500;
2875
+
2876
+ // Precedence: Options > env > default; non-finite and negative sizes fall back to the default
2877
+ const cacheSize = (/** @type {number | undefined} */ option, /** @type {string | undefined} */ envVar) => {
2878
+ const size = option !== undefined ? option : (envVar !== undefined ? Number(envVar) : defaultSize);
2879
+ return Number.isFinite(size) && size >= 0 ? Math.floor(size) : defaultSize;
2880
+ };
2881
+
2882
+ cssMinifyCache = new LRU(cacheSize(options.cacheCSS, process.env.HM_CACHE_CSS));
2883
+ jsMinifyCache = new LRU(cacheSize(options.cacheJS, process.env.HM_CACHE_JS));
2884
+ svgMinifyCache = new LRU(cacheSize(options.cacheSVG, process.env.HM_CACHE_SVG));
2885
+ }
2886
+
2887
+ return { cssMinifyCache, jsMinifyCache, svgMinifyCache };
2888
+ }
2889
+
2890
+ /**
2891
+ * Get hit/miss statistics for the CSS, JavaScript, and SVG minification caches.
2892
+ *
2893
+ * Each cache is created lazily on the first `minify()` call (see `initCaches`); before
2894
+ * that first call, or for a cache never exercised (e.g., `minifyCSS` disabled), its
2895
+ * entry reports all-zero stats.
2896
+ *
2897
+ * @returns {{ css: CacheStats, js: CacheStats, svg: CacheStats }}
2898
+ */
2899
+ export function getCacheStats() {
2900
+ return {
2901
+ css: cacheStatsOrEmpty(cssMinifyCache),
2902
+ js: cacheStatsOrEmpty(jsMinifyCache),
2903
+ svg: cacheStatsOrEmpty(svgMinifyCache)
2904
+ };
2905
+ }
2906
+
2907
+ // Memoized options processing: Batch runs pass one options object to many `minify()` calls, and
2908
+ // processing it (regex parsing, closures, option signatures) is expensive. The cache is keyed on
2909
+ // the object and checked against a deep snapshot, so mutating a reused object, nested values
2910
+ // included, still triggers reprocessing. Each call works on a shallow copy, as `minifyHTML`
2911
+ // reassigns top-level keys (wrapped minifiers, sorters, UID comment patterns) per call.
2912
+ /** @type {MinifierOptions} */
2913
+ const EMPTY_OPTIONS = {};
2914
+ /** @type {WeakMap<object, {snapshot: unknown, processed: ProcessedOptions}>} */
2915
+ const processedOptionsCache = new WeakMap();
2916
+
2917
+ // A “plain” value is an array or plain object, which the snapshot copies and
2918
+ // compares structurally; anything else (functions, RegExps, class instances)
2919
+ // is kept and compared by identity—a false mismatch merely reprocesses
2920
+ /** @param {unknown} value */
2921
+ function isPlainValue(value) {
2922
+ if (Array.isArray(value)) return true;
2923
+ if (value === null || typeof value !== 'object') return false;
2924
+ const proto = Object.getPrototypeOf(value);
2925
+ return proto === Object.prototype || proto === null;
2926
+ }
2927
+
2928
+ // Budget for `snapshotValue`: Bounds the work spent on pathological options
2929
+ // objects; when the budget runs out, the object isn’t memoized and falls
2930
+ // back to per-call processing
2931
+ const SNAPSHOT_MAX_NODES = 1024;
2932
+ const SNAPSHOT_UNSAFE = Symbol('snapshot-unsafe');
2933
+ // Skipped memoization is fail-safe but silent—warn once per process
2934
+ let snapshotBudgetWarned = false;
2935
+
2936
+ /**
2937
+ * @param {unknown} value
2938
+ * @param {{budget: number}} state
2939
+ * @returns {unknown} The copy, or `SNAPSHOT_UNSAFE` once the budget is exhausted
2940
+ */
2941
+ function snapshotValue(value, state) {
2942
+ if (!isPlainValue(value)) return value;
2943
+ if (--state.budget < 0) return SNAPSHOT_UNSAFE;
2944
+ if (Array.isArray(value)) {
2945
+ const copy = new Array(value.length);
2946
+ for (let i = 0; i < value.length; i++) {
2947
+ const child = snapshotValue(value[i], state);
2948
+ if (child === SNAPSHOT_UNSAFE) return SNAPSHOT_UNSAFE;
2949
+ copy[i] = child;
2950
+ }
2951
+ return copy;
2952
+ }
2953
+ /** @type {Record<string, unknown>} */
2954
+ const copy = {};
2955
+ const source = /** @type {Record<string, unknown>} */ (value);
2956
+ for (const key of Object.keys(source)) {
2957
+ const child = snapshotValue(source[key], state);
2958
+ if (child === SNAPSHOT_UNSAFE) return SNAPSHOT_UNSAFE;
2959
+ copy[key] = child;
2960
+ }
2961
+ return copy;
2962
+ }
2963
+
2964
+ /**
2965
+ * @param {unknown} snapshot
2966
+ * @param {unknown} current
2967
+ * @returns {boolean}
2968
+ */
2969
+ function valueUnchanged(snapshot, current) {
2970
+ if (snapshot === current) return true;
2971
+ if (!isPlainValue(snapshot) || !isPlainValue(current)) return false;
2972
+ if (Array.isArray(snapshot)) {
2973
+ if (!Array.isArray(current) || snapshot.length !== current.length) return false;
2974
+ for (let i = 0; i < snapshot.length; i++) {
2975
+ if (!valueUnchanged(snapshot[i], current[i])) return false;
2976
+ }
2977
+ return true;
2978
+ }
2979
+ if (Array.isArray(current)) return false;
2980
+ const snapshotObj = /** @type {Record<string, unknown>} */ (snapshot);
2981
+ const currentObj = /** @type {Record<string, unknown>} */ (current);
2982
+ const snapshotKeys = Object.keys(snapshotObj);
2983
+ if (snapshotKeys.length !== Object.keys(currentObj).length) return false;
2984
+ for (const key of snapshotKeys) {
2985
+ if (!Object.hasOwn(currentObj, key) || !valueUnchanged(snapshotObj[key], currentObj[key])) {
2986
+ return false;
2987
+ }
2988
+ }
2989
+ return true;
2990
+ }
2991
+
2992
+ /**
2993
+ * @param {string} value
2994
+ * @param {MinifierOptions} [options]
2995
+ * @returns {Promise<string>}
2996
+ */
2997
+ export const minify = async function (value, options) {
2998
+ const start = Date.now();
2999
+
3000
+ const inputOptions = options || EMPTY_OPTIONS;
3001
+
3002
+ const caches = initCaches(inputOptions);
3003
+
3004
+ // WeakMap keys must be objects
3005
+ const canMemoize = typeof inputOptions === 'object' && inputOptions !== null;
3006
+ const cached = canMemoize ? processedOptionsCache.get(inputOptions) : undefined;
3007
+ /** @type {ProcessedOptions} */
3008
+ let processedBase;
3009
+ // `valueUnchanged` needs no budget of its own: Recursion is bounded by the
3010
+ // stored snapshot, which is a finite tree of at most `SNAPSHOT_MAX_NODES`
3011
+ if (cached && valueUnchanged(cached.snapshot, inputOptions)) {
3012
+ processedBase = cached.processed;
3013
+ } else {
3014
+ processedBase = processOptions(inputOptions, {
3015
+ getLightningCSS,
3016
+ getTerser,
3017
+ getSwc,
3018
+ getSvgo,
3019
+ cssMinifyCache: caches.cssMinifyCache,
3020
+ jsMinifyCache: caches.jsMinifyCache,
3021
+ svgMinifyCache: caches.svgMinifyCache
3022
+ });
3023
+ if (canMemoize) {
3024
+ const snapshot = snapshotValue(inputOptions, { budget: SNAPSHOT_MAX_NODES });
3025
+ if (snapshot !== SNAPSHOT_UNSAFE) {
3026
+ processedOptionsCache.set(inputOptions, { snapshot, processed: processedBase });
3027
+ } else if (!snapshotBudgetWarned) {
3028
+ snapshotBudgetWarned = true;
3029
+ const warn = typeof inputOptions.log === 'function' ? inputOptions.log : console.warn;
3030
+ warn('HTML Minifier: Options object exceeds the memoization complexity limit; options will be processed on every call');
3031
+ }
3032
+ }
3033
+ }
3034
+ // Warnings are deduplicated per document, so the state has to live on the per-call
3035
+ // copy; the `minifyCSS` closure hangs off the memoized base, shared across calls
3036
+ /** @type {NonNullable<ProcessedOptions['cssContext']>} */
3037
+ const cssContext = { warned: new Set() };
3038
+ // What the parse finds out about the document for post-processing, sparing an output scan
3039
+ const documentState = { hasMathML: false };
3040
+ // Whether the document holds a `base` element, looked up in it on the first `target=_self`
3041
+ /** @type {NonNullable<ProcessedOptions['baseState']>} */
3042
+ const baseState = { hasBase: undefined, source: value };
3043
+ // Work on a shallow copy so per-call reassignments don’t reach the cached base. The
3044
+ // per-document state is part of the copy from the start, as properties added to it
3045
+ // later slow down every option lookup.
3046
+ const processedOptions = /** @type {ProcessedOptions} */ ({ ...processedBase, cssContext, documentState, baseState });
3047
+
3048
+ // Unused-CSS removal needs the whole document’s symbols before the first `style` element is
3049
+ // minified, so they are collected upfront from the raw input—where there is a style sheet at all
3050
+ if (processedOptions.removeUnusedCSS && RE_STYLE_ELEMENT.test(value)) {
3051
+ Object.assign(cssContext, collectUsage(
3052
+ value,
3053
+ processedOptions.removeUnusedCSS.scripts,
3054
+ value.indexOf('&') !== -1
3055
+ ? /** @type {(text: string) => string} */ (await getDecodeHTML())
3056
+ : undefined
3057
+ ));
3058
+ }
3059
+
3060
+ let result = await minifyHTML(value, processedOptions);
3061
+
3062
+ if (processedOptions.mergeScripts) {
3063
+ result = mergeConsecutiveScripts(result, documentState.hasMathML);
3064
+ }
3065
+
3066
+ processedOptions.log('minified in: ' + (Date.now() - start) + 'ms');
1328
3067
  return result;
1329
3068
  };
3069
+
3070
+ export {
3071
+ presets,
3072
+ getPreset,
3073
+ getPresetNames
3074
+ };
3075
+
3076
+ export default {
3077
+ minify,
3078
+ presets,
3079
+ getPreset,
3080
+ getPresetNames,
3081
+ getCacheStats
3082
+ };