html-minifier-next 4.12.1 → 4.13.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.
package/src/htmlparser.js CHANGED
@@ -6,7 +6,8 @@
6
6
  */
7
7
 
8
8
  /*
9
- * // Use like so:
9
+ * Use like so:
10
+ *
10
11
  * HTMLParser(htmlString, {
11
12
  * start: function(tag, attrs, unary) {},
12
13
  * end: function(tag) {},
@@ -25,11 +26,11 @@ class CaseInsensitiveSet extends Set {
25
26
  const singleAttrIdentifier = /([^\s"'<>/=]+)/;
26
27
  const singleAttrAssigns = [/=/];
27
28
  const singleAttrValues = [
28
- // attr value double quotes
29
+ // Attr value double quotes
29
30
  /"([^"]*)"+/.source,
30
- // attr value, single quotes
31
+ // Attr value, single quotes
31
32
  /'([^']*)'+/.source,
32
- // attr value, no quotes
33
+ // Attr value, no quotes
33
34
  /([^ \t\n\f\r"'`=<>]+)/.source
34
35
  ];
35
36
  // https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName
@@ -58,18 +59,17 @@ const empty = new CaseInsensitiveSet(['area', 'base', 'basefont', 'br', 'col', '
58
59
  // Inline elements
59
60
  const inline = new CaseInsensitiveSet(['a', 'abbr', 'acronym', 'applet', 'b', 'basefont', 'bdo', 'big', 'br', 'button', 'cite', 'code', 'del', 'dfn', 'em', 'font', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'map', 'noscript', 'object', 'q', 's', 'samp', 'script', 'select', 'selectedcontent', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'svg', 'textarea', 'tt', 'u', 'var']);
60
61
 
61
- // Elements that you can, intentionally, leave open
62
- // (and which close themselves)
62
+ // Elements that you can, intentionally, leave open (and which close themselves)
63
63
  const closeSelf = new CaseInsensitiveSet(['colgroup', 'dd', 'dt', 'li', 'option', 'p', 'td', 'tfoot', 'th', 'thead', 'tr', 'source']);
64
64
 
65
- // Attributes that have their values filled in disabled='disabled'
65
+ // Attributes that have their values filled in `disabled='disabled'`
66
66
  const fillAttrs = new CaseInsensitiveSet(['checked', 'compact', 'declare', 'defer', 'disabled', 'ismap', 'multiple', 'nohref', 'noresize', 'noshade', 'nowrap', 'readonly', 'selected']);
67
67
 
68
68
  // Special elements (can contain anything)
69
69
  const special = new CaseInsensitiveSet(['script', 'style']);
70
70
 
71
- // HTML elements https://html.spec.whatwg.org/multipage/indices.html#elements-3
72
- // Phrasing Content https://html.spec.whatwg.org/multipage/dom.html#phrasing-content
71
+ // HTML elements, https://html.spec.whatwg.org/multipage/indices.html#elements-3
72
+ // Phrasing content, https://html.spec.whatwg.org/multipage/dom.html#phrasing-content
73
73
  const nonPhrasing = new CaseInsensitiveSet(['address', 'article', 'aside', 'base', 'blockquote', 'body', 'caption', 'col', 'colgroup', 'dd', 'details', 'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'legend', 'li', 'menuitem', 'meta', 'ol', 'optgroup', 'option', 'param', 'rp', 'rt', 'source', 'style', 'summary', 'tbody', 'td', 'tfoot', 'th', 'thead', 'title', 'tr', 'track', 'ul']);
74
74
 
75
75
  const reCache = {};
@@ -191,7 +191,7 @@ export class HTMLParser {
191
191
 
192
192
  if (conditionalEnd >= 0) {
193
193
  if (handler.comment) {
194
- await handler.comment(html.substring(2, conditionalEnd + 1), true /* non-standard */);
194
+ await handler.comment(html.substring(2, conditionalEnd + 1), true /* Non-standard */);
195
195
  }
196
196
  advance(conditionalEnd + 2);
197
197
  prevTag = '';
@@ -368,7 +368,7 @@ export class HTMLParser {
368
368
  attr = [];
369
369
  attr[0] = fullAttr;
370
370
  attr[baseIndex] = manualMatch[1]; // Attribute name
371
- attr[baseIndex + 1] = '='; // customAssign (falls back to “=” for huge attributes)
371
+ attr[baseIndex + 1] = '='; // `customAssign` (falls back to “=” for huge attributes)
372
372
  const value = input.slice(manualMatch[0].length + 1, closeQuote);
373
373
  // Place value at correct index based on quote type
374
374
  if (quoteChar === '"') {
@@ -0,0 +1,511 @@
1
+ // Imports
2
+
3
+ import { decodeHTMLStrict } from 'entities';
4
+ import {
5
+ RE_CONDITIONAL_COMMENT,
6
+ RE_EVENT_ATTR_DEFAULT,
7
+ RE_CAN_REMOVE_ATTR_QUOTES,
8
+ RE_AMP_ENTITY,
9
+ generalDefaults,
10
+ tagDefaults,
11
+ executableScriptsMimetypes,
12
+ keepScriptsMimetypes,
13
+ isSimpleBoolean,
14
+ isBooleanValue,
15
+ srcsetTags,
16
+ reEmptyAttribute
17
+ } from './constants.js';
18
+ import { trimWhitespace, collapseWhitespaceAll } from './whitespace.js';
19
+ import { shouldMinifyInnerHTML } from './options.js';
20
+
21
+ // Validators
22
+
23
+ function isConditionalComment(text) {
24
+ return RE_CONDITIONAL_COMMENT.test(text);
25
+ }
26
+
27
+ function isIgnoredComment(text, options) {
28
+ for (let i = 0, len = options.ignoreCustomComments.length; i < len; i++) {
29
+ if (options.ignoreCustomComments[i].test(text)) {
30
+ return true;
31
+ }
32
+ }
33
+ return false;
34
+ }
35
+
36
+ function isEventAttribute(attrName, options) {
37
+ const patterns = options.customEventAttributes;
38
+ if (patterns) {
39
+ for (let i = patterns.length; i--;) {
40
+ if (patterns[i].test(attrName)) {
41
+ return true;
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+ return RE_EVENT_ATTR_DEFAULT.test(attrName);
47
+ }
48
+
49
+ function canRemoveAttributeQuotes(value) {
50
+ // https://mathiasbynens.be/notes/unquoted-attribute-values
51
+ return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
52
+ }
53
+
54
+ function attributesInclude(attributes, attribute) {
55
+ for (let i = attributes.length; i--;) {
56
+ if (attributes[i].name.toLowerCase() === attribute) {
57
+ return true;
58
+ }
59
+ }
60
+ return false;
61
+ }
62
+
63
+ function isAttributeRedundant(tag, attrName, attrValue, attrs) {
64
+ attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
65
+
66
+ // Legacy attributes
67
+ if (tag === 'script' && attrName === 'language' && attrValue === 'javascript') {
68
+ return true;
69
+ }
70
+ if (tag === 'script' && attrName === 'charset' && !attributesInclude(attrs, 'src')) {
71
+ return true;
72
+ }
73
+ if (tag === 'a' && attrName === 'name' && attributesInclude(attrs, 'id')) {
74
+ return true;
75
+ }
76
+
77
+ // Check general defaults
78
+ if (generalDefaults[attrName] === attrValue) {
79
+ return true;
80
+ }
81
+
82
+ // Check tag-specific defaults
83
+ return tagDefaults[tag]?.[attrName] === attrValue;
84
+ }
85
+
86
+ function isScriptTypeAttribute(attrValue = '') {
87
+ attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
88
+ return attrValue === '' || executableScriptsMimetypes.has(attrValue);
89
+ }
90
+
91
+ function keepScriptTypeAttribute(attrValue = '') {
92
+ attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
93
+ return keepScriptsMimetypes.has(attrValue);
94
+ }
95
+
96
+ function isExecutableScript(tag, attrs) {
97
+ if (tag !== 'script') {
98
+ return false;
99
+ }
100
+ for (let i = 0, len = attrs.length; i < len; i++) {
101
+ const attrName = attrs[i].name.toLowerCase();
102
+ if (attrName === 'type') {
103
+ return isScriptTypeAttribute(attrs[i].value);
104
+ }
105
+ }
106
+ return true;
107
+ }
108
+
109
+ function isStyleLinkTypeAttribute(attrValue = '') {
110
+ attrValue = trimWhitespace(attrValue).toLowerCase();
111
+ return attrValue === '' || attrValue === 'text/css';
112
+ }
113
+
114
+ function isStyleSheet(tag, attrs) {
115
+ if (tag !== 'style') {
116
+ return false;
117
+ }
118
+ for (let i = 0, len = attrs.length; i < len; i++) {
119
+ const attrName = attrs[i].name.toLowerCase();
120
+ if (attrName === 'type') {
121
+ return isStyleLinkTypeAttribute(attrs[i].value);
122
+ }
123
+ }
124
+ return true;
125
+ }
126
+
127
+ function isBooleanAttribute(attrName, attrValue) {
128
+ return isSimpleBoolean.has(attrName) || (attrName === 'draggable' && !isBooleanValue.has(attrValue));
129
+ }
130
+
131
+ function isUriTypeAttribute(attrName, tag) {
132
+ return (
133
+ (/^(?:a|area|link|base)$/.test(tag) && attrName === 'href') ||
134
+ (tag === 'img' && /^(?:src|longdesc|usemap)$/.test(attrName)) ||
135
+ (tag === 'object' && /^(?:classid|codebase|data|usemap)$/.test(attrName)) ||
136
+ (tag === 'q' && attrName === 'cite') ||
137
+ (tag === 'blockquote' && attrName === 'cite') ||
138
+ ((tag === 'ins' || tag === 'del') && attrName === 'cite') ||
139
+ (tag === 'form' && attrName === 'action') ||
140
+ (tag === 'input' && (attrName === 'src' || attrName === 'usemap')) ||
141
+ (tag === 'head' && attrName === 'profile') ||
142
+ (tag === 'script' && (attrName === 'src' || attrName === 'for'))
143
+ );
144
+ }
145
+
146
+ function isNumberTypeAttribute(attrName, tag) {
147
+ return (
148
+ (/^(?:a|area|object|button)$/.test(tag) && attrName === 'tabindex') ||
149
+ (tag === 'input' && (attrName === 'maxlength' || attrName === 'tabindex')) ||
150
+ (tag === 'select' && (attrName === 'size' || attrName === 'tabindex')) ||
151
+ (tag === 'textarea' && /^(?:rows|cols|tabindex)$/.test(attrName)) ||
152
+ (tag === 'colgroup' && attrName === 'span') ||
153
+ (tag === 'col' && attrName === 'span') ||
154
+ ((tag === 'th' || tag === 'td') && (attrName === 'rowspan' || attrName === 'colspan'))
155
+ );
156
+ }
157
+
158
+ function isLinkType(tag, attrs, value) {
159
+ if (tag !== 'link') return false;
160
+ const needle = String(value).toLowerCase();
161
+ for (let i = 0; i < attrs.length; i++) {
162
+ if (attrs[i].name.toLowerCase() === 'rel') {
163
+ const tokens = String(attrs[i].value).toLowerCase().split(/\s+/);
164
+ if (tokens.includes(needle)) return true;
165
+ }
166
+ }
167
+ return false;
168
+ }
169
+
170
+ function isMediaQuery(tag, attrs, attrName) {
171
+ return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleSheet(tag, attrs));
172
+ }
173
+
174
+ function isSrcset(attrName, tag) {
175
+ return attrName === 'srcset' && srcsetTags.has(tag);
176
+ }
177
+
178
+ function isMetaViewport(tag, attrs) {
179
+ if (tag !== 'meta') {
180
+ return false;
181
+ }
182
+ for (let i = 0, len = attrs.length; i < len; i++) {
183
+ if (attrs[i].name === 'name' && attrs[i].value === 'viewport') {
184
+ return true;
185
+ }
186
+ }
187
+ return false;
188
+ }
189
+
190
+ function isContentSecurityPolicy(tag, attrs) {
191
+ if (tag !== 'meta') {
192
+ return false;
193
+ }
194
+ for (let i = 0, len = attrs.length; i < len; i++) {
195
+ if (attrs[i].name.toLowerCase() === 'http-equiv' && attrs[i].value.toLowerCase() === 'content-security-policy') {
196
+ return true;
197
+ }
198
+ }
199
+ return false;
200
+ }
201
+
202
+ function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
203
+ const isValueEmpty = !attrValue || /^\s*$/.test(attrValue);
204
+ if (!isValueEmpty) {
205
+ return false;
206
+ }
207
+ if (typeof options.removeEmptyAttributes === 'function') {
208
+ return options.removeEmptyAttributes(attrName, tag);
209
+ }
210
+ return (tag === 'input' && attrName === 'value') || reEmptyAttribute.test(attrName);
211
+ }
212
+
213
+ function hasAttrName(name, attrs) {
214
+ for (let i = attrs.length - 1; i >= 0; i--) {
215
+ if (attrs[i].name === name) {
216
+ return true;
217
+ }
218
+ }
219
+ return false;
220
+ }
221
+
222
+ // Cleaners
223
+
224
+ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
225
+ // Apply early whitespace normalization if enabled
226
+ // Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
227
+ if (options.collapseAttributeWhitespace) {
228
+ attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
229
+ }
230
+
231
+ if (isEventAttribute(attrName, options)) {
232
+ attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
233
+ try {
234
+ return await options.minifyJS(attrValue, true);
235
+ } catch (err) {
236
+ if (!options.continueOnMinifyError) {
237
+ throw err;
238
+ }
239
+ options.log && options.log(err);
240
+ return attrValue;
241
+ }
242
+ } else if (attrName === 'class') {
243
+ attrValue = trimWhitespace(attrValue);
244
+ if (options.sortClassName) {
245
+ attrValue = options.sortClassName(attrValue);
246
+ } else {
247
+ attrValue = collapseWhitespaceAll(attrValue);
248
+ }
249
+ return attrValue;
250
+ } else if (isUriTypeAttribute(attrName, tag)) {
251
+ attrValue = trimWhitespace(attrValue);
252
+ if (isLinkType(tag, attrs, 'canonical')) {
253
+ return attrValue;
254
+ }
255
+ try {
256
+ const out = await options.minifyURLs(attrValue);
257
+ return typeof out === 'string' ? out : attrValue;
258
+ } catch (err) {
259
+ if (!options.continueOnMinifyError) {
260
+ throw err;
261
+ }
262
+ options.log && options.log(err);
263
+ return attrValue;
264
+ }
265
+ } else if (isNumberTypeAttribute(attrName, tag)) {
266
+ return trimWhitespace(attrValue);
267
+ } else if (attrName === 'style') {
268
+ attrValue = trimWhitespace(attrValue);
269
+ if (attrValue) {
270
+ if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
271
+ attrValue = attrValue.replace(/\s*;$/, ';');
272
+ }
273
+ try {
274
+ attrValue = await options.minifyCSS(attrValue, 'inline');
275
+ } catch (err) {
276
+ if (!options.continueOnMinifyError) {
277
+ throw err;
278
+ }
279
+ options.log && options.log(err);
280
+ }
281
+ }
282
+ return attrValue;
283
+ } else if (isSrcset(attrName, tag)) {
284
+ // https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
285
+ attrValue = (await Promise.all(trimWhitespace(attrValue).split(/\s*,\s*/).map(async function (candidate) {
286
+ let url = candidate;
287
+ let descriptor = '';
288
+ const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
289
+ if (match) {
290
+ url = url.slice(0, -match[0].length);
291
+ const num = +match[1].slice(0, -1);
292
+ const suffix = match[1].slice(-1);
293
+ if (num !== 1 || suffix !== 'x') {
294
+ descriptor = ' ' + num + suffix;
295
+ }
296
+ }
297
+ try {
298
+ const out = await options.minifyURLs(url);
299
+ return (typeof out === 'string' ? out : url) + descriptor;
300
+ } catch (err) {
301
+ if (!options.continueOnMinifyError) {
302
+ throw err;
303
+ }
304
+ options.log && options.log(err);
305
+ return url + descriptor;
306
+ }
307
+ }))).join(', ');
308
+ } else if (isMetaViewport(tag, attrs) && attrName === 'content') {
309
+ attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
310
+ // 0.90000 → 0.9
311
+ // 1.0 → 1
312
+ // 1.0001 → 1.0001 (unchanged)
313
+ return (+numString).toString();
314
+ });
315
+ } else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
316
+ return collapseWhitespaceAll(attrValue);
317
+ } else if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
318
+ attrValue = trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
319
+ } else if (tag === 'script' && attrName === 'type') {
320
+ attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
321
+ } else if (isMediaQuery(tag, attrs, attrName)) {
322
+ attrValue = trimWhitespace(attrValue);
323
+ try {
324
+ return await options.minifyCSS(attrValue, 'media');
325
+ } catch (err) {
326
+ if (!options.continueOnMinifyError) {
327
+ throw err;
328
+ }
329
+ options.log && options.log(err);
330
+ return attrValue;
331
+ }
332
+ } else if (tag === 'iframe' && attrName === 'srcdoc') {
333
+ // Recursively minify HTML content within `srcdoc` attribute
334
+ // Fast-path: Skip if nothing would change
335
+ if (!shouldMinifyInnerHTML(options)) {
336
+ return attrValue;
337
+ }
338
+ return minifyHTMLSelf(attrValue, options, true);
339
+ }
340
+ return attrValue;
341
+ }
342
+
343
+ /**
344
+ * Choose appropriate quote character for an attribute value
345
+ * @param {string} attrValue - The attribute value
346
+ * @param {Object} options - Minifier options
347
+ * @returns {string} The chosen quote character (`"` or `'`)
348
+ */
349
+ function chooseAttributeQuote(attrValue, options) {
350
+ if (typeof options.quoteCharacter !== 'undefined') {
351
+ return options.quoteCharacter === '\'' ? '\'' : '"';
352
+ }
353
+
354
+ // Count quotes in a single pass
355
+ let apos = 0, quot = 0;
356
+ for (let i = 0; i < attrValue.length; i++) {
357
+ if (attrValue[i] === "'") apos++;
358
+ else if (attrValue[i] === '"') quot++;
359
+ }
360
+ return apos < quot ? '\'' : '"';
361
+ }
362
+
363
+ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
364
+ const attrName = options.name(attr.name);
365
+ let attrValue = attr.value;
366
+
367
+ if (options.decodeEntities && attrValue) {
368
+ // Fast path: Only decode when entities are present
369
+ if (attrValue.indexOf('&') !== -1) {
370
+ attrValue = decodeHTMLStrict(attrValue);
371
+ }
372
+ }
373
+
374
+ if ((options.removeRedundantAttributes &&
375
+ isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
376
+ (options.removeScriptTypeAttributes && tag === 'script' &&
377
+ attrName === 'type' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue)) ||
378
+ (options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
379
+ attrName === 'type' && isStyleLinkTypeAttribute(attrValue))) {
380
+ return;
381
+ }
382
+
383
+ if (attrValue) {
384
+ attrValue = await cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML);
385
+ }
386
+
387
+ if (options.removeEmptyAttributes &&
388
+ canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
389
+ return;
390
+ }
391
+
392
+ if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
393
+ attrValue = attrValue.replace(RE_AMP_ENTITY, '&amp;$1');
394
+ }
395
+
396
+ return {
397
+ attr,
398
+ name: attrName,
399
+ value: attrValue
400
+ };
401
+ }
402
+
403
+ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
404
+ const attrName = normalized.name;
405
+ let attrValue = normalized.value;
406
+ const attr = normalized.attr;
407
+ let attrQuote = attr.quote;
408
+ let attrFragment;
409
+ let emittedAttrValue;
410
+
411
+ if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
412
+ attrValue.indexOf(uidAttr) !== -1 || !canRemoveAttributeQuotes(attrValue))) {
413
+ // Determine the appropriate quote character
414
+ if (!options.preventAttributesEscaping) {
415
+ // Normal mode: choose quotes and escape
416
+ attrQuote = chooseAttributeQuote(attrValue, options);
417
+ if (attrQuote === '"') {
418
+ attrValue = attrValue.replace(/"/g, '&#34;');
419
+ } else {
420
+ attrValue = attrValue.replace(/'/g, '&#39;');
421
+ }
422
+ } else {
423
+ // `preventAttributesEscaping` mode: choose safe quotes but don't escape
424
+ // except when both quote types are present—then escape to prevent invalid HTML
425
+ const hasDoubleQuote = attrValue.indexOf('"') !== -1;
426
+ const hasSingleQuote = attrValue.indexOf("'") !== -1;
427
+
428
+ // Both quote types present: Escaping is required to guarantee valid HTML delimiter matching
429
+ if (hasDoubleQuote && hasSingleQuote) {
430
+ attrQuote = chooseAttributeQuote(attrValue, options);
431
+ if (attrQuote === '"') {
432
+ attrValue = attrValue.replace(/"/g, '&#34;');
433
+ } else {
434
+ attrValue = attrValue.replace(/'/g, '&#39;');
435
+ }
436
+ // Auto quote selection: Prefer the opposite quote type when value contains one quote type, default to double quotes when none present
437
+ } else if (typeof options.quoteCharacter === 'undefined') {
438
+ if (attrQuote === '"' && hasDoubleQuote && !hasSingleQuote) {
439
+ attrQuote = "'";
440
+ } else if (attrQuote === "'" && hasSingleQuote && !hasDoubleQuote) {
441
+ attrQuote = '"';
442
+ // Fallback for invalid/unsupported attrQuote values (not `"`, `'`, or empty string): Choose safe default based on value content
443
+ } else if (attrQuote !== '"' && attrQuote !== "'" && attrQuote !== '') {
444
+ if (hasSingleQuote && !hasDoubleQuote) {
445
+ attrQuote = '"';
446
+ } else if (hasDoubleQuote && !hasSingleQuote) {
447
+ attrQuote = "'";
448
+ } else {
449
+ attrQuote = '"';
450
+ }
451
+ }
452
+ } else {
453
+ attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
454
+ }
455
+ }
456
+ emittedAttrValue = attrQuote + attrValue + attrQuote;
457
+ if (!isLast && !options.removeTagWhitespace) {
458
+ emittedAttrValue += ' ';
459
+ }
460
+ } else if (isLast && !hasUnarySlash) {
461
+ // Last attribute in a non-self-closing tag: no space needed
462
+ emittedAttrValue = attrValue;
463
+ } else {
464
+ // Not last attribute, or is a self-closing tag: add space
465
+ emittedAttrValue = attrValue + ' ';
466
+ }
467
+
468
+ if (typeof attrValue === 'undefined' || (options.collapseBooleanAttributes &&
469
+ isBooleanAttribute(attrName.toLowerCase(), (attrValue || '').toLowerCase()))) {
470
+ attrFragment = attrName;
471
+ if (!isLast) {
472
+ attrFragment += ' ';
473
+ }
474
+ } else {
475
+ attrFragment = attrName + attr.customAssign + emittedAttrValue;
476
+ }
477
+
478
+ return attr.customOpen + attrFragment + attr.customClose;
479
+ }
480
+
481
+ // Exports
482
+
483
+ export {
484
+ // Validators
485
+ isConditionalComment,
486
+ isIgnoredComment,
487
+ isEventAttribute,
488
+ canRemoveAttributeQuotes,
489
+ attributesInclude,
490
+ isAttributeRedundant,
491
+ isScriptTypeAttribute,
492
+ keepScriptTypeAttribute,
493
+ isExecutableScript,
494
+ isStyleLinkTypeAttribute,
495
+ isStyleSheet,
496
+ isBooleanAttribute,
497
+ isUriTypeAttribute,
498
+ isNumberTypeAttribute,
499
+ isLinkType,
500
+ isMediaQuery,
501
+ isSrcset,
502
+ isMetaViewport,
503
+ isContentSecurityPolicy,
504
+ canDeleteEmptyAttribute,
505
+ hasAttrName,
506
+
507
+ // Cleaners
508
+ cleanAttributeValue,
509
+ normalizeAttr,
510
+ buildAttr
511
+ };