html-minifier-next 4.8.0 → 4.8.3

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.
@@ -1,1810 +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
- // Hoisted, reusable RegExp patterns and tiny helpers to avoid repeated allocations in hot paths
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
- function attributesInclude(attributes, attribute) {
183
- for (let i = attributes.length; i--;) {
184
- if (attributes[i].name.toLowerCase() === attribute) {
185
- return true;
186
- }
10
+ let lightningCSSPromise;
11
+ async function getLightningCSS() {
12
+ if (!lightningCSSPromise) {
13
+ lightningCSSPromise = import('lightningcss').then(m => m.transform);
187
14
  }
188
- return false;
15
+ return lightningCSSPromise;
189
16
  }
190
17
 
191
- // Default attribute values (could apply to any element)
192
- const generalDefaults = {
193
- autocorrect: 'on',
194
- fetchpriority: 'auto',
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
- // https://mathiasbynens.be/demo/javascript-mime-type
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
- const keepScriptsMimetypes = new Set([
258
- 'module'
259
- ]);
260
-
261
- function isScriptTypeAttribute(attrValue = '') {
262
- attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
263
- return attrValue === '' || executableScriptsMimetypes.has(attrValue);
264
- }
265
-
266
- function keepScriptTypeAttribute(attrValue = '') {
267
- attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
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 parseElementSpec(str, options) {
697
- if (typeof str !== 'string') {
698
- return null;
699
- }
700
-
701
- const trimmed = str.trim();
702
- if (!trimmed) {
703
- return null;
704
- }
705
-
706
- // Simple tag name: “td”
707
- if (!/[<>]/.test(trimmed)) {
708
- return { tag: options.name(trimmed), attrs: null };
709
- }
710
-
711
- // HTML-like markup: “<span aria-hidden='true'>” or “<td></td>”
712
- // Extract opening tag using regex
713
- const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
714
- if (!match) {
715
- return null;
716
- }
717
-
718
- const tag = options.name(match[1]);
719
- const attrString = match[2];
720
-
721
- if (!attrString.trim()) {
722
- return { tag, attrs: null };
723
- }
724
-
725
- // Parse attributes from string
726
- const attrs = {};
727
- const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
728
- let attrMatch;
729
-
730
- while ((attrMatch = attrRegex.exec(attrString))) {
731
- const attrName = options.name(attrMatch[1]);
732
- const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
733
- // Boolean attributes have no value (undefined)
734
- attrs[attrName] = attrValue;
735
- }
736
-
737
- return {
738
- tag,
739
- attrs: Object.keys(attrs).length > 0 ? attrs : null
740
- };
741
- }
742
-
743
- function parseRemoveEmptyElementsExcept(input, options) {
744
- if (!Array.isArray(input)) {
745
- return [];
746
- }
747
-
748
- return input.map(item => {
749
- if (typeof item === 'string') {
750
- const spec = parseElementSpec(item, options);
751
- if (!spec && options.log) {
752
- options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
753
- }
754
- return spec;
755
- }
756
- if (options.log) {
757
- options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
758
- }
759
- return null;
760
- }).filter(Boolean);
761
- }
762
-
763
- function shouldPreserveEmptyElement(tag, attrs, preserveList) {
764
- for (const spec of preserveList) {
765
- // Tag name must match
766
- if (spec.tag !== tag) {
767
- continue;
768
- }
769
-
770
- // If no attributes specified in spec, tag match is enough
771
- if (!spec.attrs) {
772
- return true;
773
- }
774
-
775
- // Check if all specified attributes match
776
- const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
777
- const attr = attrs.find(a => a.name === name);
778
- if (!attr) {
779
- return false; // Attribute not present
780
- }
781
- // Boolean attribute in spec (undefined value) matches if attribute is present
782
- if (value === undefined) {
783
- return true;
784
- }
785
- // Valued attribute must match exactly
786
- return attr.value === value;
787
- });
788
-
789
- if (allAttrsMatch) {
790
- return true;
791
- }
792
- }
793
-
794
- return false;
795
- }
796
-
797
- function canCollapseWhitespace(tag) {
798
- return !/^(?:script|style|pre|textarea)$/.test(tag);
799
- }
800
-
801
- function canTrimWhitespace(tag) {
802
- return !/^(?:pre|textarea)$/.test(tag);
803
- }
804
-
805
- async function normalizeAttr(attr, attrs, tag, options) {
806
- const attrName = options.name(attr.name);
807
- let attrValue = attr.value;
808
-
809
- if (options.decodeEntities && attrValue) {
810
- // Fast path: only decode when entities are present
811
- if (attrValue.indexOf('&') !== -1) {
812
- attrValue = decodeHTMLStrict(attrValue);
813
- }
814
- }
815
-
816
- if ((options.removeRedundantAttributes &&
817
- isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
818
- (options.removeScriptTypeAttributes && tag === 'script' &&
819
- attrName === 'type' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue)) ||
820
- (options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
821
- attrName === 'type' && isStyleLinkTypeAttribute(attrValue))) {
822
- return;
823
- }
824
-
825
- if (attrValue) {
826
- attrValue = await cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML);
827
- }
828
-
829
- if (options.removeEmptyAttributes &&
830
- canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
831
- return;
832
- }
833
-
834
- if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
835
- attrValue = attrValue.replace(RE_AMP_ENTITY, '&amp;$1');
836
- }
837
-
838
- return {
839
- attr,
840
- name: attrName,
841
- value: attrValue
842
- };
843
- }
844
-
845
- function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
846
- const attrName = normalized.name;
847
- let attrValue = normalized.value;
848
- const attr = normalized.attr;
849
- let attrQuote = attr.quote;
850
- let attrFragment;
851
- let emittedAttrValue;
852
-
853
- if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
854
- ~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
855
- if (!options.preventAttributesEscaping) {
856
- if (typeof options.quoteCharacter === 'undefined') {
857
- const apos = (attrValue.match(/'/g) || []).length;
858
- const quot = (attrValue.match(/"/g) || []).length;
859
- attrQuote = apos < quot ? '\'' : '"';
860
- } else {
861
- attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
862
- }
863
- if (attrQuote === '"') {
864
- attrValue = attrValue.replace(/"/g, '&#34;');
865
- } else {
866
- attrValue = attrValue.replace(/'/g, '&#39;');
867
- }
868
- }
869
- emittedAttrValue = attrQuote + attrValue + attrQuote;
870
- if (!isLast && !options.removeTagWhitespace) {
871
- emittedAttrValue += ' ';
872
- }
873
- } else if (isLast && !hasUnarySlash && !/\/$/.test(attrValue)) {
874
- // Make sure trailing slash is not interpreted as HTML self-closing tag
875
- emittedAttrValue = attrValue;
876
- } else {
877
- emittedAttrValue = attrValue + ' ';
878
- }
879
-
880
- if (typeof attrValue === 'undefined' || (options.collapseBooleanAttributes &&
881
- isBooleanAttribute(attrName.toLowerCase(), attrValue.toLowerCase()))) {
882
- attrFragment = attrName;
883
- if (!isLast) {
884
- attrFragment += ' ';
885
- }
886
- } else {
887
- attrFragment = attrName + attr.customAssign + emittedAttrValue;
888
- }
889
-
890
- return attr.customOpen + attrFragment + attr.customClose;
891
- }
892
-
893
- function identity(value) {
894
- return value;
895
- }
896
-
897
- function identityAsync(value) {
898
- return Promise.resolve(value);
899
- }
900
-
901
- function shouldMinifyInnerHTML(options) {
902
- return Boolean(
903
- options.collapseWhitespace ||
904
- options.removeComments ||
905
- options.removeOptionalTags ||
906
- options.minifyJS !== identity ||
907
- options.minifyCSS !== identityAsync ||
908
- options.minifyURLs !== identity
909
- );
910
- }
911
-
912
- const processOptions = (inputOptions) => {
913
- const options = {
914
- name: function (name) {
915
- return name.toLowerCase();
916
- },
917
- canCollapseWhitespace,
918
- canTrimWhitespace,
919
- continueOnMinifyError: true,
920
- html5: true,
921
- ignoreCustomComments: [
922
- /^!/,
923
- /^\s*#/
924
- ],
925
- ignoreCustomFragments: [
926
- /<%[\s\S]*?%>/,
927
- /<\?[\s\S]*?\?>/
928
- ],
929
- includeAutoGeneratedTags: true,
930
- log: identity,
931
- minifyCSS: identityAsync,
932
- minifyJS: identity,
933
- minifyURLs: identity
934
- };
935
-
936
- Object.keys(inputOptions).forEach(function (key) {
937
- const option = inputOptions[key];
938
-
939
- if (key === 'caseSensitive') {
940
- if (option) {
941
- options.name = identity;
942
- }
943
- } else if (key === 'log') {
944
- if (typeof option === 'function') {
945
- options.log = option;
946
- }
947
- } else if (key === 'minifyCSS' && typeof option !== 'function') {
948
- if (!option) {
949
- return;
950
- }
951
-
952
- const lightningCssOptions = typeof option === 'object' ? option : {};
953
-
954
- options.minifyCSS = async function (text, type) {
955
- // Fast path: nothing to minify
956
- if (!text || !text.trim()) {
957
- return text;
958
- }
959
- text = await replaceAsync(
960
- text,
961
- /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
962
- async function (match, prefix, dq, sq, unq, suffix) {
963
- const quote = dq != null ? '"' : (sq != null ? "'" : '');
964
- const url = dq ?? sq ?? unq ?? '';
965
- try {
966
- const out = await options.minifyURLs(url);
967
- return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
968
- } catch (err) {
969
- if (!options.continueOnMinifyError) {
970
- throw err;
971
- }
972
- options.log && options.log(err);
973
- return match;
974
- }
975
- }
976
- );
977
- // Cache key: wrapped content, type, options signature
978
- const inputCSS = wrapCSS(text, type);
979
- const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
980
- // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
981
- const cssKey = inputCSS.length > 2048
982
- ? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
983
- : (inputCSS + '|' + type + '|' + cssSig);
984
-
985
- try {
986
- const cached = cssMinifyCache.get(cssKey);
987
- if (cached) {
988
- return cached;
989
- }
990
-
991
- const result = transformCSS({
992
- filename: 'input.css',
993
- code: Buffer.from(inputCSS),
994
- minify: true,
995
- errorRecovery: !!options.continueOnMinifyError,
996
- ...lightningCssOptions
997
- });
998
-
999
- const outputCSS = unwrapCSS(result.code.toString(), type);
1000
-
1001
- // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
1002
- // This preserves:
1003
- // 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
1004
- // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
1005
- // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
1006
- const isCDATA = text.includes('<![CDATA[');
1007
- const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
1008
- const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
1009
- const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
1010
-
1011
- // Preserve if output is empty and input had template syntax or UIDs
1012
- // This catches cases where Lightning CSS removed content that should be preserved
1013
- const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
1014
-
1015
- cssMinifyCache.set(cssKey, finalOutput);
1016
- return finalOutput;
1017
- } catch (err) {
1018
- cssMinifyCache.delete(cssKey);
1019
- if (!options.continueOnMinifyError) {
1020
- throw err;
1021
- }
1022
- options.log && options.log(err);
1023
- return text;
1024
- }
1025
- };
1026
- } else if (key === 'minifyJS' && typeof option !== 'function') {
1027
- if (!option) {
1028
- return;
1029
- }
1030
-
1031
- const terserOptions = typeof option === 'object' ? option : {};
1032
-
1033
- terserOptions.parse = {
1034
- ...terserOptions.parse,
1035
- bare_returns: false
1036
- };
1037
-
1038
- options.minifyJS = async function (text, inline) {
1039
- const start = text.match(/^\s*<!--.*/);
1040
- const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
1041
-
1042
- terserOptions.parse.bare_returns = inline;
1043
-
1044
- let jsKey;
1045
- try {
1046
- // Fast path: avoid invoking Terser for empty/whitespace-only content
1047
- if (!code || !code.trim()) {
1048
- return '';
1049
- }
1050
- // Cache key: content, inline, options signature (subset)
1051
- const terserSig = stableStringify({
1052
- compress: terserOptions.compress,
1053
- mangle: terserOptions.mangle,
1054
- ecma: terserOptions.ecma,
1055
- toplevel: terserOptions.toplevel,
1056
- module: terserOptions.module,
1057
- keep_fnames: terserOptions.keep_fnames,
1058
- format: terserOptions.format,
1059
- cont: !!options.continueOnMinifyError,
1060
- });
1061
- // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
1062
- jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
1063
- const cached = jsMinifyCache.get(jsKey);
1064
- if (cached) {
1065
- return await cached;
1066
- }
1067
- const inFlight = (async () => {
1068
- const result = await terser(code, terserOptions);
1069
- return result.code.replace(RE_TRAILING_SEMICOLON, '');
1070
- })();
1071
- jsMinifyCache.set(jsKey, inFlight);
1072
- const resolved = await inFlight;
1073
- jsMinifyCache.set(jsKey, resolved);
1074
- return resolved;
1075
- } catch (err) {
1076
- if (jsKey) jsMinifyCache.delete(jsKey);
1077
- if (!options.continueOnMinifyError) {
1078
- throw err;
1079
- }
1080
- options.log && options.log(err);
1081
- return text;
1082
- }
1083
- };
1084
- } else if (key === 'minifyURLs' && typeof option !== 'function') {
1085
- if (!option) {
1086
- return;
1087
- }
1088
-
1089
- let relateUrlOptions = option;
1090
-
1091
- if (typeof option === 'string') {
1092
- relateUrlOptions = { site: option };
1093
- } else if (typeof option !== 'object') {
1094
- relateUrlOptions = {};
1095
- }
1096
-
1097
- options.minifyURLs = function (text) {
1098
- try {
1099
- return RelateURL.relate(text, relateUrlOptions);
1100
- } catch (err) {
1101
- if (!options.continueOnMinifyError) {
1102
- throw err;
1103
- }
1104
- options.log && options.log(err);
1105
- return text;
1106
- }
1107
- };
1108
- } else {
1109
- options[key] = option;
1110
- }
1111
- });
1112
- return options;
1113
- };
1114
-
1115
- function uniqueId(value) {
1116
- let id;
1117
- do {
1118
- id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
1119
- } while (~value.indexOf(id));
1120
- return id;
1121
- }
1122
-
1123
- const specialContentTags = new Set(['script', 'style']);
1124
-
1125
- async function createSortFns(value, options, uidIgnore, uidAttr) {
1126
- const attrChains = options.sortAttributes && Object.create(null);
1127
- const classChain = options.sortClassName && new TokenChain();
1128
-
1129
- function attrNames(attrs) {
1130
- return attrs.map(function (attr) {
1131
- return options.name(attr.name);
1132
- });
1133
- }
1134
-
1135
- function shouldSkipUID(token, uid) {
1136
- return !uid || token.indexOf(uid) === -1;
1137
- }
1138
-
1139
- function shouldSkipUIDs(token) {
1140
- return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
1141
- }
1142
-
1143
- async function scan(input) {
1144
- let currentTag, currentType;
1145
- const parser = new HTMLParser(input, {
1146
- start: function (tag, attrs) {
1147
- if (attrChains) {
1148
- if (!attrChains[tag]) {
1149
- attrChains[tag] = new TokenChain();
1150
- }
1151
- attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
1152
- }
1153
- for (let i = 0, len = attrs.length; i < len; i++) {
1154
- const attr = attrs[i];
1155
- if (classChain && attr.value && options.name(attr.name) === 'class') {
1156
- classChain.add(trimWhitespace(attr.value).split(/[ \t\n\f\r]+/).filter(shouldSkipUIDs));
1157
- } else if (options.processScripts && attr.name.toLowerCase() === 'type') {
1158
- currentTag = tag;
1159
- currentType = attr.value;
1160
- }
1161
- }
1162
- },
1163
- end: function () {
1164
- currentTag = '';
1165
- },
1166
- chars: async function (text) {
1167
- // Only recursively scan HTML content, not JSON-LD or other non-HTML script types
1168
- // `scan()` is for analyzing HTML attribute order, not for parsing JSON
1169
- if (options.processScripts && specialContentTags.has(currentTag) &&
1170
- options.processScripts.indexOf(currentType) > -1 &&
1171
- currentType === 'text/html') {
1172
- await scan(text);
1173
- }
1174
- }
1175
- });
1176
-
1177
- await parser.parse();
1178
- }
1179
-
1180
- const log = options.log;
1181
- options.log = identity;
1182
- options.sortAttributes = false;
1183
- options.sortClassName = false;
1184
- const firstPassOutput = await minifyHTML(value, options);
1185
- await scan(firstPassOutput);
1186
- options.log = log;
1187
- if (attrChains) {
1188
- const attrSorters = Object.create(null);
1189
- for (const tag in attrChains) {
1190
- attrSorters[tag] = attrChains[tag].createSorter();
1191
- }
1192
- options.sortAttributes = function (tag, attrs) {
1193
- const sorter = attrSorters[tag];
1194
- if (sorter) {
1195
- const attrMap = Object.create(null);
1196
- const names = attrNames(attrs);
1197
- names.forEach(function (name, index) {
1198
- (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
1199
- });
1200
- sorter.sort(names).forEach(function (name, index) {
1201
- attrs[index] = attrMap[name].shift();
1202
- });
1203
- }
1204
- };
1205
- }
1206
- if (classChain) {
1207
- const sorter = classChain.createSorter();
1208
- options.sortClassName = function (value) {
1209
- return sorter.sort(value.split(/[ \n\f\r]+/)).join(' ');
1210
- };
1211
- }
1212
- }
1213
-
1214
- async function minifyHTML(value, options, partialMarkup) {
1215
- // Check input length limitation to prevent ReDoS attacks
1216
- if (options.maxInputLength && value.length > options.maxInputLength) {
1217
- throw new Error(`Input length (${value.length}) exceeds maximum allowed length (${options.maxInputLength})`);
1218
- }
1219
-
1220
- if (options.collapseWhitespace) {
1221
- value = collapseWhitespace(value, options, true, true);
1222
- }
1223
-
1224
- const buffer = [];
1225
- let charsPrevTag;
1226
- let currentChars = '';
1227
- let hasChars;
1228
- let currentTag = '';
1229
- let currentAttrs = [];
1230
- const stackNoTrimWhitespace = [];
1231
- const stackNoCollapseWhitespace = [];
1232
- let optionalStartTag = '';
1233
- let optionalEndTag = '';
1234
- const ignoredMarkupChunks = [];
1235
- const ignoredCustomMarkupChunks = [];
1236
- let uidIgnore;
1237
- let uidAttr;
1238
- let uidPattern;
1239
- // Create inline tags/text sets with custom elements
1240
- const customElementsInput = options.inlineCustomElements ?? [];
1241
- const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
1242
- const normalizedCustomElements = customElementsArr.map(name => options.name(name));
1243
- const inlineTextSet = new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements]);
1244
- const inlineElements = new Set([...inlineElementsToKeepWhitespaceAround, ...normalizedCustomElements]);
1245
-
1246
- // Parse `removeEmptyElementsExcept` option
1247
- let removeEmptyElementsExcept;
1248
- if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
1249
- if (options.log) {
1250
- options.log('Warning: "removeEmptyElementsExcept" option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
1251
- }
1252
- removeEmptyElementsExcept = [];
1253
- } else {
1254
- removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
1255
- }
1256
-
1257
- // Temporarily replace ignored chunks with comments,
1258
- // so that we don’t have to worry what’s there.
1259
- // For all we care there might be
1260
- // completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
1261
- value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
1262
- if (!uidIgnore) {
1263
- uidIgnore = uniqueId(value);
1264
- const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
1265
- if (options.ignoreCustomComments) {
1266
- options.ignoreCustomComments = options.ignoreCustomComments.slice();
1267
- } else {
1268
- options.ignoreCustomComments = [];
1269
- }
1270
- options.ignoreCustomComments.push(pattern);
1271
- }
1272
- const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
1273
- ignoredMarkupChunks.push(group1);
1274
- return token;
1275
- });
1276
-
1277
- const customFragments = options.ignoreCustomFragments.map(function (re) {
1278
- return re.source;
1279
- });
1280
- if (customFragments.length) {
1281
- // Warn about potential ReDoS if custom fragments use unlimited quantifiers
1282
- for (let i = 0; i < customFragments.length; i++) {
1283
- if (/[*+]/.test(customFragments[i])) {
1284
- options.log('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
1285
- break;
1286
- }
1287
- }
1288
-
1289
- // Safe approach: Use bounded quantifiers instead of unlimited ones to prevent ReDoS
1290
- const maxQuantifier = options.customFragmentQuantifierLimit || 200;
1291
- const whitespacePattern = `\\s{0,${maxQuantifier}}`;
1292
-
1293
- // Use bounded quantifiers to prevent ReDoS - this approach prevents exponential backtracking
1294
- const reCustomIgnore = new RegExp(
1295
- whitespacePattern + '(?:' + customFragments.join('|') + '){1,' + maxQuantifier + '}' + whitespacePattern,
1296
- 'g'
1297
- );
1298
- // Temporarily replace custom ignored fragments with unique attributes
1299
- value = value.replace(reCustomIgnore, function (match) {
1300
- if (!uidAttr) {
1301
- uidAttr = uniqueId(value);
1302
- uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
1303
-
1304
- if (options.minifyCSS) {
1305
- options.minifyCSS = (function (fn) {
1306
- return function (text, type) {
1307
- text = text.replace(uidPattern, function (match, prefix, index) {
1308
- const chunks = ignoredCustomMarkupChunks[+index];
1309
- return chunks[1] + uidAttr + index + uidAttr + chunks[2];
1310
- });
1311
-
1312
- return fn(text, type);
1313
- };
1314
- })(options.minifyCSS);
1315
- }
1316
-
1317
- if (options.minifyJS) {
1318
- options.minifyJS = (function (fn) {
1319
- return function (text, type) {
1320
- return fn(text.replace(uidPattern, function (match, prefix, index) {
1321
- const chunks = ignoredCustomMarkupChunks[+index];
1322
- return chunks[1] + uidAttr + index + uidAttr + chunks[2];
1323
- }), type);
1324
- };
1325
- })(options.minifyJS);
1326
- }
1327
- }
1328
-
1329
- const token = uidAttr + ignoredCustomMarkupChunks.length + uidAttr;
1330
- ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
1331
- return '\t' + token + '\t';
1332
- });
1333
- }
1334
-
1335
- if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
1336
- (options.sortClassName && typeof options.sortClassName !== 'function')) {
1337
- await createSortFns(value, options, uidIgnore, uidAttr);
1338
- }
1339
-
1340
- function _canCollapseWhitespace(tag, attrs) {
1341
- return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
1342
- }
1343
-
1344
- function _canTrimWhitespace(tag, attrs) {
1345
- return options.canTrimWhitespace(tag, attrs, canTrimWhitespace);
1346
- }
1347
-
1348
- function removeStartTag() {
1349
- let index = buffer.length - 1;
1350
- while (index > 0 && !/^<[^/!]/.test(buffer[index])) {
1351
- index--;
1352
- }
1353
- buffer.length = Math.max(0, index);
1354
- }
1355
-
1356
- function removeEndTag() {
1357
- let index = buffer.length - 1;
1358
- while (index > 0 && !/^<\//.test(buffer[index])) {
1359
- index--;
1360
- }
1361
- buffer.length = Math.max(0, index);
1362
- }
1363
-
1364
- // Look for trailing whitespaces, bypass any inline tags
1365
- function trimTrailingWhitespace(index, nextTag) {
1366
- for (let endTag = null; index >= 0 && _canTrimWhitespace(endTag); index--) {
1367
- const str = buffer[index];
1368
- const match = str.match(/^<\/([\w:-]+)>$/);
1369
- if (match) {
1370
- endTag = match[1];
1371
- } else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, null, nextTag, options, inlineElements, inlineTextSet))) {
1372
- break;
1373
- }
1374
- }
1375
- }
1376
-
1377
- // Look for trailing whitespaces from previously processed text
1378
- // which may not be trimmed due to a following comment or an empty
1379
- // element which has now been removed
1380
- function squashTrailingWhitespace(nextTag) {
1381
- let charsIndex = buffer.length - 1;
1382
- if (buffer.length > 1) {
1383
- const item = buffer[buffer.length - 1];
1384
- if (/^(?:<!|$)/.test(item) && item.indexOf(uidIgnore) === -1) {
1385
- charsIndex--;
1386
- }
1387
- }
1388
- trimTrailingWhitespace(charsIndex, nextTag);
1389
- }
1390
-
1391
- const parser = new HTMLParser(value, {
1392
- partialMarkup: partialMarkup ?? options.partialMarkup,
1393
- continueOnParseError: options.continueOnParseError,
1394
- customAttrAssign: options.customAttrAssign,
1395
- customAttrSurround: options.customAttrSurround,
1396
- html5: options.html5,
1397
-
1398
- start: async function (tag, attrs, unary, unarySlash, autoGenerated) {
1399
- if (tag.toLowerCase() === 'svg') {
1400
- options = Object.create(options);
1401
- options.caseSensitive = true;
1402
- options.keepClosingSlash = true;
1403
- options.name = identity;
1404
- }
1405
- tag = options.name(tag);
1406
- currentTag = tag;
1407
- charsPrevTag = tag;
1408
- if (!inlineTextSet.has(tag)) {
1409
- currentChars = '';
1410
- }
1411
- hasChars = false;
1412
- currentAttrs = attrs;
1413
-
1414
- let optional = options.removeOptionalTags;
1415
- if (optional) {
1416
- const htmlTag = htmlTags.has(tag);
1417
- // <html> may be omitted if first thing inside is not a comment
1418
- // <head> may be omitted if first thing inside is an element
1419
- // <body> may be omitted if first thing inside is not space, comment, <meta>, <link>, <script>, <style> or <template>
1420
- // <colgroup> may be omitted if first thing inside is <col>
1421
- // <tbody> may be omitted if first thing inside is <tr>
1422
- if (htmlTag && canRemoveParentTag(optionalStartTag, tag)) {
1423
- removeStartTag();
1424
- }
1425
- optionalStartTag = '';
1426
- // End-tag-followed-by-start-tag omission rules
1427
- if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag)) {
1428
- removeEndTag();
1429
- // <colgroup> cannot be omitted if preceding </colgroup> is omitted
1430
- // <tbody> cannot be omitted if preceding </tbody>, </thead> or </tfoot> is omitted
1431
- optional = !isStartTagMandatory(optionalEndTag, tag);
1432
- }
1433
- optionalEndTag = '';
1434
- }
1435
-
1436
- // Set whitespace flags for nested tags (e.g., <code> within a <pre>)
1437
- if (options.collapseWhitespace) {
1438
- if (!stackNoTrimWhitespace.length) {
1439
- squashTrailingWhitespace(tag);
1440
- }
1441
- if (!unary) {
1442
- if (!_canTrimWhitespace(tag, attrs) || stackNoTrimWhitespace.length) {
1443
- stackNoTrimWhitespace.push(tag);
1444
- }
1445
- if (!_canCollapseWhitespace(tag, attrs) || stackNoCollapseWhitespace.length) {
1446
- stackNoCollapseWhitespace.push(tag);
1447
- }
1448
- }
1449
- }
1450
-
1451
- const openTag = '<' + tag;
1452
- const hasUnarySlash = unarySlash && options.keepClosingSlash;
1453
-
1454
- buffer.push(openTag);
1455
-
1456
- if (options.sortAttributes) {
1457
- options.sortAttributes(tag, attrs);
1458
- }
1459
-
1460
- const parts = [];
1461
- for (let i = attrs.length, isLast = true; --i >= 0;) {
1462
- const normalized = await normalizeAttr(attrs[i], attrs, tag, options);
1463
- if (normalized) {
1464
- parts.unshift(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
1465
- isLast = false;
1466
- }
1467
- }
1468
- if (parts.length > 0) {
1469
- buffer.push(' ');
1470
- buffer.push.apply(buffer, parts);
1471
- } else if (optional && optionalStartTags.has(tag)) {
1472
- // Start tag must never be omitted if it has any attributes
1473
- optionalStartTag = tag;
1474
- }
1475
-
1476
- buffer.push(buffer.pop() + (hasUnarySlash ? '/' : '') + '>');
1477
-
1478
- if (autoGenerated && !options.includeAutoGeneratedTags) {
1479
- removeStartTag();
1480
- optionalStartTag = '';
1481
- }
1482
- },
1483
- end: function (tag, attrs, autoGenerated) {
1484
- if (tag.toLowerCase() === 'svg') {
1485
- options = Object.getPrototypeOf(options);
1486
- }
1487
- tag = options.name(tag);
1488
-
1489
- // Check if current tag is in a whitespace stack
1490
- if (options.collapseWhitespace) {
1491
- if (stackNoTrimWhitespace.length) {
1492
- if (tag === stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1]) {
1493
- stackNoTrimWhitespace.pop();
1494
- }
1495
- } else {
1496
- squashTrailingWhitespace('/' + tag);
1497
- }
1498
- if (stackNoCollapseWhitespace.length &&
1499
- tag === stackNoCollapseWhitespace[stackNoCollapseWhitespace.length - 1]) {
1500
- stackNoCollapseWhitespace.pop();
1501
- }
1502
- }
1503
-
1504
- let isElementEmpty = false;
1505
- if (tag === currentTag) {
1506
- currentTag = '';
1507
- isElementEmpty = !hasChars;
1508
- }
1509
-
1510
- if (options.removeOptionalTags) {
1511
- // <html>, <head> or <body> may be omitted if the element is empty
1512
- if (isElementEmpty && topLevelTags.has(optionalStartTag)) {
1513
- removeStartTag();
1514
- }
1515
- optionalStartTag = '';
1516
- // </html> or </body> may be omitted if not followed by comment
1517
- // </head> may be omitted if not followed by space or comment
1518
- // </p> may be omitted if no more content in non-</a> parent
1519
- // except for </dt> or </thead>, end tags may be omitted if no more content in parent element
1520
- if (htmlTags.has(tag) && optionalEndTag && !trailingTags.has(optionalEndTag) && (optionalEndTag !== 'p' || !pInlineTags.has(tag))) {
1521
- removeEndTag();
1522
- }
1523
- optionalEndTag = optionalEndTags.has(tag) ? tag : '';
1524
- }
1525
-
1526
- if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
1527
- let preserve = false;
1528
- if (removeEmptyElementsExcept.length) {
1529
- // Normalize attribute names for comparison with specs
1530
- const normalizedAttrs = attrs.map(attr => ({ ...attr, name: options.name(attr.name) }));
1531
- preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
1532
- }
1533
-
1534
- if (!preserve) {
1535
- // Remove last “element” from buffer
1536
- removeStartTag();
1537
- optionalStartTag = '';
1538
- optionalEndTag = '';
1539
- } else {
1540
- // Preserve the element - add closing tag
1541
- if (autoGenerated && !options.includeAutoGeneratedTags) {
1542
- optionalEndTag = '';
1543
- } else {
1544
- buffer.push('</' + tag + '>');
1545
- }
1546
- charsPrevTag = '/' + tag;
1547
- if (!inlineElements.has(tag)) {
1548
- currentChars = '';
1549
- } else if (isElementEmpty) {
1550
- currentChars += '|';
1551
- }
1552
- }
1553
- } else {
1554
- if (autoGenerated && !options.includeAutoGeneratedTags) {
1555
- optionalEndTag = '';
1556
- } else {
1557
- buffer.push('</' + tag + '>');
1558
- }
1559
- charsPrevTag = '/' + tag;
1560
- if (!inlineElements.has(tag)) {
1561
- currentChars = '';
1562
- } else if (isElementEmpty) {
1563
- currentChars += '|';
1564
- }
1565
- }
1566
- },
1567
- chars: async function (text, prevTag, nextTag) {
1568
- prevTag = prevTag === '' ? 'comment' : prevTag;
1569
- nextTag = nextTag === '' ? 'comment' : nextTag;
1570
- if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
1571
- if (text.indexOf('&') !== -1) {
1572
- text = decodeHTML(text);
1573
- }
1574
- }
1575
- if (options.collapseWhitespace) {
1576
- if (!stackNoTrimWhitespace.length) {
1577
- if (prevTag === 'comment') {
1578
- const prevComment = buffer[buffer.length - 1];
1579
- if (prevComment.indexOf(uidIgnore) === -1) {
1580
- if (!prevComment) {
1581
- prevTag = charsPrevTag;
1582
- }
1583
- if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
1584
- const charsIndex = buffer.length - 2;
1585
- buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
1586
- text = trailingSpaces + text;
1587
- return '';
1588
- });
1589
- }
1590
- }
1591
- }
1592
- if (prevTag) {
1593
- if (prevTag === '/nobr' || prevTag === 'wbr') {
1594
- if (/^\s/.test(text)) {
1595
- let tagIndex = buffer.length - 1;
1596
- while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
1597
- tagIndex--;
1598
- }
1599
- trimTrailingWhitespace(tagIndex - 1, 'br');
1600
- }
1601
- } else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
1602
- text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
1603
- }
1604
- }
1605
- if (prevTag || nextTag) {
1606
- text = collapseWhitespaceSmart(text, prevTag, nextTag, options, inlineElements, inlineTextSet);
1607
- } else {
1608
- text = collapseWhitespace(text, options, true, true);
1609
- }
1610
- if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
1611
- trimTrailingWhitespace(buffer.length - 1, nextTag);
1612
- }
1613
- }
1614
- if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
1615
- text = collapseWhitespace(text, options, false, false, true);
1616
- }
1617
- }
1618
- if (specialContentTags.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs))) {
1619
- text = await processScript(text, options, currentAttrs);
1620
- }
1621
- if (isExecutableScript(currentTag, currentAttrs)) {
1622
- text = await options.minifyJS(text);
1623
- }
1624
- if (isStyleSheet(currentTag, currentAttrs)) {
1625
- text = await options.minifyCSS(text);
1626
- }
1627
- if (options.removeOptionalTags && text) {
1628
- // <html> may be omitted if first thing inside is not a comment
1629
- // <body> may be omitted if first thing inside is not space, comment, <meta>, <link>, <script>, <style> or <template>
1630
- if (optionalStartTag === 'html' || (optionalStartTag === 'body' && !/^\s/.test(text))) {
1631
- removeStartTag();
1632
- }
1633
- optionalStartTag = '';
1634
- // </html> or </body> may be omitted if not followed by comment
1635
- // </head>, </colgroup> or </caption> may be omitted if not followed by space or comment
1636
- if (compactTags.has(optionalEndTag) || (looseTags.has(optionalEndTag) && !/^\s/.test(text))) {
1637
- removeEndTag();
1638
- }
1639
- // Don’t reset optionalEndTag if text is only whitespace and will be collapsed (not conservatively)
1640
- if (!/^\s+$/.test(text) || !options.collapseWhitespace || options.conservativeCollapse) {
1641
- optionalEndTag = '';
1642
- }
1643
- }
1644
- charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
1645
- if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
1646
- // Escape any `&` symbols that start either:
1647
- // 1) a legacy named character reference (i.e., one that doesn’t end with `;`)
1648
- // 2) or any other character reference (i.e., one that does end with `;`)
1649
- // Note that `&` can be escaped as `&amp`, without the semi-colon.
1650
- // https://mathiasbynens.be/notes/ambiguous-ampersands
1651
- if (text.indexOf('&') !== -1) {
1652
- text = text.replace(/&((?:Iacute|aacute|uacute|plusmn|Otilde|otilde|agrave|Agrave|Yacute|yacute|Oslash|oslash|atilde|Atilde|brvbar|ccedil|Ccedil|Ograve|curren|divide|eacute|Eacute|ograve|Oacute|egrave|Egrave|Ugrave|frac12|frac14|frac34|ugrave|oacute|iacute|Ntilde|ntilde|Uacute|middot|igrave|Igrave|iquest|Aacute|cedil|laquo|micro|iexcl|Icirc|icirc|acirc|Ucirc|Ecirc|ocirc|Ocirc|ecirc|ucirc|Aring|aring|AElig|aelig|acute|pound|raquo|Acirc|times|THORN|szlig|thorn|COPY|auml|ordf|ordm|Uuml|macr|uuml|Auml|ouml|Ouml|para|nbsp|euml|quot|QUOT|Euml|yuml|cent|sect|copy|sup1|sup2|sup3|iuml|Iuml|ETH|shy|reg|not|yen|amp|AMP|REG|uml|eth|deg|gt|GT|LT|lt)(?!;)|(?:#?[0-9a-zA-Z]+;))/g, '&amp$1');
1653
- }
1654
- if (text.indexOf('<') !== -1) {
1655
- text = text.replace(/</g, '&lt;');
1656
- }
1657
- }
1658
- if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
1659
- text = text.replace(uidPattern, function (match, prefix, index) {
1660
- return ignoredCustomMarkupChunks[+index][0];
1661
- });
1662
- }
1663
- currentChars += text;
1664
- if (text) {
1665
- hasChars = true;
1666
- }
1667
- buffer.push(text);
1668
- },
1669
- comment: async function (text, nonStandard) {
1670
- const prefix = nonStandard ? '<!' : '<!--';
1671
- const suffix = nonStandard ? '>' : '-->';
1672
- if (isConditionalComment(text)) {
1673
- text = prefix + await cleanConditionalComment(text, options) + suffix;
1674
- } else if (options.removeComments) {
1675
- if (isIgnoredComment(text, options)) {
1676
- text = '<!--' + text + '-->';
1677
- } else {
1678
- text = '';
1679
- }
1680
- } else {
1681
- text = prefix + text + suffix;
1682
- }
1683
- if (options.removeOptionalTags && text) {
1684
- // Preceding comments suppress tag omissions
1685
- optionalStartTag = '';
1686
- optionalEndTag = '';
1687
- }
1688
- buffer.push(text);
1689
- },
1690
- doctype: function (doctype) {
1691
- buffer.push(options.useShortDoctype
1692
- ? '<!doctype' +
1693
- (options.removeTagWhitespace ? '' : ' ') + 'html>'
1694
- : collapseWhitespaceAll(doctype));
1695
- }
1696
- });
1697
-
1698
- await parser.parse();
1699
-
1700
- if (options.removeOptionalTags) {
1701
- // <html> may be omitted if first thing inside is not a comment
1702
- // <head> or <body> may be omitted if empty
1703
- if (topLevelTags.has(optionalStartTag)) {
1704
- removeStartTag();
1705
- }
1706
- // except for </dt> or </thead>, end tags may be omitted if no more content in parent element
1707
- if (optionalEndTag && !trailingTags.has(optionalEndTag)) {
1708
- removeEndTag();
1709
- }
1710
- }
1711
- if (options.collapseWhitespace) {
1712
- squashTrailingWhitespace('br');
1713
- }
1714
-
1715
- return joinResultSegments(buffer, options, uidPattern
1716
- ? function (str) {
1717
- return str.replace(uidPattern, function (match, prefix, index, suffix) {
1718
- let chunk = ignoredCustomMarkupChunks[+index][0];
1719
- if (options.collapseWhitespace) {
1720
- if (prefix !== '\t') {
1721
- chunk = prefix + chunk;
1722
- }
1723
- if (suffix !== '\t') {
1724
- chunk += suffix;
1725
- }
1726
- return collapseWhitespace(chunk, {
1727
- preserveLineBreaks: options.preserveLineBreaks,
1728
- conservativeCollapse: !options.trimCustomFragments
1729
- }, /^[ \n\r\t\f]/.test(chunk), /[ \n\r\t\f]$/.test(chunk));
1730
- }
1731
- return chunk;
1732
- });
1733
- }
1734
- : identity, uidIgnore
1735
- ? function (str) {
1736
- return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (match, index) {
1737
- return ignoredMarkupChunks[+index];
1738
- });
1739
- }
1740
- : identity);
1741
- }
1742
-
1743
- function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
1744
- let str;
1745
- const maxLineLength = options.maxLineLength;
1746
- const noNewlinesBeforeTagClose = options.noNewlinesBeforeTagClose;
1747
-
1748
- if (maxLineLength) {
1749
- let line = ''; const lines = [];
1750
- while (results.length) {
1751
- const len = line.length;
1752
- const end = results[0].indexOf('\n');
1753
- const isClosingTag = Boolean(results[0].match(endTag));
1754
- const shouldKeepSameLine = noNewlinesBeforeTagClose && isClosingTag;
1755
-
1756
- if (end < 0) {
1757
- line += restoreIgnore(restoreCustom(results.shift()));
1758
- } else {
1759
- line += restoreIgnore(restoreCustom(results[0].slice(0, end)));
1760
- results[0] = results[0].slice(end + 1);
1761
- }
1762
- if (len > 0 && line.length > maxLineLength && !shouldKeepSameLine) {
1763
- lines.push(line.slice(0, len));
1764
- line = line.slice(len);
1765
- } else if (end >= 0) {
1766
- lines.push(line);
1767
- line = '';
1768
- }
1769
- }
1770
- if (line) {
1771
- lines.push(line);
1772
- }
1773
- str = lines.join('\n');
1774
- } else {
1775
- str = restoreIgnore(restoreCustom(results.join('')));
1776
- }
1777
- return options.collapseWhitespace ? collapseWhitespace(str, options, true, true) : str;
1778
- }
1779
-
1780
- /**
1781
- * @param {string} value
1782
- * @param {MinifierOptions} [options]
1783
- * @returns {Promise<string>}
1784
- */
1785
- export const minify = async function (value, options) {
1786
- const start = Date.now();
1787
- options = processOptions(options || {});
1788
- const result = await minifyHTML(value, options);
1789
- options.log('minified in: ' + (Date.now() - start) + 'ms');
1790
- return result;
1791
- };
1792
-
1793
- export { presets, getPreset, getPresetNames };
1794
-
1795
- export default { minify, presets, getPreset, getPresetNames };
1796
-
1797
- /**
1798
- * @typedef {Object} HTMLAttribute
1799
- * Representation of an attribute from the HTML parser.
1800
- *
1801
- * @prop {string} name
1802
- * @prop {string} [value]
1803
- * @prop {string} [quote]
1804
- * @prop {string} [customAssign]
1805
- * @prop {string} [customOpen]
1806
- * @prop {string} [customClose]
1807
- */
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
+ */
1808
39
 
1809
40
  /**
1810
41
  * @typedef {Object} MinifierOptions
@@ -2109,7 +340,7 @@ export default { minify, presets, getPreset, getPresetNames };
2109
340
  * Default: `false`
2110
341
  *
2111
342
  * @prop {boolean} [removeRedundantAttributes]
2112
- * Remove attributes that are redundant because they match the element's
343
+ * Remove attributes that are redundant because they match the elements
2113
344
  * default values (for example `<button type="submit">`).
2114
345
  * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_redundant_attributes
2115
346
  *
@@ -2164,4 +395,1830 @@ export default { minify, presets, getPreset, getPresetNames };
2164
395
  * See also: https://perfectionkills.com/experimenting-with-html-minifier/#use_short_doctype
2165
396
  *
2166
397
  * Default: `false`
2167
- */
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, '&amp;$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, '&#34;');
1275
+ } else {
1276
+ attrValue = attrValue.replace(/'/g, '&#39;');
1277
+ }
1278
+ }
1279
+ emittedAttrValue = attrQuote + attrValue + attrQuote;
1280
+ if (!isLast && !options.removeTagWhitespace) {
1281
+ emittedAttrValue += ' ';
1282
+ }
1283
+ } else if (isLast && !hasUnarySlash) {
1284
+ // Last attribute in a non-self-closing tag: no space needed
1285
+ emittedAttrValue = attrValue;
1286
+ } else {
1287
+ // Not last attribute, or is a self-closing tag: add space
1288
+ emittedAttrValue = attrValue + ' ';
1289
+ }
1290
+
1291
+ if (typeof attrValue === 'undefined' || (options.collapseBooleanAttributes &&
1292
+ isBooleanAttribute(attrName.toLowerCase(), attrValue.toLowerCase()))) {
1293
+ attrFragment = attrName;
1294
+ if (!isLast) {
1295
+ attrFragment += ' ';
1296
+ }
1297
+ } else {
1298
+ attrFragment = attrName + attr.customAssign + emittedAttrValue;
1299
+ }
1300
+
1301
+ return attr.customOpen + attrFragment + attr.customClose;
1302
+ }
1303
+
1304
+ function identity(value) {
1305
+ return value;
1306
+ }
1307
+
1308
+ function identityAsync(value) {
1309
+ return Promise.resolve(value);
1310
+ }
1311
+
1312
+ function shouldMinifyInnerHTML(options) {
1313
+ return Boolean(
1314
+ options.collapseWhitespace ||
1315
+ options.removeComments ||
1316
+ options.removeOptionalTags ||
1317
+ options.minifyJS !== identity ||
1318
+ options.minifyCSS !== identityAsync ||
1319
+ options.minifyURLs !== identity
1320
+ );
1321
+ }
1322
+
1323
+ /**
1324
+ * @param {Partial<MinifierOptions>} inputOptions - User-provided options
1325
+ * @returns {MinifierOptions} Normalized options with defaults applied
1326
+ */
1327
+ const processOptions = (inputOptions) => {
1328
+ const options = {
1329
+ name: function (name) {
1330
+ return name.toLowerCase();
1331
+ },
1332
+ canCollapseWhitespace,
1333
+ canTrimWhitespace,
1334
+ continueOnMinifyError: true,
1335
+ html5: true,
1336
+ ignoreCustomComments: [
1337
+ /^!/,
1338
+ /^\s*#/
1339
+ ],
1340
+ ignoreCustomFragments: [
1341
+ /<%[\s\S]*?%>/,
1342
+ /<\?[\s\S]*?\?>/
1343
+ ],
1344
+ includeAutoGeneratedTags: true,
1345
+ log: identity,
1346
+ minifyCSS: identityAsync,
1347
+ minifyJS: identity,
1348
+ minifyURLs: identity
1349
+ };
1350
+
1351
+ Object.keys(inputOptions).forEach(function (key) {
1352
+ const option = inputOptions[key];
1353
+
1354
+ if (key === 'caseSensitive') {
1355
+ if (option) {
1356
+ options.name = identity;
1357
+ }
1358
+ } else if (key === 'log') {
1359
+ if (typeof option === 'function') {
1360
+ options.log = option;
1361
+ }
1362
+ } else if (key === 'minifyCSS' && typeof option !== 'function') {
1363
+ if (!option) {
1364
+ return;
1365
+ }
1366
+
1367
+ const lightningCssOptions = typeof option === 'object' ? option : {};
1368
+
1369
+ options.minifyCSS = async function (text, type) {
1370
+ // Fast path: nothing to minify
1371
+ if (!text || !text.trim()) {
1372
+ return text;
1373
+ }
1374
+ text = await replaceAsync(
1375
+ text,
1376
+ /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
1377
+ async function (match, prefix, dq, sq, unq, suffix) {
1378
+ const quote = dq != null ? '"' : (sq != null ? "'" : '');
1379
+ const url = dq ?? sq ?? unq ?? '';
1380
+ try {
1381
+ const out = await options.minifyURLs(url);
1382
+ return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
1383
+ } catch (err) {
1384
+ if (!options.continueOnMinifyError) {
1385
+ throw err;
1386
+ }
1387
+ options.log && options.log(err);
1388
+ return match;
1389
+ }
1390
+ }
1391
+ );
1392
+ // Cache key: wrapped content, type, options signature
1393
+ const inputCSS = wrapCSS(text, type);
1394
+ const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
1395
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
1396
+ const cssKey = inputCSS.length > 2048
1397
+ ? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
1398
+ : (inputCSS + '|' + type + '|' + cssSig);
1399
+
1400
+ try {
1401
+ const cached = cssMinifyCache.get(cssKey);
1402
+ if (cached) {
1403
+ return cached;
1404
+ }
1405
+
1406
+ const transformCSS = await getLightningCSS();
1407
+ const result = transformCSS({
1408
+ filename: 'input.css',
1409
+ code: Buffer.from(inputCSS),
1410
+ minify: true,
1411
+ errorRecovery: !!options.continueOnMinifyError,
1412
+ ...lightningCssOptions
1413
+ });
1414
+
1415
+ const outputCSS = unwrapCSS(result.code.toString(), type);
1416
+
1417
+ // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
1418
+ // This preserves:
1419
+ // 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
1420
+ // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
1421
+ // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
1422
+ const isCDATA = text.includes('<![CDATA[');
1423
+ const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
1424
+ const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
1425
+ const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
1426
+
1427
+ // Preserve if output is empty and input had template syntax or UIDs
1428
+ // This catches cases where Lightning CSS removed content that should be preserved
1429
+ const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
1430
+
1431
+ cssMinifyCache.set(cssKey, finalOutput);
1432
+ return finalOutput;
1433
+ } catch (err) {
1434
+ cssMinifyCache.delete(cssKey);
1435
+ if (!options.continueOnMinifyError) {
1436
+ throw err;
1437
+ }
1438
+ options.log && options.log(err);
1439
+ return text;
1440
+ }
1441
+ };
1442
+ } else if (key === 'minifyJS' && typeof option !== 'function') {
1443
+ if (!option) {
1444
+ return;
1445
+ }
1446
+
1447
+ const terserOptions = typeof option === 'object' ? option : {};
1448
+
1449
+ terserOptions.parse = {
1450
+ ...terserOptions.parse,
1451
+ bare_returns: false
1452
+ };
1453
+
1454
+ options.minifyJS = async function (text, inline) {
1455
+ const start = text.match(/^\s*<!--.*/);
1456
+ const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
1457
+
1458
+ terserOptions.parse.bare_returns = inline;
1459
+
1460
+ let jsKey;
1461
+ try {
1462
+ // Fast path: avoid invoking Terser for empty/whitespace-only content
1463
+ if (!code || !code.trim()) {
1464
+ return '';
1465
+ }
1466
+ // Cache key: content, inline, options signature (subset)
1467
+ const terserSig = stableStringify({
1468
+ compress: terserOptions.compress,
1469
+ mangle: terserOptions.mangle,
1470
+ ecma: terserOptions.ecma,
1471
+ toplevel: terserOptions.toplevel,
1472
+ module: terserOptions.module,
1473
+ keep_fnames: terserOptions.keep_fnames,
1474
+ format: terserOptions.format,
1475
+ cont: !!options.continueOnMinifyError,
1476
+ });
1477
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
1478
+ jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
1479
+ const cached = jsMinifyCache.get(jsKey);
1480
+ if (cached) {
1481
+ return await cached;
1482
+ }
1483
+ const inFlight = (async () => {
1484
+ const terser = await getTerser();
1485
+ const result = await terser(code, terserOptions);
1486
+ return result.code.replace(RE_TRAILING_SEMICOLON, '');
1487
+ })();
1488
+ jsMinifyCache.set(jsKey, inFlight);
1489
+ const resolved = await inFlight;
1490
+ jsMinifyCache.set(jsKey, resolved);
1491
+ return resolved;
1492
+ } catch (err) {
1493
+ if (jsKey) jsMinifyCache.delete(jsKey);
1494
+ if (!options.continueOnMinifyError) {
1495
+ throw err;
1496
+ }
1497
+ options.log && options.log(err);
1498
+ return text;
1499
+ }
1500
+ };
1501
+ } else if (key === 'minifyURLs' && typeof option !== 'function') {
1502
+ if (!option) {
1503
+ return;
1504
+ }
1505
+
1506
+ let relateUrlOptions = option;
1507
+
1508
+ if (typeof option === 'string') {
1509
+ relateUrlOptions = { site: option };
1510
+ } else if (typeof option !== 'object') {
1511
+ relateUrlOptions = {};
1512
+ }
1513
+
1514
+ options.minifyURLs = function (text) {
1515
+ try {
1516
+ return RelateURL.relate(text, relateUrlOptions);
1517
+ } catch (err) {
1518
+ if (!options.continueOnMinifyError) {
1519
+ throw err;
1520
+ }
1521
+ options.log && options.log(err);
1522
+ return text;
1523
+ }
1524
+ };
1525
+ } else {
1526
+ options[key] = option;
1527
+ }
1528
+ });
1529
+ return options;
1530
+ };
1531
+
1532
+ function uniqueId(value) {
1533
+ let id;
1534
+ do {
1535
+ id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
1536
+ } while (~value.indexOf(id));
1537
+ return id;
1538
+ }
1539
+
1540
+ const specialContentTags = new Set(['script', 'style']);
1541
+
1542
+ async function createSortFns(value, options, uidIgnore, uidAttr) {
1543
+ const attrChains = options.sortAttributes && Object.create(null);
1544
+ const classChain = options.sortClassName && new TokenChain();
1545
+
1546
+ function attrNames(attrs) {
1547
+ return attrs.map(function (attr) {
1548
+ return options.name(attr.name);
1549
+ });
1550
+ }
1551
+
1552
+ function shouldSkipUID(token, uid) {
1553
+ return !uid || token.indexOf(uid) === -1;
1554
+ }
1555
+
1556
+ function shouldSkipUIDs(token) {
1557
+ return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
1558
+ }
1559
+
1560
+ async function scan(input) {
1561
+ let currentTag, currentType;
1562
+ const parser = new HTMLParser(input, {
1563
+ start: function (tag, attrs) {
1564
+ if (attrChains) {
1565
+ if (!attrChains[tag]) {
1566
+ attrChains[tag] = new TokenChain();
1567
+ }
1568
+ attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
1569
+ }
1570
+ for (let i = 0, len = attrs.length; i < len; i++) {
1571
+ const attr = attrs[i];
1572
+ if (classChain && attr.value && options.name(attr.name) === 'class') {
1573
+ classChain.add(trimWhitespace(attr.value).split(/[ \t\n\f\r]+/).filter(shouldSkipUIDs));
1574
+ } else if (options.processScripts && attr.name.toLowerCase() === 'type') {
1575
+ currentTag = tag;
1576
+ currentType = attr.value;
1577
+ }
1578
+ }
1579
+ },
1580
+ end: function () {
1581
+ currentTag = '';
1582
+ },
1583
+ chars: async function (text) {
1584
+ // Only recursively scan HTML content, not JSON-LD or other non-HTML script types
1585
+ // `scan()` is for analyzing HTML attribute order, not for parsing JSON
1586
+ if (options.processScripts && specialContentTags.has(currentTag) &&
1587
+ options.processScripts.indexOf(currentType) > -1 &&
1588
+ currentType === 'text/html') {
1589
+ await scan(text);
1590
+ }
1591
+ }
1592
+ });
1593
+
1594
+ await parser.parse();
1595
+ }
1596
+
1597
+ const log = options.log;
1598
+ options.log = identity;
1599
+ options.sortAttributes = false;
1600
+ options.sortClassName = false;
1601
+ const firstPassOutput = await minifyHTML(value, options);
1602
+ await scan(firstPassOutput);
1603
+ options.log = log;
1604
+ if (attrChains) {
1605
+ const attrSorters = Object.create(null);
1606
+ for (const tag in attrChains) {
1607
+ attrSorters[tag] = attrChains[tag].createSorter();
1608
+ }
1609
+ options.sortAttributes = function (tag, attrs) {
1610
+ const sorter = attrSorters[tag];
1611
+ if (sorter) {
1612
+ const attrMap = Object.create(null);
1613
+ const names = attrNames(attrs);
1614
+ names.forEach(function (name, index) {
1615
+ (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
1616
+ });
1617
+ sorter.sort(names).forEach(function (name, index) {
1618
+ attrs[index] = attrMap[name].shift();
1619
+ });
1620
+ }
1621
+ };
1622
+ }
1623
+ if (classChain) {
1624
+ const sorter = classChain.createSorter();
1625
+ options.sortClassName = function (value) {
1626
+ return sorter.sort(value.split(/[ \n\f\r]+/)).join(' ');
1627
+ };
1628
+ }
1629
+ }
1630
+
1631
+ /**
1632
+ * @param {string} value - HTML content to minify
1633
+ * @param {MinifierOptions} options - Normalized minification options
1634
+ * @param {boolean} [partialMarkup] - Whether treating input as partial markup
1635
+ * @returns {Promise<string>} Minified HTML
1636
+ */
1637
+ async function minifyHTML(value, options, partialMarkup) {
1638
+ // Check input length limitation to prevent ReDoS attacks
1639
+ if (options.maxInputLength && value.length > options.maxInputLength) {
1640
+ throw new Error(`Input length (${value.length}) exceeds maximum allowed length (${options.maxInputLength})`);
1641
+ }
1642
+
1643
+ if (options.collapseWhitespace) {
1644
+ value = collapseWhitespace(value, options, true, true);
1645
+ }
1646
+
1647
+ const buffer = [];
1648
+ let charsPrevTag;
1649
+ let currentChars = '';
1650
+ let hasChars;
1651
+ let currentTag = '';
1652
+ let currentAttrs = [];
1653
+ const stackNoTrimWhitespace = [];
1654
+ const stackNoCollapseWhitespace = [];
1655
+ let optionalStartTag = '';
1656
+ let optionalEndTag = '';
1657
+ const ignoredMarkupChunks = [];
1658
+ const ignoredCustomMarkupChunks = [];
1659
+ let uidIgnore;
1660
+ let uidAttr;
1661
+ let uidPattern;
1662
+ // Create inline tags/text sets with custom elements
1663
+ const customElementsInput = options.inlineCustomElements ?? [];
1664
+ const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
1665
+ const normalizedCustomElements = customElementsArr.map(name => options.name(name));
1666
+ // Fast path: reuse base Sets if no custom elements
1667
+ const inlineTextSet = normalizedCustomElements.length
1668
+ ? new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements])
1669
+ : inlineElementsToKeepWhitespaceWithin;
1670
+ const inlineElements = normalizedCustomElements.length
1671
+ ? new Set([...inlineElementsToKeepWhitespaceAround, ...normalizedCustomElements])
1672
+ : inlineElementsToKeepWhitespaceAround;
1673
+
1674
+ // Parse `removeEmptyElementsExcept` option
1675
+ let removeEmptyElementsExcept;
1676
+ if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
1677
+ if (options.log) {
1678
+ options.log('Warning: “removeEmptyElementsExcept” option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
1679
+ }
1680
+ removeEmptyElementsExcept = [];
1681
+ } else {
1682
+ removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
1683
+ }
1684
+
1685
+ // Temporarily replace ignored chunks with comments,
1686
+ // so that we don’t have to worry what’s there.
1687
+ // For all we care there might be
1688
+ // completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
1689
+ value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
1690
+ if (!uidIgnore) {
1691
+ uidIgnore = uniqueId(value);
1692
+ const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
1693
+ if (options.ignoreCustomComments) {
1694
+ options.ignoreCustomComments = options.ignoreCustomComments.slice();
1695
+ } else {
1696
+ options.ignoreCustomComments = [];
1697
+ }
1698
+ options.ignoreCustomComments.push(pattern);
1699
+ }
1700
+ const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
1701
+ ignoredMarkupChunks.push(group1);
1702
+ return token;
1703
+ });
1704
+
1705
+ const customFragments = options.ignoreCustomFragments.map(function (re) {
1706
+ return re.source;
1707
+ });
1708
+ if (customFragments.length) {
1709
+ // Warn about potential ReDoS if custom fragments use unlimited quantifiers
1710
+ for (let i = 0; i < customFragments.length; i++) {
1711
+ if (/[*+]/.test(customFragments[i])) {
1712
+ options.log('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
1713
+ break;
1714
+ }
1715
+ }
1716
+
1717
+ // Safe approach: Use bounded quantifiers instead of unlimited ones to prevent ReDoS
1718
+ const maxQuantifier = options.customFragmentQuantifierLimit || 200;
1719
+ const whitespacePattern = `\\s{0,${maxQuantifier}}`;
1720
+
1721
+ // Use bounded quantifiers to prevent ReDoS—this approach prevents exponential backtracking
1722
+ const reCustomIgnore = new RegExp(
1723
+ whitespacePattern + '(?:' + customFragments.join('|') + '){1,' + maxQuantifier + '}' + whitespacePattern,
1724
+ 'g'
1725
+ );
1726
+ // Temporarily replace custom ignored fragments with unique attributes
1727
+ value = value.replace(reCustomIgnore, function (match) {
1728
+ if (!uidAttr) {
1729
+ uidAttr = uniqueId(value);
1730
+ uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
1731
+
1732
+ if (options.minifyCSS) {
1733
+ options.minifyCSS = (function (fn) {
1734
+ return function (text, type) {
1735
+ text = text.replace(uidPattern, function (match, prefix, index) {
1736
+ const chunks = ignoredCustomMarkupChunks[+index];
1737
+ return chunks[1] + uidAttr + index + uidAttr + chunks[2];
1738
+ });
1739
+
1740
+ return fn(text, type);
1741
+ };
1742
+ })(options.minifyCSS);
1743
+ }
1744
+
1745
+ if (options.minifyJS) {
1746
+ options.minifyJS = (function (fn) {
1747
+ return function (text, type) {
1748
+ return fn(text.replace(uidPattern, function (match, prefix, index) {
1749
+ const chunks = ignoredCustomMarkupChunks[+index];
1750
+ return chunks[1] + uidAttr + index + uidAttr + chunks[2];
1751
+ }), type);
1752
+ };
1753
+ })(options.minifyJS);
1754
+ }
1755
+ }
1756
+
1757
+ const token = uidAttr + ignoredCustomMarkupChunks.length + uidAttr;
1758
+ ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
1759
+ return '\t' + token + '\t';
1760
+ });
1761
+ }
1762
+
1763
+ if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
1764
+ (options.sortClassName && typeof options.sortClassName !== 'function')) {
1765
+ await createSortFns(value, options, uidIgnore, uidAttr);
1766
+ }
1767
+
1768
+ function _canCollapseWhitespace(tag, attrs) {
1769
+ return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
1770
+ }
1771
+
1772
+ function _canTrimWhitespace(tag, attrs) {
1773
+ return options.canTrimWhitespace(tag, attrs, canTrimWhitespace);
1774
+ }
1775
+
1776
+ function removeStartTag() {
1777
+ let index = buffer.length - 1;
1778
+ while (index > 0 && !/^<[^/!]/.test(buffer[index])) {
1779
+ index--;
1780
+ }
1781
+ buffer.length = Math.max(0, index);
1782
+ }
1783
+
1784
+ function removeEndTag() {
1785
+ let index = buffer.length - 1;
1786
+ while (index > 0 && !/^<\//.test(buffer[index])) {
1787
+ index--;
1788
+ }
1789
+ buffer.length = Math.max(0, index);
1790
+ }
1791
+
1792
+ // Look for trailing whitespaces, bypass any inline tags
1793
+ function trimTrailingWhitespace(index, nextTag) {
1794
+ for (let endTag = null; index >= 0 && _canTrimWhitespace(endTag); index--) {
1795
+ const str = buffer[index];
1796
+ const match = str.match(/^<\/([\w:-]+)>$/);
1797
+ if (match) {
1798
+ endTag = match[1];
1799
+ } else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, null, nextTag, options, inlineElements, inlineTextSet))) {
1800
+ break;
1801
+ }
1802
+ }
1803
+ }
1804
+
1805
+ // Look for trailing whitespaces from previously processed text
1806
+ // which may not be trimmed due to a following comment or an empty
1807
+ // element which has now been removed
1808
+ function squashTrailingWhitespace(nextTag) {
1809
+ let charsIndex = buffer.length - 1;
1810
+ if (buffer.length > 1) {
1811
+ const item = buffer[buffer.length - 1];
1812
+ if (/^(?:<!|$)/.test(item) && item.indexOf(uidIgnore) === -1) {
1813
+ charsIndex--;
1814
+ }
1815
+ }
1816
+ trimTrailingWhitespace(charsIndex, nextTag);
1817
+ }
1818
+
1819
+ const parser = new HTMLParser(value, {
1820
+ partialMarkup: partialMarkup ?? options.partialMarkup,
1821
+ continueOnParseError: options.continueOnParseError,
1822
+ customAttrAssign: options.customAttrAssign,
1823
+ customAttrSurround: options.customAttrSurround,
1824
+ html5: options.html5,
1825
+
1826
+ start: async function (tag, attrs, unary, unarySlash, autoGenerated) {
1827
+ if (tag.toLowerCase() === 'svg') {
1828
+ options = Object.create(options);
1829
+ options.caseSensitive = true;
1830
+ options.keepClosingSlash = true;
1831
+ options.name = identity;
1832
+ }
1833
+ tag = options.name(tag);
1834
+ currentTag = tag;
1835
+ charsPrevTag = tag;
1836
+ if (!inlineTextSet.has(tag)) {
1837
+ currentChars = '';
1838
+ }
1839
+ hasChars = false;
1840
+ currentAttrs = attrs;
1841
+
1842
+ let optional = options.removeOptionalTags;
1843
+ if (optional) {
1844
+ const htmlTag = htmlTags.has(tag);
1845
+ // `<html>` may be omitted if first thing inside is not a comment
1846
+ // `<head>` may be omitted if first thing inside is an element
1847
+ // `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`, `<script>`, <`style>`, or `<template>`
1848
+ // `<colgroup>` may be omitted if first thing inside is `<col>`
1849
+ // `<tbody>` may be omitted if first thing inside is `<tr>`
1850
+ if (htmlTag && canRemoveParentTag(optionalStartTag, tag)) {
1851
+ removeStartTag();
1852
+ }
1853
+ optionalStartTag = '';
1854
+ // End-tag-followed-by-start-tag omission rules
1855
+ if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag)) {
1856
+ removeEndTag();
1857
+ // `<colgroup>` cannot be omitted if preceding `</colgroup>` is omitted
1858
+ // `<tbody>` cannot be omitted if preceding `</tbody>`, `</thead>`, or `</tfoot>` is omitted
1859
+ optional = !isStartTagMandatory(optionalEndTag, tag);
1860
+ }
1861
+ optionalEndTag = '';
1862
+ }
1863
+
1864
+ // Set whitespace flags for nested tags (e.g., <code> within a <pre>)
1865
+ if (options.collapseWhitespace) {
1866
+ if (!stackNoTrimWhitespace.length) {
1867
+ squashTrailingWhitespace(tag);
1868
+ }
1869
+ if (!unary) {
1870
+ if (!_canTrimWhitespace(tag, attrs) || stackNoTrimWhitespace.length) {
1871
+ stackNoTrimWhitespace.push(tag);
1872
+ }
1873
+ if (!_canCollapseWhitespace(tag, attrs) || stackNoCollapseWhitespace.length) {
1874
+ stackNoCollapseWhitespace.push(tag);
1875
+ }
1876
+ }
1877
+ }
1878
+
1879
+ const openTag = '<' + tag;
1880
+ const hasUnarySlash = unarySlash && options.keepClosingSlash;
1881
+
1882
+ buffer.push(openTag);
1883
+
1884
+ if (options.sortAttributes) {
1885
+ options.sortAttributes(tag, attrs);
1886
+ }
1887
+
1888
+ const parts = [];
1889
+ for (let i = attrs.length, isLast = true; --i >= 0;) {
1890
+ const normalized = await normalizeAttr(attrs[i], attrs, tag, options);
1891
+ if (normalized) {
1892
+ parts.push(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
1893
+ isLast = false;
1894
+ }
1895
+ }
1896
+ parts.reverse();
1897
+ if (parts.length > 0) {
1898
+ buffer.push(' ');
1899
+ buffer.push.apply(buffer, parts);
1900
+ } else if (optional && optionalStartTags.has(tag)) {
1901
+ // Start tag must never be omitted if it has any attributes
1902
+ optionalStartTag = tag;
1903
+ }
1904
+
1905
+ buffer.push(buffer.pop() + (hasUnarySlash ? '/' : '') + '>');
1906
+
1907
+ if (autoGenerated && !options.includeAutoGeneratedTags) {
1908
+ removeStartTag();
1909
+ optionalStartTag = '';
1910
+ }
1911
+ },
1912
+ end: function (tag, attrs, autoGenerated) {
1913
+ if (tag.toLowerCase() === 'svg') {
1914
+ options = Object.getPrototypeOf(options);
1915
+ }
1916
+ tag = options.name(tag);
1917
+
1918
+ // Check if current tag is in a whitespace stack
1919
+ if (options.collapseWhitespace) {
1920
+ if (stackNoTrimWhitespace.length) {
1921
+ if (tag === stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1]) {
1922
+ stackNoTrimWhitespace.pop();
1923
+ }
1924
+ } else {
1925
+ squashTrailingWhitespace('/' + tag);
1926
+ }
1927
+ if (stackNoCollapseWhitespace.length &&
1928
+ tag === stackNoCollapseWhitespace[stackNoCollapseWhitespace.length - 1]) {
1929
+ stackNoCollapseWhitespace.pop();
1930
+ }
1931
+ }
1932
+
1933
+ let isElementEmpty = false;
1934
+ if (tag === currentTag) {
1935
+ currentTag = '';
1936
+ isElementEmpty = !hasChars;
1937
+ }
1938
+
1939
+ if (options.removeOptionalTags) {
1940
+ // `<html>`, `<head>` or `<body>` may be omitted if the element is empty
1941
+ if (isElementEmpty && topLevelTags.has(optionalStartTag)) {
1942
+ removeStartTag();
1943
+ }
1944
+ optionalStartTag = '';
1945
+ // `</html>` or `</body>` may be omitted if not followed by comment
1946
+ // `</head>` may be omitted if not followed by space or comment
1947
+ // `</p>` may be omitted if no more content in non-`</a>` parent
1948
+ // except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent element
1949
+ if (htmlTags.has(tag) && optionalEndTag && !trailingTags.has(optionalEndTag) && (optionalEndTag !== 'p' || !pInlineTags.has(tag))) {
1950
+ removeEndTag();
1951
+ }
1952
+ optionalEndTag = optionalEndTags.has(tag) ? tag : '';
1953
+ }
1954
+
1955
+ if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
1956
+ let preserve = false;
1957
+ if (removeEmptyElementsExcept.length) {
1958
+ // Normalize attribute names for comparison with specs
1959
+ const normalizedAttrs = attrs.map(attr => ({ ...attr, name: options.name(attr.name) }));
1960
+ preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
1961
+ }
1962
+
1963
+ if (!preserve) {
1964
+ // Remove last “element” from buffer
1965
+ removeStartTag();
1966
+ optionalStartTag = '';
1967
+ optionalEndTag = '';
1968
+ } else {
1969
+ // Preserve the element—add closing tag
1970
+ if (autoGenerated && !options.includeAutoGeneratedTags) {
1971
+ optionalEndTag = '';
1972
+ } else {
1973
+ buffer.push('</' + tag + '>');
1974
+ }
1975
+ charsPrevTag = '/' + tag;
1976
+ if (!inlineElements.has(tag)) {
1977
+ currentChars = '';
1978
+ } else if (isElementEmpty) {
1979
+ currentChars += '|';
1980
+ }
1981
+ }
1982
+ } else {
1983
+ if (autoGenerated && !options.includeAutoGeneratedTags) {
1984
+ optionalEndTag = '';
1985
+ } else {
1986
+ buffer.push('</' + tag + '>');
1987
+ }
1988
+ charsPrevTag = '/' + tag;
1989
+ if (!inlineElements.has(tag)) {
1990
+ currentChars = '';
1991
+ } else if (isElementEmpty) {
1992
+ currentChars += '|';
1993
+ }
1994
+ }
1995
+ },
1996
+ chars: async function (text, prevTag, nextTag) {
1997
+ prevTag = prevTag === '' ? 'comment' : prevTag;
1998
+ nextTag = nextTag === '' ? 'comment' : nextTag;
1999
+ if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
2000
+ if (text.indexOf('&') !== -1) {
2001
+ text = decodeHTML(text);
2002
+ }
2003
+ }
2004
+ if (options.collapseWhitespace) {
2005
+ if (!stackNoTrimWhitespace.length) {
2006
+ if (prevTag === 'comment') {
2007
+ const prevComment = buffer[buffer.length - 1];
2008
+ if (prevComment.indexOf(uidIgnore) === -1) {
2009
+ if (!prevComment) {
2010
+ prevTag = charsPrevTag;
2011
+ }
2012
+ if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
2013
+ const charsIndex = buffer.length - 2;
2014
+ buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
2015
+ text = trailingSpaces + text;
2016
+ return '';
2017
+ });
2018
+ }
2019
+ }
2020
+ }
2021
+ if (prevTag) {
2022
+ if (prevTag === '/nobr' || prevTag === 'wbr') {
2023
+ if (/^\s/.test(text)) {
2024
+ let tagIndex = buffer.length - 1;
2025
+ while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
2026
+ tagIndex--;
2027
+ }
2028
+ trimTrailingWhitespace(tagIndex - 1, 'br');
2029
+ }
2030
+ } else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
2031
+ text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
2032
+ }
2033
+ }
2034
+ if (prevTag || nextTag) {
2035
+ text = collapseWhitespaceSmart(text, prevTag, nextTag, options, inlineElements, inlineTextSet);
2036
+ } else {
2037
+ text = collapseWhitespace(text, options, true, true);
2038
+ }
2039
+ if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
2040
+ trimTrailingWhitespace(buffer.length - 1, nextTag);
2041
+ }
2042
+ }
2043
+ if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
2044
+ text = collapseWhitespace(text, options, false, false, true);
2045
+ }
2046
+ }
2047
+ if (specialContentTags.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs))) {
2048
+ text = await processScript(text, options, currentAttrs);
2049
+ }
2050
+ if (isExecutableScript(currentTag, currentAttrs)) {
2051
+ text = await options.minifyJS(text);
2052
+ }
2053
+ if (isStyleSheet(currentTag, currentAttrs)) {
2054
+ text = await options.minifyCSS(text);
2055
+ }
2056
+ if (options.removeOptionalTags && text) {
2057
+ // `<html>` may be omitted if first thing inside is not a comment
2058
+ // `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`, `<script>`, `<style>`, or `<template>`
2059
+ if (optionalStartTag === 'html' || (optionalStartTag === 'body' && !/^\s/.test(text))) {
2060
+ removeStartTag();
2061
+ }
2062
+ optionalStartTag = '';
2063
+ // `</html>` or `</body>` may be omitted if not followed by comment
2064
+ // `</head>`, `</colgroup>`, or `</caption>` may be omitted if not followed by space or comment
2065
+ if (compactTags.has(optionalEndTag) || (looseTags.has(optionalEndTag) && !/^\s/.test(text))) {
2066
+ removeEndTag();
2067
+ }
2068
+ // Don’t reset optionalEndTag if text is only whitespace and will be collapsed (not conservatively)
2069
+ if (!/^\s+$/.test(text) || !options.collapseWhitespace || options.conservativeCollapse) {
2070
+ optionalEndTag = '';
2071
+ }
2072
+ }
2073
+ charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
2074
+ if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
2075
+ // Escape any `&` symbols that start either:
2076
+ // 1) a legacy named character reference (i.e., one that doesn’t end with `;`)
2077
+ // 2) or any other character reference (i.e., one that does end with `;`)
2078
+ // Note that `&` can be escaped as `&amp`, without the semi-colon.
2079
+ // https://mathiasbynens.be/notes/ambiguous-ampersands
2080
+ if (text.indexOf('&') !== -1) {
2081
+ text = text.replace(/&((?:Iacute|aacute|uacute|plusmn|Otilde|otilde|agrave|Agrave|Yacute|yacute|Oslash|oslash|atilde|Atilde|brvbar|ccedil|Ccedil|Ograve|curren|divide|eacute|Eacute|ograve|Oacute|egrave|Egrave|Ugrave|frac12|frac14|frac34|ugrave|oacute|iacute|Ntilde|ntilde|Uacute|middot|igrave|Igrave|iquest|Aacute|cedil|laquo|micro|iexcl|Icirc|icirc|acirc|Ucirc|Ecirc|ocirc|Ocirc|ecirc|ucirc|Aring|aring|AElig|aelig|acute|pound|raquo|Acirc|times|THORN|szlig|thorn|COPY|auml|ordf|ordm|Uuml|macr|uuml|Auml|ouml|Ouml|para|nbsp|euml|quot|QUOT|Euml|yuml|cent|sect|copy|sup1|sup2|sup3|iuml|Iuml|ETH|shy|reg|not|yen|amp|AMP|REG|uml|eth|deg|gt|GT|LT|lt)(?!;)|(?:#?[0-9a-zA-Z]+;))/g, '&amp$1');
2082
+ }
2083
+ if (text.indexOf('<') !== -1) {
2084
+ text = text.replace(/</g, '&lt;');
2085
+ }
2086
+ }
2087
+ if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
2088
+ text = text.replace(uidPattern, function (match, prefix, index) {
2089
+ return ignoredCustomMarkupChunks[+index][0];
2090
+ });
2091
+ }
2092
+ currentChars += text;
2093
+ if (text) {
2094
+ hasChars = true;
2095
+ }
2096
+ buffer.push(text);
2097
+ },
2098
+ comment: async function (text, nonStandard) {
2099
+ const prefix = nonStandard ? '<!' : '<!--';
2100
+ const suffix = nonStandard ? '>' : '-->';
2101
+ if (isConditionalComment(text)) {
2102
+ text = prefix + await cleanConditionalComment(text, options) + suffix;
2103
+ } else if (options.removeComments) {
2104
+ if (isIgnoredComment(text, options)) {
2105
+ text = '<!--' + text + '-->';
2106
+ } else {
2107
+ text = '';
2108
+ }
2109
+ } else {
2110
+ text = prefix + text + suffix;
2111
+ }
2112
+ if (options.removeOptionalTags && text) {
2113
+ // Preceding comments suppress tag omissions
2114
+ optionalStartTag = '';
2115
+ optionalEndTag = '';
2116
+ }
2117
+ buffer.push(text);
2118
+ },
2119
+ doctype: function (doctype) {
2120
+ buffer.push(options.useShortDoctype
2121
+ ? '<!doctype' +
2122
+ (options.removeTagWhitespace ? '' : ' ') + 'html>'
2123
+ : collapseWhitespaceAll(doctype));
2124
+ }
2125
+ });
2126
+
2127
+ await parser.parse();
2128
+
2129
+ if (options.removeOptionalTags) {
2130
+ // `<html>` may be omitted if first thing inside is not a comment
2131
+ // `<head>` or `<body>` may be omitted if empty
2132
+ if (topLevelTags.has(optionalStartTag)) {
2133
+ removeStartTag();
2134
+ }
2135
+ // except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent element
2136
+ if (optionalEndTag && !trailingTags.has(optionalEndTag)) {
2137
+ removeEndTag();
2138
+ }
2139
+ }
2140
+ if (options.collapseWhitespace) {
2141
+ squashTrailingWhitespace('br');
2142
+ }
2143
+
2144
+ return joinResultSegments(buffer, options, uidPattern
2145
+ ? function (str) {
2146
+ return str.replace(uidPattern, function (match, prefix, index, suffix) {
2147
+ let chunk = ignoredCustomMarkupChunks[+index][0];
2148
+ if (options.collapseWhitespace) {
2149
+ if (prefix !== '\t') {
2150
+ chunk = prefix + chunk;
2151
+ }
2152
+ if (suffix !== '\t') {
2153
+ chunk += suffix;
2154
+ }
2155
+ return collapseWhitespace(chunk, {
2156
+ preserveLineBreaks: options.preserveLineBreaks,
2157
+ conservativeCollapse: !options.trimCustomFragments
2158
+ }, /^[ \n\r\t\f]/.test(chunk), /[ \n\r\t\f]$/.test(chunk));
2159
+ }
2160
+ return chunk;
2161
+ });
2162
+ }
2163
+ : identity, uidIgnore
2164
+ ? function (str) {
2165
+ return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (match, index) {
2166
+ return ignoredMarkupChunks[+index];
2167
+ });
2168
+ }
2169
+ : identity);
2170
+ }
2171
+
2172
+ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
2173
+ let str;
2174
+ const maxLineLength = options.maxLineLength;
2175
+ const noNewlinesBeforeTagClose = options.noNewlinesBeforeTagClose;
2176
+
2177
+ if (maxLineLength) {
2178
+ let line = ''; const lines = [];
2179
+ while (results.length) {
2180
+ const len = line.length;
2181
+ const end = results[0].indexOf('\n');
2182
+ const isClosingTag = Boolean(results[0].match(endTag));
2183
+ const shouldKeepSameLine = noNewlinesBeforeTagClose && isClosingTag;
2184
+
2185
+ if (end < 0) {
2186
+ line += restoreIgnore(restoreCustom(results.shift()));
2187
+ } else {
2188
+ line += restoreIgnore(restoreCustom(results[0].slice(0, end)));
2189
+ results[0] = results[0].slice(end + 1);
2190
+ }
2191
+ if (len > 0 && line.length > maxLineLength && !shouldKeepSameLine) {
2192
+ lines.push(line.slice(0, len));
2193
+ line = line.slice(len);
2194
+ } else if (end >= 0) {
2195
+ lines.push(line);
2196
+ line = '';
2197
+ }
2198
+ }
2199
+ if (line) {
2200
+ lines.push(line);
2201
+ }
2202
+ str = lines.join('\n');
2203
+ } else {
2204
+ str = restoreIgnore(restoreCustom(results.join('')));
2205
+ }
2206
+ return options.collapseWhitespace ? collapseWhitespace(str, options, true, true) : str;
2207
+ }
2208
+
2209
+ /**
2210
+ * @param {string} value
2211
+ * @param {MinifierOptions} [options]
2212
+ * @returns {Promise<string>}
2213
+ */
2214
+ export const minify = async function (value, options) {
2215
+ const start = Date.now();
2216
+ options = processOptions(options || {});
2217
+ const result = await minifyHTML(value, options);
2218
+ options.log('minified in: ' + (Date.now() - start) + 'ms');
2219
+ return result;
2220
+ };
2221
+
2222
+ export { presets, getPreset, getPresetNames };
2223
+
2224
+ export default { minify, presets, getPreset, getPresetNames };