html-minifier 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE.txt +23 -0
  2. package/README.md +851 -110
  3. package/cli.js +991 -207
  4. package/dist/types/htmlminifier.d.ts +629 -0
  5. package/dist/types/htmlminifier.d.ts.map +1 -0
  6. package/dist/types/htmlparser.d.ts +28 -0
  7. package/dist/types/htmlparser.d.ts.map +1 -0
  8. package/dist/types/lib/attributes.d.ts +87 -0
  9. package/dist/types/lib/attributes.d.ts.map +1 -0
  10. package/dist/types/lib/constants.d.ts +421 -0
  11. package/dist/types/lib/constants.d.ts.map +1 -0
  12. package/dist/types/lib/content.d.ts +44 -0
  13. package/dist/types/lib/content.d.ts.map +1 -0
  14. package/dist/types/lib/elements.d.ts +48 -0
  15. package/dist/types/lib/elements.d.ts.map +1 -0
  16. package/dist/types/lib/fragments.d.ts +47 -0
  17. package/dist/types/lib/fragments.d.ts.map +1 -0
  18. package/dist/types/lib/option-definitions.d.ts +33 -0
  19. package/dist/types/lib/option-definitions.d.ts.map +1 -0
  20. package/dist/types/lib/options.d.ts +93 -0
  21. package/dist/types/lib/options.d.ts.map +1 -0
  22. package/dist/types/lib/unused-css.d.ts +76 -0
  23. package/dist/types/lib/unused-css.d.ts.map +1 -0
  24. package/dist/types/lib/urls.d.ts +15 -0
  25. package/dist/types/lib/urls.d.ts.map +1 -0
  26. package/dist/types/lib/utils.d.ts +132 -0
  27. package/dist/types/lib/utils.d.ts.map +1 -0
  28. package/dist/types/lib/whitespace.d.ts +97 -0
  29. package/dist/types/lib/whitespace.d.ts.map +1 -0
  30. package/dist/types/presets.d.ts +50 -0
  31. package/dist/types/presets.d.ts.map +1 -0
  32. package/dist/types/tokenchain.d.ts +36 -0
  33. package/dist/types/tokenchain.d.ts.map +1 -0
  34. package/html-minifier.schema.json +322 -0
  35. package/package.json +70 -58
  36. package/src/htmlminifier.js +2845 -1107
  37. package/src/htmlparser.js +1163 -452
  38. package/src/lib/attributes.js +1196 -0
  39. package/src/lib/constants.js +521 -0
  40. package/src/lib/content.js +237 -0
  41. package/src/lib/elements.js +268 -0
  42. package/src/lib/file-pool.js +193 -0
  43. package/src/lib/file-write.js +87 -0
  44. package/src/lib/fragments.js +340 -0
  45. package/src/lib/option-definitions.js +246 -0
  46. package/src/lib/options.js +841 -0
  47. package/src/lib/unused-css.js +499 -0
  48. package/src/lib/urls.js +146 -0
  49. package/src/lib/utils.js +1026 -0
  50. package/src/lib/whitespace.js +431 -0
  51. package/src/presets.js +58 -0
  52. package/src/tokenchain.js +142 -56
  53. package/LICENSE +0 -22
  54. package/sample-cli-config-file.conf +0 -40
  55. package/src/utils.js +0 -18
@@ -0,0 +1,1196 @@
1
+ /**
2
+ * HTML attribute parsing, decoding, and minification
3
+ */
4
+
5
+ import {
6
+ RE_EVENT_ATTR_DEFAULT,
7
+ RE_CAN_REMOVE_ATTR_QUOTES,
8
+ RE_AMP_REFERENCE_ATTR,
9
+ RE_CR,
10
+ RE_BASE_ELEMENT,
11
+ RE_ATTR_WS_CHECK,
12
+ RE_ATTR_WS_COLLAPSE,
13
+ RE_WS_CHAR,
14
+ generalDefaults,
15
+ tagDefaults,
16
+ metaHttpEquivNoEffect,
17
+ metaNamesNoEffect,
18
+ targetSelfElements,
19
+ tagDefaultsSVG,
20
+ svgElementsInheritingByReference,
21
+ namespaceHTML,
22
+ namespaceSVG,
23
+ namespaceMathML,
24
+ namespaceXLink,
25
+ executableScriptsMimetypes,
26
+ keepScriptsMimetypes,
27
+ isSimpleBoolean,
28
+ isBooleanValue,
29
+ collapsibleValues,
30
+ srcsetElements,
31
+ RE_EMPTY_ATTRIBUTE,
32
+ RE_STYLE_ELEMENT
33
+ } from './constants.js';
34
+ import { trimWhitespace, collapseWhitespaceAll } from './whitespace.js';
35
+ import { shouldMinifyInnerHTML } from './options.js';
36
+ import { collectUsage } from './unused-css.js';
37
+ import { escapeXML, identity, isThenable, lowercase } from './utils.js';
38
+
39
+ /** @import { ProcessedOptions } from './options.js' */
40
+
41
+ /**
42
+ * @typedef {{ name: string, value?: string | undefined, quote?: string, customAssign?: string, customOpen?: string, customClose?: string }} HTMLAttribute
43
+ * Internal counterpart of the public typedef in htmlminifier.js—keep in sync.
44
+ */
45
+
46
+ // Lazy-load entities (used for `decodeEntities`, event-handler attribute decoding
47
+ // before `minifyJS`, `srcdoc` decoding, and values SVGO reads)
48
+
49
+ /** @type {Promise<Function> | undefined} */
50
+ let decodeHTMLAttributePromise;
51
+ /** @type {((text: string) => string) | undefined} The resolved `decodeHTMLAttribute`, once the lazy import above has settled */
52
+ let decodeHTMLAttributeFn;
53
+ async function getDecodeHTMLAttribute() {
54
+ if (!decodeHTMLAttributePromise) {
55
+ decodeHTMLAttributePromise = import('entities').then(m => (decodeHTMLAttributeFn = m.decodeHTMLAttribute));
56
+ }
57
+ return decodeHTMLAttributePromise;
58
+ }
59
+
60
+ const RE_ATTRIBUTE_ESCAPE = /["&\u00A0]/g;
61
+ const attributeEscapes = { '"': '&quot;', '&': '&amp;', '\u00A0': '&nbsp;' };
62
+
63
+ /**
64
+ * Escapes a quoted attribute value, keeping `<` and `>` (unlike `entities` 9’s `escapeAttribute`)
65
+ * @param {string} value
66
+ * @returns {string}
67
+ */
68
+ function escapeAttribute(value) {
69
+ return value.replace(RE_ATTRIBUTE_ESCAPE, char => attributeEscapes[/** @type {'"' | '&' | '\u00A0'} */ (char)]);
70
+ }
71
+
72
+ // Validators
73
+
74
+ /**
75
+ * @param {string} text
76
+ * @param {ProcessedOptions} options
77
+ */
78
+ function isIgnoredComment(text, options) {
79
+ for (const pattern of options.ignoreCustomComments) {
80
+ if (pattern.test(text)) {
81
+ return true;
82
+ }
83
+ }
84
+ return false;
85
+ }
86
+
87
+ /**
88
+ * @param {string} attrName
89
+ * @param {{customEventAttributes?: RegExp[]}} options
90
+ * @param {string} [attrNameOut] - The name as written, for the patterns
91
+ */
92
+ function isEventAttribute(attrName, options, attrNameOut = attrName) {
93
+ const patterns = options.customEventAttributes;
94
+ if (patterns) {
95
+ for (const pattern of patterns) {
96
+ if (pattern.test(attrNameOut)) {
97
+ return true;
98
+ }
99
+ }
100
+ return false;
101
+ }
102
+ return RE_EVENT_ATTR_DEFAULT.test(attrName);
103
+ }
104
+
105
+ /** @param {string} value */
106
+ function canRemoveAttributeQuotes(value) {
107
+ // https://mathiasbynens.be/notes/unquoted-attribute-values
108
+ return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
109
+ }
110
+
111
+ /**
112
+ * @param {HTMLAttribute[]} attributes
113
+ * @param {string} attribute
114
+ */
115
+ function attributesInclude(attributes, attribute) {
116
+ for (const attr of attributes) {
117
+ if (attr.name.toLowerCase() === attribute) {
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+
124
+ /**
125
+ * Remove duplicate attributes from an attribute list.
126
+ * Per HTML spec, when an attribute appears multiple times, the first occurrence wins.
127
+ * Duplicate attributes result in invalid HTML, so only the first is kept.
128
+ * @param {HTMLAttribute[]} attrs - Array of attribute objects with `name` property
129
+ * @param {boolean} caseSensitive - Whether to compare names case-sensitively (for XML/SVG)
130
+ * @returns {HTMLAttribute[]} Deduplicated attribute array (modifies in place and returns)
131
+ */
132
+ function deduplicateAttributes(attrs, caseSensitive) {
133
+ if (attrs.length < 2) {
134
+ return attrs;
135
+ }
136
+
137
+ const seen = new Set();
138
+ let writeIndex = 0;
139
+
140
+ for (const attr of attrs) {
141
+ const key = caseSensitive ? attr.name : attr.name.toLowerCase();
142
+
143
+ if (!seen.has(key)) {
144
+ seen.add(key);
145
+ attrs[writeIndex++] = attr;
146
+ }
147
+ }
148
+
149
+ attrs.length = writeIndex;
150
+ return attrs;
151
+ }
152
+
153
+ // Names that can have a general or tag-specific default value, for the fast pre-check
154
+ // in `isAttributeRedundant` (a Set lookup beats two `Object.hasOwn` plus property reads);
155
+ // the legacy rules (`script[language|charset]`, `a[name]`) are named here explicitly
156
+ const redundantDefaultNames = new Set([...Object.keys(generalDefaults), 'language', 'charset', 'name']);
157
+ for (const defaults of Object.values(tagDefaults)) {
158
+ for (const name of Object.keys(defaults)) {
159
+ redundantDefaultNames.add(name);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Whether `xmlns` or `xmlns:xlink` repeats the namespace HTML puts the element or its
165
+ * attributes in anyway
166
+ * @param {string} attrName - In lowercase
167
+ * @param {string} attrValue
168
+ * @param {ProcessedOptions} options
169
+ */
170
+ function isNamespaceRedundant(attrName, attrValue, options) {
171
+ // XML, which SVGO reads, needs the declarations, which go after it (see `minifySVG`)
172
+ if (options.insideSVG && options.minifySVG) {
173
+ return false;
174
+ }
175
+ if (attrName === 'xmlns') {
176
+ if (!options.insideForeignContent) {
177
+ return attrValue === namespaceHTML;
178
+ }
179
+ return attrValue === (options.namespaceForeign === 'math' ? namespaceMathML : namespaceSVG);
180
+ }
181
+ return Boolean(options.insideForeignContent) && attrValue === namespaceXLink;
182
+ }
183
+
184
+ /**
185
+ * Whether `target=_self` is redundant, which it is not where a `base` element may set another
186
+ * target—as far as the document shows, which partial markup does not
187
+ * @param {string} attrValue
188
+ * @param {ProcessedOptions} options
189
+ */
190
+ function isTargetSelfRedundant(attrValue, options) {
191
+ // Matched as is, as padded it names a browsing context of its own
192
+ if (options.partialMarkup || attrValue.toLowerCase() !== '_self') {
193
+ return false;
194
+ }
195
+ // Written as markup or within a value
196
+ const state = options.baseState;
197
+ return !state || !(state.hasBase ??= RE_BASE_ELEMENT.test(state.source));
198
+ }
199
+
200
+ /**
201
+ * @param {string} tag - In lowercase
202
+ * @param {string} attrName - In lowercase
203
+ * @param {string} attrValue
204
+ * @param {HTMLAttribute[]} attrs
205
+ */
206
+ function isAttributeRedundantSVG(tag, attrName, attrValue, attrs) {
207
+ const defaults = Object.hasOwn(tagDefaultsSVG, tag)
208
+ ? /** @type {Record<string, string | string[]>} */ (/** @type {Record<string, unknown>} */ (tagDefaultsSVG)[tag])
209
+ : undefined;
210
+ const value = defaults && Object.hasOwn(defaults, attrName) ? defaults[attrName] : undefined;
211
+ if (value === undefined) {
212
+ return false;
213
+ }
214
+ if (svgElementsInheritingByReference.has(tag) && attrs.some(a => {
215
+ const name = a.name.toLowerCase();
216
+ return name === 'href' || name === 'xlink:href';
217
+ })) {
218
+ return false;
219
+ }
220
+ attrValue = trimWhitespace(attrValue.toLowerCase());
221
+ return typeof value === 'string' ? attrValue === value : value.includes(attrValue);
222
+ }
223
+
224
+ /**
225
+ * @param {string} tag
226
+ * @param {string} attrName
227
+ * @param {string} attrValue
228
+ * @param {HTMLAttribute[]} attrs
229
+ * @param {ProcessedOptions} options
230
+ */
231
+ function isAttributeRedundant(tag, attrName, attrValue, attrs, options) {
232
+ // Foreign names are as written (`viewBox`, `viewbox`), which the HTML parser reads alike
233
+ if (options.insideForeignContent) {
234
+ const attrNameLower = attrName.toLowerCase();
235
+ if (attrNameLower === 'xmlns' || attrNameLower === 'xmlns:xlink') {
236
+ return isNamespaceRedundant(attrNameLower, attrValue, options);
237
+ }
238
+ if (options.namespaceForeign !== 'svg') {
239
+ return false;
240
+ }
241
+ const tagLower = tag.toLowerCase();
242
+ if (tagLower === 'a' && attrNameLower === 'target') {
243
+ return isTargetSelfRedundant(attrValue, options);
244
+ }
245
+ return isAttributeRedundantSVG(tagLower, attrNameLower, attrValue, attrs);
246
+ }
247
+ if (attrName === 'xmlns') {
248
+ return isNamespaceRedundant(attrName, attrValue, options);
249
+ }
250
+ if (attrName === 'target' && targetSelfElements.has(tag)) {
251
+ return isTargetSelfRedundant(attrValue, options);
252
+ }
253
+ // Without effect in HTML (unlike in foreign content, where it’s in the XML namespace)
254
+ if (attrName === 'xml:lang') {
255
+ return true;
256
+ }
257
+
258
+ // Fast path: Check if this element–attribute combination can possibly be redundant before
259
+ // normalizing the value; own properties only, so `constructor` has no default
260
+ if (!redundantDefaultNames.has(attrName)) {
261
+ return false;
262
+ }
263
+ const generalDefault = Object.hasOwn(generalDefaults, attrName)
264
+ ? /** @type {Record<string, string>} */ (generalDefaults)[attrName]
265
+ : undefined;
266
+ const tagAttrDefaults = Object.hasOwn(tagDefaults, tag)
267
+ ? /** @type {Record<string, string>} */ (/** @type {Record<string, unknown>} */ (tagDefaults)[tag])
268
+ : undefined;
269
+ const tagDefault = tagAttrDefaults && Object.hasOwn(tagAttrDefaults, attrName) ? tagAttrDefaults[attrName] : undefined;
270
+
271
+ const isLegacyAttr = (tag === 'script' && (attrName === 'language' || attrName === 'charset')) || (tag === 'a' && attrName === 'name');
272
+
273
+ // Without a default for this very attribute, nor a legacy rule, the value cannot be redundant
274
+ if (generalDefault === undefined && tagDefault === undefined && !isLegacyAttr) {
275
+ return false;
276
+ }
277
+
278
+ // Value needs to be checked, so normalize it
279
+ attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
280
+
281
+ if (tag === 'script' && attrName === 'language' && attrValue === 'javascript') {
282
+ return true;
283
+ }
284
+ if (tag === 'script' && attrName === 'charset' && !attributesInclude(attrs, 'src')) {
285
+ return true;
286
+ }
287
+ if (tag === 'a' && attrName === 'name' && attributesInclude(attrs, 'id')) {
288
+ return true;
289
+ }
290
+ if (tag === 'ol' && attrName === 'start' && attributesInclude(attrs, 'reversed')) {
291
+ return false;
292
+ }
293
+
294
+ return attrValue === generalDefault || attrValue === tagDefault;
295
+ }
296
+
297
+ function isScriptTypeAttribute(attrValue = '') {
298
+ attrValue = trimWhitespace(attrValue.split(/;/, 2)[0] ?? '').toLowerCase();
299
+ return attrValue === '' || executableScriptsMimetypes.has(attrValue);
300
+ }
301
+
302
+ function keepScriptTypeAttribute(attrValue = '') {
303
+ attrValue = trimWhitespace(attrValue.split(/;/, 2)[0] ?? '').toLowerCase();
304
+ return keepScriptsMimetypes.has(attrValue);
305
+ }
306
+
307
+ /**
308
+ * @param {string} tag
309
+ * @param {HTMLAttribute[]} attrs
310
+ */
311
+ function isExecutableScript(tag, attrs) {
312
+ if (tag !== 'script') {
313
+ return false;
314
+ }
315
+ for (const attr of attrs) {
316
+ if (attr.name.toLowerCase() === 'type') {
317
+ return isScriptTypeAttribute(attr.value);
318
+ }
319
+ }
320
+ return true;
321
+ }
322
+
323
+ function isStyleLinkTypeAttribute(attrValue = '') {
324
+ attrValue = trimWhitespace(attrValue).toLowerCase();
325
+ return attrValue === '' || attrValue === 'text/css';
326
+ }
327
+
328
+ /**
329
+ * @param {string} tag
330
+ * @param {HTMLAttribute[]} attrs
331
+ */
332
+ function isStyleElement(tag, attrs) {
333
+ if (tag !== 'style') {
334
+ return false;
335
+ }
336
+ for (const attr of attrs) {
337
+ if (attr.name.toLowerCase() === 'type') {
338
+ return isStyleLinkTypeAttribute(attr.value);
339
+ }
340
+ }
341
+ return true;
342
+ }
343
+
344
+ /**
345
+ * Whether an attribute collapses to its name; takes name and value as written, lower-casing
346
+ * the value only for the few names that depend on it
347
+ *
348
+ * @param {string} attrName
349
+ * @param {string} attrValue
350
+ */
351
+ function collapsesToBooleanName(attrName, attrValue) {
352
+ const name = attrName.toLowerCase();
353
+ if (isSimpleBoolean.has(name)) {
354
+ return true;
355
+ }
356
+ const values = collapsibleValues.get(name);
357
+ if (values) {
358
+ return values.has(attrValue.toLowerCase());
359
+ }
360
+ return name === 'draggable' && !isBooleanValue.has(attrValue.toLowerCase());
361
+ }
362
+
363
+ const uriTypeAttributes = new Map([
364
+ ['a', new Set(['href'])],
365
+ ['area', new Set(['href'])],
366
+ ['audio', new Set(['src'])],
367
+ ['base', new Set(['href'])],
368
+ ['blockquote', new Set(['cite'])],
369
+ ['button', new Set(['formaction'])],
370
+ ['del', new Set(['cite'])],
371
+ ['embed', new Set(['src'])],
372
+ ['form', new Set(['action'])],
373
+ ['frame', new Set(['longdesc', 'src'])],
374
+ ['head', new Set(['profile'])],
375
+ ['iframe', new Set(['src'])],
376
+ ['img', new Set(['longdesc', 'src', 'usemap'])],
377
+ ['input', new Set(['formaction', 'src', 'usemap'])],
378
+ ['ins', new Set(['cite'])],
379
+ ['link', new Set(['href'])],
380
+ ['object', new Set(['classid', 'codebase', 'data', 'usemap'])],
381
+ ['q', new Set(['cite'])],
382
+ ['script', new Set(['src'])],
383
+ ['source', new Set(['src'])],
384
+ ['track', new Set(['src'])],
385
+ ['video', new Set(['poster', 'src'])]
386
+ ]);
387
+
388
+ /**
389
+ * @param {string} attrName
390
+ * @param {string} tag
391
+ */
392
+ function isUriTypeAttribute(attrName, tag) {
393
+ const set = uriTypeAttributes.get(tag);
394
+ return set ? set.has(attrName) : false;
395
+ }
396
+
397
+ const numberTypeAttributes = new Map([
398
+ ['a', new Set(['tabindex'])],
399
+ ['area', new Set(['tabindex'])],
400
+ ['button', new Set(['tabindex'])],
401
+ ['col', new Set(['span'])],
402
+ ['colgroup', new Set(['span'])],
403
+ ['input', new Set(['maxlength', 'tabindex'])],
404
+ ['object', new Set(['tabindex'])],
405
+ ['select', new Set(['size', 'tabindex'])],
406
+ ['td', new Set(['colspan', 'rowspan'])],
407
+ ['textarea', new Set(['cols', 'rows', 'tabindex'])],
408
+ ['th', new Set(['colspan', 'rowspan'])]
409
+ ]);
410
+
411
+ /**
412
+ * @param {string} attrName
413
+ * @param {string} tag
414
+ */
415
+ function isNumberTypeAttribute(attrName, tag) {
416
+ const set = numberTypeAttributes.get(tag);
417
+ return set ? set.has(attrName) : false;
418
+ }
419
+
420
+ /**
421
+ * @param {string} tag
422
+ * @param {HTMLAttribute[]} attrs
423
+ * @param {string} value
424
+ */
425
+ function isLinkType(tag, attrs, value) {
426
+ if (tag !== 'link') return false;
427
+ const needle = String(value).toLowerCase();
428
+ for (const attr of attrs) {
429
+ if (attr.name.toLowerCase() === 'rel') {
430
+ const tokens = String(attr.value).toLowerCase().split(/\s+/);
431
+ if (tokens.includes(needle)) return true;
432
+ }
433
+ }
434
+ return false;
435
+ }
436
+
437
+ /**
438
+ * @param {string} tag
439
+ * @param {HTMLAttribute[]} attrs
440
+ * @param {string} attrName
441
+ */
442
+ function isMediaQuery(tag, attrs, attrName) {
443
+ return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleElement(tag, attrs));
444
+ }
445
+
446
+ /**
447
+ * @param {string} attrName
448
+ * @param {string} tag
449
+ */
450
+ function isSrcset(attrName, tag) {
451
+ return (attrName === 'srcset' && srcsetElements.has(tag)) ||
452
+ (attrName === 'imagesrcset' && tag === 'link');
453
+ }
454
+
455
+ // Whitespace (as `\s`) and comma for the `srcset` parser, with ASCII checked by code first
456
+ /** @param {number} code */
457
+ function isWsChar(code) {
458
+ return code === 32 || (code >= 9 && code <= 13) ||
459
+ (code > 127 && RE_WS_CHAR.test(String.fromCharCode(code)));
460
+ }
461
+
462
+ /** @param {number} code */
463
+ function isWsOrComma(code) {
464
+ return code === 44 /* , */ || isWsChar(code);
465
+ }
466
+
467
+ /**
468
+ * @param {string} tag
469
+ * @param {HTMLAttribute[]} attrs
470
+ */
471
+ function isMetaViewport(tag, attrs) {
472
+ if (tag !== 'meta') {
473
+ return false;
474
+ }
475
+ for (const attr of attrs) {
476
+ if (attr.name.toLowerCase() === 'name' && (attr.value || '').toLowerCase() === 'viewport') {
477
+ return true;
478
+ }
479
+ }
480
+ return false;
481
+ }
482
+
483
+ /**
484
+ * @param {string} tag
485
+ * @param {HTMLAttribute[]} attrs
486
+ */
487
+ function isContentSecurityPolicy(tag, attrs) {
488
+ if (tag !== 'meta') {
489
+ return false;
490
+ }
491
+ for (const attr of attrs) {
492
+ if (attr.name.toLowerCase() === 'http-equiv' && (attr.value || '').toLowerCase() === 'content-security-policy') {
493
+ return true;
494
+ }
495
+ }
496
+ return false;
497
+ }
498
+
499
+ /**
500
+ * Whether a `meta` element has no effect in any current browser, holding no more than the
501
+ * `http-equiv` or `name` that says so, and `content`
502
+ * @param {HTMLAttribute[]} attrs - Deduplicated
503
+ * @param {string[]} markers - What custom fragment and `htmlmin:ignore` placeholders hold
504
+ */
505
+ function isMetaWithoutEffect(attrs, markers) {
506
+ let hasKey = false;
507
+ for (const attr of attrs) {
508
+ const name = attr.name.toLowerCase();
509
+ const value = attr.value ?? '';
510
+ if (attr.customOpen || attr.customClose || markers.some(marker => value.indexOf(marker) !== -1)) {
511
+ return false;
512
+ }
513
+ if (name === 'http-equiv' || name === 'name') {
514
+ if (!(name === 'name' ? metaNamesNoEffect : metaHttpEquivNoEffect).has(value.toLowerCase())) {
515
+ return false;
516
+ }
517
+ hasKey = true;
518
+ } else if (name !== 'content') {
519
+ return false;
520
+ }
521
+ }
522
+ return hasKey;
523
+ }
524
+
525
+ /**
526
+ * The charset a `content` value declares, as browsers extract it, if any
527
+ * https://html.spec.whatwg.org/multipage/urls-and-fetching.html#algorithm-for-extracting-a-character-encoding-from-a-meta-element
528
+ * @param {string} value
529
+ * @returns {string | undefined}
530
+ */
531
+ function extractCharset(value) {
532
+ const re = /charset[\t\n\f\r ]*(=)?[\t\n\f\r ]*/gi;
533
+ let match;
534
+ while ((match = re.exec(value))) {
535
+ if (!match[1]) {
536
+ continue;
537
+ }
538
+ const start = re.lastIndex;
539
+ const quote = value.charAt(start);
540
+ if (quote === '"' || quote === "'") {
541
+ const end = value.indexOf(quote, start + 1);
542
+ return end === -1 ? undefined : value.slice(start + 1, end);
543
+ }
544
+ return value.slice(start).split(/[\t\n\f\r ;]/, 1)[0];
545
+ }
546
+ return undefined;
547
+ }
548
+
549
+ /**
550
+ * Replace a `meta` element’s `http-equiv=Content-Type` and `content` with the `charset` they
551
+ * declare, which browsers read the same
552
+ * @param {HTMLAttribute[]} attrs - Deduplicated; changed in place
553
+ * @param {string[]} markers - What custom fragment and `htmlmin:ignore` placeholders hold
554
+ */
555
+ function replaceContentTypePragma(attrs, markers) {
556
+ if (attrs.length !== 2 || attrs.some(attr => attr.customOpen || attr.customClose)) {
557
+ return;
558
+ }
559
+ const httpEquiv = attrs.find(attr => attr.name.toLowerCase() === 'http-equiv');
560
+ const content = attrs.find(attr => attr.name.toLowerCase() === 'content');
561
+ if (!httpEquiv || !content || (httpEquiv.value ?? '').toLowerCase() !== 'content-type') {
562
+ return;
563
+ }
564
+ const value = content.value ?? '';
565
+ // Character references are left to the attribute, which browsers decode before extracting
566
+ const charset = value.indexOf('&') !== -1 || markers.some(marker => value.indexOf(marker) !== -1) ? undefined : extractCharset(value);
567
+ if (charset && trimWhitespace(charset)) {
568
+ attrs.splice(0, 2, { ...content, name: 'charset', value: charset });
569
+ }
570
+ }
571
+
572
+ /**
573
+ * A `rel` value without repeated keywords, nor ones others imply
574
+ * @param {string} tag - In lowercase
575
+ * @param {string} attrValue
576
+ */
577
+ function cleanRelKeywords(tag, attrValue) {
578
+ const keywords = trimWhitespace(attrValue).split(RE_ATTR_WS_COLLAPSE);
579
+ const seen = new Set();
580
+ for (const keyword of keywords) {
581
+ seen.add(keyword.toLowerCase());
582
+ }
583
+ // Only Internet Explorer needed `shortcut`; `noreferrer` implies `noopener` (as does
584
+ // `target=_blank`, but only since Chrome 88, Firefox 79, and Safari 12.1 [TBC])
585
+ const implied = tag === 'link' && seen.has('icon')
586
+ ? 'shortcut'
587
+ : (tag === 'a' || tag === 'area' || tag === 'form') && seen.has('noreferrer') ? 'noopener' : '';
588
+ if (seen.size === keywords.length && !seen.has(implied)) {
589
+ return attrValue;
590
+ }
591
+ seen.clear();
592
+ return keywords.filter(keyword => {
593
+ const keywordLower = keyword.toLowerCase();
594
+ if (keywordLower === implied || seen.has(keywordLower)) {
595
+ return false;
596
+ }
597
+ seen.add(keywordLower);
598
+ return true;
599
+ }).join(' ');
600
+ }
601
+
602
+ /**
603
+ * @param {string} tag
604
+ * @param {string} attrName
605
+ * @param {string | undefined} attrValue
606
+ * @param {{removeEmptyAttributes?: boolean | Function}} options
607
+ * @param {string} [tagOut] - The tag name as written, for the hook
608
+ * @param {string} [attrNameOut] - The attribute name as written, for the hook
609
+ */
610
+ function canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut = tag, attrNameOut = attrName) {
611
+ const isValueEmpty = !attrValue || attrValue.trim() === '';
612
+ if (!isValueEmpty) {
613
+ return false;
614
+ }
615
+ if (typeof options.removeEmptyAttributes === 'function') {
616
+ return options.removeEmptyAttributes(attrNameOut, tagOut);
617
+ }
618
+ return (tag === 'input' && attrName === 'value') || RE_EMPTY_ATTRIBUTE.test(attrName);
619
+ }
620
+
621
+ /**
622
+ * @param {string} name
623
+ * @param {HTMLAttribute[]} attrs
624
+ */
625
+ function hasAttrName(name, attrs) {
626
+ for (const attr of attrs) {
627
+ if (attr.name === name) {
628
+ return true;
629
+ }
630
+ }
631
+ return false;
632
+ }
633
+
634
+ // Cleaners
635
+
636
+ const collapseAttributeWhitespaceExempt = new Set(['pattern', 'placeholder', 'title']);
637
+ // `value` whitespace matters only on form-submission and machine-readable elements
638
+ const valueWhitespaceExemptElements = new Set(['button', 'data', 'input', 'option', 'param']);
639
+
640
+ /**
641
+ * Drop repeated names from a whitespace-separated class list, keeping the first of each.
642
+ * @param {string} value
643
+ * @returns {string}
644
+ */
645
+ function dedupeClassNames(value) {
646
+ if (!RE_ATTR_WS_CHECK.test(value)) {
647
+ return value;
648
+ }
649
+ const names = value.split(RE_ATTR_WS_COLLAPSE);
650
+ const unique = new Set(names);
651
+ return unique.size === names.length ? value : [...unique].join(' ');
652
+ }
653
+
654
+ // Returns the cleaned attribute value directly (sync) or as a Promise (async);
655
+ // callers must handle both cases—use `isThenable()` to distinguish
656
+ /**
657
+ * @param {string} tag
658
+ * @param {string} attrName
659
+ * @param {string} attrValue
660
+ * @param {ProcessedOptions} options
661
+ * @param {HTMLAttribute[]} attrs
662
+ * @param {Function} minifyHTMLSelf
663
+ * @param {string[]} [markers] - What custom fragment and `htmlmin:ignore` placeholders hold
664
+ * @param {string} [attrNameOut] - The name as written, for the patterns
665
+ */
666
+ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf, markers = [], attrNameOut = attrName) {
667
+ const isEventAttr = isEventAttribute(attrName, options, attrNameOut);
668
+
669
+ // Special spaces (no-break space, hair space, etc.) stay, for consistency
670
+ // with `collapseWhitespace`
671
+ if (options.collapseAttributeWhitespace && !collapseAttributeWhitespaceExempt.has(attrName) && !(attrName === 'value' && valueWhitespaceExemptElements.has(tag)) && !isEventAttr) {
672
+ // Fast path: Only process if whitespace exists (avoids regex overhead on clean values)
673
+ if (RE_ATTR_WS_CHECK.test(attrValue)) {
674
+ attrValue = trimWhitespace(attrValue).replace(RE_ATTR_WS_COLLAPSE, ' ');
675
+ }
676
+ }
677
+
678
+ if (isEventAttr) {
679
+ attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
680
+ // Browsers decode attribute values before running event-handler JS—
681
+ // decode first so the minifier gets valid JavaScript
682
+ if (!options.decodeEntities && options.minifyJS !== identity && attrValue.indexOf('&') !== -1) {
683
+ return getDecodeHTMLAttribute().then(decode => {
684
+ const decoded = decode(attrValue);
685
+ const result = options.minifyJS(decoded, true);
686
+ const reEncode = (/** @type {string} */ v) => (v && v.indexOf('&') !== -1) ? v.replace(RE_AMP_REFERENCE_ATTR, '&amp;') : v;
687
+ if (isThenable(result)) {
688
+ return result.then(reEncode, (/** @type {Error} */ err) => {
689
+ if (!options.continueOnMinifyError) throw err;
690
+ options.log(err);
691
+ return attrValue;
692
+ });
693
+ }
694
+ return reEncode(result);
695
+ });
696
+ }
697
+ const result = options.minifyJS(attrValue, true);
698
+ if (isThenable(result)) {
699
+ return result.catch((/** @type {Error} */ err) => {
700
+ if (!options.continueOnMinifyError) throw err;
701
+ options.log(err);
702
+ return attrValue;
703
+ });
704
+ }
705
+ return result;
706
+ }
707
+
708
+ if (attrName === 'class') {
709
+ attrValue = trimWhitespace(attrValue);
710
+ // Names around a fragment placeholder (`ignoreCustomFragments`, `htmlmin:ignore`)
711
+ // may not repeat once a template renders
712
+ const holdsFragment = markers.some(marker => attrValue.indexOf(marker) !== -1);
713
+ if (options.sortClassNames) {
714
+ // `createSortFns` has replaced any truthy non-function value by now
715
+ attrValue = /** @type {(value: string) => string} */ (options.sortClassNames)(attrValue);
716
+ } else {
717
+ attrValue = collapseWhitespaceAll(attrValue);
718
+ }
719
+ return holdsFragment ? attrValue : dedupeClassNames(attrValue);
720
+ }
721
+
722
+ if (isUriTypeAttribute(attrName, tag)) {
723
+ attrValue = trimWhitespace(attrValue);
724
+ if (isLinkType(tag, attrs, 'canonical')) {
725
+ return attrValue;
726
+ }
727
+ const result = options.minifyURLs(attrValue);
728
+ if (isThenable(result)) {
729
+ return result
730
+ .then((/** @type {unknown} */ out) => typeof out === 'string' ? out : attrValue)
731
+ .catch((/** @type {Error} */ err) => {
732
+ if (!options.continueOnMinifyError) throw err;
733
+ options.log(err);
734
+ return attrValue;
735
+ });
736
+ }
737
+ return typeof result === 'string' ? result : attrValue;
738
+ }
739
+
740
+ if (isNumberTypeAttribute(attrName, tag)) {
741
+ return trimWhitespace(attrValue);
742
+ }
743
+
744
+ if (attrName === 'style') {
745
+ attrValue = trimWhitespace(attrValue);
746
+ if (attrValue) {
747
+ if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
748
+ attrValue = attrValue.replace(/\s*;$/, ';');
749
+ }
750
+ const originalAttrValue = attrValue;
751
+ const cssResult = options.minifyCSS(attrValue, 'inline', options.cssContext);
752
+ if (isThenable(cssResult)) {
753
+ return cssResult
754
+ .then((/** @type {string} */ minified) => {
755
+ // CSS of nothing but properties without values (`color:`, `margin:;padding:`) is empty
756
+ if (minified && /^(?:[a-z-]+:[;\s]*)+$/i.test(minified)) return '';
757
+ return minified;
758
+ })
759
+ .catch((/** @type {Error} */ err) => {
760
+ if (!options.continueOnMinifyError) throw err;
761
+ options.log(err);
762
+ return originalAttrValue;
763
+ });
764
+ }
765
+ // Sync path (`minifyCSS` disabled—identity function)
766
+ if (cssResult && /^(?:[a-z-]+:[;\s]*)+$/i.test(cssResult)) return '';
767
+ return cssResult ?? attrValue;
768
+ }
769
+ return attrValue;
770
+ }
771
+
772
+ if (isSrcset(attrName, tag)) {
773
+ // Split into image candidate strings following the spec parsing algorithm
774
+ // (https://html.spec.whatwg.org/multipage/images.html#parsing-a-srcset-attribute);
775
+ // a naive split on commas would corrupt URLs that contain commas
776
+ const value = trimWhitespace(attrValue);
777
+ /** @type {string[]} */
778
+ const candidates = [];
779
+ let pos = 0;
780
+ while (pos < value.length) {
781
+ // Skip whitespace and separator commas
782
+ while (pos < value.length && isWsOrComma(value.charCodeAt(pos))) pos++;
783
+ if (pos >= value.length) break;
784
+ const start = pos;
785
+ // URL: a run of non-whitespace characters (which may contain commas)
786
+ while (pos < value.length && !isWsChar(value.charCodeAt(pos))) pos++;
787
+ if (value.charAt(pos - 1) === ',') {
788
+ // Trailing comma(s) end the candidate—a URL without descriptor
789
+ candidates.push(value.slice(start, pos).replace(/,+$/, ''));
790
+ continue;
791
+ }
792
+ // Descriptor: everything up to the next comma outside parentheses
793
+ let inParens = false;
794
+ while (pos < value.length) {
795
+ const code = value.charCodeAt(pos);
796
+ if (!inParens && code === 44 /* , */) break;
797
+ if (code === 40 /* ( */) inParens = true;
798
+ else if (code === 41 /* ) */) inParens = false;
799
+ pos++;
800
+ }
801
+ candidates.push(trimWhitespace(value.slice(start, pos)));
802
+ }
803
+ const processed = candidates.map(candidate => {
804
+ let url = candidate;
805
+ let descriptor = '';
806
+ const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
807
+ if (match) {
808
+ url = url.slice(0, -match[0].length);
809
+ const group = match[1] ?? '';
810
+ const num = +group.slice(0, -1);
811
+ const suffix = group.slice(-1);
812
+ if (num !== 1 || suffix !== 'x') {
813
+ descriptor = ' ' + num + suffix;
814
+ }
815
+ }
816
+ const out = options.minifyURLs(url);
817
+ if (isThenable(out)) {
818
+ return out
819
+ .then((/** @type {unknown} */ result) => (typeof result === 'string' ? result : url) + descriptor)
820
+ .catch((/** @type {Error} */ err) => {
821
+ if (!options.continueOnMinifyError) throw err;
822
+ options.log(err);
823
+ return url + descriptor;
824
+ });
825
+ }
826
+ return (typeof out === 'string' ? out : url) + descriptor;
827
+ });
828
+ if (processed.some(isThenable)) {
829
+ return Promise.all(processed).then(results => results.join(', '));
830
+ }
831
+ return processed.join(', ');
832
+ }
833
+
834
+ if (attrName === 'rel' && options.removeRedundantAttributes && !options.insideForeignContent) {
835
+ // Which keywords a template renders, the value doesn’t show
836
+ return markers.some(marker => attrValue.indexOf(marker) !== -1) ? attrValue : cleanRelKeywords(tag, attrValue);
837
+ }
838
+
839
+ if (attrName === 'content' && isMetaViewport(tag, attrs)) {
840
+ return attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
841
+ // 0.90000 → 0.9
842
+ // 1.0 → 1
843
+ // 1.0001 → 1.0001 (unchanged)
844
+ return (+numString).toString();
845
+ });
846
+ }
847
+
848
+ if (attrName.toLowerCase() === 'content' && isContentSecurityPolicy(tag, attrs)) {
849
+ return collapseWhitespaceAll(attrValue);
850
+ }
851
+
852
+ if (options.customAttrCollapse && options.customAttrCollapse.test(attrNameOut)) {
853
+ return trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
854
+ }
855
+
856
+ if (tag === 'script' && attrName === 'type') {
857
+ return trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
858
+ }
859
+
860
+ if (isMediaQuery(tag, attrs, attrName)) {
861
+ attrValue = trimWhitespace(attrValue);
862
+ // Only media queries with features (in parentheses) need minifying; media types like `all`,
863
+ // `screen`, or `print` are minimal already
864
+ if (!/[()]/.test(attrValue)) {
865
+ return attrValue;
866
+ }
867
+ const originalAttrValue = attrValue;
868
+ const cssResult = options.minifyCSS(attrValue, 'media', options.cssContext);
869
+ if (isThenable(cssResult)) {
870
+ return cssResult.catch((/** @type {Error} */ err) => {
871
+ if (!options.continueOnMinifyError) throw err;
872
+ options.log(err);
873
+ return originalAttrValue;
874
+ });
875
+ }
876
+ return cssResult ?? attrValue;
877
+ }
878
+
879
+ if (tag === 'iframe' && attrName === 'srcdoc') {
880
+ // Fast path: Skip if nothing would change
881
+ if (!shouldMinifyInnerHTML(options)) {
882
+ return attrValue;
883
+ }
884
+ return minifySrcdoc(attrValue, options, minifyHTMLSelf);
885
+ }
886
+
887
+ return attrValue;
888
+ }
889
+
890
+ /**
891
+ * Recursively minify the document an `iframe srcdoc` attribute holds.
892
+ *
893
+ * Browsers resolve character references before parsing `srcdoc`, so an
894
+ * entity-encoded document is decoded first (unless `decodeEntities` already
895
+ * did) and re-encoded afterwards—but only then, so a literal value passes
896
+ * through byte-identical.
897
+ *
898
+ * @param {string} attrValue
899
+ * @param {ProcessedOptions} options
900
+ * @param {Function} minifyHTMLSelf
901
+ * @returns {Promise<string>}
902
+ */
903
+ async function minifySrcdoc(attrValue, options, minifyHTMLSelf) {
904
+ let markup = attrValue;
905
+ let wasEncoded = false;
906
+ if (!options.decodeEntities && attrValue.indexOf('&') !== -1) {
907
+ const decode = await getDecodeHTMLAttribute();
908
+ markup = decode(attrValue);
909
+ wasEncoded = markup !== attrValue;
910
+ }
911
+
912
+ // The inner document sees none of the parent’s markup, so its `base` element is its own,
913
+ // and its style sheets minify against the symbols the `srcdoc` content references itself
914
+ const srcdocOptions = { ...options, baseState: { hasBase: undefined, source: markup } };
915
+ if (options.removeUnusedCSS && RE_STYLE_ELEMENT.test(markup)) {
916
+ const decode = markup.indexOf('&') !== -1 ? await getDecodeHTMLAttribute() : undefined;
917
+ srcdocOptions.cssContext = {
918
+ warned: options.cssContext ? options.cssContext.warned : new Set(),
919
+ ...collectUsage(markup, options.removeUnusedCSS.scripts, /** @type {((text: string) => string) | undefined} */ (decode))
920
+ };
921
+ }
922
+
923
+ try {
924
+ const minified = await minifyHTMLSelf(markup, srcdocOptions, true);
925
+ return wasEncoded ? escapeAttribute(minified) : minified;
926
+ } catch (err) {
927
+ if (!options.continueOnMinifyError) throw err;
928
+ options.log(/** @type {Error} */ (err));
929
+ return attrValue;
930
+ }
931
+ }
932
+
933
+ /**
934
+ * Choose appropriate quote character for an attribute value
935
+ * @param {string} attrValue - The attribute value
936
+ * @param {{quoteCharacter?: string}} options - Minifier options
937
+ * @returns {string} The chosen quote character (`"` or `'`)
938
+ */
939
+ function chooseAttributeQuote(attrValue, options) {
940
+ if (typeof options.quoteCharacter !== 'undefined') {
941
+ return options.quoteCharacter === '\'' ? '\'' : '"';
942
+ }
943
+
944
+ // Count quotes in a single pass
945
+ let apos = 0, quot = 0;
946
+ for (let i = 0; i < attrValue.length; i++) {
947
+ if (attrValue[i] === "'") apos++;
948
+ else if (attrValue[i] === '"') quot++;
949
+ }
950
+ return apos < quot ? '\'' : '"';
951
+ }
952
+
953
+ // Returns the normalized attribute object directly (sync) or as a Promise (async);
954
+ // callers must handle both cases—use `isThenable()` to distinguish
955
+ /**
956
+ * @param {HTMLAttribute} attr
957
+ * @param {HTMLAttribute[]} attrs
958
+ * @param {string} tag
959
+ * @param {string} tagOut - The tag name as written, for hooks
960
+ * @param {ProcessedOptions} options
961
+ * @param {Function} minifyHTML
962
+ * @param {string[]} [markers] - What custom fragment and `htmlmin:ignore` placeholders hold
963
+ */
964
+ function normalizeAttr(attr, attrs, tag, tagOut, options, minifyHTML, markers) {
965
+ const attrName = options.name(attr.name);
966
+ // The name to write and to hand to hooks: as in the source for HTML names
967
+ // under `caseSensitive`, as named otherwise (foreign names are named as written anyway)
968
+ const attrNameOut = options.namesAsWritten && options.name === lowercase ? attr.name : attrName;
969
+ const attrValue = attr.value;
970
+
971
+ // Entity decoding requires a lazy import—async only while that import is unresolved
972
+ if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
973
+ if (decodeHTMLAttributeFn) {
974
+ return normalizeAttrContinue(attrName, attrNameOut, decodeHTMLAttributeFn(attrValue), attr, attrs, tag, tagOut, options, minifyHTML, markers);
975
+ }
976
+ return getDecodeHTMLAttribute().then(decode => {
977
+ return normalizeAttrContinue(attrName, attrNameOut, decode(attrValue), attr, attrs, tag, tagOut, options, minifyHTML, markers);
978
+ });
979
+ }
980
+
981
+ return normalizeAttrContinue(attrName, attrNameOut, attrValue, attr, attrs, tag, tagOut, options, minifyHTML, markers);
982
+ }
983
+
984
+ // Internal: Handles attribute normalization after entity decoding (if any)
985
+ /**
986
+ * @param {string} attrName
987
+ * @param {string} attrNameOut
988
+ * @param {string | undefined} attrValue
989
+ * @param {HTMLAttribute} attr
990
+ * @param {HTMLAttribute[]} attrs
991
+ * @param {string} tag
992
+ * @param {string} tagOut
993
+ * @param {ProcessedOptions} options
994
+ * @param {Function} minifyHTML
995
+ * @param {string[]} [markers] - What custom fragment and `htmlmin:ignore` placeholders hold
996
+ */
997
+ function normalizeAttrContinue(attrName, attrNameOut, attrValue, attr, attrs, tag, tagOut, options, minifyHTML, markers) {
998
+ if ((options.removeRedundantAttributes &&
999
+ isAttributeRedundant(tag, attrName, attrValue ?? '', attrs, options)) ||
1000
+ (options.removeDefaultTypeAttributes && attrName === 'type' && (
1001
+ ((tag === 'style' || tag === 'link') && isStyleLinkTypeAttribute(attrValue)) ||
1002
+ (tag === 'script' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue))
1003
+ ))) {
1004
+ return;
1005
+ }
1006
+
1007
+ if (attrValue) {
1008
+ const cleaned = cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML, markers, attrNameOut);
1009
+ if (isThenable(cleaned)) {
1010
+ return cleaned.then((/** @type {string | undefined} */ v) => normalizeAttrFinish(attrName, attrNameOut, v, attr, tag, tagOut, options));
1011
+ }
1012
+ return normalizeAttrFinish(attrName, attrNameOut, cleaned, attr, tag, tagOut, options);
1013
+ }
1014
+
1015
+ return normalizeAttrFinish(attrName, attrNameOut, attrValue, attr, tag, tagOut, options);
1016
+ }
1017
+
1018
+ // Internal: Final checks and result assembly after value cleaning
1019
+ /**
1020
+ * @param {string} attrName
1021
+ * @param {string} attrNameOut
1022
+ * @param {string | undefined} attrValue
1023
+ * @param {HTMLAttribute} attr
1024
+ * @param {string} tag
1025
+ * @param {string} tagOut
1026
+ * @param {ProcessedOptions} options
1027
+ */
1028
+ function normalizeAttrFinish(attrName, attrNameOut, attrValue, attr, tag, tagOut, options) {
1029
+ if (options.removeEmptyAttributes &&
1030
+ canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut, attrNameOut)) {
1031
+ return;
1032
+ }
1033
+
1034
+ if (attrValue && options.insideSVG && options.minifySVG) {
1035
+ attrValue = escapeXML(attrValue, true);
1036
+ } else if (options.decodeEntities && attrValue) {
1037
+ if (attrValue.indexOf('&') !== -1) {
1038
+ attrValue = attrValue.replace(RE_AMP_REFERENCE_ATTR, '&amp;');
1039
+ }
1040
+ if (attrValue.indexOf('\r') !== -1) {
1041
+ attrValue = attrValue.replace(RE_CR, '&#13;');
1042
+ }
1043
+ }
1044
+
1045
+ return {
1046
+ attr,
1047
+ name: attrNameOut,
1048
+ value: attrValue
1049
+ };
1050
+ }
1051
+
1052
+ /**
1053
+ * @param {{name: string, value: string | undefined, attr: HTMLAttribute}} normalized
1054
+ * @param {string | boolean | undefined} hasUnarySlash
1055
+ * @param {ProcessedOptions} options
1056
+ * @param {boolean} isLast
1057
+ * @param {string | undefined} uidAttr
1058
+ */
1059
+ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
1060
+ const attrName = normalized.name;
1061
+ let attrValue = normalized.value;
1062
+ const attr = normalized.attr;
1063
+ let attrQuote = attr.quote;
1064
+ const readAsXML = Boolean(options.insideSVG) && Boolean(options.minifySVG);
1065
+
1066
+ // SVGO reads the whole SVG block as XML, where every attribute carries a value
1067
+ if (readAsXML && typeof attrValue === 'undefined') {
1068
+ attrValue = '';
1069
+ }
1070
+
1071
+ // An attribute written as its name alone skips the quoting work below
1072
+ if (typeof attrValue === 'undefined' ||
1073
+ (options.collapseBooleanAttributes && collapsesToBooleanName(attrName, attrValue ?? '')) ||
1074
+ (options.collapseEmptyAttributes && attrValue === '' && (attr.customAssign ?? '=') === '=')) {
1075
+ return attr.customOpen + attrName + (isLast ? '' : ' ') + attr.customClose;
1076
+ }
1077
+
1078
+ const shouldAddQuotes = typeof attrValue !== 'undefined' && (
1079
+ // If `removeAttributeQuotes` is enabled, add quotes only if they can’t be removed
1080
+ (options.removeAttributeQuotes && ((uidAttr ? attrValue.indexOf(uidAttr) !== -1 : false) || !canRemoveAttributeQuotes(attrValue))) ||
1081
+ // If `removeAttributeQuotes` is not enabled, preserve original quote style, or add
1082
+ // quotes if value requires them
1083
+ (!options.removeAttributeQuotes && (attrQuote !== '' || !canRemoveAttributeQuotes(attrValue) ||
1084
+ // XML quotes every attribute value
1085
+ readAsXML ||
1086
+ // With `removeTagWhitespace`, unquoted values that aren’t last get a space added, which can
1087
+ // create ambiguous or invalid HTML—add quotes to be safe
1088
+ (options.removeTagWhitespace && attrQuote === '' && !isLast)))
1089
+ );
1090
+
1091
+ let emittedAttrValue;
1092
+ if (shouldAddQuotes) {
1093
+ attrValue = attrValue ?? '';
1094
+ if (!options.preventAttributesEscaping) {
1095
+ // Normal mode: Choose the quote type that minimizes escaping, unless the original quotes are
1096
+ // kept and need no escaping
1097
+ const needsEscaping = (attrQuote === '"' && attrValue.indexOf('"') !== -1) || (attrQuote === "'" && attrValue.indexOf("'") !== -1);
1098
+
1099
+ if (options.removeAttributeQuotes || typeof options.quoteCharacter !== 'undefined' || needsEscaping || attrQuote === '') {
1100
+ attrQuote = chooseAttributeQuote(attrValue, options);
1101
+ }
1102
+
1103
+ // `indexOf` first, as values rarely hold the quote and as it’s cheaper than `replace`
1104
+ if (attrQuote === '"') {
1105
+ if (attrValue.indexOf('"') !== -1) attrValue = attrValue.replace(/"/g, '&#34;');
1106
+ } else if (attrValue.indexOf("'") !== -1) {
1107
+ attrValue = attrValue.replace(/'/g, '&#39;');
1108
+ }
1109
+ } else {
1110
+ // `preventAttributesEscaping` mode: Choose safe quotes but don’t escape
1111
+ // except when both quote types are present—then escape to prevent invalid HTML
1112
+ const hasDoubleQuote = attrValue.indexOf('"') !== -1;
1113
+ const hasSingleQuote = attrValue.indexOf("'") !== -1;
1114
+
1115
+ // Both quote types present: Escaping is required to guarantee valid HTML delimiter matching
1116
+ if (hasDoubleQuote && hasSingleQuote) {
1117
+ attrQuote = chooseAttributeQuote(attrValue, options);
1118
+ if (attrQuote === '"') {
1119
+ attrValue = attrValue.replace(/"/g, '&#34;');
1120
+ } else {
1121
+ attrValue = attrValue.replace(/'/g, '&#39;');
1122
+ }
1123
+ // No `quoteCharacter` set: Prefer the quote type the value doesn’t hold, default to double
1124
+ } else if (typeof options.quoteCharacter === 'undefined') {
1125
+ if (attrQuote === '"' && hasDoubleQuote && !hasSingleQuote) {
1126
+ attrQuote = "'";
1127
+ } else if (attrQuote === "'" && hasSingleQuote && !hasDoubleQuote) {
1128
+ attrQuote = '"';
1129
+ // If no quote character yet (empty string), choose based on content
1130
+ } else if (attrQuote === '') {
1131
+ if (hasSingleQuote && !hasDoubleQuote) {
1132
+ attrQuote = '"';
1133
+ } else if (hasDoubleQuote && !hasSingleQuote) {
1134
+ attrQuote = "'";
1135
+ } else {
1136
+ attrQuote = '"';
1137
+ }
1138
+ // Fallback for invalid/unsupported attrQuote values (not `"`, `'`, or empty string):
1139
+ // Choose safe default based on value content
1140
+ } else if (attrQuote !== '"' && attrQuote !== "'") {
1141
+ if (hasSingleQuote && !hasDoubleQuote) {
1142
+ attrQuote = '"';
1143
+ } else if (hasDoubleQuote && !hasSingleQuote) {
1144
+ attrQuote = "'";
1145
+ } else {
1146
+ attrQuote = '"';
1147
+ }
1148
+ }
1149
+ } else {
1150
+ // `quoteCharacter` is explicitly set
1151
+ const preferredQuote = options.quoteCharacter === '\'' ? '\'' : '"';
1152
+ // If the preferred quote conflicts with value content, switch to the opposite one
1153
+ if ((preferredQuote === '"' && hasDoubleQuote && !hasSingleQuote) || (preferredQuote === "'" && hasSingleQuote && !hasDoubleQuote)) {
1154
+ attrQuote = preferredQuote === '"' ? "'" : '"';
1155
+ } else if ((preferredQuote === '"' && hasDoubleQuote && hasSingleQuote) || (preferredQuote === "'" && hasSingleQuote && hasDoubleQuote)) {
1156
+ // Both quote types present: Fall back to escaping despite `preventAttributesEscaping`
1157
+ attrQuote = preferredQuote;
1158
+ if (attrQuote === '"') {
1159
+ attrValue = attrValue.replace(/"/g, '&#34;');
1160
+ } else {
1161
+ attrValue = attrValue.replace(/'/g, '&#39;');
1162
+ }
1163
+ } else {
1164
+ attrQuote = preferredQuote;
1165
+ }
1166
+ }
1167
+ }
1168
+ emittedAttrValue = attrQuote + attrValue + attrQuote;
1169
+ if (!isLast && !options.removeTagWhitespace) {
1170
+ emittedAttrValue += ' ';
1171
+ }
1172
+ } else if (isLast && !hasUnarySlash) {
1173
+ // Last attribute in a non-self-closing tag: No space needed
1174
+ emittedAttrValue = attrValue;
1175
+ } else {
1176
+ // Not last attribute, or a self-closing tag: Unquoted values need a space to delimit them
1177
+ emittedAttrValue = attrValue + ' ';
1178
+ }
1179
+
1180
+ return attr.customOpen + attrName + attr.customAssign + emittedAttrValue + attr.customClose;
1181
+ }
1182
+
1183
+ export {
1184
+ // Validators
1185
+ isIgnoredComment,
1186
+ isExecutableScript,
1187
+ isStyleElement,
1188
+ isMetaWithoutEffect,
1189
+ hasAttrName,
1190
+
1191
+ // Cleaners
1192
+ normalizeAttr,
1193
+ buildAttr,
1194
+ deduplicateAttributes,
1195
+ replaceContentTypePragma
1196
+ };