html-minifier-next 4.7.1 → 4.8.2

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