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.
- package/LICENSE.txt +23 -0
- package/README.md +851 -110
- package/cli.js +991 -207
- package/dist/types/htmlminifier.d.ts +629 -0
- package/dist/types/htmlminifier.d.ts.map +1 -0
- package/dist/types/htmlparser.d.ts +28 -0
- package/dist/types/htmlparser.d.ts.map +1 -0
- package/dist/types/lib/attributes.d.ts +87 -0
- package/dist/types/lib/attributes.d.ts.map +1 -0
- package/dist/types/lib/constants.d.ts +421 -0
- package/dist/types/lib/constants.d.ts.map +1 -0
- package/dist/types/lib/content.d.ts +44 -0
- package/dist/types/lib/content.d.ts.map +1 -0
- package/dist/types/lib/elements.d.ts +48 -0
- package/dist/types/lib/elements.d.ts.map +1 -0
- package/dist/types/lib/fragments.d.ts +47 -0
- package/dist/types/lib/fragments.d.ts.map +1 -0
- package/dist/types/lib/option-definitions.d.ts +33 -0
- package/dist/types/lib/option-definitions.d.ts.map +1 -0
- package/dist/types/lib/options.d.ts +93 -0
- package/dist/types/lib/options.d.ts.map +1 -0
- package/dist/types/lib/unused-css.d.ts +76 -0
- package/dist/types/lib/unused-css.d.ts.map +1 -0
- package/dist/types/lib/urls.d.ts +15 -0
- package/dist/types/lib/urls.d.ts.map +1 -0
- package/dist/types/lib/utils.d.ts +132 -0
- package/dist/types/lib/utils.d.ts.map +1 -0
- package/dist/types/lib/whitespace.d.ts +97 -0
- package/dist/types/lib/whitespace.d.ts.map +1 -0
- package/dist/types/presets.d.ts +50 -0
- package/dist/types/presets.d.ts.map +1 -0
- package/dist/types/tokenchain.d.ts +36 -0
- package/dist/types/tokenchain.d.ts.map +1 -0
- package/html-minifier.schema.json +322 -0
- package/package.json +71 -60
- package/src/htmlminifier.js +2862 -1106
- package/src/htmlparser.js +1163 -452
- package/src/lib/attributes.js +1196 -0
- package/src/lib/constants.js +521 -0
- package/src/lib/content.js +237 -0
- package/src/lib/elements.js +268 -0
- package/src/lib/file-pool.js +193 -0
- package/src/lib/file-write.js +87 -0
- package/src/lib/fragments.js +340 -0
- package/src/lib/option-definitions.js +246 -0
- package/src/lib/options.js +841 -0
- package/src/lib/unused-css.js +499 -0
- package/src/lib/urls.js +146 -0
- package/src/lib/utils.js +1026 -0
- package/src/lib/whitespace.js +431 -0
- package/src/presets.js +58 -0
- package/src/tokenchain.js +142 -56
- package/LICENSE +0 -22
- package/sample-cli-config-file.conf +0 -40
- 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 = { '"': '"', '&': '&', '\u00A0': ' ' };
|
|
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, '&') : 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, '&');
|
|
1039
|
+
}
|
|
1040
|
+
if (attrValue.indexOf('\r') !== -1) {
|
|
1041
|
+
attrValue = attrValue.replace(RE_CR, ' ');
|
|
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, '"');
|
|
1106
|
+
} else if (attrValue.indexOf("'") !== -1) {
|
|
1107
|
+
attrValue = attrValue.replace(/'/g, ''');
|
|
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, '"');
|
|
1120
|
+
} else {
|
|
1121
|
+
attrValue = attrValue.replace(/'/g, ''');
|
|
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, '"');
|
|
1160
|
+
} else {
|
|
1161
|
+
attrValue = attrValue.replace(/'/g, ''');
|
|
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
|
+
};
|