html-minifier-next 4.7.1 → 4.8.2
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/README.md +19 -19
- package/cli.js +4 -3
- package/dist/htmlminifier.cjs +1884 -1660
- package/dist/htmlminifier.esm.bundle.js +33820 -33531
- package/dist/types/htmlminifier.d.ts +29 -3
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/htmlparser.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/htmlminifier.js +1877 -1662
- package/src/htmlparser.js +10 -1
package/src/htmlminifier.js
CHANGED
|
@@ -1,1676 +1,41 @@
|
|
|
1
|
-
import { transform as transformCSS } from 'lightningcss';
|
|
2
1
|
import { decodeHTMLStrict, decodeHTML } from 'entities';
|
|
3
2
|
import RelateURL from 'relateurl';
|
|
4
|
-
import { minify as terser } from 'terser';
|
|
5
3
|
import { HTMLParser, endTag } from './htmlparser.js';
|
|
6
4
|
import TokenChain from './tokenchain.js';
|
|
7
5
|
import { replaceAsync } from './utils.js';
|
|
8
6
|
import { presets, getPreset, getPresetNames } from './presets.js';
|
|
9
7
|
|
|
10
|
-
//
|
|
11
|
-
const RE_WS_START = /^[ \n\r\t\f]+/;
|
|
12
|
-
const RE_WS_END = /[ \n\r\t\f]+$/;
|
|
13
|
-
const RE_ALL_WS_NBSP = /[ \n\r\t\f\xA0]+/g;
|
|
14
|
-
const RE_NBSP_LEADING_GROUP = /(^|\xA0+)[^\xA0]+/g;
|
|
15
|
-
const RE_NBSP_LEAD_GROUP = /(\xA0+)[^\xA0]+/g;
|
|
16
|
-
const RE_NBSP_TRAILING_GROUP = /[^\xA0]+(\xA0+)/g;
|
|
17
|
-
const RE_NBSP_TRAILING_STRIP = /[^\xA0]+$/;
|
|
18
|
-
const RE_CONDITIONAL_COMMENT = /^\[if\s[^\]]+]|\[endif]$/;
|
|
19
|
-
const RE_EVENT_ATTR_DEFAULT = /^on[a-z]{3,}$/;
|
|
20
|
-
const RE_CAN_REMOVE_ATTR_QUOTES = /^[^ \t\n\f\r"'`=<>]+$/;
|
|
21
|
-
const RE_TRAILING_SEMICOLON = /;$/;
|
|
22
|
-
const RE_AMP_ENTITY = /&(#?[0-9a-zA-Z]+;)/g;
|
|
23
|
-
|
|
24
|
-
// Tiny stable stringify for options signatures (sorted keys, shallow, nested objects)
|
|
25
|
-
function stableStringify(obj) {
|
|
26
|
-
if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
27
|
-
if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
|
|
28
|
-
const keys = Object.keys(obj).sort();
|
|
29
|
-
let out = '{';
|
|
30
|
-
for (let i = 0; i < keys.length; i++) {
|
|
31
|
-
const k = keys[i];
|
|
32
|
-
out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
|
|
33
|
-
}
|
|
34
|
-
return out + '}';
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Minimal LRU cache for strings and promises
|
|
38
|
-
class LRU {
|
|
39
|
-
constructor(limit = 200) {
|
|
40
|
-
this.limit = limit;
|
|
41
|
-
this.map = new Map();
|
|
42
|
-
}
|
|
43
|
-
get(key) {
|
|
44
|
-
const v = this.map.get(key);
|
|
45
|
-
if (v !== undefined) {
|
|
46
|
-
this.map.delete(key);
|
|
47
|
-
this.map.set(key, v);
|
|
48
|
-
}
|
|
49
|
-
return v;
|
|
50
|
-
}
|
|
51
|
-
set(key, value) {
|
|
52
|
-
if (this.map.has(key)) this.map.delete(key);
|
|
53
|
-
this.map.set(key, value);
|
|
54
|
-
if (this.map.size > this.limit) {
|
|
55
|
-
const first = this.map.keys().next().value;
|
|
56
|
-
this.map.delete(first);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
delete(key) { this.map.delete(key); }
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Per-process caches
|
|
63
|
-
const jsMinifyCache = new LRU(200);
|
|
64
|
-
const cssMinifyCache = new LRU(200);
|
|
65
|
-
|
|
66
|
-
const trimWhitespace = str => {
|
|
67
|
-
if (!str) return str;
|
|
68
|
-
// Fast path: if no whitespace at start or end, return early
|
|
69
|
-
if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
|
|
70
|
-
return str;
|
|
71
|
-
}
|
|
72
|
-
return str.replace(RE_WS_START, '').replace(RE_WS_END, '');
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
function collapseWhitespaceAll(str) {
|
|
76
|
-
if (!str) return str;
|
|
77
|
-
// Fast path: if there are no common whitespace characters, return early
|
|
78
|
-
if (!/[ \n\r\t\f\xA0]/.test(str)) {
|
|
79
|
-
return str;
|
|
80
|
-
}
|
|
81
|
-
// Non-breaking space is specifically handled inside the replacer function here:
|
|
82
|
-
return str.replace(RE_ALL_WS_NBSP, function (spaces) {
|
|
83
|
-
return spaces === '\t' ? '\t' : spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
|
|
88
|
-
let lineBreakBefore = ''; let lineBreakAfter = '';
|
|
89
|
-
|
|
90
|
-
if (!str) return str;
|
|
91
|
-
|
|
92
|
-
if (options.preserveLineBreaks) {
|
|
93
|
-
str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function () {
|
|
94
|
-
lineBreakBefore = '\n';
|
|
95
|
-
return '';
|
|
96
|
-
}).replace(/[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*$/, function () {
|
|
97
|
-
lineBreakAfter = '\n';
|
|
98
|
-
return '';
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (trimLeft) {
|
|
103
|
-
// Non-breaking space is specifically handled inside the replacer function here:
|
|
104
|
-
str = str.replace(/^[ \n\r\t\f\xA0]+/, function (spaces) {
|
|
105
|
-
const conservative = !lineBreakBefore && options.conservativeCollapse;
|
|
106
|
-
if (conservative && spaces === '\t') {
|
|
107
|
-
return '\t';
|
|
108
|
-
}
|
|
109
|
-
return spaces.replace(/^[^\xA0]+/, '').replace(RE_NBSP_LEAD_GROUP, '$1 ') || (conservative ? ' ' : '');
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (trimRight) {
|
|
114
|
-
// Non-breaking space is specifically handled inside the replacer function here:
|
|
115
|
-
str = str.replace(/[ \n\r\t\f\xA0]+$/, function (spaces) {
|
|
116
|
-
const conservative = !lineBreakAfter && options.conservativeCollapse;
|
|
117
|
-
if (conservative && spaces === '\t') {
|
|
118
|
-
return '\t';
|
|
119
|
-
}
|
|
120
|
-
return spaces.replace(RE_NBSP_TRAILING_GROUP, ' $1').replace(RE_NBSP_TRAILING_STRIP, '') || (conservative ? ' ' : '');
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (collapseAll) {
|
|
125
|
-
// Strip non-space whitespace then compress spaces to one
|
|
126
|
-
str = collapseWhitespaceAll(str);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return lineBreakBefore + str + lineBreakAfter;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Non-empty elements that will maintain whitespace around them
|
|
133
|
-
const inlineElementsToKeepWhitespaceAround = ['a', 'abbr', 'acronym', 'b', 'bdi', 'bdo', 'big', 'button', 'cite', 'code', 'del', 'dfn', 'em', 'font', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'mark', 'math', 'meter', 'nobr', 'object', 'output', 'progress', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'select', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'svg', 'textarea', 'time', 'tt', 'u', 'var', 'wbr'];
|
|
134
|
-
// Non-empty elements that will maintain whitespace within them
|
|
135
|
-
const inlineElementsToKeepWhitespaceWithin = new Set(['a', 'abbr', 'acronym', 'b', 'big', 'del', 'em', 'font', 'i', 'ins', 'kbd', 'mark', 'nobr', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'time', 'tt', 'u', 'var']);
|
|
136
|
-
// Elements that will always maintain whitespace around them
|
|
137
|
-
const inlineElementsToKeepWhitespace = new Set(['comment', 'img', 'input', 'wbr']);
|
|
138
|
-
|
|
139
|
-
function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements, inlineTextSet) {
|
|
140
|
-
let trimLeft = prevTag && !inlineElementsToKeepWhitespace.has(prevTag);
|
|
141
|
-
if (trimLeft && !options.collapseInlineTagWhitespace) {
|
|
142
|
-
trimLeft = prevTag.charAt(0) === '/' ? !inlineElements.has(prevTag.slice(1)) : !inlineTextSet.has(prevTag);
|
|
143
|
-
}
|
|
144
|
-
let trimRight = nextTag && !inlineElementsToKeepWhitespace.has(nextTag);
|
|
145
|
-
if (trimRight && !options.collapseInlineTagWhitespace) {
|
|
146
|
-
trimRight = nextTag.charAt(0) === '/' ? !inlineTextSet.has(nextTag.slice(1)) : !inlineElements.has(nextTag);
|
|
147
|
-
}
|
|
148
|
-
return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function isConditionalComment(text) {
|
|
152
|
-
return RE_CONDITIONAL_COMMENT.test(text);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function isIgnoredComment(text, options) {
|
|
156
|
-
for (let i = 0, len = options.ignoreCustomComments.length; i < len; i++) {
|
|
157
|
-
if (options.ignoreCustomComments[i].test(text)) {
|
|
158
|
-
return true;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return false;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function isEventAttribute(attrName, options) {
|
|
165
|
-
const patterns = options.customEventAttributes;
|
|
166
|
-
if (patterns) {
|
|
167
|
-
for (let i = patterns.length; i--;) {
|
|
168
|
-
if (patterns[i].test(attrName)) {
|
|
169
|
-
return true;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return false;
|
|
173
|
-
}
|
|
174
|
-
return RE_EVENT_ATTR_DEFAULT.test(attrName);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function canRemoveAttributeQuotes(value) {
|
|
178
|
-
// https://mathiasbynens.be/notes/unquoted-attribute-values
|
|
179
|
-
return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
|
|
180
|
-
}
|
|
8
|
+
// Lazy-load heavy dependencies only when needed
|
|
181
9
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
10
|
+
let lightningCSSPromise;
|
|
11
|
+
async function getLightningCSS() {
|
|
12
|
+
if (!lightningCSSPromise) {
|
|
13
|
+
lightningCSSPromise = import('lightningcss').then(m => m.transform);
|
|
187
14
|
}
|
|
188
|
-
return
|
|
15
|
+
return lightningCSSPromise;
|
|
189
16
|
}
|
|
190
17
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
loading: 'eager',
|
|
196
|
-
popovertargetaction: 'toggle'
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
// Tag-specific default attribute values
|
|
200
|
-
const tagDefaults = {
|
|
201
|
-
area: { shape: 'rect' },
|
|
202
|
-
button: { type: 'submit' },
|
|
203
|
-
form: {
|
|
204
|
-
enctype: 'application/x-www-form-urlencoded',
|
|
205
|
-
method: 'get'
|
|
206
|
-
},
|
|
207
|
-
html: { dir: 'ltr' },
|
|
208
|
-
img: { decoding: 'auto' },
|
|
209
|
-
input: {
|
|
210
|
-
colorspace: 'limited-srgb',
|
|
211
|
-
type: 'text'
|
|
212
|
-
},
|
|
213
|
-
marquee: {
|
|
214
|
-
behavior: 'scroll',
|
|
215
|
-
direction: 'left'
|
|
216
|
-
},
|
|
217
|
-
style: { media: 'all' },
|
|
218
|
-
textarea: { wrap: 'soft' },
|
|
219
|
-
track: { kind: 'subtitles' }
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
function isAttributeRedundant(tag, attrName, attrValue, attrs) {
|
|
223
|
-
attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
|
|
224
|
-
|
|
225
|
-
// Legacy attributes
|
|
226
|
-
if (tag === 'script' && attrName === 'language' && attrValue === 'javascript') {
|
|
227
|
-
return true;
|
|
228
|
-
}
|
|
229
|
-
if (tag === 'script' && attrName === 'charset' && !attributesInclude(attrs, 'src')) {
|
|
230
|
-
return true;
|
|
231
|
-
}
|
|
232
|
-
if (tag === 'a' && attrName === 'name' && attributesInclude(attrs, 'id')) {
|
|
233
|
-
return true;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Check general defaults
|
|
237
|
-
if (generalDefaults[attrName] === attrValue) {
|
|
238
|
-
return true;
|
|
18
|
+
let terserPromise;
|
|
19
|
+
async function getTerser() {
|
|
20
|
+
if (!terserPromise) {
|
|
21
|
+
terserPromise = import('terser').then(m => m.minify);
|
|
239
22
|
}
|
|
240
|
-
|
|
241
|
-
// Check tag-specific defaults
|
|
242
|
-
return tagDefaults[tag]?.[attrName] === attrValue;
|
|
23
|
+
return terserPromise;
|
|
243
24
|
}
|
|
244
25
|
|
|
245
|
-
//
|
|
246
|
-
// https://developer.mozilla.org/en/docs/Web/HTML/Element/script#attr-type
|
|
247
|
-
const executableScriptsMimetypes = new Set([
|
|
248
|
-
'text/javascript',
|
|
249
|
-
'text/ecmascript',
|
|
250
|
-
'text/jscript',
|
|
251
|
-
'application/javascript',
|
|
252
|
-
'application/x-javascript',
|
|
253
|
-
'application/ecmascript',
|
|
254
|
-
'module'
|
|
255
|
-
]);
|
|
26
|
+
// Type definitions
|
|
256
27
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
return keepScriptsMimetypes.has(attrValue);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function isExecutableScript(tag, attrs) {
|
|
272
|
-
if (tag !== 'script') {
|
|
273
|
-
return false;
|
|
274
|
-
}
|
|
275
|
-
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
276
|
-
const attrName = attrs[i].name.toLowerCase();
|
|
277
|
-
if (attrName === 'type') {
|
|
278
|
-
return isScriptTypeAttribute(attrs[i].value);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
return true;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function isStyleLinkTypeAttribute(attrValue = '') {
|
|
285
|
-
attrValue = trimWhitespace(attrValue).toLowerCase();
|
|
286
|
-
return attrValue === '' || attrValue === 'text/css';
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function isStyleSheet(tag, attrs) {
|
|
290
|
-
if (tag !== 'style') {
|
|
291
|
-
return false;
|
|
292
|
-
}
|
|
293
|
-
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
294
|
-
const attrName = attrs[i].name.toLowerCase();
|
|
295
|
-
if (attrName === 'type') {
|
|
296
|
-
return isStyleLinkTypeAttribute(attrs[i].value);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
return true;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const isSimpleBoolean = new Set(['allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'compact', 'controls', 'declare', 'default', 'defaultchecked', 'defaultmuted', 'defaultselected', 'defer', 'disabled', 'enabled', 'formnovalidate', 'hidden', 'indeterminate', 'inert', 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nohref', 'noresize', 'noshade', 'novalidate', 'nowrap', 'open', 'pauseonexit', 'readonly', 'required', 'reversed', 'scoped', 'seamless', 'selected', 'sortable', 'truespeed', 'typemustmatch', 'visible']);
|
|
303
|
-
const isBooleanValue = new Set(['true', 'false']);
|
|
304
|
-
|
|
305
|
-
function isBooleanAttribute(attrName, attrValue) {
|
|
306
|
-
return isSimpleBoolean.has(attrName) || (attrName === 'draggable' && !isBooleanValue.has(attrValue));
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function isUriTypeAttribute(attrName, tag) {
|
|
310
|
-
return (
|
|
311
|
-
(/^(?:a|area|link|base)$/.test(tag) && attrName === 'href') ||
|
|
312
|
-
(tag === 'img' && /^(?:src|longdesc|usemap)$/.test(attrName)) ||
|
|
313
|
-
(tag === 'object' && /^(?:classid|codebase|data|usemap)$/.test(attrName)) ||
|
|
314
|
-
(tag === 'q' && attrName === 'cite') ||
|
|
315
|
-
(tag === 'blockquote' && attrName === 'cite') ||
|
|
316
|
-
((tag === 'ins' || tag === 'del') && attrName === 'cite') ||
|
|
317
|
-
(tag === 'form' && attrName === 'action') ||
|
|
318
|
-
(tag === 'input' && (attrName === 'src' || attrName === 'usemap')) ||
|
|
319
|
-
(tag === 'head' && attrName === 'profile') ||
|
|
320
|
-
(tag === 'script' && (attrName === 'src' || attrName === 'for'))
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function isNumberTypeAttribute(attrName, tag) {
|
|
325
|
-
return (
|
|
326
|
-
(/^(?:a|area|object|button)$/.test(tag) && attrName === 'tabindex') ||
|
|
327
|
-
(tag === 'input' && (attrName === 'maxlength' || attrName === 'tabindex')) ||
|
|
328
|
-
(tag === 'select' && (attrName === 'size' || attrName === 'tabindex')) ||
|
|
329
|
-
(tag === 'textarea' && /^(?:rows|cols|tabindex)$/.test(attrName)) ||
|
|
330
|
-
(tag === 'colgroup' && attrName === 'span') ||
|
|
331
|
-
(tag === 'col' && attrName === 'span') ||
|
|
332
|
-
((tag === 'th' || tag === 'td') && (attrName === 'rowspan' || attrName === 'colspan'))
|
|
333
|
-
);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function isLinkType(tag, attrs, value) {
|
|
337
|
-
if (tag !== 'link') return false;
|
|
338
|
-
const needle = String(value).toLowerCase();
|
|
339
|
-
for (let i = 0; i < attrs.length; i++) {
|
|
340
|
-
if (attrs[i].name.toLowerCase() === 'rel') {
|
|
341
|
-
const tokens = String(attrs[i].value).toLowerCase().split(/\s+/);
|
|
342
|
-
if (tokens.includes(needle)) return true;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
return false;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function isMediaQuery(tag, attrs, attrName) {
|
|
349
|
-
return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleSheet(tag, attrs));
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const srcsetTags = new Set(['img', 'source']);
|
|
353
|
-
|
|
354
|
-
function isSrcset(attrName, tag) {
|
|
355
|
-
return attrName === 'srcset' && srcsetTags.has(tag);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
|
|
359
|
-
if (isEventAttribute(attrName, options)) {
|
|
360
|
-
attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
|
|
361
|
-
return options.minifyJS(attrValue, true);
|
|
362
|
-
} else if (attrName === 'class') {
|
|
363
|
-
attrValue = trimWhitespace(attrValue);
|
|
364
|
-
if (options.sortClassName) {
|
|
365
|
-
attrValue = options.sortClassName(attrValue);
|
|
366
|
-
} else {
|
|
367
|
-
attrValue = collapseWhitespaceAll(attrValue);
|
|
368
|
-
}
|
|
369
|
-
return attrValue;
|
|
370
|
-
} else if (isUriTypeAttribute(attrName, tag)) {
|
|
371
|
-
attrValue = trimWhitespace(attrValue);
|
|
372
|
-
if (isLinkType(tag, attrs, 'canonical')) {
|
|
373
|
-
return attrValue;
|
|
374
|
-
}
|
|
375
|
-
try {
|
|
376
|
-
const out = await options.minifyURLs(attrValue);
|
|
377
|
-
return typeof out === 'string' ? out : attrValue;
|
|
378
|
-
} catch (err) {
|
|
379
|
-
if (!options.continueOnMinifyError) {
|
|
380
|
-
throw err;
|
|
381
|
-
}
|
|
382
|
-
options.log && options.log(err);
|
|
383
|
-
return attrValue;
|
|
384
|
-
}
|
|
385
|
-
} else if (isNumberTypeAttribute(attrName, tag)) {
|
|
386
|
-
return trimWhitespace(attrValue);
|
|
387
|
-
} else if (attrName === 'style') {
|
|
388
|
-
attrValue = trimWhitespace(attrValue);
|
|
389
|
-
if (attrValue) {
|
|
390
|
-
if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
|
|
391
|
-
attrValue = attrValue.replace(/\s*;$/, ';');
|
|
392
|
-
}
|
|
393
|
-
attrValue = await options.minifyCSS(attrValue, 'inline');
|
|
394
|
-
}
|
|
395
|
-
return attrValue;
|
|
396
|
-
} else if (isSrcset(attrName, tag)) {
|
|
397
|
-
// https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
|
|
398
|
-
attrValue = (await Promise.all(trimWhitespace(attrValue).split(/\s+,\s*|\s*,\s+/).map(async function (candidate) {
|
|
399
|
-
let url = candidate;
|
|
400
|
-
let descriptor = '';
|
|
401
|
-
const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
|
|
402
|
-
if (match) {
|
|
403
|
-
url = url.slice(0, -match[0].length);
|
|
404
|
-
const num = +match[1].slice(0, -1);
|
|
405
|
-
const suffix = match[1].slice(-1);
|
|
406
|
-
if (num !== 1 || suffix !== 'x') {
|
|
407
|
-
descriptor = ' ' + num + suffix;
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
try {
|
|
411
|
-
const out = await options.minifyURLs(url);
|
|
412
|
-
return (typeof out === 'string' ? out : url) + descriptor;
|
|
413
|
-
} catch (err) {
|
|
414
|
-
if (!options.continueOnMinifyError) {
|
|
415
|
-
throw err;
|
|
416
|
-
}
|
|
417
|
-
options.log && options.log(err);
|
|
418
|
-
return url + descriptor;
|
|
419
|
-
}
|
|
420
|
-
}))).join(', ');
|
|
421
|
-
} else if (isMetaViewport(tag, attrs) && attrName === 'content') {
|
|
422
|
-
attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
|
|
423
|
-
// "0.90000" -> "0.9"
|
|
424
|
-
// "1.0" -> "1"
|
|
425
|
-
// "1.0001" -> "1.0001" (unchanged)
|
|
426
|
-
return (+numString).toString();
|
|
427
|
-
});
|
|
428
|
-
} else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
|
|
429
|
-
return collapseWhitespaceAll(attrValue);
|
|
430
|
-
} else if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
|
|
431
|
-
attrValue = trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
|
|
432
|
-
} else if (tag === 'script' && attrName === 'type') {
|
|
433
|
-
attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
|
|
434
|
-
} else if (isMediaQuery(tag, attrs, attrName)) {
|
|
435
|
-
attrValue = trimWhitespace(attrValue);
|
|
436
|
-
return options.minifyCSS(attrValue, 'media');
|
|
437
|
-
} else if (tag === 'iframe' && attrName === 'srcdoc') {
|
|
438
|
-
// Recursively minify HTML content within srcdoc attribute
|
|
439
|
-
// Fast-path: skip if nothing would change
|
|
440
|
-
if (!shouldMinifyInnerHTML(options)) {
|
|
441
|
-
return attrValue;
|
|
442
|
-
}
|
|
443
|
-
return minifyHTMLSelf(attrValue, options, true);
|
|
444
|
-
}
|
|
445
|
-
return attrValue;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function isMetaViewport(tag, attrs) {
|
|
449
|
-
if (tag !== 'meta') {
|
|
450
|
-
return false;
|
|
451
|
-
}
|
|
452
|
-
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
453
|
-
if (attrs[i].name === 'name' && attrs[i].value === 'viewport') {
|
|
454
|
-
return true;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
function isContentSecurityPolicy(tag, attrs) {
|
|
460
|
-
if (tag !== 'meta') {
|
|
461
|
-
return false;
|
|
462
|
-
}
|
|
463
|
-
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
464
|
-
if (attrs[i].name.toLowerCase() === 'http-equiv' && attrs[i].value.toLowerCase() === 'content-security-policy') {
|
|
465
|
-
return true;
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// Wrap CSS declarations for inline styles and media queries
|
|
471
|
-
// This ensures proper context for CSS minification
|
|
472
|
-
function wrapCSS(text, type) {
|
|
473
|
-
switch (type) {
|
|
474
|
-
case 'inline':
|
|
475
|
-
return '*{' + text + '}';
|
|
476
|
-
case 'media':
|
|
477
|
-
return '@media ' + text + '{a{top:0}}';
|
|
478
|
-
default:
|
|
479
|
-
return text;
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function unwrapCSS(text, type) {
|
|
484
|
-
let matches;
|
|
485
|
-
switch (type) {
|
|
486
|
-
case 'inline':
|
|
487
|
-
matches = text.match(/^\*\{([\s\S]*)\}$/);
|
|
488
|
-
break;
|
|
489
|
-
case 'media':
|
|
490
|
-
matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/);
|
|
491
|
-
break;
|
|
492
|
-
}
|
|
493
|
-
return matches ? matches[1] : text;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
async function cleanConditionalComment(comment, options) {
|
|
497
|
-
return options.processConditionalComments
|
|
498
|
-
? await replaceAsync(comment, /^(\[if\s[^\]]+]>)([\s\S]*?)(<!\[endif])$/, async function (match, prefix, text, suffix) {
|
|
499
|
-
return prefix + await minifyHTML(text, options, true) + suffix;
|
|
500
|
-
})
|
|
501
|
-
: comment;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
const jsonScriptTypes = new Set([
|
|
505
|
-
'application/json',
|
|
506
|
-
'application/ld+json',
|
|
507
|
-
'application/manifest+json',
|
|
508
|
-
'application/vnd.geo+json',
|
|
509
|
-
'importmap',
|
|
510
|
-
'speculationrules',
|
|
511
|
-
]);
|
|
512
|
-
|
|
513
|
-
function minifyJson(text, options) {
|
|
514
|
-
try {
|
|
515
|
-
return JSON.stringify(JSON.parse(text));
|
|
516
|
-
}
|
|
517
|
-
catch (err) {
|
|
518
|
-
if (!options.continueOnMinifyError) {
|
|
519
|
-
throw err;
|
|
520
|
-
}
|
|
521
|
-
options.log && options.log(err);
|
|
522
|
-
return text;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
function hasJsonScriptType(attrs) {
|
|
527
|
-
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
528
|
-
const attrName = attrs[i].name.toLowerCase();
|
|
529
|
-
if (attrName === 'type') {
|
|
530
|
-
const attrValue = trimWhitespace((attrs[i].value || '').split(/;/, 2)[0]).toLowerCase();
|
|
531
|
-
if (jsonScriptTypes.has(attrValue)) {
|
|
532
|
-
return true;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
return false;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
async function processScript(text, options, currentAttrs) {
|
|
540
|
-
for (let i = 0, len = currentAttrs.length; i < len; i++) {
|
|
541
|
-
const attrName = currentAttrs[i].name.toLowerCase();
|
|
542
|
-
if (attrName === 'type') {
|
|
543
|
-
const rawValue = currentAttrs[i].value;
|
|
544
|
-
const normalizedValue = trimWhitespace((rawValue || '').split(/;/, 2)[0]).toLowerCase();
|
|
545
|
-
// Minify JSON script types automatically
|
|
546
|
-
if (jsonScriptTypes.has(normalizedValue)) {
|
|
547
|
-
return minifyJson(text, options);
|
|
548
|
-
}
|
|
549
|
-
// Process custom script types if specified
|
|
550
|
-
if (options.processScripts && options.processScripts.indexOf(rawValue) > -1) {
|
|
551
|
-
return await minifyHTML(text, options);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
return text;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags with the following extensions:
|
|
559
|
-
// - retain <body> if followed by <noscript>
|
|
560
|
-
// - <rb>, <rt>, <rtc>, <rp> follow HTML Ruby Markup Extensions draft (https://www.w3.org/TR/html-ruby-extensions/)
|
|
561
|
-
// - retain all tags which are adjacent to non-standard HTML tags
|
|
562
|
-
const optionalStartTags = new Set(['html', 'head', 'body', 'colgroup', 'tbody']);
|
|
563
|
-
const optionalEndTags = new Set(['html', 'head', 'body', 'li', 'dt', 'dd', 'p', 'rb', 'rt', 'rtc', 'rp', 'optgroup', 'option', 'colgroup', 'caption', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th']);
|
|
564
|
-
const headerTags = new Set(['meta', 'link', 'script', 'style', 'template', 'noscript']);
|
|
565
|
-
const descriptionTags = new Set(['dt', 'dd']);
|
|
566
|
-
const pBlockTags = new Set(['address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'div', 'dl', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'main', 'menu', 'nav', 'ol', 'p', 'pre', 'search', 'section', 'table', 'ul']);
|
|
567
|
-
const pInlineTags = new Set(['a', 'audio', 'del', 'ins', 'map', 'noscript', 'video']);
|
|
568
|
-
const rubyEndTagOmission = new Set(['rb', 'rt', 'rtc', 'rp']); // </rb>, </rt>, </rp> can be omitted if followed by <rb>, <rt>, <rtc>, or <rp>
|
|
569
|
-
const rubyRtcEndTagOmission = new Set(['rb', 'rtc']); // </rtc> can be omitted if followed by <rb> or <rtc> (not <rt> or <rp>)
|
|
570
|
-
const optionTag = new Set(['option', 'optgroup']);
|
|
571
|
-
const tableContentTags = new Set(['tbody', 'tfoot']);
|
|
572
|
-
const tableSectionTags = new Set(['thead', 'tbody', 'tfoot']);
|
|
573
|
-
const cellTags = new Set(['td', 'th']);
|
|
574
|
-
const topLevelTags = new Set(['html', 'head', 'body']);
|
|
575
|
-
const compactTags = new Set(['html', 'body']);
|
|
576
|
-
const looseTags = new Set(['head', 'colgroup', 'caption']);
|
|
577
|
-
const trailingTags = new Set(['dt', 'thead']);
|
|
578
|
-
const htmlTags = new Set(['a', 'abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'b', 'base', 'basefont', 'bdi', 'bdo', 'bgsound', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'command', 'content', 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'embed', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'iframe', 'image', 'img', 'input', 'ins', 'isindex', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', 'listing', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meta', 'meter', 'multicol', 'nav', 'nobr', 'noembed', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', 'output', 'p', 'param', 'picture', 'plaintext', 'pre', 'progress', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'script', 'search', 'section', 'select', 'selectedcontent', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr', 'xmp']);
|
|
579
|
-
|
|
580
|
-
function canRemoveParentTag(optionalStartTag, tag) {
|
|
581
|
-
switch (optionalStartTag) {
|
|
582
|
-
case 'html':
|
|
583
|
-
case 'head':
|
|
584
|
-
return true;
|
|
585
|
-
case 'body':
|
|
586
|
-
return !headerTags.has(tag);
|
|
587
|
-
case 'colgroup':
|
|
588
|
-
return tag === 'col';
|
|
589
|
-
case 'tbody':
|
|
590
|
-
return tag === 'tr';
|
|
591
|
-
}
|
|
592
|
-
return false;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
function isStartTagMandatory(optionalEndTag, tag) {
|
|
596
|
-
switch (tag) {
|
|
597
|
-
case 'colgroup':
|
|
598
|
-
return optionalEndTag === 'colgroup';
|
|
599
|
-
case 'tbody':
|
|
600
|
-
return tableSectionTags.has(optionalEndTag);
|
|
601
|
-
}
|
|
602
|
-
return false;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
function canRemovePrecedingTag(optionalEndTag, tag) {
|
|
606
|
-
switch (optionalEndTag) {
|
|
607
|
-
case 'html':
|
|
608
|
-
case 'head':
|
|
609
|
-
case 'body':
|
|
610
|
-
case 'colgroup':
|
|
611
|
-
case 'caption':
|
|
612
|
-
return true;
|
|
613
|
-
case 'li':
|
|
614
|
-
case 'optgroup':
|
|
615
|
-
case 'tr':
|
|
616
|
-
return tag === optionalEndTag;
|
|
617
|
-
case 'dt':
|
|
618
|
-
case 'dd':
|
|
619
|
-
return descriptionTags.has(tag);
|
|
620
|
-
case 'p':
|
|
621
|
-
return pBlockTags.has(tag);
|
|
622
|
-
case 'rb':
|
|
623
|
-
case 'rt':
|
|
624
|
-
case 'rp':
|
|
625
|
-
return rubyEndTagOmission.has(tag);
|
|
626
|
-
case 'rtc':
|
|
627
|
-
return rubyRtcEndTagOmission.has(tag);
|
|
628
|
-
case 'option':
|
|
629
|
-
return optionTag.has(tag);
|
|
630
|
-
case 'thead':
|
|
631
|
-
case 'tbody':
|
|
632
|
-
return tableContentTags.has(tag);
|
|
633
|
-
case 'tfoot':
|
|
634
|
-
return tag === 'tbody';
|
|
635
|
-
case 'td':
|
|
636
|
-
case 'th':
|
|
637
|
-
return cellTags.has(tag);
|
|
638
|
-
}
|
|
639
|
-
return false;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
const reEmptyAttribute = new RegExp(
|
|
643
|
-
'^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
|
|
644
|
-
'?:down|up|over|move|out)|key(?:press|down|up)))$');
|
|
645
|
-
|
|
646
|
-
function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
|
|
647
|
-
const isValueEmpty = !attrValue || /^\s*$/.test(attrValue);
|
|
648
|
-
if (!isValueEmpty) {
|
|
649
|
-
return false;
|
|
650
|
-
}
|
|
651
|
-
if (typeof options.removeEmptyAttributes === 'function') {
|
|
652
|
-
return options.removeEmptyAttributes(attrName, tag);
|
|
653
|
-
}
|
|
654
|
-
return (tag === 'input' && attrName === 'value') || reEmptyAttribute.test(attrName);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
function hasAttrName(name, attrs) {
|
|
658
|
-
for (let i = attrs.length - 1; i >= 0; i--) {
|
|
659
|
-
if (attrs[i].name === name) {
|
|
660
|
-
return true;
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
return false;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
function canRemoveElement(tag, attrs) {
|
|
667
|
-
switch (tag) {
|
|
668
|
-
case 'textarea':
|
|
669
|
-
return false;
|
|
670
|
-
case 'audio':
|
|
671
|
-
case 'script':
|
|
672
|
-
case 'video':
|
|
673
|
-
if (hasAttrName('src', attrs)) {
|
|
674
|
-
return false;
|
|
675
|
-
}
|
|
676
|
-
break;
|
|
677
|
-
case 'iframe':
|
|
678
|
-
if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
|
|
679
|
-
return false;
|
|
680
|
-
}
|
|
681
|
-
break;
|
|
682
|
-
case 'object':
|
|
683
|
-
if (hasAttrName('data', attrs)) {
|
|
684
|
-
return false;
|
|
685
|
-
}
|
|
686
|
-
break;
|
|
687
|
-
case 'applet':
|
|
688
|
-
if (hasAttrName('code', attrs)) {
|
|
689
|
-
return false;
|
|
690
|
-
}
|
|
691
|
-
break;
|
|
692
|
-
}
|
|
693
|
-
return true;
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
function canCollapseWhitespace(tag) {
|
|
697
|
-
return !/^(?:script|style|pre|textarea)$/.test(tag);
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
function canTrimWhitespace(tag) {
|
|
701
|
-
return !/^(?:pre|textarea)$/.test(tag);
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
async function normalizeAttr(attr, attrs, tag, options) {
|
|
705
|
-
const attrName = options.name(attr.name);
|
|
706
|
-
let attrValue = attr.value;
|
|
707
|
-
|
|
708
|
-
if (options.decodeEntities && attrValue) {
|
|
709
|
-
// Fast path: only decode when entities are present
|
|
710
|
-
if (attrValue.indexOf('&') !== -1) {
|
|
711
|
-
attrValue = decodeHTMLStrict(attrValue);
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
if ((options.removeRedundantAttributes &&
|
|
716
|
-
isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
|
|
717
|
-
(options.removeScriptTypeAttributes && tag === 'script' &&
|
|
718
|
-
attrName === 'type' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue)) ||
|
|
719
|
-
(options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
|
|
720
|
-
attrName === 'type' && isStyleLinkTypeAttribute(attrValue))) {
|
|
721
|
-
return;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
if (attrValue) {
|
|
725
|
-
attrValue = await cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML);
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
if (options.removeEmptyAttributes &&
|
|
729
|
-
canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
|
|
730
|
-
return;
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
|
|
734
|
-
attrValue = attrValue.replace(RE_AMP_ENTITY, '&$1');
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
return {
|
|
738
|
-
attr,
|
|
739
|
-
name: attrName,
|
|
740
|
-
value: attrValue
|
|
741
|
-
};
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
745
|
-
const attrName = normalized.name;
|
|
746
|
-
let attrValue = normalized.value;
|
|
747
|
-
const attr = normalized.attr;
|
|
748
|
-
let attrQuote = attr.quote;
|
|
749
|
-
let attrFragment;
|
|
750
|
-
let emittedAttrValue;
|
|
751
|
-
|
|
752
|
-
if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
|
|
753
|
-
~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
|
|
754
|
-
if (!options.preventAttributesEscaping) {
|
|
755
|
-
if (typeof options.quoteCharacter === 'undefined') {
|
|
756
|
-
const apos = (attrValue.match(/'/g) || []).length;
|
|
757
|
-
const quot = (attrValue.match(/"/g) || []).length;
|
|
758
|
-
attrQuote = apos < quot ? '\'' : '"';
|
|
759
|
-
} else {
|
|
760
|
-
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
761
|
-
}
|
|
762
|
-
if (attrQuote === '"') {
|
|
763
|
-
attrValue = attrValue.replace(/"/g, '"');
|
|
764
|
-
} else {
|
|
765
|
-
attrValue = attrValue.replace(/'/g, ''');
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
emittedAttrValue = attrQuote + attrValue + attrQuote;
|
|
769
|
-
if (!isLast && !options.removeTagWhitespace) {
|
|
770
|
-
emittedAttrValue += ' ';
|
|
771
|
-
}
|
|
772
|
-
} else if (isLast && !hasUnarySlash && !/\/$/.test(attrValue)) {
|
|
773
|
-
// Make sure trailing slash is not interpreted as HTML self-closing tag
|
|
774
|
-
emittedAttrValue = attrValue;
|
|
775
|
-
} else {
|
|
776
|
-
emittedAttrValue = attrValue + ' ';
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
if (typeof attrValue === 'undefined' || (options.collapseBooleanAttributes &&
|
|
780
|
-
isBooleanAttribute(attrName.toLowerCase(), attrValue.toLowerCase()))) {
|
|
781
|
-
attrFragment = attrName;
|
|
782
|
-
if (!isLast) {
|
|
783
|
-
attrFragment += ' ';
|
|
784
|
-
}
|
|
785
|
-
} else {
|
|
786
|
-
attrFragment = attrName + attr.customAssign + emittedAttrValue;
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
return attr.customOpen + attrFragment + attr.customClose;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
function identity(value) {
|
|
793
|
-
return value;
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
function identityAsync(value) {
|
|
797
|
-
return Promise.resolve(value);
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
function shouldMinifyInnerHTML(options) {
|
|
801
|
-
return Boolean(
|
|
802
|
-
options.collapseWhitespace ||
|
|
803
|
-
options.removeComments ||
|
|
804
|
-
options.removeOptionalTags ||
|
|
805
|
-
options.minifyJS !== identity ||
|
|
806
|
-
options.minifyCSS !== identityAsync ||
|
|
807
|
-
options.minifyURLs !== identity
|
|
808
|
-
);
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
const processOptions = (inputOptions) => {
|
|
812
|
-
const options = {
|
|
813
|
-
name: function (name) {
|
|
814
|
-
return name.toLowerCase();
|
|
815
|
-
},
|
|
816
|
-
canCollapseWhitespace,
|
|
817
|
-
canTrimWhitespace,
|
|
818
|
-
continueOnMinifyError: true,
|
|
819
|
-
html5: true,
|
|
820
|
-
ignoreCustomComments: [
|
|
821
|
-
/^!/,
|
|
822
|
-
/^\s*#/
|
|
823
|
-
],
|
|
824
|
-
ignoreCustomFragments: [
|
|
825
|
-
/<%[\s\S]*?%>/,
|
|
826
|
-
/<\?[\s\S]*?\?>/
|
|
827
|
-
],
|
|
828
|
-
includeAutoGeneratedTags: true,
|
|
829
|
-
log: identity,
|
|
830
|
-
minifyCSS: identityAsync,
|
|
831
|
-
minifyJS: identity,
|
|
832
|
-
minifyURLs: identity
|
|
833
|
-
};
|
|
834
|
-
|
|
835
|
-
Object.keys(inputOptions).forEach(function (key) {
|
|
836
|
-
const option = inputOptions[key];
|
|
837
|
-
|
|
838
|
-
if (key === 'caseSensitive') {
|
|
839
|
-
if (option) {
|
|
840
|
-
options.name = identity;
|
|
841
|
-
}
|
|
842
|
-
} else if (key === 'log') {
|
|
843
|
-
if (typeof option === 'function') {
|
|
844
|
-
options.log = option;
|
|
845
|
-
}
|
|
846
|
-
} else if (key === 'minifyCSS' && typeof option !== 'function') {
|
|
847
|
-
if (!option) {
|
|
848
|
-
return;
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
const lightningCssOptions = typeof option === 'object' ? option : {};
|
|
852
|
-
|
|
853
|
-
options.minifyCSS = async function (text, type) {
|
|
854
|
-
// Fast path: nothing to minify
|
|
855
|
-
if (!text || !text.trim()) {
|
|
856
|
-
return text;
|
|
857
|
-
}
|
|
858
|
-
text = await replaceAsync(
|
|
859
|
-
text,
|
|
860
|
-
/(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
|
|
861
|
-
async function (match, prefix, dq, sq, unq, suffix) {
|
|
862
|
-
const quote = dq != null ? '"' : (sq != null ? "'" : '');
|
|
863
|
-
const url = dq ?? sq ?? unq ?? '';
|
|
864
|
-
try {
|
|
865
|
-
const out = await options.minifyURLs(url);
|
|
866
|
-
return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
|
|
867
|
-
} catch (err) {
|
|
868
|
-
if (!options.continueOnMinifyError) {
|
|
869
|
-
throw err;
|
|
870
|
-
}
|
|
871
|
-
options.log && options.log(err);
|
|
872
|
-
return match;
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
);
|
|
876
|
-
// Cache key: wrapped content, type, options signature
|
|
877
|
-
const inputCSS = wrapCSS(text, type);
|
|
878
|
-
const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
|
|
879
|
-
// For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
|
|
880
|
-
const cssKey = inputCSS.length > 2048
|
|
881
|
-
? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
|
|
882
|
-
: (inputCSS + '|' + type + '|' + cssSig);
|
|
883
|
-
|
|
884
|
-
try {
|
|
885
|
-
const cached = cssMinifyCache.get(cssKey);
|
|
886
|
-
if (cached) {
|
|
887
|
-
return cached;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
const result = transformCSS({
|
|
891
|
-
filename: 'input.css',
|
|
892
|
-
code: Buffer.from(inputCSS),
|
|
893
|
-
minify: true,
|
|
894
|
-
errorRecovery: !!options.continueOnMinifyError,
|
|
895
|
-
...lightningCssOptions
|
|
896
|
-
});
|
|
897
|
-
|
|
898
|
-
const outputCSS = unwrapCSS(result.code.toString(), type);
|
|
899
|
-
|
|
900
|
-
// If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
|
|
901
|
-
// This preserves:
|
|
902
|
-
// 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
|
|
903
|
-
// 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
|
|
904
|
-
// CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
|
|
905
|
-
const isCDATA = text.includes('<![CDATA[');
|
|
906
|
-
const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
|
|
907
|
-
const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
|
|
908
|
-
const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
|
|
909
|
-
|
|
910
|
-
// Preserve if output is empty and input had template syntax or UIDs
|
|
911
|
-
// This catches cases where Lightning CSS removed content that should be preserved
|
|
912
|
-
const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
|
|
913
|
-
|
|
914
|
-
cssMinifyCache.set(cssKey, finalOutput);
|
|
915
|
-
return finalOutput;
|
|
916
|
-
} catch (err) {
|
|
917
|
-
cssMinifyCache.delete(cssKey);
|
|
918
|
-
if (!options.continueOnMinifyError) {
|
|
919
|
-
throw err;
|
|
920
|
-
}
|
|
921
|
-
options.log && options.log(err);
|
|
922
|
-
return text;
|
|
923
|
-
}
|
|
924
|
-
};
|
|
925
|
-
} else if (key === 'minifyJS' && typeof option !== 'function') {
|
|
926
|
-
if (!option) {
|
|
927
|
-
return;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
const terserOptions = typeof option === 'object' ? option : {};
|
|
931
|
-
|
|
932
|
-
terserOptions.parse = {
|
|
933
|
-
...terserOptions.parse,
|
|
934
|
-
bare_returns: false
|
|
935
|
-
};
|
|
936
|
-
|
|
937
|
-
options.minifyJS = async function (text, inline) {
|
|
938
|
-
const start = text.match(/^\s*<!--.*/);
|
|
939
|
-
const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
|
|
940
|
-
|
|
941
|
-
terserOptions.parse.bare_returns = inline;
|
|
942
|
-
|
|
943
|
-
let jsKey;
|
|
944
|
-
try {
|
|
945
|
-
// Fast path: avoid invoking Terser for empty/whitespace-only content
|
|
946
|
-
if (!code || !code.trim()) {
|
|
947
|
-
return '';
|
|
948
|
-
}
|
|
949
|
-
// Cache key: content, inline, options signature (subset)
|
|
950
|
-
const terserSig = stableStringify({
|
|
951
|
-
compress: terserOptions.compress,
|
|
952
|
-
mangle: terserOptions.mangle,
|
|
953
|
-
ecma: terserOptions.ecma,
|
|
954
|
-
toplevel: terserOptions.toplevel,
|
|
955
|
-
module: terserOptions.module,
|
|
956
|
-
keep_fnames: terserOptions.keep_fnames,
|
|
957
|
-
format: terserOptions.format,
|
|
958
|
-
cont: !!options.continueOnMinifyError,
|
|
959
|
-
});
|
|
960
|
-
// For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
|
|
961
|
-
jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
|
|
962
|
-
const cached = jsMinifyCache.get(jsKey);
|
|
963
|
-
if (cached) {
|
|
964
|
-
return await cached;
|
|
965
|
-
}
|
|
966
|
-
const inFlight = (async () => {
|
|
967
|
-
const result = await terser(code, terserOptions);
|
|
968
|
-
return result.code.replace(RE_TRAILING_SEMICOLON, '');
|
|
969
|
-
})();
|
|
970
|
-
jsMinifyCache.set(jsKey, inFlight);
|
|
971
|
-
const resolved = await inFlight;
|
|
972
|
-
jsMinifyCache.set(jsKey, resolved);
|
|
973
|
-
return resolved;
|
|
974
|
-
} catch (err) {
|
|
975
|
-
if (jsKey) jsMinifyCache.delete(jsKey);
|
|
976
|
-
if (!options.continueOnMinifyError) {
|
|
977
|
-
throw err;
|
|
978
|
-
}
|
|
979
|
-
options.log && options.log(err);
|
|
980
|
-
return text;
|
|
981
|
-
}
|
|
982
|
-
};
|
|
983
|
-
} else if (key === 'minifyURLs' && typeof option !== 'function') {
|
|
984
|
-
if (!option) {
|
|
985
|
-
return;
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
let relateUrlOptions = option;
|
|
989
|
-
|
|
990
|
-
if (typeof option === 'string') {
|
|
991
|
-
relateUrlOptions = { site: option };
|
|
992
|
-
} else if (typeof option !== 'object') {
|
|
993
|
-
relateUrlOptions = {};
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
options.minifyURLs = function (text) {
|
|
997
|
-
try {
|
|
998
|
-
return RelateURL.relate(text, relateUrlOptions);
|
|
999
|
-
} catch (err) {
|
|
1000
|
-
if (!options.continueOnMinifyError) {
|
|
1001
|
-
throw err;
|
|
1002
|
-
}
|
|
1003
|
-
options.log && options.log(err);
|
|
1004
|
-
return text;
|
|
1005
|
-
}
|
|
1006
|
-
};
|
|
1007
|
-
} else {
|
|
1008
|
-
options[key] = option;
|
|
1009
|
-
}
|
|
1010
|
-
});
|
|
1011
|
-
return options;
|
|
1012
|
-
};
|
|
1013
|
-
|
|
1014
|
-
function uniqueId(value) {
|
|
1015
|
-
let id;
|
|
1016
|
-
do {
|
|
1017
|
-
id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
|
|
1018
|
-
} while (~value.indexOf(id));
|
|
1019
|
-
return id;
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
const specialContentTags = new Set(['script', 'style']);
|
|
1023
|
-
|
|
1024
|
-
async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
1025
|
-
const attrChains = options.sortAttributes && Object.create(null);
|
|
1026
|
-
const classChain = options.sortClassName && new TokenChain();
|
|
1027
|
-
|
|
1028
|
-
function attrNames(attrs) {
|
|
1029
|
-
return attrs.map(function (attr) {
|
|
1030
|
-
return options.name(attr.name);
|
|
1031
|
-
});
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
function shouldSkipUID(token, uid) {
|
|
1035
|
-
return !uid || token.indexOf(uid) === -1;
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
function shouldSkipUIDs(token) {
|
|
1039
|
-
return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
async function scan(input) {
|
|
1043
|
-
let currentTag, currentType;
|
|
1044
|
-
const parser = new HTMLParser(input, {
|
|
1045
|
-
start: function (tag, attrs) {
|
|
1046
|
-
if (attrChains) {
|
|
1047
|
-
if (!attrChains[tag]) {
|
|
1048
|
-
attrChains[tag] = new TokenChain();
|
|
1049
|
-
}
|
|
1050
|
-
attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
|
|
1051
|
-
}
|
|
1052
|
-
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
1053
|
-
const attr = attrs[i];
|
|
1054
|
-
if (classChain && attr.value && options.name(attr.name) === 'class') {
|
|
1055
|
-
classChain.add(trimWhitespace(attr.value).split(/[ \t\n\f\r]+/).filter(shouldSkipUIDs));
|
|
1056
|
-
} else if (options.processScripts && attr.name.toLowerCase() === 'type') {
|
|
1057
|
-
currentTag = tag;
|
|
1058
|
-
currentType = attr.value;
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
},
|
|
1062
|
-
end: function () {
|
|
1063
|
-
currentTag = '';
|
|
1064
|
-
},
|
|
1065
|
-
chars: async function (text) {
|
|
1066
|
-
// Only recursively scan HTML content, not JSON-LD or other non-HTML script types
|
|
1067
|
-
// `scan()` is for analyzing HTML attribute order, not for parsing JSON
|
|
1068
|
-
if (options.processScripts && specialContentTags.has(currentTag) &&
|
|
1069
|
-
options.processScripts.indexOf(currentType) > -1 &&
|
|
1070
|
-
currentType === 'text/html') {
|
|
1071
|
-
await scan(text);
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
});
|
|
1075
|
-
|
|
1076
|
-
await parser.parse();
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
const log = options.log;
|
|
1080
|
-
options.log = identity;
|
|
1081
|
-
options.sortAttributes = false;
|
|
1082
|
-
options.sortClassName = false;
|
|
1083
|
-
const firstPassOutput = await minifyHTML(value, options);
|
|
1084
|
-
await scan(firstPassOutput);
|
|
1085
|
-
options.log = log;
|
|
1086
|
-
if (attrChains) {
|
|
1087
|
-
const attrSorters = Object.create(null);
|
|
1088
|
-
for (const tag in attrChains) {
|
|
1089
|
-
attrSorters[tag] = attrChains[tag].createSorter();
|
|
1090
|
-
}
|
|
1091
|
-
options.sortAttributes = function (tag, attrs) {
|
|
1092
|
-
const sorter = attrSorters[tag];
|
|
1093
|
-
if (sorter) {
|
|
1094
|
-
const attrMap = Object.create(null);
|
|
1095
|
-
const names = attrNames(attrs);
|
|
1096
|
-
names.forEach(function (name, index) {
|
|
1097
|
-
(attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
|
|
1098
|
-
});
|
|
1099
|
-
sorter.sort(names).forEach(function (name, index) {
|
|
1100
|
-
attrs[index] = attrMap[name].shift();
|
|
1101
|
-
});
|
|
1102
|
-
}
|
|
1103
|
-
};
|
|
1104
|
-
}
|
|
1105
|
-
if (classChain) {
|
|
1106
|
-
const sorter = classChain.createSorter();
|
|
1107
|
-
options.sortClassName = function (value) {
|
|
1108
|
-
return sorter.sort(value.split(/[ \n\f\r]+/)).join(' ');
|
|
1109
|
-
};
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
async function minifyHTML(value, options, partialMarkup) {
|
|
1114
|
-
// Check input length limitation to prevent ReDoS attacks
|
|
1115
|
-
if (options.maxInputLength && value.length > options.maxInputLength) {
|
|
1116
|
-
throw new Error(`Input length (${value.length}) exceeds maximum allowed length (${options.maxInputLength})`);
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
if (options.collapseWhitespace) {
|
|
1120
|
-
value = collapseWhitespace(value, options, true, true);
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
const buffer = [];
|
|
1124
|
-
let charsPrevTag;
|
|
1125
|
-
let currentChars = '';
|
|
1126
|
-
let hasChars;
|
|
1127
|
-
let currentTag = '';
|
|
1128
|
-
let currentAttrs = [];
|
|
1129
|
-
const stackNoTrimWhitespace = [];
|
|
1130
|
-
const stackNoCollapseWhitespace = [];
|
|
1131
|
-
let optionalStartTag = '';
|
|
1132
|
-
let optionalEndTag = '';
|
|
1133
|
-
const ignoredMarkupChunks = [];
|
|
1134
|
-
const ignoredCustomMarkupChunks = [];
|
|
1135
|
-
let uidIgnore;
|
|
1136
|
-
let uidAttr;
|
|
1137
|
-
let uidPattern;
|
|
1138
|
-
// Create inline tags/text sets with custom elements
|
|
1139
|
-
const customElementsInput = options.inlineCustomElements ?? [];
|
|
1140
|
-
const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
|
|
1141
|
-
const normalizedCustomElements = customElementsArr.map(name => options.name(name));
|
|
1142
|
-
const inlineTextSet = new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements]);
|
|
1143
|
-
const inlineElements = new Set([...inlineElementsToKeepWhitespaceAround, ...normalizedCustomElements]);
|
|
1144
|
-
|
|
1145
|
-
// Temporarily replace ignored chunks with comments,
|
|
1146
|
-
// so that we don’t have to worry what’s there.
|
|
1147
|
-
// For all we care there might be
|
|
1148
|
-
// completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
|
|
1149
|
-
value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
|
|
1150
|
-
if (!uidIgnore) {
|
|
1151
|
-
uidIgnore = uniqueId(value);
|
|
1152
|
-
const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
|
|
1153
|
-
if (options.ignoreCustomComments) {
|
|
1154
|
-
options.ignoreCustomComments = options.ignoreCustomComments.slice();
|
|
1155
|
-
} else {
|
|
1156
|
-
options.ignoreCustomComments = [];
|
|
1157
|
-
}
|
|
1158
|
-
options.ignoreCustomComments.push(pattern);
|
|
1159
|
-
}
|
|
1160
|
-
const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
|
|
1161
|
-
ignoredMarkupChunks.push(group1);
|
|
1162
|
-
return token;
|
|
1163
|
-
});
|
|
1164
|
-
|
|
1165
|
-
const customFragments = options.ignoreCustomFragments.map(function (re) {
|
|
1166
|
-
return re.source;
|
|
1167
|
-
});
|
|
1168
|
-
if (customFragments.length) {
|
|
1169
|
-
// Warn about potential ReDoS if custom fragments use unlimited quantifiers
|
|
1170
|
-
for (let i = 0; i < customFragments.length; i++) {
|
|
1171
|
-
if (/[*+]/.test(customFragments[i])) {
|
|
1172
|
-
options.log('Warning: Custom fragment contains unlimited quantifiers (* or +) which may cause ReDoS vulnerability');
|
|
1173
|
-
break;
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
// Safe approach: Use bounded quantifiers instead of unlimited ones to prevent ReDoS
|
|
1178
|
-
const maxQuantifier = options.customFragmentQuantifierLimit || 200;
|
|
1179
|
-
const whitespacePattern = `\\s{0,${maxQuantifier}}`;
|
|
1180
|
-
|
|
1181
|
-
// Use bounded quantifiers to prevent ReDoS - this approach prevents exponential backtracking
|
|
1182
|
-
const reCustomIgnore = new RegExp(
|
|
1183
|
-
whitespacePattern + '(?:' + customFragments.join('|') + '){1,' + maxQuantifier + '}' + whitespacePattern,
|
|
1184
|
-
'g'
|
|
1185
|
-
);
|
|
1186
|
-
// Temporarily replace custom ignored fragments with unique attributes
|
|
1187
|
-
value = value.replace(reCustomIgnore, function (match) {
|
|
1188
|
-
if (!uidAttr) {
|
|
1189
|
-
uidAttr = uniqueId(value);
|
|
1190
|
-
uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
|
|
1191
|
-
|
|
1192
|
-
if (options.minifyCSS) {
|
|
1193
|
-
options.minifyCSS = (function (fn) {
|
|
1194
|
-
return function (text, type) {
|
|
1195
|
-
text = text.replace(uidPattern, function (match, prefix, index) {
|
|
1196
|
-
const chunks = ignoredCustomMarkupChunks[+index];
|
|
1197
|
-
return chunks[1] + uidAttr + index + uidAttr + chunks[2];
|
|
1198
|
-
});
|
|
1199
|
-
|
|
1200
|
-
return fn(text, type);
|
|
1201
|
-
};
|
|
1202
|
-
})(options.minifyCSS);
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
if (options.minifyJS) {
|
|
1206
|
-
options.minifyJS = (function (fn) {
|
|
1207
|
-
return function (text, type) {
|
|
1208
|
-
return fn(text.replace(uidPattern, function (match, prefix, index) {
|
|
1209
|
-
const chunks = ignoredCustomMarkupChunks[+index];
|
|
1210
|
-
return chunks[1] + uidAttr + index + uidAttr + chunks[2];
|
|
1211
|
-
}), type);
|
|
1212
|
-
};
|
|
1213
|
-
})(options.minifyJS);
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
const token = uidAttr + ignoredCustomMarkupChunks.length + uidAttr;
|
|
1218
|
-
ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
|
|
1219
|
-
return '\t' + token + '\t';
|
|
1220
|
-
});
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
|
|
1224
|
-
(options.sortClassName && typeof options.sortClassName !== 'function')) {
|
|
1225
|
-
await createSortFns(value, options, uidIgnore, uidAttr);
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
function _canCollapseWhitespace(tag, attrs) {
|
|
1229
|
-
return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
function _canTrimWhitespace(tag, attrs) {
|
|
1233
|
-
return options.canTrimWhitespace(tag, attrs, canTrimWhitespace);
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
function removeStartTag() {
|
|
1237
|
-
let index = buffer.length - 1;
|
|
1238
|
-
while (index > 0 && !/^<[^/!]/.test(buffer[index])) {
|
|
1239
|
-
index--;
|
|
1240
|
-
}
|
|
1241
|
-
buffer.length = Math.max(0, index);
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
function removeEndTag() {
|
|
1245
|
-
let index = buffer.length - 1;
|
|
1246
|
-
while (index > 0 && !/^<\//.test(buffer[index])) {
|
|
1247
|
-
index--;
|
|
1248
|
-
}
|
|
1249
|
-
buffer.length = Math.max(0, index);
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
// Look for trailing whitespaces, bypass any inline tags
|
|
1253
|
-
function trimTrailingWhitespace(index, nextTag) {
|
|
1254
|
-
for (let endTag = null; index >= 0 && _canTrimWhitespace(endTag); index--) {
|
|
1255
|
-
const str = buffer[index];
|
|
1256
|
-
const match = str.match(/^<\/([\w:-]+)>$/);
|
|
1257
|
-
if (match) {
|
|
1258
|
-
endTag = match[1];
|
|
1259
|
-
} else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, null, nextTag, options, inlineElements, inlineTextSet))) {
|
|
1260
|
-
break;
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
// Look for trailing whitespaces from previously processed text
|
|
1266
|
-
// which may not be trimmed due to a following comment or an empty
|
|
1267
|
-
// element which has now been removed
|
|
1268
|
-
function squashTrailingWhitespace(nextTag) {
|
|
1269
|
-
let charsIndex = buffer.length - 1;
|
|
1270
|
-
if (buffer.length > 1) {
|
|
1271
|
-
const item = buffer[buffer.length - 1];
|
|
1272
|
-
if (/^(?:<!|$)/.test(item) && item.indexOf(uidIgnore) === -1) {
|
|
1273
|
-
charsIndex--;
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
trimTrailingWhitespace(charsIndex, nextTag);
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
const parser = new HTMLParser(value, {
|
|
1280
|
-
partialMarkup: partialMarkup ?? options.partialMarkup,
|
|
1281
|
-
continueOnParseError: options.continueOnParseError,
|
|
1282
|
-
customAttrAssign: options.customAttrAssign,
|
|
1283
|
-
customAttrSurround: options.customAttrSurround,
|
|
1284
|
-
html5: options.html5,
|
|
1285
|
-
|
|
1286
|
-
start: async function (tag, attrs, unary, unarySlash, autoGenerated) {
|
|
1287
|
-
if (tag.toLowerCase() === 'svg') {
|
|
1288
|
-
options = Object.create(options);
|
|
1289
|
-
options.caseSensitive = true;
|
|
1290
|
-
options.keepClosingSlash = true;
|
|
1291
|
-
options.name = identity;
|
|
1292
|
-
}
|
|
1293
|
-
tag = options.name(tag);
|
|
1294
|
-
currentTag = tag;
|
|
1295
|
-
charsPrevTag = tag;
|
|
1296
|
-
if (!inlineTextSet.has(tag)) {
|
|
1297
|
-
currentChars = '';
|
|
1298
|
-
}
|
|
1299
|
-
hasChars = false;
|
|
1300
|
-
currentAttrs = attrs;
|
|
1301
|
-
|
|
1302
|
-
let optional = options.removeOptionalTags;
|
|
1303
|
-
if (optional) {
|
|
1304
|
-
const htmlTag = htmlTags.has(tag);
|
|
1305
|
-
// <html> may be omitted if first thing inside is not a comment
|
|
1306
|
-
// <head> may be omitted if first thing inside is an element
|
|
1307
|
-
// <body> may be omitted if first thing inside is not space, comment, <meta>, <link>, <script>, <style> or <template>
|
|
1308
|
-
// <colgroup> may be omitted if first thing inside is <col>
|
|
1309
|
-
// <tbody> may be omitted if first thing inside is <tr>
|
|
1310
|
-
if (htmlTag && canRemoveParentTag(optionalStartTag, tag)) {
|
|
1311
|
-
removeStartTag();
|
|
1312
|
-
}
|
|
1313
|
-
optionalStartTag = '';
|
|
1314
|
-
// End-tag-followed-by-start-tag omission rules
|
|
1315
|
-
if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag)) {
|
|
1316
|
-
removeEndTag();
|
|
1317
|
-
// <colgroup> cannot be omitted if preceding </colgroup> is omitted
|
|
1318
|
-
// <tbody> cannot be omitted if preceding </tbody>, </thead> or </tfoot> is omitted
|
|
1319
|
-
optional = !isStartTagMandatory(optionalEndTag, tag);
|
|
1320
|
-
}
|
|
1321
|
-
optionalEndTag = '';
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
// Set whitespace flags for nested tags (e.g., <code> within a <pre>)
|
|
1325
|
-
if (options.collapseWhitespace) {
|
|
1326
|
-
if (!stackNoTrimWhitespace.length) {
|
|
1327
|
-
squashTrailingWhitespace(tag);
|
|
1328
|
-
}
|
|
1329
|
-
if (!unary) {
|
|
1330
|
-
if (!_canTrimWhitespace(tag, attrs) || stackNoTrimWhitespace.length) {
|
|
1331
|
-
stackNoTrimWhitespace.push(tag);
|
|
1332
|
-
}
|
|
1333
|
-
if (!_canCollapseWhitespace(tag, attrs) || stackNoCollapseWhitespace.length) {
|
|
1334
|
-
stackNoCollapseWhitespace.push(tag);
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
const openTag = '<' + tag;
|
|
1340
|
-
const hasUnarySlash = unarySlash && options.keepClosingSlash;
|
|
1341
|
-
|
|
1342
|
-
buffer.push(openTag);
|
|
1343
|
-
|
|
1344
|
-
if (options.sortAttributes) {
|
|
1345
|
-
options.sortAttributes(tag, attrs);
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
const parts = [];
|
|
1349
|
-
for (let i = attrs.length, isLast = true; --i >= 0;) {
|
|
1350
|
-
const normalized = await normalizeAttr(attrs[i], attrs, tag, options);
|
|
1351
|
-
if (normalized) {
|
|
1352
|
-
parts.unshift(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
|
|
1353
|
-
isLast = false;
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
if (parts.length > 0) {
|
|
1357
|
-
buffer.push(' ');
|
|
1358
|
-
buffer.push.apply(buffer, parts);
|
|
1359
|
-
} else if (optional && optionalStartTags.has(tag)) {
|
|
1360
|
-
// Start tag must never be omitted if it has any attributes
|
|
1361
|
-
optionalStartTag = tag;
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
buffer.push(buffer.pop() + (hasUnarySlash ? '/' : '') + '>');
|
|
1365
|
-
|
|
1366
|
-
if (autoGenerated && !options.includeAutoGeneratedTags) {
|
|
1367
|
-
removeStartTag();
|
|
1368
|
-
optionalStartTag = '';
|
|
1369
|
-
}
|
|
1370
|
-
},
|
|
1371
|
-
end: function (tag, attrs, autoGenerated) {
|
|
1372
|
-
if (tag.toLowerCase() === 'svg') {
|
|
1373
|
-
options = Object.getPrototypeOf(options);
|
|
1374
|
-
}
|
|
1375
|
-
tag = options.name(tag);
|
|
1376
|
-
|
|
1377
|
-
// Check if current tag is in a whitespace stack
|
|
1378
|
-
if (options.collapseWhitespace) {
|
|
1379
|
-
if (stackNoTrimWhitespace.length) {
|
|
1380
|
-
if (tag === stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1]) {
|
|
1381
|
-
stackNoTrimWhitespace.pop();
|
|
1382
|
-
}
|
|
1383
|
-
} else {
|
|
1384
|
-
squashTrailingWhitespace('/' + tag);
|
|
1385
|
-
}
|
|
1386
|
-
if (stackNoCollapseWhitespace.length &&
|
|
1387
|
-
tag === stackNoCollapseWhitespace[stackNoCollapseWhitespace.length - 1]) {
|
|
1388
|
-
stackNoCollapseWhitespace.pop();
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
let isElementEmpty = false;
|
|
1393
|
-
if (tag === currentTag) {
|
|
1394
|
-
currentTag = '';
|
|
1395
|
-
isElementEmpty = !hasChars;
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
if (options.removeOptionalTags) {
|
|
1399
|
-
// <html>, <head> or <body> may be omitted if the element is empty
|
|
1400
|
-
if (isElementEmpty && topLevelTags.has(optionalStartTag)) {
|
|
1401
|
-
removeStartTag();
|
|
1402
|
-
}
|
|
1403
|
-
optionalStartTag = '';
|
|
1404
|
-
// </html> or </body> may be omitted if not followed by comment
|
|
1405
|
-
// </head> may be omitted if not followed by space or comment
|
|
1406
|
-
// </p> may be omitted if no more content in non-</a> parent
|
|
1407
|
-
// except for </dt> or </thead>, end tags may be omitted if no more content in parent element
|
|
1408
|
-
if (htmlTags.has(tag) && optionalEndTag && !trailingTags.has(optionalEndTag) && (optionalEndTag !== 'p' || !pInlineTags.has(tag))) {
|
|
1409
|
-
removeEndTag();
|
|
1410
|
-
}
|
|
1411
|
-
optionalEndTag = optionalEndTags.has(tag) ? tag : '';
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
|
|
1415
|
-
// Remove last “element” from buffer
|
|
1416
|
-
removeStartTag();
|
|
1417
|
-
optionalStartTag = '';
|
|
1418
|
-
optionalEndTag = '';
|
|
1419
|
-
} else {
|
|
1420
|
-
if (autoGenerated && !options.includeAutoGeneratedTags) {
|
|
1421
|
-
optionalEndTag = '';
|
|
1422
|
-
} else {
|
|
1423
|
-
buffer.push('</' + tag + '>');
|
|
1424
|
-
}
|
|
1425
|
-
charsPrevTag = '/' + tag;
|
|
1426
|
-
if (!inlineElements.has(tag)) {
|
|
1427
|
-
currentChars = '';
|
|
1428
|
-
} else if (isElementEmpty) {
|
|
1429
|
-
currentChars += '|';
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
},
|
|
1433
|
-
chars: async function (text, prevTag, nextTag) {
|
|
1434
|
-
prevTag = prevTag === '' ? 'comment' : prevTag;
|
|
1435
|
-
nextTag = nextTag === '' ? 'comment' : nextTag;
|
|
1436
|
-
if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
|
|
1437
|
-
if (text.indexOf('&') !== -1) {
|
|
1438
|
-
text = decodeHTML(text);
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
if (options.collapseWhitespace) {
|
|
1442
|
-
if (!stackNoTrimWhitespace.length) {
|
|
1443
|
-
if (prevTag === 'comment') {
|
|
1444
|
-
const prevComment = buffer[buffer.length - 1];
|
|
1445
|
-
if (prevComment.indexOf(uidIgnore) === -1) {
|
|
1446
|
-
if (!prevComment) {
|
|
1447
|
-
prevTag = charsPrevTag;
|
|
1448
|
-
}
|
|
1449
|
-
if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
|
|
1450
|
-
const charsIndex = buffer.length - 2;
|
|
1451
|
-
buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
|
|
1452
|
-
text = trailingSpaces + text;
|
|
1453
|
-
return '';
|
|
1454
|
-
});
|
|
1455
|
-
}
|
|
1456
|
-
}
|
|
1457
|
-
}
|
|
1458
|
-
if (prevTag) {
|
|
1459
|
-
if (prevTag === '/nobr' || prevTag === 'wbr') {
|
|
1460
|
-
if (/^\s/.test(text)) {
|
|
1461
|
-
let tagIndex = buffer.length - 1;
|
|
1462
|
-
while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
|
|
1463
|
-
tagIndex--;
|
|
1464
|
-
}
|
|
1465
|
-
trimTrailingWhitespace(tagIndex - 1, 'br');
|
|
1466
|
-
}
|
|
1467
|
-
} else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
|
|
1468
|
-
text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
if (prevTag || nextTag) {
|
|
1472
|
-
text = collapseWhitespaceSmart(text, prevTag, nextTag, options, inlineElements, inlineTextSet);
|
|
1473
|
-
} else {
|
|
1474
|
-
text = collapseWhitespace(text, options, true, true);
|
|
1475
|
-
}
|
|
1476
|
-
if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
|
|
1477
|
-
trimTrailingWhitespace(buffer.length - 1, nextTag);
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
|
|
1481
|
-
text = collapseWhitespace(text, options, false, false, true);
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
if (specialContentTags.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs))) {
|
|
1485
|
-
text = await processScript(text, options, currentAttrs);
|
|
1486
|
-
}
|
|
1487
|
-
if (isExecutableScript(currentTag, currentAttrs)) {
|
|
1488
|
-
text = await options.minifyJS(text);
|
|
1489
|
-
}
|
|
1490
|
-
if (isStyleSheet(currentTag, currentAttrs)) {
|
|
1491
|
-
text = await options.minifyCSS(text);
|
|
1492
|
-
}
|
|
1493
|
-
if (options.removeOptionalTags && text) {
|
|
1494
|
-
// <html> may be omitted if first thing inside is not a comment
|
|
1495
|
-
// <body> may be omitted if first thing inside is not space, comment, <meta>, <link>, <script>, <style> or <template>
|
|
1496
|
-
if (optionalStartTag === 'html' || (optionalStartTag === 'body' && !/^\s/.test(text))) {
|
|
1497
|
-
removeStartTag();
|
|
1498
|
-
}
|
|
1499
|
-
optionalStartTag = '';
|
|
1500
|
-
// </html> or </body> may be omitted if not followed by comment
|
|
1501
|
-
// </head>, </colgroup> or </caption> may be omitted if not followed by space or comment
|
|
1502
|
-
if (compactTags.has(optionalEndTag) || (looseTags.has(optionalEndTag) && !/^\s/.test(text))) {
|
|
1503
|
-
removeEndTag();
|
|
1504
|
-
}
|
|
1505
|
-
// Don’t reset optionalEndTag if text is only whitespace and will be collapsed (not conservatively)
|
|
1506
|
-
if (!/^\s+$/.test(text) || !options.collapseWhitespace || options.conservativeCollapse) {
|
|
1507
|
-
optionalEndTag = '';
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
|
|
1511
|
-
if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
|
|
1512
|
-
// Escape any `&` symbols that start either:
|
|
1513
|
-
// 1) a legacy named character reference (i.e., one that doesn’t end with `;`)
|
|
1514
|
-
// 2) or any other character reference (i.e., one that does end with `;`)
|
|
1515
|
-
// Note that `&` can be escaped as `&`, without the semi-colon.
|
|
1516
|
-
// https://mathiasbynens.be/notes/ambiguous-ampersands
|
|
1517
|
-
if (text.indexOf('&') !== -1) {
|
|
1518
|
-
text = text.replace(/&((?:Iacute|aacute|uacute|plusmn|Otilde|otilde|agrave|Agrave|Yacute|yacute|Oslash|oslash|atilde|Atilde|brvbar|ccedil|Ccedil|Ograve|curren|divide|eacute|Eacute|ograve|Oacute|egrave|Egrave|Ugrave|frac12|frac14|frac34|ugrave|oacute|iacute|Ntilde|ntilde|Uacute|middot|igrave|Igrave|iquest|Aacute|cedil|laquo|micro|iexcl|Icirc|icirc|acirc|Ucirc|Ecirc|ocirc|Ocirc|ecirc|ucirc|Aring|aring|AElig|aelig|acute|pound|raquo|Acirc|times|THORN|szlig|thorn|COPY|auml|ordf|ordm|Uuml|macr|uuml|Auml|ouml|Ouml|para|nbsp|euml|quot|QUOT|Euml|yuml|cent|sect|copy|sup1|sup2|sup3|iuml|Iuml|ETH|shy|reg|not|yen|amp|AMP|REG|uml|eth|deg|gt|GT|LT|lt)(?!;)|(?:#?[0-9a-zA-Z]+;))/g, '&$1');
|
|
1519
|
-
}
|
|
1520
|
-
if (text.indexOf('<') !== -1) {
|
|
1521
|
-
text = text.replace(/</g, '<');
|
|
1522
|
-
}
|
|
1523
|
-
}
|
|
1524
|
-
if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
|
|
1525
|
-
text = text.replace(uidPattern, function (match, prefix, index) {
|
|
1526
|
-
return ignoredCustomMarkupChunks[+index][0];
|
|
1527
|
-
});
|
|
1528
|
-
}
|
|
1529
|
-
currentChars += text;
|
|
1530
|
-
if (text) {
|
|
1531
|
-
hasChars = true;
|
|
1532
|
-
}
|
|
1533
|
-
buffer.push(text);
|
|
1534
|
-
},
|
|
1535
|
-
comment: async function (text, nonStandard) {
|
|
1536
|
-
const prefix = nonStandard ? '<!' : '<!--';
|
|
1537
|
-
const suffix = nonStandard ? '>' : '-->';
|
|
1538
|
-
if (isConditionalComment(text)) {
|
|
1539
|
-
text = prefix + await cleanConditionalComment(text, options) + suffix;
|
|
1540
|
-
} else if (options.removeComments) {
|
|
1541
|
-
if (isIgnoredComment(text, options)) {
|
|
1542
|
-
text = '<!--' + text + '-->';
|
|
1543
|
-
} else {
|
|
1544
|
-
text = '';
|
|
1545
|
-
}
|
|
1546
|
-
} else {
|
|
1547
|
-
text = prefix + text + suffix;
|
|
1548
|
-
}
|
|
1549
|
-
if (options.removeOptionalTags && text) {
|
|
1550
|
-
// Preceding comments suppress tag omissions
|
|
1551
|
-
optionalStartTag = '';
|
|
1552
|
-
optionalEndTag = '';
|
|
1553
|
-
}
|
|
1554
|
-
buffer.push(text);
|
|
1555
|
-
},
|
|
1556
|
-
doctype: function (doctype) {
|
|
1557
|
-
buffer.push(options.useShortDoctype
|
|
1558
|
-
? '<!doctype' +
|
|
1559
|
-
(options.removeTagWhitespace ? '' : ' ') + 'html>'
|
|
1560
|
-
: collapseWhitespaceAll(doctype));
|
|
1561
|
-
}
|
|
1562
|
-
});
|
|
1563
|
-
|
|
1564
|
-
await parser.parse();
|
|
1565
|
-
|
|
1566
|
-
if (options.removeOptionalTags) {
|
|
1567
|
-
// <html> may be omitted if first thing inside is not a comment
|
|
1568
|
-
// <head> or <body> may be omitted if empty
|
|
1569
|
-
if (topLevelTags.has(optionalStartTag)) {
|
|
1570
|
-
removeStartTag();
|
|
1571
|
-
}
|
|
1572
|
-
// except for </dt> or </thead>, end tags may be omitted if no more content in parent element
|
|
1573
|
-
if (optionalEndTag && !trailingTags.has(optionalEndTag)) {
|
|
1574
|
-
removeEndTag();
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
if (options.collapseWhitespace) {
|
|
1578
|
-
squashTrailingWhitespace('br');
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
return joinResultSegments(buffer, options, uidPattern
|
|
1582
|
-
? function (str) {
|
|
1583
|
-
return str.replace(uidPattern, function (match, prefix, index, suffix) {
|
|
1584
|
-
let chunk = ignoredCustomMarkupChunks[+index][0];
|
|
1585
|
-
if (options.collapseWhitespace) {
|
|
1586
|
-
if (prefix !== '\t') {
|
|
1587
|
-
chunk = prefix + chunk;
|
|
1588
|
-
}
|
|
1589
|
-
if (suffix !== '\t') {
|
|
1590
|
-
chunk += suffix;
|
|
1591
|
-
}
|
|
1592
|
-
return collapseWhitespace(chunk, {
|
|
1593
|
-
preserveLineBreaks: options.preserveLineBreaks,
|
|
1594
|
-
conservativeCollapse: !options.trimCustomFragments
|
|
1595
|
-
}, /^[ \n\r\t\f]/.test(chunk), /[ \n\r\t\f]$/.test(chunk));
|
|
1596
|
-
}
|
|
1597
|
-
return chunk;
|
|
1598
|
-
});
|
|
1599
|
-
}
|
|
1600
|
-
: identity, uidIgnore
|
|
1601
|
-
? function (str) {
|
|
1602
|
-
return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (match, index) {
|
|
1603
|
-
return ignoredMarkupChunks[+index];
|
|
1604
|
-
});
|
|
1605
|
-
}
|
|
1606
|
-
: identity);
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
|
|
1610
|
-
let str;
|
|
1611
|
-
const maxLineLength = options.maxLineLength;
|
|
1612
|
-
const noNewlinesBeforeTagClose = options.noNewlinesBeforeTagClose;
|
|
1613
|
-
|
|
1614
|
-
if (maxLineLength) {
|
|
1615
|
-
let line = ''; const lines = [];
|
|
1616
|
-
while (results.length) {
|
|
1617
|
-
const len = line.length;
|
|
1618
|
-
const end = results[0].indexOf('\n');
|
|
1619
|
-
const isClosingTag = Boolean(results[0].match(endTag));
|
|
1620
|
-
const shouldKeepSameLine = noNewlinesBeforeTagClose && isClosingTag;
|
|
1621
|
-
|
|
1622
|
-
if (end < 0) {
|
|
1623
|
-
line += restoreIgnore(restoreCustom(results.shift()));
|
|
1624
|
-
} else {
|
|
1625
|
-
line += restoreIgnore(restoreCustom(results[0].slice(0, end)));
|
|
1626
|
-
results[0] = results[0].slice(end + 1);
|
|
1627
|
-
}
|
|
1628
|
-
if (len > 0 && line.length > maxLineLength && !shouldKeepSameLine) {
|
|
1629
|
-
lines.push(line.slice(0, len));
|
|
1630
|
-
line = line.slice(len);
|
|
1631
|
-
} else if (end >= 0) {
|
|
1632
|
-
lines.push(line);
|
|
1633
|
-
line = '';
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
if (line) {
|
|
1637
|
-
lines.push(line);
|
|
1638
|
-
}
|
|
1639
|
-
str = lines.join('\n');
|
|
1640
|
-
} else {
|
|
1641
|
-
str = restoreIgnore(restoreCustom(results.join('')));
|
|
1642
|
-
}
|
|
1643
|
-
return options.collapseWhitespace ? collapseWhitespace(str, options, true, true) : str;
|
|
1644
|
-
}
|
|
1645
|
-
|
|
1646
|
-
/**
|
|
1647
|
-
* @param {string} value
|
|
1648
|
-
* @param {MinifierOptions} [options]
|
|
1649
|
-
* @returns {Promise<string>}
|
|
1650
|
-
*/
|
|
1651
|
-
export const minify = async function (value, options) {
|
|
1652
|
-
const start = Date.now();
|
|
1653
|
-
options = processOptions(options || {});
|
|
1654
|
-
const result = await minifyHTML(value, options);
|
|
1655
|
-
options.log('minified in: ' + (Date.now() - start) + 'ms');
|
|
1656
|
-
return result;
|
|
1657
|
-
};
|
|
1658
|
-
|
|
1659
|
-
export { presets, getPreset, getPresetNames };
|
|
1660
|
-
|
|
1661
|
-
export default { minify, presets, getPreset, getPresetNames };
|
|
1662
|
-
|
|
1663
|
-
/**
|
|
1664
|
-
* @typedef {Object} HTMLAttribute
|
|
1665
|
-
* Representation of an attribute from the HTML parser.
|
|
1666
|
-
*
|
|
1667
|
-
* @prop {string} name
|
|
1668
|
-
* @prop {string} [value]
|
|
1669
|
-
* @prop {string} [quote]
|
|
1670
|
-
* @prop {string} [customAssign]
|
|
1671
|
-
* @prop {string} [customOpen]
|
|
1672
|
-
* @prop {string} [customClose]
|
|
1673
|
-
*/
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} HTMLAttribute
|
|
30
|
+
* Representation of an attribute from the HTML parser.
|
|
31
|
+
*
|
|
32
|
+
* @prop {string} name
|
|
33
|
+
* @prop {string} [value]
|
|
34
|
+
* @prop {string} [quote]
|
|
35
|
+
* @prop {string} [customAssign]
|
|
36
|
+
* @prop {string} [customOpen]
|
|
37
|
+
* @prop {string} [customClose]
|
|
38
|
+
*/
|
|
1674
39
|
|
|
1675
40
|
/**
|
|
1676
41
|
* @typedef {Object} MinifierOptions
|
|
@@ -1698,7 +63,7 @@ export default { minify, presets, getPreset, getPresetNames };
|
|
|
1698
63
|
*
|
|
1699
64
|
* @prop {boolean} [collapseBooleanAttributes]
|
|
1700
65
|
* Collapse boolean attributes to their name only (for example
|
|
1701
|
-
* `disabled="disabled"`
|
|
66
|
+
* `disabled="disabled"` → `disabled`).
|
|
1702
67
|
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes
|
|
1703
68
|
*
|
|
1704
69
|
* Default: `false`
|
|
@@ -1942,6 +307,31 @@ export default { minify, presets, getPreset, getPresetNames };
|
|
|
1942
307
|
*
|
|
1943
308
|
* Default: `false`
|
|
1944
309
|
*
|
|
310
|
+
* @prop {string[]} [removeEmptyElementsExcept]
|
|
311
|
+
* Specifies empty elements to preserve when `removeEmptyElements` is enabled.
|
|
312
|
+
* Has no effect unless `removeEmptyElements: true`.
|
|
313
|
+
*
|
|
314
|
+
* Accepts tag names or HTML-like element specifications:
|
|
315
|
+
*
|
|
316
|
+
* * Tag name only: `["td", "span"]`—preserves all empty elements of these types
|
|
317
|
+
* * With valued attributes: `["<span aria-hidden='true'>"]`—preserves only when attribute values match
|
|
318
|
+
* * With boolean attributes: `["<input disabled>"]`—preserves only when boolean attribute is present
|
|
319
|
+
* * Mixed: `["<button type='button' disabled>"]`—all specified attributes must match
|
|
320
|
+
*
|
|
321
|
+
* Attribute matching:
|
|
322
|
+
*
|
|
323
|
+
* * All specified attributes must be present and match (valued attributes must have exact values)
|
|
324
|
+
* * Additional attributes on the element are allowed
|
|
325
|
+
* * Attribute name matching respects the `caseSensitive` option
|
|
326
|
+
* * Supports double quotes, single quotes, and unquoted attribute values in specifications
|
|
327
|
+
*
|
|
328
|
+
* Limitations:
|
|
329
|
+
*
|
|
330
|
+
* * Self-closing syntax (e.g., `["<span/>"]`) is not supported; use `["span"]` instead
|
|
331
|
+
* * Definitions containing `>` within quoted attribute values (e.g., `["<span title='a>b'>"]`) are not supported
|
|
332
|
+
*
|
|
333
|
+
* Default: `[]`
|
|
334
|
+
*
|
|
1945
335
|
* @prop {boolean} [removeOptionalTags]
|
|
1946
336
|
* Drop optional start/end tags where the HTML specification permits it
|
|
1947
337
|
* (for example `</li>`, optional `<html>` etc.).
|
|
@@ -1950,7 +340,7 @@ export default { minify, presets, getPreset, getPresetNames };
|
|
|
1950
340
|
* Default: `false`
|
|
1951
341
|
*
|
|
1952
342
|
* @prop {boolean} [removeRedundantAttributes]
|
|
1953
|
-
* Remove attributes that are redundant because they match the element
|
|
343
|
+
* Remove attributes that are redundant because they match the element’s
|
|
1954
344
|
* default values (for example `<button type="submit">`).
|
|
1955
345
|
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_redundant_attributes
|
|
1956
346
|
*
|
|
@@ -1969,7 +359,7 @@ export default { minify, presets, getPreset, getPresetNames };
|
|
|
1969
359
|
* Default: `false`
|
|
1970
360
|
*
|
|
1971
361
|
* @prop {boolean} [removeTagWhitespace]
|
|
1972
|
-
* **Note that this will
|
|
362
|
+
* **Note that this will result in invalid HTML!**
|
|
1973
363
|
*
|
|
1974
364
|
* When true, extra whitespace between tag name and attributes (or before
|
|
1975
365
|
* the closing bracket) will be removed where possible. Affects output spacing
|
|
@@ -2005,4 +395,1829 @@ export default { minify, presets, getPreset, getPresetNames };
|
|
|
2005
395
|
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#use_short_doctype
|
|
2006
396
|
*
|
|
2007
397
|
* Default: `false`
|
|
2008
|
-
*/
|
|
398
|
+
*/
|
|
399
|
+
|
|
400
|
+
// Hoisted, reusable RegExp patterns and tiny helpers to avoid repeated allocations in hot paths
|
|
401
|
+
const RE_WS_START = /^[ \n\r\t\f]+/;
|
|
402
|
+
const RE_WS_END = /[ \n\r\t\f]+$/;
|
|
403
|
+
const RE_ALL_WS_NBSP = /[ \n\r\t\f\xA0]+/g;
|
|
404
|
+
const RE_NBSP_LEADING_GROUP = /(^|\xA0+)[^\xA0]+/g;
|
|
405
|
+
const RE_NBSP_LEAD_GROUP = /(\xA0+)[^\xA0]+/g;
|
|
406
|
+
const RE_NBSP_TRAILING_GROUP = /[^\xA0]+(\xA0+)/g;
|
|
407
|
+
const RE_NBSP_TRAILING_STRIP = /[^\xA0]+$/;
|
|
408
|
+
const RE_CONDITIONAL_COMMENT = /^\[if\s[^\]]+]|\[endif]$/;
|
|
409
|
+
const RE_EVENT_ATTR_DEFAULT = /^on[a-z]{3,}$/;
|
|
410
|
+
const RE_CAN_REMOVE_ATTR_QUOTES = /^[^ \t\n\f\r"'`=<>]+$/;
|
|
411
|
+
const RE_TRAILING_SEMICOLON = /;$/;
|
|
412
|
+
const RE_AMP_ENTITY = /&(#?[0-9a-zA-Z]+;)/g;
|
|
413
|
+
|
|
414
|
+
// Tiny stable stringify for options signatures (sorted keys, shallow, nested objects)
|
|
415
|
+
function stableStringify(obj) {
|
|
416
|
+
if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
417
|
+
if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
|
|
418
|
+
const keys = Object.keys(obj).sort();
|
|
419
|
+
let out = '{';
|
|
420
|
+
for (let i = 0; i < keys.length; i++) {
|
|
421
|
+
const k = keys[i];
|
|
422
|
+
out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
|
|
423
|
+
}
|
|
424
|
+
return out + '}';
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Minimal LRU cache for strings and promises
|
|
428
|
+
class LRU {
|
|
429
|
+
constructor(limit = 200) {
|
|
430
|
+
this.limit = limit;
|
|
431
|
+
this.map = new Map();
|
|
432
|
+
}
|
|
433
|
+
get(key) {
|
|
434
|
+
const v = this.map.get(key);
|
|
435
|
+
if (v !== undefined) {
|
|
436
|
+
this.map.delete(key);
|
|
437
|
+
this.map.set(key, v);
|
|
438
|
+
}
|
|
439
|
+
return v;
|
|
440
|
+
}
|
|
441
|
+
set(key, value) {
|
|
442
|
+
if (this.map.has(key)) this.map.delete(key);
|
|
443
|
+
this.map.set(key, value);
|
|
444
|
+
if (this.map.size > this.limit) {
|
|
445
|
+
const first = this.map.keys().next().value;
|
|
446
|
+
this.map.delete(first);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
delete(key) { this.map.delete(key); }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Per-process caches
|
|
453
|
+
const jsMinifyCache = new LRU(200);
|
|
454
|
+
const cssMinifyCache = new LRU(200);
|
|
455
|
+
|
|
456
|
+
const trimWhitespace = str => {
|
|
457
|
+
if (!str) return str;
|
|
458
|
+
// Fast path: if no whitespace at start or end, return early
|
|
459
|
+
if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
|
|
460
|
+
return str;
|
|
461
|
+
}
|
|
462
|
+
return str.replace(RE_WS_START, '').replace(RE_WS_END, '');
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
function collapseWhitespaceAll(str) {
|
|
466
|
+
if (!str) return str;
|
|
467
|
+
// Fast path: if there are no common whitespace characters, return early
|
|
468
|
+
if (!/[ \n\r\t\f\xA0]/.test(str)) {
|
|
469
|
+
return str;
|
|
470
|
+
}
|
|
471
|
+
// Non-breaking space is specifically handled inside the replacer function here:
|
|
472
|
+
return str.replace(RE_ALL_WS_NBSP, function (spaces) {
|
|
473
|
+
return spaces === '\t' ? '\t' : spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
|
|
478
|
+
let lineBreakBefore = ''; let lineBreakAfter = '';
|
|
479
|
+
|
|
480
|
+
if (!str) return str;
|
|
481
|
+
|
|
482
|
+
if (options.preserveLineBreaks) {
|
|
483
|
+
str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function () {
|
|
484
|
+
lineBreakBefore = '\n';
|
|
485
|
+
return '';
|
|
486
|
+
}).replace(/[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*$/, function () {
|
|
487
|
+
lineBreakAfter = '\n';
|
|
488
|
+
return '';
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (trimLeft) {
|
|
493
|
+
// Non-breaking space is specifically handled inside the replacer function here:
|
|
494
|
+
str = str.replace(/^[ \n\r\t\f\xA0]+/, function (spaces) {
|
|
495
|
+
const conservative = !lineBreakBefore && options.conservativeCollapse;
|
|
496
|
+
if (conservative && spaces === '\t') {
|
|
497
|
+
return '\t';
|
|
498
|
+
}
|
|
499
|
+
return spaces.replace(/^[^\xA0]+/, '').replace(RE_NBSP_LEAD_GROUP, '$1 ') || (conservative ? ' ' : '');
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (trimRight) {
|
|
504
|
+
// Non-breaking space is specifically handled inside the replacer function here:
|
|
505
|
+
str = str.replace(/[ \n\r\t\f\xA0]+$/, function (spaces) {
|
|
506
|
+
const conservative = !lineBreakAfter && options.conservativeCollapse;
|
|
507
|
+
if (conservative && spaces === '\t') {
|
|
508
|
+
return '\t';
|
|
509
|
+
}
|
|
510
|
+
return spaces.replace(RE_NBSP_TRAILING_GROUP, ' $1').replace(RE_NBSP_TRAILING_STRIP, '') || (conservative ? ' ' : '');
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (collapseAll) {
|
|
515
|
+
// Strip non-space whitespace then compress spaces to one
|
|
516
|
+
str = collapseWhitespaceAll(str);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return lineBreakBefore + str + lineBreakAfter;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Non-empty elements that will maintain whitespace around them
|
|
523
|
+
const inlineElementsToKeepWhitespaceAround = new Set(['a', 'abbr', 'acronym', 'b', 'bdi', 'bdo', 'big', 'button', 'cite', 'code', 'del', 'dfn', 'em', 'font', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'mark', 'math', 'meter', 'nobr', 'object', 'output', 'progress', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'select', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'svg', 'textarea', 'time', 'tt', 'u', 'var', 'wbr']);
|
|
524
|
+
// Non-empty elements that will maintain whitespace within them
|
|
525
|
+
const inlineElementsToKeepWhitespaceWithin = new Set(['a', 'abbr', 'acronym', 'b', 'big', 'del', 'em', 'font', 'i', 'ins', 'kbd', 'mark', 'nobr', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'time', 'tt', 'u', 'var']);
|
|
526
|
+
// Elements that will always maintain whitespace around them
|
|
527
|
+
const inlineElementsToKeepWhitespace = new Set(['comment', 'img', 'input', 'wbr']);
|
|
528
|
+
|
|
529
|
+
function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements, inlineTextSet) {
|
|
530
|
+
let trimLeft = prevTag && !inlineElementsToKeepWhitespace.has(prevTag);
|
|
531
|
+
if (trimLeft && !options.collapseInlineTagWhitespace) {
|
|
532
|
+
trimLeft = prevTag.charAt(0) === '/' ? !inlineElements.has(prevTag.slice(1)) : !inlineTextSet.has(prevTag);
|
|
533
|
+
}
|
|
534
|
+
let trimRight = nextTag && !inlineElementsToKeepWhitespace.has(nextTag);
|
|
535
|
+
if (trimRight && !options.collapseInlineTagWhitespace) {
|
|
536
|
+
trimRight = nextTag.charAt(0) === '/' ? !inlineTextSet.has(nextTag.slice(1)) : !inlineElements.has(nextTag);
|
|
537
|
+
}
|
|
538
|
+
return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function isConditionalComment(text) {
|
|
542
|
+
return RE_CONDITIONAL_COMMENT.test(text);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function isIgnoredComment(text, options) {
|
|
546
|
+
for (let i = 0, len = options.ignoreCustomComments.length; i < len; i++) {
|
|
547
|
+
if (options.ignoreCustomComments[i].test(text)) {
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function isEventAttribute(attrName, options) {
|
|
555
|
+
const patterns = options.customEventAttributes;
|
|
556
|
+
if (patterns) {
|
|
557
|
+
for (let i = patterns.length; i--;) {
|
|
558
|
+
if (patterns[i].test(attrName)) {
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
return RE_EVENT_ATTR_DEFAULT.test(attrName);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function canRemoveAttributeQuotes(value) {
|
|
568
|
+
// https://mathiasbynens.be/notes/unquoted-attribute-values
|
|
569
|
+
return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function attributesInclude(attributes, attribute) {
|
|
573
|
+
for (let i = attributes.length; i--;) {
|
|
574
|
+
if (attributes[i].name.toLowerCase() === attribute) {
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Default attribute values (could apply to any element)
|
|
582
|
+
const generalDefaults = {
|
|
583
|
+
autocorrect: 'on',
|
|
584
|
+
fetchpriority: 'auto',
|
|
585
|
+
loading: 'eager',
|
|
586
|
+
popovertargetaction: 'toggle'
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
// Tag-specific default attribute values
|
|
590
|
+
const tagDefaults = {
|
|
591
|
+
area: { shape: 'rect' },
|
|
592
|
+
button: { type: 'submit' },
|
|
593
|
+
form: {
|
|
594
|
+
enctype: 'application/x-www-form-urlencoded',
|
|
595
|
+
method: 'get'
|
|
596
|
+
},
|
|
597
|
+
html: { dir: 'ltr' },
|
|
598
|
+
img: { decoding: 'auto' },
|
|
599
|
+
input: {
|
|
600
|
+
colorspace: 'limited-srgb',
|
|
601
|
+
type: 'text'
|
|
602
|
+
},
|
|
603
|
+
marquee: {
|
|
604
|
+
behavior: 'scroll',
|
|
605
|
+
direction: 'left'
|
|
606
|
+
},
|
|
607
|
+
style: { media: 'all' },
|
|
608
|
+
textarea: { wrap: 'soft' },
|
|
609
|
+
track: { kind: 'subtitles' }
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
function isAttributeRedundant(tag, attrName, attrValue, attrs) {
|
|
613
|
+
attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
|
|
614
|
+
|
|
615
|
+
// Legacy attributes
|
|
616
|
+
if (tag === 'script' && attrName === 'language' && attrValue === 'javascript') {
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
if (tag === 'script' && attrName === 'charset' && !attributesInclude(attrs, 'src')) {
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
if (tag === 'a' && attrName === 'name' && attributesInclude(attrs, 'id')) {
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Check general defaults
|
|
627
|
+
if (generalDefaults[attrName] === attrValue) {
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Check tag-specific defaults
|
|
632
|
+
return tagDefaults[tag]?.[attrName] === attrValue;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// https://mathiasbynens.be/demo/javascript-mime-type
|
|
636
|
+
// https://developer.mozilla.org/en/docs/Web/HTML/Element/script#attr-type
|
|
637
|
+
const executableScriptsMimetypes = new Set([
|
|
638
|
+
'text/javascript',
|
|
639
|
+
'text/ecmascript',
|
|
640
|
+
'text/jscript',
|
|
641
|
+
'application/javascript',
|
|
642
|
+
'application/x-javascript',
|
|
643
|
+
'application/ecmascript',
|
|
644
|
+
'module'
|
|
645
|
+
]);
|
|
646
|
+
|
|
647
|
+
const keepScriptsMimetypes = new Set([
|
|
648
|
+
'module'
|
|
649
|
+
]);
|
|
650
|
+
|
|
651
|
+
function isScriptTypeAttribute(attrValue = '') {
|
|
652
|
+
attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
|
|
653
|
+
return attrValue === '' || executableScriptsMimetypes.has(attrValue);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function keepScriptTypeAttribute(attrValue = '') {
|
|
657
|
+
attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
|
|
658
|
+
return keepScriptsMimetypes.has(attrValue);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function isExecutableScript(tag, attrs) {
|
|
662
|
+
if (tag !== 'script') {
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
666
|
+
const attrName = attrs[i].name.toLowerCase();
|
|
667
|
+
if (attrName === 'type') {
|
|
668
|
+
return isScriptTypeAttribute(attrs[i].value);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function isStyleLinkTypeAttribute(attrValue = '') {
|
|
675
|
+
attrValue = trimWhitespace(attrValue).toLowerCase();
|
|
676
|
+
return attrValue === '' || attrValue === 'text/css';
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function isStyleSheet(tag, attrs) {
|
|
680
|
+
if (tag !== 'style') {
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
684
|
+
const attrName = attrs[i].name.toLowerCase();
|
|
685
|
+
if (attrName === 'type') {
|
|
686
|
+
return isStyleLinkTypeAttribute(attrs[i].value);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const isSimpleBoolean = new Set(['allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'compact', 'controls', 'declare', 'default', 'defaultchecked', 'defaultmuted', 'defaultselected', 'defer', 'disabled', 'enabled', 'formnovalidate', 'hidden', 'indeterminate', 'inert', 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nohref', 'noresize', 'noshade', 'novalidate', 'nowrap', 'open', 'pauseonexit', 'readonly', 'required', 'reversed', 'scoped', 'seamless', 'selected', 'sortable', 'truespeed', 'typemustmatch', 'visible']);
|
|
693
|
+
const isBooleanValue = new Set(['true', 'false']);
|
|
694
|
+
|
|
695
|
+
function isBooleanAttribute(attrName, attrValue) {
|
|
696
|
+
return isSimpleBoolean.has(attrName) || (attrName === 'draggable' && !isBooleanValue.has(attrValue));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function isUriTypeAttribute(attrName, tag) {
|
|
700
|
+
return (
|
|
701
|
+
(/^(?:a|area|link|base)$/.test(tag) && attrName === 'href') ||
|
|
702
|
+
(tag === 'img' && /^(?:src|longdesc|usemap)$/.test(attrName)) ||
|
|
703
|
+
(tag === 'object' && /^(?:classid|codebase|data|usemap)$/.test(attrName)) ||
|
|
704
|
+
(tag === 'q' && attrName === 'cite') ||
|
|
705
|
+
(tag === 'blockquote' && attrName === 'cite') ||
|
|
706
|
+
((tag === 'ins' || tag === 'del') && attrName === 'cite') ||
|
|
707
|
+
(tag === 'form' && attrName === 'action') ||
|
|
708
|
+
(tag === 'input' && (attrName === 'src' || attrName === 'usemap')) ||
|
|
709
|
+
(tag === 'head' && attrName === 'profile') ||
|
|
710
|
+
(tag === 'script' && (attrName === 'src' || attrName === 'for'))
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function isNumberTypeAttribute(attrName, tag) {
|
|
715
|
+
return (
|
|
716
|
+
(/^(?:a|area|object|button)$/.test(tag) && attrName === 'tabindex') ||
|
|
717
|
+
(tag === 'input' && (attrName === 'maxlength' || attrName === 'tabindex')) ||
|
|
718
|
+
(tag === 'select' && (attrName === 'size' || attrName === 'tabindex')) ||
|
|
719
|
+
(tag === 'textarea' && /^(?:rows|cols|tabindex)$/.test(attrName)) ||
|
|
720
|
+
(tag === 'colgroup' && attrName === 'span') ||
|
|
721
|
+
(tag === 'col' && attrName === 'span') ||
|
|
722
|
+
((tag === 'th' || tag === 'td') && (attrName === 'rowspan' || attrName === 'colspan'))
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function isLinkType(tag, attrs, value) {
|
|
727
|
+
if (tag !== 'link') return false;
|
|
728
|
+
const needle = String(value).toLowerCase();
|
|
729
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
730
|
+
if (attrs[i].name.toLowerCase() === 'rel') {
|
|
731
|
+
const tokens = String(attrs[i].value).toLowerCase().split(/\s+/);
|
|
732
|
+
if (tokens.includes(needle)) return true;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function isMediaQuery(tag, attrs, attrName) {
|
|
739
|
+
return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleSheet(tag, attrs));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const srcsetTags = new Set(['img', 'source']);
|
|
743
|
+
|
|
744
|
+
function isSrcset(attrName, tag) {
|
|
745
|
+
return attrName === 'srcset' && srcsetTags.has(tag);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
|
|
749
|
+
if (isEventAttribute(attrName, options)) {
|
|
750
|
+
attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
|
|
751
|
+
return options.minifyJS(attrValue, true);
|
|
752
|
+
} else if (attrName === 'class') {
|
|
753
|
+
attrValue = trimWhitespace(attrValue);
|
|
754
|
+
if (options.sortClassName) {
|
|
755
|
+
attrValue = options.sortClassName(attrValue);
|
|
756
|
+
} else {
|
|
757
|
+
attrValue = collapseWhitespaceAll(attrValue);
|
|
758
|
+
}
|
|
759
|
+
return attrValue;
|
|
760
|
+
} else if (isUriTypeAttribute(attrName, tag)) {
|
|
761
|
+
attrValue = trimWhitespace(attrValue);
|
|
762
|
+
if (isLinkType(tag, attrs, 'canonical')) {
|
|
763
|
+
return attrValue;
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
const out = await options.minifyURLs(attrValue);
|
|
767
|
+
return typeof out === 'string' ? out : attrValue;
|
|
768
|
+
} catch (err) {
|
|
769
|
+
if (!options.continueOnMinifyError) {
|
|
770
|
+
throw err;
|
|
771
|
+
}
|
|
772
|
+
options.log && options.log(err);
|
|
773
|
+
return attrValue;
|
|
774
|
+
}
|
|
775
|
+
} else if (isNumberTypeAttribute(attrName, tag)) {
|
|
776
|
+
return trimWhitespace(attrValue);
|
|
777
|
+
} else if (attrName === 'style') {
|
|
778
|
+
attrValue = trimWhitespace(attrValue);
|
|
779
|
+
if (attrValue) {
|
|
780
|
+
if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
|
|
781
|
+
attrValue = attrValue.replace(/\s*;$/, ';');
|
|
782
|
+
}
|
|
783
|
+
attrValue = await options.minifyCSS(attrValue, 'inline');
|
|
784
|
+
}
|
|
785
|
+
return attrValue;
|
|
786
|
+
} else if (isSrcset(attrName, tag)) {
|
|
787
|
+
// https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
|
|
788
|
+
attrValue = (await Promise.all(trimWhitespace(attrValue).split(/\s+,\s*|\s*,\s+/).map(async function (candidate) {
|
|
789
|
+
let url = candidate;
|
|
790
|
+
let descriptor = '';
|
|
791
|
+
const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
|
|
792
|
+
if (match) {
|
|
793
|
+
url = url.slice(0, -match[0].length);
|
|
794
|
+
const num = +match[1].slice(0, -1);
|
|
795
|
+
const suffix = match[1].slice(-1);
|
|
796
|
+
if (num !== 1 || suffix !== 'x') {
|
|
797
|
+
descriptor = ' ' + num + suffix;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
try {
|
|
801
|
+
const out = await options.minifyURLs(url);
|
|
802
|
+
return (typeof out === 'string' ? out : url) + descriptor;
|
|
803
|
+
} catch (err) {
|
|
804
|
+
if (!options.continueOnMinifyError) {
|
|
805
|
+
throw err;
|
|
806
|
+
}
|
|
807
|
+
options.log && options.log(err);
|
|
808
|
+
return url + descriptor;
|
|
809
|
+
}
|
|
810
|
+
}))).join(', ');
|
|
811
|
+
} else if (isMetaViewport(tag, attrs) && attrName === 'content') {
|
|
812
|
+
attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
|
|
813
|
+
// “0.90000” → “0.9”
|
|
814
|
+
// “1.0” → “1”
|
|
815
|
+
// “1.0001” → “1.0001” (unchanged)
|
|
816
|
+
return (+numString).toString();
|
|
817
|
+
});
|
|
818
|
+
} else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
|
|
819
|
+
return collapseWhitespaceAll(attrValue);
|
|
820
|
+
} else if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
|
|
821
|
+
attrValue = trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
|
|
822
|
+
} else if (tag === 'script' && attrName === 'type') {
|
|
823
|
+
attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
|
|
824
|
+
} else if (isMediaQuery(tag, attrs, attrName)) {
|
|
825
|
+
attrValue = trimWhitespace(attrValue);
|
|
826
|
+
return options.minifyCSS(attrValue, 'media');
|
|
827
|
+
} else if (tag === 'iframe' && attrName === 'srcdoc') {
|
|
828
|
+
// Recursively minify HTML content within srcdoc attribute
|
|
829
|
+
// Fast-path: skip if nothing would change
|
|
830
|
+
if (!shouldMinifyInnerHTML(options)) {
|
|
831
|
+
return attrValue;
|
|
832
|
+
}
|
|
833
|
+
return minifyHTMLSelf(attrValue, options, true);
|
|
834
|
+
}
|
|
835
|
+
return attrValue;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function isMetaViewport(tag, attrs) {
|
|
839
|
+
if (tag !== 'meta') {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
843
|
+
if (attrs[i].name === 'name' && attrs[i].value === 'viewport') {
|
|
844
|
+
return true;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function isContentSecurityPolicy(tag, attrs) {
|
|
850
|
+
if (tag !== 'meta') {
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
854
|
+
if (attrs[i].name.toLowerCase() === 'http-equiv' && attrs[i].value.toLowerCase() === 'content-security-policy') {
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Wrap CSS declarations for inline styles and media queries
|
|
861
|
+
// This ensures proper context for CSS minification
|
|
862
|
+
function wrapCSS(text, type) {
|
|
863
|
+
switch (type) {
|
|
864
|
+
case 'inline':
|
|
865
|
+
return '*{' + text + '}';
|
|
866
|
+
case 'media':
|
|
867
|
+
return '@media ' + text + '{a{top:0}}';
|
|
868
|
+
default:
|
|
869
|
+
return text;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function unwrapCSS(text, type) {
|
|
874
|
+
let matches;
|
|
875
|
+
switch (type) {
|
|
876
|
+
case 'inline':
|
|
877
|
+
matches = text.match(/^\*\{([\s\S]*)\}$/);
|
|
878
|
+
break;
|
|
879
|
+
case 'media':
|
|
880
|
+
matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/);
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
return matches ? matches[1] : text;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
async function cleanConditionalComment(comment, options) {
|
|
887
|
+
return options.processConditionalComments
|
|
888
|
+
? await replaceAsync(comment, /^(\[if\s[^\]]+]>)([\s\S]*?)(<!\[endif])$/, async function (match, prefix, text, suffix) {
|
|
889
|
+
return prefix + await minifyHTML(text, options, true) + suffix;
|
|
890
|
+
})
|
|
891
|
+
: comment;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const jsonScriptTypes = new Set([
|
|
895
|
+
'application/json',
|
|
896
|
+
'application/ld+json',
|
|
897
|
+
'application/manifest+json',
|
|
898
|
+
'application/vnd.geo+json',
|
|
899
|
+
'importmap',
|
|
900
|
+
'speculationrules',
|
|
901
|
+
]);
|
|
902
|
+
|
|
903
|
+
function minifyJson(text, options) {
|
|
904
|
+
try {
|
|
905
|
+
return JSON.stringify(JSON.parse(text));
|
|
906
|
+
}
|
|
907
|
+
catch (err) {
|
|
908
|
+
if (!options.continueOnMinifyError) {
|
|
909
|
+
throw err;
|
|
910
|
+
}
|
|
911
|
+
options.log && options.log(err);
|
|
912
|
+
return text;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function hasJsonScriptType(attrs) {
|
|
917
|
+
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
918
|
+
const attrName = attrs[i].name.toLowerCase();
|
|
919
|
+
if (attrName === 'type') {
|
|
920
|
+
const attrValue = trimWhitespace((attrs[i].value || '').split(/;/, 2)[0]).toLowerCase();
|
|
921
|
+
if (jsonScriptTypes.has(attrValue)) {
|
|
922
|
+
return true;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
async function processScript(text, options, currentAttrs) {
|
|
930
|
+
for (let i = 0, len = currentAttrs.length; i < len; i++) {
|
|
931
|
+
const attrName = currentAttrs[i].name.toLowerCase();
|
|
932
|
+
if (attrName === 'type') {
|
|
933
|
+
const rawValue = currentAttrs[i].value;
|
|
934
|
+
const normalizedValue = trimWhitespace((rawValue || '').split(/;/, 2)[0]).toLowerCase();
|
|
935
|
+
// Minify JSON script types automatically
|
|
936
|
+
if (jsonScriptTypes.has(normalizedValue)) {
|
|
937
|
+
return minifyJson(text, options);
|
|
938
|
+
}
|
|
939
|
+
// Process custom script types if specified
|
|
940
|
+
if (options.processScripts && options.processScripts.indexOf(rawValue) > -1) {
|
|
941
|
+
return await minifyHTML(text, options);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return text;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags with the following extensions:
|
|
949
|
+
// - retain `<body>` if followed by `<noscript>`
|
|
950
|
+
// - `<rb>`, `<rt>`, `<rtc>`, `<rp>` follow HTML Ruby Markup Extensions draft (https://www.w3.org/TR/html-ruby-extensions/)
|
|
951
|
+
// - retain all tags which are adjacent to non-standard HTML tags
|
|
952
|
+
const optionalStartTags = new Set(['html', 'head', 'body', 'colgroup', 'tbody']);
|
|
953
|
+
const optionalEndTags = new Set(['html', 'head', 'body', 'li', 'dt', 'dd', 'p', 'rb', 'rt', 'rtc', 'rp', 'optgroup', 'option', 'colgroup', 'caption', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th']);
|
|
954
|
+
const headerTags = new Set(['meta', 'link', 'script', 'style', 'template', 'noscript']);
|
|
955
|
+
const descriptionTags = new Set(['dt', 'dd']);
|
|
956
|
+
const pBlockTags = new Set(['address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'div', 'dl', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'main', 'menu', 'nav', 'ol', 'p', 'pre', 'search', 'section', 'table', 'ul']);
|
|
957
|
+
const pInlineTags = new Set(['a', 'audio', 'del', 'ins', 'map', 'noscript', 'video']);
|
|
958
|
+
const rubyEndTagOmission = new Set(['rb', 'rt', 'rtc', 'rp']); // `</rb>`, `</rt>`, `</rp>` can be omitted if followed by `<rb>`, `<rt>`, `<rtc>`, or `<rp>`
|
|
959
|
+
const rubyRtcEndTagOmission = new Set(['rb', 'rtc']); // `</rtc>` can be omitted if followed by `<rb>` or `<rtc>` (not `<rt>` or `<rp>`)
|
|
960
|
+
const optionTag = new Set(['option', 'optgroup']);
|
|
961
|
+
const tableContentTags = new Set(['tbody', 'tfoot']);
|
|
962
|
+
const tableSectionTags = new Set(['thead', 'tbody', 'tfoot']);
|
|
963
|
+
const cellTags = new Set(['td', 'th']);
|
|
964
|
+
const topLevelTags = new Set(['html', 'head', 'body']);
|
|
965
|
+
const compactTags = new Set(['html', 'body']);
|
|
966
|
+
const looseTags = new Set(['head', 'colgroup', 'caption']);
|
|
967
|
+
const trailingTags = new Set(['dt', 'thead']);
|
|
968
|
+
const htmlTags = new Set(['a', 'abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'b', 'base', 'basefont', 'bdi', 'bdo', 'bgsound', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'command', 'content', 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'embed', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'iframe', 'image', 'img', 'input', 'ins', 'isindex', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', 'listing', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meta', 'meter', 'multicol', 'nav', 'nobr', 'noembed', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', 'output', 'p', 'param', 'picture', 'plaintext', 'pre', 'progress', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'script', 'search', 'section', 'select', 'selectedcontent', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr', 'xmp']);
|
|
969
|
+
|
|
970
|
+
function canRemoveParentTag(optionalStartTag, tag) {
|
|
971
|
+
switch (optionalStartTag) {
|
|
972
|
+
case 'html':
|
|
973
|
+
case 'head':
|
|
974
|
+
return true;
|
|
975
|
+
case 'body':
|
|
976
|
+
return !headerTags.has(tag);
|
|
977
|
+
case 'colgroup':
|
|
978
|
+
return tag === 'col';
|
|
979
|
+
case 'tbody':
|
|
980
|
+
return tag === 'tr';
|
|
981
|
+
}
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function isStartTagMandatory(optionalEndTag, tag) {
|
|
986
|
+
switch (tag) {
|
|
987
|
+
case 'colgroup':
|
|
988
|
+
return optionalEndTag === 'colgroup';
|
|
989
|
+
case 'tbody':
|
|
990
|
+
return tableSectionTags.has(optionalEndTag);
|
|
991
|
+
}
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function canRemovePrecedingTag(optionalEndTag, tag) {
|
|
996
|
+
switch (optionalEndTag) {
|
|
997
|
+
case 'html':
|
|
998
|
+
case 'head':
|
|
999
|
+
case 'body':
|
|
1000
|
+
case 'colgroup':
|
|
1001
|
+
case 'caption':
|
|
1002
|
+
return true;
|
|
1003
|
+
case 'li':
|
|
1004
|
+
case 'optgroup':
|
|
1005
|
+
case 'tr':
|
|
1006
|
+
return tag === optionalEndTag;
|
|
1007
|
+
case 'dt':
|
|
1008
|
+
case 'dd':
|
|
1009
|
+
return descriptionTags.has(tag);
|
|
1010
|
+
case 'p':
|
|
1011
|
+
return pBlockTags.has(tag);
|
|
1012
|
+
case 'rb':
|
|
1013
|
+
case 'rt':
|
|
1014
|
+
case 'rp':
|
|
1015
|
+
return rubyEndTagOmission.has(tag);
|
|
1016
|
+
case 'rtc':
|
|
1017
|
+
return rubyRtcEndTagOmission.has(tag);
|
|
1018
|
+
case 'option':
|
|
1019
|
+
return optionTag.has(tag);
|
|
1020
|
+
case 'thead':
|
|
1021
|
+
case 'tbody':
|
|
1022
|
+
return tableContentTags.has(tag);
|
|
1023
|
+
case 'tfoot':
|
|
1024
|
+
return tag === 'tbody';
|
|
1025
|
+
case 'td':
|
|
1026
|
+
case 'th':
|
|
1027
|
+
return cellTags.has(tag);
|
|
1028
|
+
}
|
|
1029
|
+
return false;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const reEmptyAttribute = new RegExp(
|
|
1033
|
+
'^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
|
|
1034
|
+
'?:down|up|over|move|out)|key(?:press|down|up)))$');
|
|
1035
|
+
|
|
1036
|
+
function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
|
|
1037
|
+
const isValueEmpty = !attrValue || /^\s*$/.test(attrValue);
|
|
1038
|
+
if (!isValueEmpty) {
|
|
1039
|
+
return false;
|
|
1040
|
+
}
|
|
1041
|
+
if (typeof options.removeEmptyAttributes === 'function') {
|
|
1042
|
+
return options.removeEmptyAttributes(attrName, tag);
|
|
1043
|
+
}
|
|
1044
|
+
return (tag === 'input' && attrName === 'value') || reEmptyAttribute.test(attrName);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function hasAttrName(name, attrs) {
|
|
1048
|
+
for (let i = attrs.length - 1; i >= 0; i--) {
|
|
1049
|
+
if (attrs[i].name === name) {
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function canRemoveElement(tag, attrs) {
|
|
1057
|
+
switch (tag) {
|
|
1058
|
+
case 'textarea':
|
|
1059
|
+
return false;
|
|
1060
|
+
case 'audio':
|
|
1061
|
+
case 'script':
|
|
1062
|
+
case 'video':
|
|
1063
|
+
if (hasAttrName('src', attrs)) {
|
|
1064
|
+
return false;
|
|
1065
|
+
}
|
|
1066
|
+
break;
|
|
1067
|
+
case 'iframe':
|
|
1068
|
+
if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
|
|
1069
|
+
return false;
|
|
1070
|
+
}
|
|
1071
|
+
break;
|
|
1072
|
+
case 'object':
|
|
1073
|
+
if (hasAttrName('data', attrs)) {
|
|
1074
|
+
return false;
|
|
1075
|
+
}
|
|
1076
|
+
break;
|
|
1077
|
+
case 'applet':
|
|
1078
|
+
if (hasAttrName('code', attrs)) {
|
|
1079
|
+
return false;
|
|
1080
|
+
}
|
|
1081
|
+
break;
|
|
1082
|
+
}
|
|
1083
|
+
return true;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* @param {string} str - Tag name or HTML-like element spec (e.g., “td” or “<span aria-hidden='true'>”)
|
|
1088
|
+
* @param {MinifierOptions} options - Options object for name normalization
|
|
1089
|
+
* @returns {{tag: string, attrs: Object.<string, string|undefined>|null}|null} Parsed spec or null if invalid
|
|
1090
|
+
*/
|
|
1091
|
+
function parseElementSpec(str, options) {
|
|
1092
|
+
if (typeof str !== 'string') {
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const trimmed = str.trim();
|
|
1097
|
+
if (!trimmed) {
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Simple tag name: “td”
|
|
1102
|
+
if (!/[<>]/.test(trimmed)) {
|
|
1103
|
+
return { tag: options.name(trimmed), attrs: null };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// HTML-like markup: “<span aria-hidden='true'>” or “<td></td>”
|
|
1107
|
+
// Extract opening tag using regex
|
|
1108
|
+
const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
|
|
1109
|
+
if (!match) {
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const tag = options.name(match[1]);
|
|
1114
|
+
const attrString = match[2];
|
|
1115
|
+
|
|
1116
|
+
if (!attrString.trim()) {
|
|
1117
|
+
return { tag, attrs: null };
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Parse attributes from string
|
|
1121
|
+
const attrs = {};
|
|
1122
|
+
const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
|
|
1123
|
+
let attrMatch;
|
|
1124
|
+
|
|
1125
|
+
while ((attrMatch = attrRegex.exec(attrString))) {
|
|
1126
|
+
const attrName = options.name(attrMatch[1]);
|
|
1127
|
+
const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
|
|
1128
|
+
// Boolean attributes have no value (undefined)
|
|
1129
|
+
attrs[attrName] = attrValue;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
return {
|
|
1133
|
+
tag,
|
|
1134
|
+
attrs: Object.keys(attrs).length > 0 ? attrs : null
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* @param {string[]} input - Array of element specifications from `removeEmptyElementsExcept` option
|
|
1140
|
+
* @param {MinifierOptions} options - Options object for parsing
|
|
1141
|
+
* @returns {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} Array of parsed element specs
|
|
1142
|
+
*/
|
|
1143
|
+
function parseRemoveEmptyElementsExcept(input, options) {
|
|
1144
|
+
if (!Array.isArray(input)) {
|
|
1145
|
+
return [];
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
return input.map(item => {
|
|
1149
|
+
if (typeof item === 'string') {
|
|
1150
|
+
const spec = parseElementSpec(item, options);
|
|
1151
|
+
if (!spec && options.log) {
|
|
1152
|
+
options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
|
|
1153
|
+
}
|
|
1154
|
+
return spec;
|
|
1155
|
+
}
|
|
1156
|
+
if (options.log) {
|
|
1157
|
+
options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
|
|
1158
|
+
}
|
|
1159
|
+
return null;
|
|
1160
|
+
}).filter(Boolean);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* @param {string} tag - Element tag name
|
|
1165
|
+
* @param {HTMLAttribute[]} attrs - Array of element attributes
|
|
1166
|
+
* @param {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} preserveList - Parsed preserve specs
|
|
1167
|
+
* @returns {boolean} True if the empty element should be preserved
|
|
1168
|
+
*/
|
|
1169
|
+
function shouldPreserveEmptyElement(tag, attrs, preserveList) {
|
|
1170
|
+
for (const spec of preserveList) {
|
|
1171
|
+
// Tag name must match
|
|
1172
|
+
if (spec.tag !== tag) {
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// If no attributes specified in spec, tag match is enough
|
|
1177
|
+
if (!spec.attrs) {
|
|
1178
|
+
return true;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Check if all specified attributes match
|
|
1182
|
+
const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
|
|
1183
|
+
const attr = attrs.find(a => a.name === name);
|
|
1184
|
+
if (!attr) {
|
|
1185
|
+
return false; // Attribute not present
|
|
1186
|
+
}
|
|
1187
|
+
// Boolean attribute in spec (undefined value) matches if attribute is present
|
|
1188
|
+
if (value === undefined) {
|
|
1189
|
+
return true;
|
|
1190
|
+
}
|
|
1191
|
+
// Valued attribute must match exactly
|
|
1192
|
+
return attr.value === value;
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
if (allAttrsMatch) {
|
|
1196
|
+
return true;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
return false;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function canCollapseWhitespace(tag) {
|
|
1204
|
+
return !/^(?:script|style|pre|textarea)$/.test(tag);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function canTrimWhitespace(tag) {
|
|
1208
|
+
return !/^(?:pre|textarea)$/.test(tag);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
async function normalizeAttr(attr, attrs, tag, options) {
|
|
1212
|
+
const attrName = options.name(attr.name);
|
|
1213
|
+
let attrValue = attr.value;
|
|
1214
|
+
|
|
1215
|
+
if (options.decodeEntities && attrValue) {
|
|
1216
|
+
// Fast path: only decode when entities are present
|
|
1217
|
+
if (attrValue.indexOf('&') !== -1) {
|
|
1218
|
+
attrValue = decodeHTMLStrict(attrValue);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if ((options.removeRedundantAttributes &&
|
|
1223
|
+
isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
|
|
1224
|
+
(options.removeScriptTypeAttributes && tag === 'script' &&
|
|
1225
|
+
attrName === 'type' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue)) ||
|
|
1226
|
+
(options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
|
|
1227
|
+
attrName === 'type' && isStyleLinkTypeAttribute(attrValue))) {
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
if (attrValue) {
|
|
1232
|
+
attrValue = await cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
if (options.removeEmptyAttributes &&
|
|
1236
|
+
canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
|
|
1241
|
+
attrValue = attrValue.replace(RE_AMP_ENTITY, '&$1');
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
return {
|
|
1245
|
+
attr,
|
|
1246
|
+
name: attrName,
|
|
1247
|
+
value: attrValue
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
1252
|
+
const attrName = normalized.name;
|
|
1253
|
+
let attrValue = normalized.value;
|
|
1254
|
+
const attr = normalized.attr;
|
|
1255
|
+
let attrQuote = attr.quote;
|
|
1256
|
+
let attrFragment;
|
|
1257
|
+
let emittedAttrValue;
|
|
1258
|
+
|
|
1259
|
+
if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
|
|
1260
|
+
~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
|
|
1261
|
+
if (!options.preventAttributesEscaping) {
|
|
1262
|
+
if (typeof options.quoteCharacter === 'undefined') {
|
|
1263
|
+
// Count quotes in a single pass instead of two regex operations
|
|
1264
|
+
let apos = 0, quot = 0;
|
|
1265
|
+
for (let i = 0; i < attrValue.length; i++) {
|
|
1266
|
+
if (attrValue[i] === "'") apos++;
|
|
1267
|
+
else if (attrValue[i] === '"') quot++;
|
|
1268
|
+
}
|
|
1269
|
+
attrQuote = apos < quot ? '\'' : '"';
|
|
1270
|
+
} else {
|
|
1271
|
+
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
1272
|
+
}
|
|
1273
|
+
if (attrQuote === '"') {
|
|
1274
|
+
attrValue = attrValue.replace(/"/g, '"');
|
|
1275
|
+
} else {
|
|
1276
|
+
attrValue = attrValue.replace(/'/g, ''');
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
emittedAttrValue = attrQuote + attrValue + attrQuote;
|
|
1280
|
+
if (!isLast && !options.removeTagWhitespace) {
|
|
1281
|
+
emittedAttrValue += ' ';
|
|
1282
|
+
}
|
|
1283
|
+
} else if (isLast && !hasUnarySlash && !/\/$/.test(attrValue)) {
|
|
1284
|
+
// Make sure trailing slash is not interpreted as HTML self-closing tag
|
|
1285
|
+
emittedAttrValue = attrValue;
|
|
1286
|
+
} else {
|
|
1287
|
+
emittedAttrValue = attrValue + ' ';
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
if (typeof attrValue === 'undefined' || (options.collapseBooleanAttributes &&
|
|
1291
|
+
isBooleanAttribute(attrName.toLowerCase(), attrValue.toLowerCase()))) {
|
|
1292
|
+
attrFragment = attrName;
|
|
1293
|
+
if (!isLast) {
|
|
1294
|
+
attrFragment += ' ';
|
|
1295
|
+
}
|
|
1296
|
+
} else {
|
|
1297
|
+
attrFragment = attrName + attr.customAssign + emittedAttrValue;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
return attr.customOpen + attrFragment + attr.customClose;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function identity(value) {
|
|
1304
|
+
return value;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function identityAsync(value) {
|
|
1308
|
+
return Promise.resolve(value);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function shouldMinifyInnerHTML(options) {
|
|
1312
|
+
return Boolean(
|
|
1313
|
+
options.collapseWhitespace ||
|
|
1314
|
+
options.removeComments ||
|
|
1315
|
+
options.removeOptionalTags ||
|
|
1316
|
+
options.minifyJS !== identity ||
|
|
1317
|
+
options.minifyCSS !== identityAsync ||
|
|
1318
|
+
options.minifyURLs !== identity
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* @param {Partial<MinifierOptions>} inputOptions - User-provided options
|
|
1324
|
+
* @returns {MinifierOptions} Normalized options with defaults applied
|
|
1325
|
+
*/
|
|
1326
|
+
const processOptions = (inputOptions) => {
|
|
1327
|
+
const options = {
|
|
1328
|
+
name: function (name) {
|
|
1329
|
+
return name.toLowerCase();
|
|
1330
|
+
},
|
|
1331
|
+
canCollapseWhitespace,
|
|
1332
|
+
canTrimWhitespace,
|
|
1333
|
+
continueOnMinifyError: true,
|
|
1334
|
+
html5: true,
|
|
1335
|
+
ignoreCustomComments: [
|
|
1336
|
+
/^!/,
|
|
1337
|
+
/^\s*#/
|
|
1338
|
+
],
|
|
1339
|
+
ignoreCustomFragments: [
|
|
1340
|
+
/<%[\s\S]*?%>/,
|
|
1341
|
+
/<\?[\s\S]*?\?>/
|
|
1342
|
+
],
|
|
1343
|
+
includeAutoGeneratedTags: true,
|
|
1344
|
+
log: identity,
|
|
1345
|
+
minifyCSS: identityAsync,
|
|
1346
|
+
minifyJS: identity,
|
|
1347
|
+
minifyURLs: identity
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
Object.keys(inputOptions).forEach(function (key) {
|
|
1351
|
+
const option = inputOptions[key];
|
|
1352
|
+
|
|
1353
|
+
if (key === 'caseSensitive') {
|
|
1354
|
+
if (option) {
|
|
1355
|
+
options.name = identity;
|
|
1356
|
+
}
|
|
1357
|
+
} else if (key === 'log') {
|
|
1358
|
+
if (typeof option === 'function') {
|
|
1359
|
+
options.log = option;
|
|
1360
|
+
}
|
|
1361
|
+
} else if (key === 'minifyCSS' && typeof option !== 'function') {
|
|
1362
|
+
if (!option) {
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const lightningCssOptions = typeof option === 'object' ? option : {};
|
|
1367
|
+
|
|
1368
|
+
options.minifyCSS = async function (text, type) {
|
|
1369
|
+
// Fast path: nothing to minify
|
|
1370
|
+
if (!text || !text.trim()) {
|
|
1371
|
+
return text;
|
|
1372
|
+
}
|
|
1373
|
+
text = await replaceAsync(
|
|
1374
|
+
text,
|
|
1375
|
+
/(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
|
|
1376
|
+
async function (match, prefix, dq, sq, unq, suffix) {
|
|
1377
|
+
const quote = dq != null ? '"' : (sq != null ? "'" : '');
|
|
1378
|
+
const url = dq ?? sq ?? unq ?? '';
|
|
1379
|
+
try {
|
|
1380
|
+
const out = await options.minifyURLs(url);
|
|
1381
|
+
return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
if (!options.continueOnMinifyError) {
|
|
1384
|
+
throw err;
|
|
1385
|
+
}
|
|
1386
|
+
options.log && options.log(err);
|
|
1387
|
+
return match;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
);
|
|
1391
|
+
// Cache key: wrapped content, type, options signature
|
|
1392
|
+
const inputCSS = wrapCSS(text, type);
|
|
1393
|
+
const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
|
|
1394
|
+
// For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
|
|
1395
|
+
const cssKey = inputCSS.length > 2048
|
|
1396
|
+
? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
|
|
1397
|
+
: (inputCSS + '|' + type + '|' + cssSig);
|
|
1398
|
+
|
|
1399
|
+
try {
|
|
1400
|
+
const cached = cssMinifyCache.get(cssKey);
|
|
1401
|
+
if (cached) {
|
|
1402
|
+
return cached;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
const transformCSS = await getLightningCSS();
|
|
1406
|
+
const result = transformCSS({
|
|
1407
|
+
filename: 'input.css',
|
|
1408
|
+
code: Buffer.from(inputCSS),
|
|
1409
|
+
minify: true,
|
|
1410
|
+
errorRecovery: !!options.continueOnMinifyError,
|
|
1411
|
+
...lightningCssOptions
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
const outputCSS = unwrapCSS(result.code.toString(), type);
|
|
1415
|
+
|
|
1416
|
+
// If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
|
|
1417
|
+
// This preserves:
|
|
1418
|
+
// 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
|
|
1419
|
+
// 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
|
|
1420
|
+
// CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
|
|
1421
|
+
const isCDATA = text.includes('<![CDATA[');
|
|
1422
|
+
const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
|
|
1423
|
+
const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
|
|
1424
|
+
const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
|
|
1425
|
+
|
|
1426
|
+
// Preserve if output is empty and input had template syntax or UIDs
|
|
1427
|
+
// This catches cases where Lightning CSS removed content that should be preserved
|
|
1428
|
+
const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
|
|
1429
|
+
|
|
1430
|
+
cssMinifyCache.set(cssKey, finalOutput);
|
|
1431
|
+
return finalOutput;
|
|
1432
|
+
} catch (err) {
|
|
1433
|
+
cssMinifyCache.delete(cssKey);
|
|
1434
|
+
if (!options.continueOnMinifyError) {
|
|
1435
|
+
throw err;
|
|
1436
|
+
}
|
|
1437
|
+
options.log && options.log(err);
|
|
1438
|
+
return text;
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
} else if (key === 'minifyJS' && typeof option !== 'function') {
|
|
1442
|
+
if (!option) {
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
const terserOptions = typeof option === 'object' ? option : {};
|
|
1447
|
+
|
|
1448
|
+
terserOptions.parse = {
|
|
1449
|
+
...terserOptions.parse,
|
|
1450
|
+
bare_returns: false
|
|
1451
|
+
};
|
|
1452
|
+
|
|
1453
|
+
options.minifyJS = async function (text, inline) {
|
|
1454
|
+
const start = text.match(/^\s*<!--.*/);
|
|
1455
|
+
const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
|
|
1456
|
+
|
|
1457
|
+
terserOptions.parse.bare_returns = inline;
|
|
1458
|
+
|
|
1459
|
+
let jsKey;
|
|
1460
|
+
try {
|
|
1461
|
+
// Fast path: avoid invoking Terser for empty/whitespace-only content
|
|
1462
|
+
if (!code || !code.trim()) {
|
|
1463
|
+
return '';
|
|
1464
|
+
}
|
|
1465
|
+
// Cache key: content, inline, options signature (subset)
|
|
1466
|
+
const terserSig = stableStringify({
|
|
1467
|
+
compress: terserOptions.compress,
|
|
1468
|
+
mangle: terserOptions.mangle,
|
|
1469
|
+
ecma: terserOptions.ecma,
|
|
1470
|
+
toplevel: terserOptions.toplevel,
|
|
1471
|
+
module: terserOptions.module,
|
|
1472
|
+
keep_fnames: terserOptions.keep_fnames,
|
|
1473
|
+
format: terserOptions.format,
|
|
1474
|
+
cont: !!options.continueOnMinifyError,
|
|
1475
|
+
});
|
|
1476
|
+
// For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
|
|
1477
|
+
jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
|
|
1478
|
+
const cached = jsMinifyCache.get(jsKey);
|
|
1479
|
+
if (cached) {
|
|
1480
|
+
return await cached;
|
|
1481
|
+
}
|
|
1482
|
+
const inFlight = (async () => {
|
|
1483
|
+
const terser = await getTerser();
|
|
1484
|
+
const result = await terser(code, terserOptions);
|
|
1485
|
+
return result.code.replace(RE_TRAILING_SEMICOLON, '');
|
|
1486
|
+
})();
|
|
1487
|
+
jsMinifyCache.set(jsKey, inFlight);
|
|
1488
|
+
const resolved = await inFlight;
|
|
1489
|
+
jsMinifyCache.set(jsKey, resolved);
|
|
1490
|
+
return resolved;
|
|
1491
|
+
} catch (err) {
|
|
1492
|
+
if (jsKey) jsMinifyCache.delete(jsKey);
|
|
1493
|
+
if (!options.continueOnMinifyError) {
|
|
1494
|
+
throw err;
|
|
1495
|
+
}
|
|
1496
|
+
options.log && options.log(err);
|
|
1497
|
+
return text;
|
|
1498
|
+
}
|
|
1499
|
+
};
|
|
1500
|
+
} else if (key === 'minifyURLs' && typeof option !== 'function') {
|
|
1501
|
+
if (!option) {
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
let relateUrlOptions = option;
|
|
1506
|
+
|
|
1507
|
+
if (typeof option === 'string') {
|
|
1508
|
+
relateUrlOptions = { site: option };
|
|
1509
|
+
} else if (typeof option !== 'object') {
|
|
1510
|
+
relateUrlOptions = {};
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
options.minifyURLs = function (text) {
|
|
1514
|
+
try {
|
|
1515
|
+
return RelateURL.relate(text, relateUrlOptions);
|
|
1516
|
+
} catch (err) {
|
|
1517
|
+
if (!options.continueOnMinifyError) {
|
|
1518
|
+
throw err;
|
|
1519
|
+
}
|
|
1520
|
+
options.log && options.log(err);
|
|
1521
|
+
return text;
|
|
1522
|
+
}
|
|
1523
|
+
};
|
|
1524
|
+
} else {
|
|
1525
|
+
options[key] = option;
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
return options;
|
|
1529
|
+
};
|
|
1530
|
+
|
|
1531
|
+
function uniqueId(value) {
|
|
1532
|
+
let id;
|
|
1533
|
+
do {
|
|
1534
|
+
id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
|
|
1535
|
+
} while (~value.indexOf(id));
|
|
1536
|
+
return id;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
const specialContentTags = new Set(['script', 'style']);
|
|
1540
|
+
|
|
1541
|
+
async function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
1542
|
+
const attrChains = options.sortAttributes && Object.create(null);
|
|
1543
|
+
const classChain = options.sortClassName && new TokenChain();
|
|
1544
|
+
|
|
1545
|
+
function attrNames(attrs) {
|
|
1546
|
+
return attrs.map(function (attr) {
|
|
1547
|
+
return options.name(attr.name);
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function shouldSkipUID(token, uid) {
|
|
1552
|
+
return !uid || token.indexOf(uid) === -1;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
function shouldSkipUIDs(token) {
|
|
1556
|
+
return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
async function scan(input) {
|
|
1560
|
+
let currentTag, currentType;
|
|
1561
|
+
const parser = new HTMLParser(input, {
|
|
1562
|
+
start: function (tag, attrs) {
|
|
1563
|
+
if (attrChains) {
|
|
1564
|
+
if (!attrChains[tag]) {
|
|
1565
|
+
attrChains[tag] = new TokenChain();
|
|
1566
|
+
}
|
|
1567
|
+
attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
|
|
1568
|
+
}
|
|
1569
|
+
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
1570
|
+
const attr = attrs[i];
|
|
1571
|
+
if (classChain && attr.value && options.name(attr.name) === 'class') {
|
|
1572
|
+
classChain.add(trimWhitespace(attr.value).split(/[ \t\n\f\r]+/).filter(shouldSkipUIDs));
|
|
1573
|
+
} else if (options.processScripts && attr.name.toLowerCase() === 'type') {
|
|
1574
|
+
currentTag = tag;
|
|
1575
|
+
currentType = attr.value;
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
},
|
|
1579
|
+
end: function () {
|
|
1580
|
+
currentTag = '';
|
|
1581
|
+
},
|
|
1582
|
+
chars: async function (text) {
|
|
1583
|
+
// Only recursively scan HTML content, not JSON-LD or other non-HTML script types
|
|
1584
|
+
// `scan()` is for analyzing HTML attribute order, not for parsing JSON
|
|
1585
|
+
if (options.processScripts && specialContentTags.has(currentTag) &&
|
|
1586
|
+
options.processScripts.indexOf(currentType) > -1 &&
|
|
1587
|
+
currentType === 'text/html') {
|
|
1588
|
+
await scan(text);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
await parser.parse();
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
const log = options.log;
|
|
1597
|
+
options.log = identity;
|
|
1598
|
+
options.sortAttributes = false;
|
|
1599
|
+
options.sortClassName = false;
|
|
1600
|
+
const firstPassOutput = await minifyHTML(value, options);
|
|
1601
|
+
await scan(firstPassOutput);
|
|
1602
|
+
options.log = log;
|
|
1603
|
+
if (attrChains) {
|
|
1604
|
+
const attrSorters = Object.create(null);
|
|
1605
|
+
for (const tag in attrChains) {
|
|
1606
|
+
attrSorters[tag] = attrChains[tag].createSorter();
|
|
1607
|
+
}
|
|
1608
|
+
options.sortAttributes = function (tag, attrs) {
|
|
1609
|
+
const sorter = attrSorters[tag];
|
|
1610
|
+
if (sorter) {
|
|
1611
|
+
const attrMap = Object.create(null);
|
|
1612
|
+
const names = attrNames(attrs);
|
|
1613
|
+
names.forEach(function (name, index) {
|
|
1614
|
+
(attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
|
|
1615
|
+
});
|
|
1616
|
+
sorter.sort(names).forEach(function (name, index) {
|
|
1617
|
+
attrs[index] = attrMap[name].shift();
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
if (classChain) {
|
|
1623
|
+
const sorter = classChain.createSorter();
|
|
1624
|
+
options.sortClassName = function (value) {
|
|
1625
|
+
return sorter.sort(value.split(/[ \n\f\r]+/)).join(' ');
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/**
|
|
1631
|
+
* @param {string} value - HTML content to minify
|
|
1632
|
+
* @param {MinifierOptions} options - Normalized minification options
|
|
1633
|
+
* @param {boolean} [partialMarkup] - Whether treating input as partial markup
|
|
1634
|
+
* @returns {Promise<string>} Minified HTML
|
|
1635
|
+
*/
|
|
1636
|
+
async function minifyHTML(value, options, partialMarkup) {
|
|
1637
|
+
// Check input length limitation to prevent ReDoS attacks
|
|
1638
|
+
if (options.maxInputLength && value.length > options.maxInputLength) {
|
|
1639
|
+
throw new Error(`Input length (${value.length}) exceeds maximum allowed length (${options.maxInputLength})`);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
if (options.collapseWhitespace) {
|
|
1643
|
+
value = collapseWhitespace(value, options, true, true);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
const buffer = [];
|
|
1647
|
+
let charsPrevTag;
|
|
1648
|
+
let currentChars = '';
|
|
1649
|
+
let hasChars;
|
|
1650
|
+
let currentTag = '';
|
|
1651
|
+
let currentAttrs = [];
|
|
1652
|
+
const stackNoTrimWhitespace = [];
|
|
1653
|
+
const stackNoCollapseWhitespace = [];
|
|
1654
|
+
let optionalStartTag = '';
|
|
1655
|
+
let optionalEndTag = '';
|
|
1656
|
+
const ignoredMarkupChunks = [];
|
|
1657
|
+
const ignoredCustomMarkupChunks = [];
|
|
1658
|
+
let uidIgnore;
|
|
1659
|
+
let uidAttr;
|
|
1660
|
+
let uidPattern;
|
|
1661
|
+
// Create inline tags/text sets with custom elements
|
|
1662
|
+
const customElementsInput = options.inlineCustomElements ?? [];
|
|
1663
|
+
const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
|
|
1664
|
+
const normalizedCustomElements = customElementsArr.map(name => options.name(name));
|
|
1665
|
+
// Fast path: reuse base Sets if no custom elements
|
|
1666
|
+
const inlineTextSet = normalizedCustomElements.length
|
|
1667
|
+
? new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements])
|
|
1668
|
+
: inlineElementsToKeepWhitespaceWithin;
|
|
1669
|
+
const inlineElements = normalizedCustomElements.length
|
|
1670
|
+
? new Set([...inlineElementsToKeepWhitespaceAround, ...normalizedCustomElements])
|
|
1671
|
+
: inlineElementsToKeepWhitespaceAround;
|
|
1672
|
+
|
|
1673
|
+
// Parse `removeEmptyElementsExcept` option
|
|
1674
|
+
let removeEmptyElementsExcept;
|
|
1675
|
+
if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
|
|
1676
|
+
if (options.log) {
|
|
1677
|
+
options.log('Warning: “removeEmptyElementsExcept” option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
|
|
1678
|
+
}
|
|
1679
|
+
removeEmptyElementsExcept = [];
|
|
1680
|
+
} else {
|
|
1681
|
+
removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// Temporarily replace ignored chunks with comments,
|
|
1685
|
+
// so that we don’t have to worry what’s there.
|
|
1686
|
+
// For all we care there might be
|
|
1687
|
+
// completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
|
|
1688
|
+
value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
|
|
1689
|
+
if (!uidIgnore) {
|
|
1690
|
+
uidIgnore = uniqueId(value);
|
|
1691
|
+
const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
|
|
1692
|
+
if (options.ignoreCustomComments) {
|
|
1693
|
+
options.ignoreCustomComments = options.ignoreCustomComments.slice();
|
|
1694
|
+
} else {
|
|
1695
|
+
options.ignoreCustomComments = [];
|
|
1696
|
+
}
|
|
1697
|
+
options.ignoreCustomComments.push(pattern);
|
|
1698
|
+
}
|
|
1699
|
+
const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
|
|
1700
|
+
ignoredMarkupChunks.push(group1);
|
|
1701
|
+
return token;
|
|
1702
|
+
});
|
|
1703
|
+
|
|
1704
|
+
const customFragments = options.ignoreCustomFragments.map(function (re) {
|
|
1705
|
+
return re.source;
|
|
1706
|
+
});
|
|
1707
|
+
if (customFragments.length) {
|
|
1708
|
+
// Warn about potential ReDoS if custom fragments use unlimited quantifiers
|
|
1709
|
+
for (let i = 0; i < customFragments.length; i++) {
|
|
1710
|
+
if (/[*+]/.test(customFragments[i])) {
|
|
1711
|
+
options.log('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
|
|
1712
|
+
break;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Safe approach: Use bounded quantifiers instead of unlimited ones to prevent ReDoS
|
|
1717
|
+
const maxQuantifier = options.customFragmentQuantifierLimit || 200;
|
|
1718
|
+
const whitespacePattern = `\\s{0,${maxQuantifier}}`;
|
|
1719
|
+
|
|
1720
|
+
// Use bounded quantifiers to prevent ReDoS—this approach prevents exponential backtracking
|
|
1721
|
+
const reCustomIgnore = new RegExp(
|
|
1722
|
+
whitespacePattern + '(?:' + customFragments.join('|') + '){1,' + maxQuantifier + '}' + whitespacePattern,
|
|
1723
|
+
'g'
|
|
1724
|
+
);
|
|
1725
|
+
// Temporarily replace custom ignored fragments with unique attributes
|
|
1726
|
+
value = value.replace(reCustomIgnore, function (match) {
|
|
1727
|
+
if (!uidAttr) {
|
|
1728
|
+
uidAttr = uniqueId(value);
|
|
1729
|
+
uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
|
|
1730
|
+
|
|
1731
|
+
if (options.minifyCSS) {
|
|
1732
|
+
options.minifyCSS = (function (fn) {
|
|
1733
|
+
return function (text, type) {
|
|
1734
|
+
text = text.replace(uidPattern, function (match, prefix, index) {
|
|
1735
|
+
const chunks = ignoredCustomMarkupChunks[+index];
|
|
1736
|
+
return chunks[1] + uidAttr + index + uidAttr + chunks[2];
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
return fn(text, type);
|
|
1740
|
+
};
|
|
1741
|
+
})(options.minifyCSS);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
if (options.minifyJS) {
|
|
1745
|
+
options.minifyJS = (function (fn) {
|
|
1746
|
+
return function (text, type) {
|
|
1747
|
+
return fn(text.replace(uidPattern, function (match, prefix, index) {
|
|
1748
|
+
const chunks = ignoredCustomMarkupChunks[+index];
|
|
1749
|
+
return chunks[1] + uidAttr + index + uidAttr + chunks[2];
|
|
1750
|
+
}), type);
|
|
1751
|
+
};
|
|
1752
|
+
})(options.minifyJS);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const token = uidAttr + ignoredCustomMarkupChunks.length + uidAttr;
|
|
1757
|
+
ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
|
|
1758
|
+
return '\t' + token + '\t';
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
|
|
1763
|
+
(options.sortClassName && typeof options.sortClassName !== 'function')) {
|
|
1764
|
+
await createSortFns(value, options, uidIgnore, uidAttr);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
function _canCollapseWhitespace(tag, attrs) {
|
|
1768
|
+
return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function _canTrimWhitespace(tag, attrs) {
|
|
1772
|
+
return options.canTrimWhitespace(tag, attrs, canTrimWhitespace);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function removeStartTag() {
|
|
1776
|
+
let index = buffer.length - 1;
|
|
1777
|
+
while (index > 0 && !/^<[^/!]/.test(buffer[index])) {
|
|
1778
|
+
index--;
|
|
1779
|
+
}
|
|
1780
|
+
buffer.length = Math.max(0, index);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function removeEndTag() {
|
|
1784
|
+
let index = buffer.length - 1;
|
|
1785
|
+
while (index > 0 && !/^<\//.test(buffer[index])) {
|
|
1786
|
+
index--;
|
|
1787
|
+
}
|
|
1788
|
+
buffer.length = Math.max(0, index);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// Look for trailing whitespaces, bypass any inline tags
|
|
1792
|
+
function trimTrailingWhitespace(index, nextTag) {
|
|
1793
|
+
for (let endTag = null; index >= 0 && _canTrimWhitespace(endTag); index--) {
|
|
1794
|
+
const str = buffer[index];
|
|
1795
|
+
const match = str.match(/^<\/([\w:-]+)>$/);
|
|
1796
|
+
if (match) {
|
|
1797
|
+
endTag = match[1];
|
|
1798
|
+
} else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, null, nextTag, options, inlineElements, inlineTextSet))) {
|
|
1799
|
+
break;
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// Look for trailing whitespaces from previously processed text
|
|
1805
|
+
// which may not be trimmed due to a following comment or an empty
|
|
1806
|
+
// element which has now been removed
|
|
1807
|
+
function squashTrailingWhitespace(nextTag) {
|
|
1808
|
+
let charsIndex = buffer.length - 1;
|
|
1809
|
+
if (buffer.length > 1) {
|
|
1810
|
+
const item = buffer[buffer.length - 1];
|
|
1811
|
+
if (/^(?:<!|$)/.test(item) && item.indexOf(uidIgnore) === -1) {
|
|
1812
|
+
charsIndex--;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
trimTrailingWhitespace(charsIndex, nextTag);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
const parser = new HTMLParser(value, {
|
|
1819
|
+
partialMarkup: partialMarkup ?? options.partialMarkup,
|
|
1820
|
+
continueOnParseError: options.continueOnParseError,
|
|
1821
|
+
customAttrAssign: options.customAttrAssign,
|
|
1822
|
+
customAttrSurround: options.customAttrSurround,
|
|
1823
|
+
html5: options.html5,
|
|
1824
|
+
|
|
1825
|
+
start: async function (tag, attrs, unary, unarySlash, autoGenerated) {
|
|
1826
|
+
if (tag.toLowerCase() === 'svg') {
|
|
1827
|
+
options = Object.create(options);
|
|
1828
|
+
options.caseSensitive = true;
|
|
1829
|
+
options.keepClosingSlash = true;
|
|
1830
|
+
options.name = identity;
|
|
1831
|
+
}
|
|
1832
|
+
tag = options.name(tag);
|
|
1833
|
+
currentTag = tag;
|
|
1834
|
+
charsPrevTag = tag;
|
|
1835
|
+
if (!inlineTextSet.has(tag)) {
|
|
1836
|
+
currentChars = '';
|
|
1837
|
+
}
|
|
1838
|
+
hasChars = false;
|
|
1839
|
+
currentAttrs = attrs;
|
|
1840
|
+
|
|
1841
|
+
let optional = options.removeOptionalTags;
|
|
1842
|
+
if (optional) {
|
|
1843
|
+
const htmlTag = htmlTags.has(tag);
|
|
1844
|
+
// `<html>` may be omitted if first thing inside is not a comment
|
|
1845
|
+
// `<head>` may be omitted if first thing inside is an element
|
|
1846
|
+
// `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`, `<script>`, <`style>`, or `<template>`
|
|
1847
|
+
// `<colgroup>` may be omitted if first thing inside is `<col>`
|
|
1848
|
+
// `<tbody>` may be omitted if first thing inside is `<tr>`
|
|
1849
|
+
if (htmlTag && canRemoveParentTag(optionalStartTag, tag)) {
|
|
1850
|
+
removeStartTag();
|
|
1851
|
+
}
|
|
1852
|
+
optionalStartTag = '';
|
|
1853
|
+
// End-tag-followed-by-start-tag omission rules
|
|
1854
|
+
if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag)) {
|
|
1855
|
+
removeEndTag();
|
|
1856
|
+
// `<colgroup>` cannot be omitted if preceding `</colgroup>` is omitted
|
|
1857
|
+
// `<tbody>` cannot be omitted if preceding `</tbody>`, `</thead>`, or `</tfoot>` is omitted
|
|
1858
|
+
optional = !isStartTagMandatory(optionalEndTag, tag);
|
|
1859
|
+
}
|
|
1860
|
+
optionalEndTag = '';
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
// Set whitespace flags for nested tags (e.g., <code> within a <pre>)
|
|
1864
|
+
if (options.collapseWhitespace) {
|
|
1865
|
+
if (!stackNoTrimWhitespace.length) {
|
|
1866
|
+
squashTrailingWhitespace(tag);
|
|
1867
|
+
}
|
|
1868
|
+
if (!unary) {
|
|
1869
|
+
if (!_canTrimWhitespace(tag, attrs) || stackNoTrimWhitespace.length) {
|
|
1870
|
+
stackNoTrimWhitespace.push(tag);
|
|
1871
|
+
}
|
|
1872
|
+
if (!_canCollapseWhitespace(tag, attrs) || stackNoCollapseWhitespace.length) {
|
|
1873
|
+
stackNoCollapseWhitespace.push(tag);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
const openTag = '<' + tag;
|
|
1879
|
+
const hasUnarySlash = unarySlash && options.keepClosingSlash;
|
|
1880
|
+
|
|
1881
|
+
buffer.push(openTag);
|
|
1882
|
+
|
|
1883
|
+
if (options.sortAttributes) {
|
|
1884
|
+
options.sortAttributes(tag, attrs);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
const parts = [];
|
|
1888
|
+
for (let i = attrs.length, isLast = true; --i >= 0;) {
|
|
1889
|
+
const normalized = await normalizeAttr(attrs[i], attrs, tag, options);
|
|
1890
|
+
if (normalized) {
|
|
1891
|
+
parts.push(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
|
|
1892
|
+
isLast = false;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
parts.reverse();
|
|
1896
|
+
if (parts.length > 0) {
|
|
1897
|
+
buffer.push(' ');
|
|
1898
|
+
buffer.push.apply(buffer, parts);
|
|
1899
|
+
} else if (optional && optionalStartTags.has(tag)) {
|
|
1900
|
+
// Start tag must never be omitted if it has any attributes
|
|
1901
|
+
optionalStartTag = tag;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
buffer.push(buffer.pop() + (hasUnarySlash ? '/' : '') + '>');
|
|
1905
|
+
|
|
1906
|
+
if (autoGenerated && !options.includeAutoGeneratedTags) {
|
|
1907
|
+
removeStartTag();
|
|
1908
|
+
optionalStartTag = '';
|
|
1909
|
+
}
|
|
1910
|
+
},
|
|
1911
|
+
end: function (tag, attrs, autoGenerated) {
|
|
1912
|
+
if (tag.toLowerCase() === 'svg') {
|
|
1913
|
+
options = Object.getPrototypeOf(options);
|
|
1914
|
+
}
|
|
1915
|
+
tag = options.name(tag);
|
|
1916
|
+
|
|
1917
|
+
// Check if current tag is in a whitespace stack
|
|
1918
|
+
if (options.collapseWhitespace) {
|
|
1919
|
+
if (stackNoTrimWhitespace.length) {
|
|
1920
|
+
if (tag === stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1]) {
|
|
1921
|
+
stackNoTrimWhitespace.pop();
|
|
1922
|
+
}
|
|
1923
|
+
} else {
|
|
1924
|
+
squashTrailingWhitespace('/' + tag);
|
|
1925
|
+
}
|
|
1926
|
+
if (stackNoCollapseWhitespace.length &&
|
|
1927
|
+
tag === stackNoCollapseWhitespace[stackNoCollapseWhitespace.length - 1]) {
|
|
1928
|
+
stackNoCollapseWhitespace.pop();
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
let isElementEmpty = false;
|
|
1933
|
+
if (tag === currentTag) {
|
|
1934
|
+
currentTag = '';
|
|
1935
|
+
isElementEmpty = !hasChars;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
if (options.removeOptionalTags) {
|
|
1939
|
+
// `<html>`, `<head>` or `<body>` may be omitted if the element is empty
|
|
1940
|
+
if (isElementEmpty && topLevelTags.has(optionalStartTag)) {
|
|
1941
|
+
removeStartTag();
|
|
1942
|
+
}
|
|
1943
|
+
optionalStartTag = '';
|
|
1944
|
+
// `</html>` or `</body>` may be omitted if not followed by comment
|
|
1945
|
+
// `</head>` may be omitted if not followed by space or comment
|
|
1946
|
+
// `</p>` may be omitted if no more content in non-`</a>` parent
|
|
1947
|
+
// except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent element
|
|
1948
|
+
if (htmlTags.has(tag) && optionalEndTag && !trailingTags.has(optionalEndTag) && (optionalEndTag !== 'p' || !pInlineTags.has(tag))) {
|
|
1949
|
+
removeEndTag();
|
|
1950
|
+
}
|
|
1951
|
+
optionalEndTag = optionalEndTags.has(tag) ? tag : '';
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
|
|
1955
|
+
let preserve = false;
|
|
1956
|
+
if (removeEmptyElementsExcept.length) {
|
|
1957
|
+
// Normalize attribute names for comparison with specs
|
|
1958
|
+
const normalizedAttrs = attrs.map(attr => ({ ...attr, name: options.name(attr.name) }));
|
|
1959
|
+
preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
if (!preserve) {
|
|
1963
|
+
// Remove last “element” from buffer
|
|
1964
|
+
removeStartTag();
|
|
1965
|
+
optionalStartTag = '';
|
|
1966
|
+
optionalEndTag = '';
|
|
1967
|
+
} else {
|
|
1968
|
+
// Preserve the element—add closing tag
|
|
1969
|
+
if (autoGenerated && !options.includeAutoGeneratedTags) {
|
|
1970
|
+
optionalEndTag = '';
|
|
1971
|
+
} else {
|
|
1972
|
+
buffer.push('</' + tag + '>');
|
|
1973
|
+
}
|
|
1974
|
+
charsPrevTag = '/' + tag;
|
|
1975
|
+
if (!inlineElements.has(tag)) {
|
|
1976
|
+
currentChars = '';
|
|
1977
|
+
} else if (isElementEmpty) {
|
|
1978
|
+
currentChars += '|';
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
} else {
|
|
1982
|
+
if (autoGenerated && !options.includeAutoGeneratedTags) {
|
|
1983
|
+
optionalEndTag = '';
|
|
1984
|
+
} else {
|
|
1985
|
+
buffer.push('</' + tag + '>');
|
|
1986
|
+
}
|
|
1987
|
+
charsPrevTag = '/' + tag;
|
|
1988
|
+
if (!inlineElements.has(tag)) {
|
|
1989
|
+
currentChars = '';
|
|
1990
|
+
} else if (isElementEmpty) {
|
|
1991
|
+
currentChars += '|';
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
},
|
|
1995
|
+
chars: async function (text, prevTag, nextTag) {
|
|
1996
|
+
prevTag = prevTag === '' ? 'comment' : prevTag;
|
|
1997
|
+
nextTag = nextTag === '' ? 'comment' : nextTag;
|
|
1998
|
+
if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
|
|
1999
|
+
if (text.indexOf('&') !== -1) {
|
|
2000
|
+
text = decodeHTML(text);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
if (options.collapseWhitespace) {
|
|
2004
|
+
if (!stackNoTrimWhitespace.length) {
|
|
2005
|
+
if (prevTag === 'comment') {
|
|
2006
|
+
const prevComment = buffer[buffer.length - 1];
|
|
2007
|
+
if (prevComment.indexOf(uidIgnore) === -1) {
|
|
2008
|
+
if (!prevComment) {
|
|
2009
|
+
prevTag = charsPrevTag;
|
|
2010
|
+
}
|
|
2011
|
+
if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
|
|
2012
|
+
const charsIndex = buffer.length - 2;
|
|
2013
|
+
buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
|
|
2014
|
+
text = trailingSpaces + text;
|
|
2015
|
+
return '';
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
if (prevTag) {
|
|
2021
|
+
if (prevTag === '/nobr' || prevTag === 'wbr') {
|
|
2022
|
+
if (/^\s/.test(text)) {
|
|
2023
|
+
let tagIndex = buffer.length - 1;
|
|
2024
|
+
while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
|
|
2025
|
+
tagIndex--;
|
|
2026
|
+
}
|
|
2027
|
+
trimTrailingWhitespace(tagIndex - 1, 'br');
|
|
2028
|
+
}
|
|
2029
|
+
} else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
|
|
2030
|
+
text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
if (prevTag || nextTag) {
|
|
2034
|
+
text = collapseWhitespaceSmart(text, prevTag, nextTag, options, inlineElements, inlineTextSet);
|
|
2035
|
+
} else {
|
|
2036
|
+
text = collapseWhitespace(text, options, true, true);
|
|
2037
|
+
}
|
|
2038
|
+
if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
|
|
2039
|
+
trimTrailingWhitespace(buffer.length - 1, nextTag);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
|
|
2043
|
+
text = collapseWhitespace(text, options, false, false, true);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
if (specialContentTags.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs))) {
|
|
2047
|
+
text = await processScript(text, options, currentAttrs);
|
|
2048
|
+
}
|
|
2049
|
+
if (isExecutableScript(currentTag, currentAttrs)) {
|
|
2050
|
+
text = await options.minifyJS(text);
|
|
2051
|
+
}
|
|
2052
|
+
if (isStyleSheet(currentTag, currentAttrs)) {
|
|
2053
|
+
text = await options.minifyCSS(text);
|
|
2054
|
+
}
|
|
2055
|
+
if (options.removeOptionalTags && text) {
|
|
2056
|
+
// `<html>` may be omitted if first thing inside is not a comment
|
|
2057
|
+
// `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`, `<script>`, `<style>`, or `<template>`
|
|
2058
|
+
if (optionalStartTag === 'html' || (optionalStartTag === 'body' && !/^\s/.test(text))) {
|
|
2059
|
+
removeStartTag();
|
|
2060
|
+
}
|
|
2061
|
+
optionalStartTag = '';
|
|
2062
|
+
// `</html>` or `</body>` may be omitted if not followed by comment
|
|
2063
|
+
// `</head>`, `</colgroup>`, or `</caption>` may be omitted if not followed by space or comment
|
|
2064
|
+
if (compactTags.has(optionalEndTag) || (looseTags.has(optionalEndTag) && !/^\s/.test(text))) {
|
|
2065
|
+
removeEndTag();
|
|
2066
|
+
}
|
|
2067
|
+
// Don’t reset optionalEndTag if text is only whitespace and will be collapsed (not conservatively)
|
|
2068
|
+
if (!/^\s+$/.test(text) || !options.collapseWhitespace || options.conservativeCollapse) {
|
|
2069
|
+
optionalEndTag = '';
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
|
|
2073
|
+
if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
|
|
2074
|
+
// Escape any `&` symbols that start either:
|
|
2075
|
+
// 1) a legacy named character reference (i.e., one that doesn’t end with `;`)
|
|
2076
|
+
// 2) or any other character reference (i.e., one that does end with `;`)
|
|
2077
|
+
// Note that `&` can be escaped as `&`, without the semi-colon.
|
|
2078
|
+
// https://mathiasbynens.be/notes/ambiguous-ampersands
|
|
2079
|
+
if (text.indexOf('&') !== -1) {
|
|
2080
|
+
text = text.replace(/&((?:Iacute|aacute|uacute|plusmn|Otilde|otilde|agrave|Agrave|Yacute|yacute|Oslash|oslash|atilde|Atilde|brvbar|ccedil|Ccedil|Ograve|curren|divide|eacute|Eacute|ograve|Oacute|egrave|Egrave|Ugrave|frac12|frac14|frac34|ugrave|oacute|iacute|Ntilde|ntilde|Uacute|middot|igrave|Igrave|iquest|Aacute|cedil|laquo|micro|iexcl|Icirc|icirc|acirc|Ucirc|Ecirc|ocirc|Ocirc|ecirc|ucirc|Aring|aring|AElig|aelig|acute|pound|raquo|Acirc|times|THORN|szlig|thorn|COPY|auml|ordf|ordm|Uuml|macr|uuml|Auml|ouml|Ouml|para|nbsp|euml|quot|QUOT|Euml|yuml|cent|sect|copy|sup1|sup2|sup3|iuml|Iuml|ETH|shy|reg|not|yen|amp|AMP|REG|uml|eth|deg|gt|GT|LT|lt)(?!;)|(?:#?[0-9a-zA-Z]+;))/g, '&$1');
|
|
2081
|
+
}
|
|
2082
|
+
if (text.indexOf('<') !== -1) {
|
|
2083
|
+
text = text.replace(/</g, '<');
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
|
|
2087
|
+
text = text.replace(uidPattern, function (match, prefix, index) {
|
|
2088
|
+
return ignoredCustomMarkupChunks[+index][0];
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
currentChars += text;
|
|
2092
|
+
if (text) {
|
|
2093
|
+
hasChars = true;
|
|
2094
|
+
}
|
|
2095
|
+
buffer.push(text);
|
|
2096
|
+
},
|
|
2097
|
+
comment: async function (text, nonStandard) {
|
|
2098
|
+
const prefix = nonStandard ? '<!' : '<!--';
|
|
2099
|
+
const suffix = nonStandard ? '>' : '-->';
|
|
2100
|
+
if (isConditionalComment(text)) {
|
|
2101
|
+
text = prefix + await cleanConditionalComment(text, options) + suffix;
|
|
2102
|
+
} else if (options.removeComments) {
|
|
2103
|
+
if (isIgnoredComment(text, options)) {
|
|
2104
|
+
text = '<!--' + text + '-->';
|
|
2105
|
+
} else {
|
|
2106
|
+
text = '';
|
|
2107
|
+
}
|
|
2108
|
+
} else {
|
|
2109
|
+
text = prefix + text + suffix;
|
|
2110
|
+
}
|
|
2111
|
+
if (options.removeOptionalTags && text) {
|
|
2112
|
+
// Preceding comments suppress tag omissions
|
|
2113
|
+
optionalStartTag = '';
|
|
2114
|
+
optionalEndTag = '';
|
|
2115
|
+
}
|
|
2116
|
+
buffer.push(text);
|
|
2117
|
+
},
|
|
2118
|
+
doctype: function (doctype) {
|
|
2119
|
+
buffer.push(options.useShortDoctype
|
|
2120
|
+
? '<!doctype' +
|
|
2121
|
+
(options.removeTagWhitespace ? '' : ' ') + 'html>'
|
|
2122
|
+
: collapseWhitespaceAll(doctype));
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
await parser.parse();
|
|
2127
|
+
|
|
2128
|
+
if (options.removeOptionalTags) {
|
|
2129
|
+
// `<html>` may be omitted if first thing inside is not a comment
|
|
2130
|
+
// `<head>` or `<body>` may be omitted if empty
|
|
2131
|
+
if (topLevelTags.has(optionalStartTag)) {
|
|
2132
|
+
removeStartTag();
|
|
2133
|
+
}
|
|
2134
|
+
// except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent element
|
|
2135
|
+
if (optionalEndTag && !trailingTags.has(optionalEndTag)) {
|
|
2136
|
+
removeEndTag();
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
if (options.collapseWhitespace) {
|
|
2140
|
+
squashTrailingWhitespace('br');
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
return joinResultSegments(buffer, options, uidPattern
|
|
2144
|
+
? function (str) {
|
|
2145
|
+
return str.replace(uidPattern, function (match, prefix, index, suffix) {
|
|
2146
|
+
let chunk = ignoredCustomMarkupChunks[+index][0];
|
|
2147
|
+
if (options.collapseWhitespace) {
|
|
2148
|
+
if (prefix !== '\t') {
|
|
2149
|
+
chunk = prefix + chunk;
|
|
2150
|
+
}
|
|
2151
|
+
if (suffix !== '\t') {
|
|
2152
|
+
chunk += suffix;
|
|
2153
|
+
}
|
|
2154
|
+
return collapseWhitespace(chunk, {
|
|
2155
|
+
preserveLineBreaks: options.preserveLineBreaks,
|
|
2156
|
+
conservativeCollapse: !options.trimCustomFragments
|
|
2157
|
+
}, /^[ \n\r\t\f]/.test(chunk), /[ \n\r\t\f]$/.test(chunk));
|
|
2158
|
+
}
|
|
2159
|
+
return chunk;
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
: identity, uidIgnore
|
|
2163
|
+
? function (str) {
|
|
2164
|
+
return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (match, index) {
|
|
2165
|
+
return ignoredMarkupChunks[+index];
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
: identity);
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
|
|
2172
|
+
let str;
|
|
2173
|
+
const maxLineLength = options.maxLineLength;
|
|
2174
|
+
const noNewlinesBeforeTagClose = options.noNewlinesBeforeTagClose;
|
|
2175
|
+
|
|
2176
|
+
if (maxLineLength) {
|
|
2177
|
+
let line = ''; const lines = [];
|
|
2178
|
+
while (results.length) {
|
|
2179
|
+
const len = line.length;
|
|
2180
|
+
const end = results[0].indexOf('\n');
|
|
2181
|
+
const isClosingTag = Boolean(results[0].match(endTag));
|
|
2182
|
+
const shouldKeepSameLine = noNewlinesBeforeTagClose && isClosingTag;
|
|
2183
|
+
|
|
2184
|
+
if (end < 0) {
|
|
2185
|
+
line += restoreIgnore(restoreCustom(results.shift()));
|
|
2186
|
+
} else {
|
|
2187
|
+
line += restoreIgnore(restoreCustom(results[0].slice(0, end)));
|
|
2188
|
+
results[0] = results[0].slice(end + 1);
|
|
2189
|
+
}
|
|
2190
|
+
if (len > 0 && line.length > maxLineLength && !shouldKeepSameLine) {
|
|
2191
|
+
lines.push(line.slice(0, len));
|
|
2192
|
+
line = line.slice(len);
|
|
2193
|
+
} else if (end >= 0) {
|
|
2194
|
+
lines.push(line);
|
|
2195
|
+
line = '';
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
if (line) {
|
|
2199
|
+
lines.push(line);
|
|
2200
|
+
}
|
|
2201
|
+
str = lines.join('\n');
|
|
2202
|
+
} else {
|
|
2203
|
+
str = restoreIgnore(restoreCustom(results.join('')));
|
|
2204
|
+
}
|
|
2205
|
+
return options.collapseWhitespace ? collapseWhitespace(str, options, true, true) : str;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/**
|
|
2209
|
+
* @param {string} value
|
|
2210
|
+
* @param {MinifierOptions} [options]
|
|
2211
|
+
* @returns {Promise<string>}
|
|
2212
|
+
*/
|
|
2213
|
+
export const minify = async function (value, options) {
|
|
2214
|
+
const start = Date.now();
|
|
2215
|
+
options = processOptions(options || {});
|
|
2216
|
+
const result = await minifyHTML(value, options);
|
|
2217
|
+
options.log('minified in: ' + (Date.now() - start) + 'ms');
|
|
2218
|
+
return result;
|
|
2219
|
+
};
|
|
2220
|
+
|
|
2221
|
+
export { presets, getPreset, getPresetNames };
|
|
2222
|
+
|
|
2223
|
+
export default { minify, presets, getPreset, getPresetNames };
|