html-minifier 4.0.0 → 5.0.1

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