html-minifier-next 4.12.2 → 4.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -26
- package/cli.js +1 -1
- package/dist/htmlminifier.cjs +1552 -1320
- package/dist/htmlminifier.esm.bundle.js +4204 -3972
- package/dist/types/htmlminifier.d.ts +10 -3
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/htmlparser.d.ts.map +1 -1
- package/dist/types/lib/attributes.d.ts +29 -0
- package/dist/types/lib/attributes.d.ts.map +1 -0
- package/dist/types/lib/constants.d.ts +83 -0
- package/dist/types/lib/constants.d.ts.map +1 -0
- package/dist/types/lib/content.d.ts +7 -0
- package/dist/types/lib/content.d.ts.map +1 -0
- package/dist/types/lib/elements.d.ts +39 -0
- package/dist/types/lib/elements.d.ts.map +1 -0
- package/dist/types/lib/options.d.ts +17 -0
- package/dist/types/lib/options.d.ts.map +1 -0
- package/dist/types/lib/utils.d.ts +21 -0
- package/dist/types/lib/utils.d.ts.map +1 -0
- package/dist/types/lib/whitespace.d.ts +7 -0
- package/dist/types/lib/whitespace.d.ts.map +1 -0
- package/dist/types/presets.d.ts.map +1 -1
- package/package.json +10 -1
- package/src/htmlminifier.js +114 -1229
- package/src/htmlparser.js +11 -11
- package/src/lib/attributes.js +511 -0
- package/src/lib/constants.js +213 -0
- package/src/lib/content.js +105 -0
- package/src/lib/elements.js +242 -0
- package/src/lib/index.js +20 -0
- package/src/lib/options.js +300 -0
- package/src/lib/utils.js +90 -0
- package/src/lib/whitespace.js +139 -0
- package/src/presets.js +0 -1
- package/src/tokenchain.js +1 -1
- package/dist/types/utils.d.ts +0 -2
- package/dist/types/utils.d.ts.map +0 -1
package/src/htmlminifier.js
CHANGED
|
@@ -1,10 +1,61 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// Imports
|
|
2
|
+
|
|
3
|
+
import { decodeHTML } from 'entities';
|
|
3
4
|
import { HTMLParser, endTag } from './htmlparser.js';
|
|
4
5
|
import TokenChain from './tokenchain.js';
|
|
5
|
-
import { replaceAsync } from './utils.js';
|
|
6
6
|
import { presets, getPreset, getPresetNames } from './presets.js';
|
|
7
7
|
|
|
8
|
+
import { LRU, identity, uniqueId } from './lib/utils.js';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
inlineElementsToKeepWhitespaceAround,
|
|
12
|
+
inlineElementsToKeepWhitespaceWithin,
|
|
13
|
+
specialContentTags,
|
|
14
|
+
htmlTags,
|
|
15
|
+
optionalStartTags,
|
|
16
|
+
optionalEndTags,
|
|
17
|
+
topLevelTags,
|
|
18
|
+
compactTags,
|
|
19
|
+
looseTags,
|
|
20
|
+
trailingTags,
|
|
21
|
+
pInlineTags
|
|
22
|
+
} from './lib/constants.js';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
trimWhitespace,
|
|
26
|
+
collapseWhitespaceAll,
|
|
27
|
+
collapseWhitespace,
|
|
28
|
+
collapseWhitespaceSmart,
|
|
29
|
+
canCollapseWhitespace,
|
|
30
|
+
canTrimWhitespace
|
|
31
|
+
} from './lib/whitespace.js';
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
isConditionalComment,
|
|
35
|
+
isIgnoredComment,
|
|
36
|
+
isExecutableScript,
|
|
37
|
+
isStyleSheet,
|
|
38
|
+
normalizeAttr,
|
|
39
|
+
buildAttr
|
|
40
|
+
} from './lib/attributes.js';
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
canRemoveParentTag,
|
|
44
|
+
isStartTagMandatory,
|
|
45
|
+
canRemovePrecedingTag,
|
|
46
|
+
canRemoveElement,
|
|
47
|
+
parseRemoveEmptyElementsExcept,
|
|
48
|
+
shouldPreserveEmptyElement
|
|
49
|
+
} from './lib/elements.js';
|
|
50
|
+
|
|
51
|
+
import {
|
|
52
|
+
cleanConditionalComment,
|
|
53
|
+
hasJsonScriptType,
|
|
54
|
+
processScript
|
|
55
|
+
} from './lib/content.js';
|
|
56
|
+
|
|
57
|
+
import { processOptions } from './lib/options.js';
|
|
58
|
+
|
|
8
59
|
// Lazy-load heavy dependencies only when needed
|
|
9
60
|
|
|
10
61
|
let lightningCSSPromise;
|
|
@@ -23,6 +74,26 @@ async function getTerser() {
|
|
|
23
74
|
return terserPromise;
|
|
24
75
|
}
|
|
25
76
|
|
|
77
|
+
let swcPromise;
|
|
78
|
+
async function getSwc() {
|
|
79
|
+
if (!swcPromise) {
|
|
80
|
+
swcPromise = import('@swc/core')
|
|
81
|
+
.then(m => m.default || m)
|
|
82
|
+
.catch(() => {
|
|
83
|
+
throw new Error(
|
|
84
|
+
'The swc minifier requires @swc/core to be installed.\n' +
|
|
85
|
+
'Install it with: npm install @swc/core'
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return swcPromise;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Minification caches
|
|
93
|
+
|
|
94
|
+
const cssMinifyCache = new LRU(200);
|
|
95
|
+
const jsMinifyCache = new LRU(200);
|
|
96
|
+
|
|
26
97
|
// Type definitions
|
|
27
98
|
|
|
28
99
|
/**
|
|
@@ -212,10 +283,14 @@ async function getTerser() {
|
|
|
212
283
|
*
|
|
213
284
|
* Default: `false`
|
|
214
285
|
*
|
|
215
|
-
* @prop {boolean | import("terser").MinifyOptions | ((text: string, inline?: boolean) => Promise<string> | string)} [minifyJS]
|
|
286
|
+
* @prop {boolean | import("terser").MinifyOptions | {engine?: 'terser' | 'swc', [key: string]: any} | ((text: string, inline?: boolean) => Promise<string> | string)} [minifyJS]
|
|
216
287
|
* When true, enables JS minification for `<script>` contents and
|
|
217
|
-
* event handler attributes. If an object is provided, it
|
|
218
|
-
*
|
|
288
|
+
* event handler attributes. If an object is provided, it can include:
|
|
289
|
+
* - `engine`: The minifier to use (`terser` or `swc`). Default: `terser`.
|
|
290
|
+
* Note: Inline event handlers (e.g., `onclick="…"`) always use Terser
|
|
291
|
+
* regardless of engine setting, as swc doesn’t support bare return statements.
|
|
292
|
+
* - Engine-specific options (e.g., Terser options if `engine: 'terser'`,
|
|
293
|
+
* SWC options if `engine: 'swc'`).
|
|
219
294
|
* If a function is provided, it will be used to perform
|
|
220
295
|
* custom JS minification. If disabled, JS is not minified.
|
|
221
296
|
*
|
|
@@ -405,1212 +480,6 @@ async function getTerser() {
|
|
|
405
480
|
* Default: `false`
|
|
406
481
|
*/
|
|
407
482
|
|
|
408
|
-
// Hoisted, reusable RegExp patterns and tiny helpers to avoid repeated allocations in hot paths
|
|
409
|
-
const RE_WS_START = /^[ \n\r\t\f]+/;
|
|
410
|
-
const RE_WS_END = /[ \n\r\t\f]+$/;
|
|
411
|
-
const RE_ALL_WS_NBSP = /[ \n\r\t\f\xA0]+/g;
|
|
412
|
-
const RE_NBSP_LEADING_GROUP = /(^|\xA0+)[^\xA0]+/g;
|
|
413
|
-
const RE_NBSP_LEAD_GROUP = /(\xA0+)[^\xA0]+/g;
|
|
414
|
-
const RE_NBSP_TRAILING_GROUP = /[^\xA0]+(\xA0+)/g;
|
|
415
|
-
const RE_NBSP_TRAILING_STRIP = /[^\xA0]+$/;
|
|
416
|
-
const RE_CONDITIONAL_COMMENT = /^\[if\s[^\]]+]|\[endif]$/;
|
|
417
|
-
const RE_EVENT_ATTR_DEFAULT = /^on[a-z]{3,}$/;
|
|
418
|
-
const RE_CAN_REMOVE_ATTR_QUOTES = /^[^ \t\n\f\r"'`=<>]+$/;
|
|
419
|
-
const RE_TRAILING_SEMICOLON = /;$/;
|
|
420
|
-
const RE_AMP_ENTITY = /&(#?[0-9a-zA-Z]+;)/g;
|
|
421
|
-
|
|
422
|
-
// Tiny stable stringify for options signatures (sorted keys, shallow, nested objects)
|
|
423
|
-
function stableStringify(obj) {
|
|
424
|
-
if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
425
|
-
if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
|
|
426
|
-
const keys = Object.keys(obj).sort();
|
|
427
|
-
let out = '{';
|
|
428
|
-
for (let i = 0; i < keys.length; i++) {
|
|
429
|
-
const k = keys[i];
|
|
430
|
-
out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
|
|
431
|
-
}
|
|
432
|
-
return out + '}';
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Minimal LRU cache for strings and promises
|
|
436
|
-
class LRU {
|
|
437
|
-
constructor(limit = 200) {
|
|
438
|
-
this.limit = limit;
|
|
439
|
-
this.map = new Map();
|
|
440
|
-
}
|
|
441
|
-
get(key) {
|
|
442
|
-
const v = this.map.get(key);
|
|
443
|
-
if (v !== undefined) {
|
|
444
|
-
this.map.delete(key);
|
|
445
|
-
this.map.set(key, v);
|
|
446
|
-
}
|
|
447
|
-
return v;
|
|
448
|
-
}
|
|
449
|
-
set(key, value) {
|
|
450
|
-
if (this.map.has(key)) this.map.delete(key);
|
|
451
|
-
this.map.set(key, value);
|
|
452
|
-
if (this.map.size > this.limit) {
|
|
453
|
-
const first = this.map.keys().next().value;
|
|
454
|
-
this.map.delete(first);
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
delete(key) { this.map.delete(key); }
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Per-process caches
|
|
461
|
-
const jsMinifyCache = new LRU(200);
|
|
462
|
-
const cssMinifyCache = new LRU(200);
|
|
463
|
-
|
|
464
|
-
const trimWhitespace = str => {
|
|
465
|
-
if (!str) return str;
|
|
466
|
-
// Fast path: If no whitespace at start or end, return early
|
|
467
|
-
if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
|
|
468
|
-
return str;
|
|
469
|
-
}
|
|
470
|
-
return str.replace(RE_WS_START, '').replace(RE_WS_END, '');
|
|
471
|
-
};
|
|
472
|
-
|
|
473
|
-
function collapseWhitespaceAll(str) {
|
|
474
|
-
if (!str) return str;
|
|
475
|
-
// Fast path: If there are no common whitespace characters, return early
|
|
476
|
-
if (!/[ \n\r\t\f\xA0]/.test(str)) {
|
|
477
|
-
return str;
|
|
478
|
-
}
|
|
479
|
-
// Non-breaking space is specifically handled inside the replacer function here:
|
|
480
|
-
return str.replace(RE_ALL_WS_NBSP, function (spaces) {
|
|
481
|
-
return spaces === '\t' ? '\t' : spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
|
|
486
|
-
let lineBreakBefore = ''; let lineBreakAfter = '';
|
|
487
|
-
|
|
488
|
-
if (!str) return str;
|
|
489
|
-
|
|
490
|
-
if (options.preserveLineBreaks) {
|
|
491
|
-
str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function () {
|
|
492
|
-
lineBreakBefore = '\n';
|
|
493
|
-
return '';
|
|
494
|
-
}).replace(/[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*$/, function () {
|
|
495
|
-
lineBreakAfter = '\n';
|
|
496
|
-
return '';
|
|
497
|
-
});
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
if (trimLeft) {
|
|
501
|
-
// Non-breaking space is specifically handled inside the replacer function here:
|
|
502
|
-
str = str.replace(/^[ \n\r\t\f\xA0]+/, function (spaces) {
|
|
503
|
-
const conservative = !lineBreakBefore && options.conservativeCollapse;
|
|
504
|
-
if (conservative && spaces === '\t') {
|
|
505
|
-
return '\t';
|
|
506
|
-
}
|
|
507
|
-
return spaces.replace(/^[^\xA0]+/, '').replace(RE_NBSP_LEAD_GROUP, '$1 ') || (conservative ? ' ' : '');
|
|
508
|
-
});
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
if (trimRight) {
|
|
512
|
-
// Non-breaking space is specifically handled inside the replacer function here:
|
|
513
|
-
str = str.replace(/[ \n\r\t\f\xA0]+$/, function (spaces) {
|
|
514
|
-
const conservative = !lineBreakAfter && options.conservativeCollapse;
|
|
515
|
-
if (conservative && spaces === '\t') {
|
|
516
|
-
return '\t';
|
|
517
|
-
}
|
|
518
|
-
return spaces.replace(RE_NBSP_TRAILING_GROUP, ' $1').replace(RE_NBSP_TRAILING_STRIP, '') || (conservative ? ' ' : '');
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
if (collapseAll) {
|
|
523
|
-
// Strip non-space whitespace then compress spaces to one
|
|
524
|
-
str = collapseWhitespaceAll(str);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
return lineBreakBefore + str + lineBreakAfter;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// Non-empty elements that will maintain whitespace around them
|
|
531
|
-
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']);
|
|
532
|
-
// Non-empty elements that will maintain whitespace within them
|
|
533
|
-
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']);
|
|
534
|
-
// Elements that will always maintain whitespace around them
|
|
535
|
-
const inlineElementsToKeepWhitespace = new Set(['comment', 'img', 'input', 'wbr']);
|
|
536
|
-
|
|
537
|
-
function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements, inlineTextSet) {
|
|
538
|
-
let trimLeft = prevTag && !inlineElementsToKeepWhitespace.has(prevTag);
|
|
539
|
-
if (trimLeft && !options.collapseInlineTagWhitespace) {
|
|
540
|
-
trimLeft = prevTag.charAt(0) === '/' ? !inlineElements.has(prevTag.slice(1)) : !inlineTextSet.has(prevTag);
|
|
541
|
-
}
|
|
542
|
-
let trimRight = nextTag && !inlineElementsToKeepWhitespace.has(nextTag);
|
|
543
|
-
if (trimRight && !options.collapseInlineTagWhitespace) {
|
|
544
|
-
trimRight = nextTag.charAt(0) === '/' ? !inlineTextSet.has(nextTag.slice(1)) : !inlineElements.has(nextTag);
|
|
545
|
-
}
|
|
546
|
-
return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
function isConditionalComment(text) {
|
|
550
|
-
return RE_CONDITIONAL_COMMENT.test(text);
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
function isIgnoredComment(text, options) {
|
|
554
|
-
for (let i = 0, len = options.ignoreCustomComments.length; i < len; i++) {
|
|
555
|
-
if (options.ignoreCustomComments[i].test(text)) {
|
|
556
|
-
return true;
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
return false;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
function isEventAttribute(attrName, options) {
|
|
563
|
-
const patterns = options.customEventAttributes;
|
|
564
|
-
if (patterns) {
|
|
565
|
-
for (let i = patterns.length; i--;) {
|
|
566
|
-
if (patterns[i].test(attrName)) {
|
|
567
|
-
return true;
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
return false;
|
|
571
|
-
}
|
|
572
|
-
return RE_EVENT_ATTR_DEFAULT.test(attrName);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
function canRemoveAttributeQuotes(value) {
|
|
576
|
-
// https://mathiasbynens.be/notes/unquoted-attribute-values
|
|
577
|
-
return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function attributesInclude(attributes, attribute) {
|
|
581
|
-
for (let i = attributes.length; i--;) {
|
|
582
|
-
if (attributes[i].name.toLowerCase() === attribute) {
|
|
583
|
-
return true;
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
return false;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// Default attribute values (could apply to any element)
|
|
590
|
-
const generalDefaults = {
|
|
591
|
-
autocorrect: 'on',
|
|
592
|
-
fetchpriority: 'auto',
|
|
593
|
-
loading: 'eager',
|
|
594
|
-
popovertargetaction: 'toggle'
|
|
595
|
-
};
|
|
596
|
-
|
|
597
|
-
// Tag-specific default attribute values
|
|
598
|
-
const tagDefaults = {
|
|
599
|
-
area: { shape: 'rect' },
|
|
600
|
-
button: { type: 'submit' },
|
|
601
|
-
form: {
|
|
602
|
-
enctype: 'application/x-www-form-urlencoded',
|
|
603
|
-
method: 'get'
|
|
604
|
-
},
|
|
605
|
-
html: { dir: 'ltr' },
|
|
606
|
-
img: { decoding: 'auto' },
|
|
607
|
-
input: {
|
|
608
|
-
colorspace: 'limited-srgb',
|
|
609
|
-
type: 'text'
|
|
610
|
-
},
|
|
611
|
-
marquee: {
|
|
612
|
-
behavior: 'scroll',
|
|
613
|
-
direction: 'left'
|
|
614
|
-
},
|
|
615
|
-
style: { media: 'all' },
|
|
616
|
-
textarea: { wrap: 'soft' },
|
|
617
|
-
track: { kind: 'subtitles' }
|
|
618
|
-
};
|
|
619
|
-
|
|
620
|
-
function isAttributeRedundant(tag, attrName, attrValue, attrs) {
|
|
621
|
-
attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
|
|
622
|
-
|
|
623
|
-
// Legacy attributes
|
|
624
|
-
if (tag === 'script' && attrName === 'language' && attrValue === 'javascript') {
|
|
625
|
-
return true;
|
|
626
|
-
}
|
|
627
|
-
if (tag === 'script' && attrName === 'charset' && !attributesInclude(attrs, 'src')) {
|
|
628
|
-
return true;
|
|
629
|
-
}
|
|
630
|
-
if (tag === 'a' && attrName === 'name' && attributesInclude(attrs, 'id')) {
|
|
631
|
-
return true;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// Check general defaults
|
|
635
|
-
if (generalDefaults[attrName] === attrValue) {
|
|
636
|
-
return true;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// Check tag-specific defaults
|
|
640
|
-
return tagDefaults[tag]?.[attrName] === attrValue;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// https://mathiasbynens.be/demo/javascript-mime-type
|
|
644
|
-
// https://developer.mozilla.org/en/docs/Web/HTML/Element/script#attr-type
|
|
645
|
-
const executableScriptsMimetypes = new Set([
|
|
646
|
-
'text/javascript',
|
|
647
|
-
'text/ecmascript',
|
|
648
|
-
'text/jscript',
|
|
649
|
-
'application/javascript',
|
|
650
|
-
'application/x-javascript',
|
|
651
|
-
'application/ecmascript',
|
|
652
|
-
'module'
|
|
653
|
-
]);
|
|
654
|
-
|
|
655
|
-
const keepScriptsMimetypes = new Set([
|
|
656
|
-
'module'
|
|
657
|
-
]);
|
|
658
|
-
|
|
659
|
-
function isScriptTypeAttribute(attrValue = '') {
|
|
660
|
-
attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
|
|
661
|
-
return attrValue === '' || executableScriptsMimetypes.has(attrValue);
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
function keepScriptTypeAttribute(attrValue = '') {
|
|
665
|
-
attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
|
|
666
|
-
return keepScriptsMimetypes.has(attrValue);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
function isExecutableScript(tag, attrs) {
|
|
670
|
-
if (tag !== 'script') {
|
|
671
|
-
return false;
|
|
672
|
-
}
|
|
673
|
-
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
674
|
-
const attrName = attrs[i].name.toLowerCase();
|
|
675
|
-
if (attrName === 'type') {
|
|
676
|
-
return isScriptTypeAttribute(attrs[i].value);
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
return true;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
function isStyleLinkTypeAttribute(attrValue = '') {
|
|
683
|
-
attrValue = trimWhitespace(attrValue).toLowerCase();
|
|
684
|
-
return attrValue === '' || attrValue === 'text/css';
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
function isStyleSheet(tag, attrs) {
|
|
688
|
-
if (tag !== 'style') {
|
|
689
|
-
return false;
|
|
690
|
-
}
|
|
691
|
-
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
692
|
-
const attrName = attrs[i].name.toLowerCase();
|
|
693
|
-
if (attrName === 'type') {
|
|
694
|
-
return isStyleLinkTypeAttribute(attrs[i].value);
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
return true;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
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']);
|
|
701
|
-
const isBooleanValue = new Set(['true', 'false']);
|
|
702
|
-
|
|
703
|
-
function isBooleanAttribute(attrName, attrValue) {
|
|
704
|
-
return isSimpleBoolean.has(attrName) || (attrName === 'draggable' && !isBooleanValue.has(attrValue));
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
function isUriTypeAttribute(attrName, tag) {
|
|
708
|
-
return (
|
|
709
|
-
(/^(?:a|area|link|base)$/.test(tag) && attrName === 'href') ||
|
|
710
|
-
(tag === 'img' && /^(?:src|longdesc|usemap)$/.test(attrName)) ||
|
|
711
|
-
(tag === 'object' && /^(?:classid|codebase|data|usemap)$/.test(attrName)) ||
|
|
712
|
-
(tag === 'q' && attrName === 'cite') ||
|
|
713
|
-
(tag === 'blockquote' && attrName === 'cite') ||
|
|
714
|
-
((tag === 'ins' || tag === 'del') && attrName === 'cite') ||
|
|
715
|
-
(tag === 'form' && attrName === 'action') ||
|
|
716
|
-
(tag === 'input' && (attrName === 'src' || attrName === 'usemap')) ||
|
|
717
|
-
(tag === 'head' && attrName === 'profile') ||
|
|
718
|
-
(tag === 'script' && (attrName === 'src' || attrName === 'for'))
|
|
719
|
-
);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
function isNumberTypeAttribute(attrName, tag) {
|
|
723
|
-
return (
|
|
724
|
-
(/^(?:a|area|object|button)$/.test(tag) && attrName === 'tabindex') ||
|
|
725
|
-
(tag === 'input' && (attrName === 'maxlength' || attrName === 'tabindex')) ||
|
|
726
|
-
(tag === 'select' && (attrName === 'size' || attrName === 'tabindex')) ||
|
|
727
|
-
(tag === 'textarea' && /^(?:rows|cols|tabindex)$/.test(attrName)) ||
|
|
728
|
-
(tag === 'colgroup' && attrName === 'span') ||
|
|
729
|
-
(tag === 'col' && attrName === 'span') ||
|
|
730
|
-
((tag === 'th' || tag === 'td') && (attrName === 'rowspan' || attrName === 'colspan'))
|
|
731
|
-
);
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
function isLinkType(tag, attrs, value) {
|
|
735
|
-
if (tag !== 'link') return false;
|
|
736
|
-
const needle = String(value).toLowerCase();
|
|
737
|
-
for (let i = 0; i < attrs.length; i++) {
|
|
738
|
-
if (attrs[i].name.toLowerCase() === 'rel') {
|
|
739
|
-
const tokens = String(attrs[i].value).toLowerCase().split(/\s+/);
|
|
740
|
-
if (tokens.includes(needle)) return true;
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
return false;
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
function isMediaQuery(tag, attrs, attrName) {
|
|
747
|
-
return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleSheet(tag, attrs));
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
const srcsetTags = new Set(['img', 'source']);
|
|
751
|
-
|
|
752
|
-
function isSrcset(attrName, tag) {
|
|
753
|
-
return attrName === 'srcset' && srcsetTags.has(tag);
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
|
|
757
|
-
// Apply early whitespace normalization if enabled
|
|
758
|
-
// Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
|
|
759
|
-
if (options.collapseAttributeWhitespace) {
|
|
760
|
-
attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
if (isEventAttribute(attrName, options)) {
|
|
764
|
-
attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
|
|
765
|
-
return options.minifyJS(attrValue, true);
|
|
766
|
-
} else if (attrName === 'class') {
|
|
767
|
-
attrValue = trimWhitespace(attrValue);
|
|
768
|
-
if (options.sortClassName) {
|
|
769
|
-
attrValue = options.sortClassName(attrValue);
|
|
770
|
-
} else {
|
|
771
|
-
attrValue = collapseWhitespaceAll(attrValue);
|
|
772
|
-
}
|
|
773
|
-
return attrValue;
|
|
774
|
-
} else if (isUriTypeAttribute(attrName, tag)) {
|
|
775
|
-
attrValue = trimWhitespace(attrValue);
|
|
776
|
-
if (isLinkType(tag, attrs, 'canonical')) {
|
|
777
|
-
return attrValue;
|
|
778
|
-
}
|
|
779
|
-
try {
|
|
780
|
-
const out = await options.minifyURLs(attrValue);
|
|
781
|
-
return typeof out === 'string' ? out : attrValue;
|
|
782
|
-
} catch (err) {
|
|
783
|
-
if (!options.continueOnMinifyError) {
|
|
784
|
-
throw err;
|
|
785
|
-
}
|
|
786
|
-
options.log && options.log(err);
|
|
787
|
-
return attrValue;
|
|
788
|
-
}
|
|
789
|
-
} else if (isNumberTypeAttribute(attrName, tag)) {
|
|
790
|
-
return trimWhitespace(attrValue);
|
|
791
|
-
} else if (attrName === 'style') {
|
|
792
|
-
attrValue = trimWhitespace(attrValue);
|
|
793
|
-
if (attrValue) {
|
|
794
|
-
if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
|
|
795
|
-
attrValue = attrValue.replace(/\s*;$/, ';');
|
|
796
|
-
}
|
|
797
|
-
attrValue = await options.minifyCSS(attrValue, 'inline');
|
|
798
|
-
}
|
|
799
|
-
return attrValue;
|
|
800
|
-
} else if (isSrcset(attrName, tag)) {
|
|
801
|
-
// https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
|
|
802
|
-
attrValue = (await Promise.all(trimWhitespace(attrValue).split(/\s+,\s*|\s*,\s+/).map(async function (candidate) {
|
|
803
|
-
let url = candidate;
|
|
804
|
-
let descriptor = '';
|
|
805
|
-
const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
|
|
806
|
-
if (match) {
|
|
807
|
-
url = url.slice(0, -match[0].length);
|
|
808
|
-
const num = +match[1].slice(0, -1);
|
|
809
|
-
const suffix = match[1].slice(-1);
|
|
810
|
-
if (num !== 1 || suffix !== 'x') {
|
|
811
|
-
descriptor = ' ' + num + suffix;
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
try {
|
|
815
|
-
const out = await options.minifyURLs(url);
|
|
816
|
-
return (typeof out === 'string' ? out : url) + descriptor;
|
|
817
|
-
} catch (err) {
|
|
818
|
-
if (!options.continueOnMinifyError) {
|
|
819
|
-
throw err;
|
|
820
|
-
}
|
|
821
|
-
options.log && options.log(err);
|
|
822
|
-
return url + descriptor;
|
|
823
|
-
}
|
|
824
|
-
}))).join(', ');
|
|
825
|
-
} else if (isMetaViewport(tag, attrs) && attrName === 'content') {
|
|
826
|
-
attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
|
|
827
|
-
// “0.90000” → “0.9”
|
|
828
|
-
// “1.0” → “1”
|
|
829
|
-
// “1.0001” → “1.0001” (unchanged)
|
|
830
|
-
return (+numString).toString();
|
|
831
|
-
});
|
|
832
|
-
} else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
|
|
833
|
-
return collapseWhitespaceAll(attrValue);
|
|
834
|
-
} else if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
|
|
835
|
-
attrValue = trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
|
|
836
|
-
} else if (tag === 'script' && attrName === 'type') {
|
|
837
|
-
attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
|
|
838
|
-
} else if (isMediaQuery(tag, attrs, attrName)) {
|
|
839
|
-
attrValue = trimWhitespace(attrValue);
|
|
840
|
-
return options.minifyCSS(attrValue, 'media');
|
|
841
|
-
} else if (tag === 'iframe' && attrName === 'srcdoc') {
|
|
842
|
-
// Recursively minify HTML content within srcdoc attribute
|
|
843
|
-
// Fast-path: Skip if nothing would change
|
|
844
|
-
if (!shouldMinifyInnerHTML(options)) {
|
|
845
|
-
return attrValue;
|
|
846
|
-
}
|
|
847
|
-
return minifyHTMLSelf(attrValue, options, true);
|
|
848
|
-
}
|
|
849
|
-
return attrValue;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
function isMetaViewport(tag, attrs) {
|
|
853
|
-
if (tag !== 'meta') {
|
|
854
|
-
return false;
|
|
855
|
-
}
|
|
856
|
-
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
857
|
-
if (attrs[i].name === 'name' && attrs[i].value === 'viewport') {
|
|
858
|
-
return true;
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
function isContentSecurityPolicy(tag, attrs) {
|
|
864
|
-
if (tag !== 'meta') {
|
|
865
|
-
return false;
|
|
866
|
-
}
|
|
867
|
-
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
868
|
-
if (attrs[i].name.toLowerCase() === 'http-equiv' && attrs[i].value.toLowerCase() === 'content-security-policy') {
|
|
869
|
-
return true;
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
// Wrap CSS declarations for inline styles and media queries
|
|
875
|
-
// This ensures proper context for CSS minification
|
|
876
|
-
function wrapCSS(text, type) {
|
|
877
|
-
switch (type) {
|
|
878
|
-
case 'inline':
|
|
879
|
-
return '*{' + text + '}';
|
|
880
|
-
case 'media':
|
|
881
|
-
return '@media ' + text + '{a{top:0}}';
|
|
882
|
-
default:
|
|
883
|
-
return text;
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
function unwrapCSS(text, type) {
|
|
888
|
-
let matches;
|
|
889
|
-
switch (type) {
|
|
890
|
-
case 'inline':
|
|
891
|
-
matches = text.match(/^\*\{([\s\S]*)\}$/);
|
|
892
|
-
break;
|
|
893
|
-
case 'media':
|
|
894
|
-
matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/);
|
|
895
|
-
break;
|
|
896
|
-
}
|
|
897
|
-
return matches ? matches[1] : text;
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
async function cleanConditionalComment(comment, options) {
|
|
901
|
-
return options.processConditionalComments
|
|
902
|
-
? await replaceAsync(comment, /^(\[if\s[^\]]+]>)([\s\S]*?)(<!\[endif])$/, async function (match, prefix, text, suffix) {
|
|
903
|
-
return prefix + await minifyHTML(text, options, true) + suffix;
|
|
904
|
-
})
|
|
905
|
-
: comment;
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
const jsonScriptTypes = new Set([
|
|
909
|
-
'application/json',
|
|
910
|
-
'application/ld+json',
|
|
911
|
-
'application/manifest+json',
|
|
912
|
-
'application/vnd.geo+json',
|
|
913
|
-
'application/problem+json',
|
|
914
|
-
'application/merge-patch+json',
|
|
915
|
-
'application/json-patch+json',
|
|
916
|
-
'importmap',
|
|
917
|
-
'speculationrules',
|
|
918
|
-
]);
|
|
919
|
-
|
|
920
|
-
function minifyJson(text, options) {
|
|
921
|
-
try {
|
|
922
|
-
return JSON.stringify(JSON.parse(text));
|
|
923
|
-
}
|
|
924
|
-
catch (err) {
|
|
925
|
-
if (!options.continueOnMinifyError) {
|
|
926
|
-
throw err;
|
|
927
|
-
}
|
|
928
|
-
options.log && options.log(err);
|
|
929
|
-
return text;
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
function hasJsonScriptType(attrs) {
|
|
934
|
-
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
935
|
-
const attrName = attrs[i].name.toLowerCase();
|
|
936
|
-
if (attrName === 'type') {
|
|
937
|
-
const attrValue = trimWhitespace((attrs[i].value || '').split(/;/, 2)[0]).toLowerCase();
|
|
938
|
-
if (jsonScriptTypes.has(attrValue)) {
|
|
939
|
-
return true;
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
return false;
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
async function processScript(text, options, currentAttrs) {
|
|
947
|
-
for (let i = 0, len = currentAttrs.length; i < len; i++) {
|
|
948
|
-
const attrName = currentAttrs[i].name.toLowerCase();
|
|
949
|
-
if (attrName === 'type') {
|
|
950
|
-
const rawValue = currentAttrs[i].value;
|
|
951
|
-
const normalizedValue = trimWhitespace((rawValue || '').split(/;/, 2)[0]).toLowerCase();
|
|
952
|
-
// Minify JSON script types automatically
|
|
953
|
-
if (jsonScriptTypes.has(normalizedValue)) {
|
|
954
|
-
return minifyJson(text, options);
|
|
955
|
-
}
|
|
956
|
-
// Process custom script types if specified
|
|
957
|
-
if (options.processScripts && options.processScripts.indexOf(rawValue) > -1) {
|
|
958
|
-
return await minifyHTML(text, options);
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
return text;
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
// Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags with the following extensions:
|
|
966
|
-
// - retain `<body>` if followed by `<noscript>`
|
|
967
|
-
// - `<rb>`, `<rt>`, `<rtc>`, `<rp>` follow HTML Ruby Markup Extensions draft (https://www.w3.org/TR/html-ruby-extensions/)
|
|
968
|
-
// - retain all tags which are adjacent to non-standard HTML tags
|
|
969
|
-
const optionalStartTags = new Set(['html', 'head', 'body', 'colgroup', 'tbody']);
|
|
970
|
-
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']);
|
|
971
|
-
const headerTags = new Set(['meta', 'link', 'script', 'style', 'template', 'noscript']);
|
|
972
|
-
const descriptionTags = new Set(['dt', 'dd']);
|
|
973
|
-
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']);
|
|
974
|
-
const pInlineTags = new Set(['a', 'audio', 'del', 'ins', 'map', 'noscript', 'video']);
|
|
975
|
-
const rubyEndTagOmission = new Set(['rb', 'rt', 'rtc', 'rp']); // `</rb>`, `</rt>`, `</rp>` can be omitted if followed by `<rb>`, `<rt>`, `<rtc>`, or `<rp>`
|
|
976
|
-
const rubyRtcEndTagOmission = new Set(['rb', 'rtc']); // `</rtc>` can be omitted if followed by `<rb>` or `<rtc>` (not `<rt>` or `<rp>`)
|
|
977
|
-
const optionTag = new Set(['option', 'optgroup']);
|
|
978
|
-
const tableContentTags = new Set(['tbody', 'tfoot']);
|
|
979
|
-
const tableSectionTags = new Set(['thead', 'tbody', 'tfoot']);
|
|
980
|
-
const cellTags = new Set(['td', 'th']);
|
|
981
|
-
const topLevelTags = new Set(['html', 'head', 'body']);
|
|
982
|
-
const compactTags = new Set(['html', 'body']);
|
|
983
|
-
const looseTags = new Set(['head', 'colgroup', 'caption']);
|
|
984
|
-
const trailingTags = new Set(['dt', 'thead']);
|
|
985
|
-
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']);
|
|
986
|
-
|
|
987
|
-
function canRemoveParentTag(optionalStartTag, tag) {
|
|
988
|
-
switch (optionalStartTag) {
|
|
989
|
-
case 'html':
|
|
990
|
-
case 'head':
|
|
991
|
-
return true;
|
|
992
|
-
case 'body':
|
|
993
|
-
return !headerTags.has(tag);
|
|
994
|
-
case 'colgroup':
|
|
995
|
-
return tag === 'col';
|
|
996
|
-
case 'tbody':
|
|
997
|
-
return tag === 'tr';
|
|
998
|
-
}
|
|
999
|
-
return false;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
function isStartTagMandatory(optionalEndTag, tag) {
|
|
1003
|
-
switch (tag) {
|
|
1004
|
-
case 'colgroup':
|
|
1005
|
-
return optionalEndTag === 'colgroup';
|
|
1006
|
-
case 'tbody':
|
|
1007
|
-
return tableSectionTags.has(optionalEndTag);
|
|
1008
|
-
}
|
|
1009
|
-
return false;
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
function canRemovePrecedingTag(optionalEndTag, tag) {
|
|
1013
|
-
switch (optionalEndTag) {
|
|
1014
|
-
case 'html':
|
|
1015
|
-
case 'head':
|
|
1016
|
-
case 'body':
|
|
1017
|
-
case 'colgroup':
|
|
1018
|
-
case 'caption':
|
|
1019
|
-
return true;
|
|
1020
|
-
case 'li':
|
|
1021
|
-
case 'optgroup':
|
|
1022
|
-
case 'tr':
|
|
1023
|
-
return tag === optionalEndTag;
|
|
1024
|
-
case 'dt':
|
|
1025
|
-
case 'dd':
|
|
1026
|
-
return descriptionTags.has(tag);
|
|
1027
|
-
case 'p':
|
|
1028
|
-
return pBlockTags.has(tag);
|
|
1029
|
-
case 'rb':
|
|
1030
|
-
case 'rt':
|
|
1031
|
-
case 'rp':
|
|
1032
|
-
return rubyEndTagOmission.has(tag);
|
|
1033
|
-
case 'rtc':
|
|
1034
|
-
return rubyRtcEndTagOmission.has(tag);
|
|
1035
|
-
case 'option':
|
|
1036
|
-
return optionTag.has(tag);
|
|
1037
|
-
case 'thead':
|
|
1038
|
-
case 'tbody':
|
|
1039
|
-
return tableContentTags.has(tag);
|
|
1040
|
-
case 'tfoot':
|
|
1041
|
-
return tag === 'tbody';
|
|
1042
|
-
case 'td':
|
|
1043
|
-
case 'th':
|
|
1044
|
-
return cellTags.has(tag);
|
|
1045
|
-
}
|
|
1046
|
-
return false;
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
const reEmptyAttribute = new RegExp(
|
|
1050
|
-
'^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
|
|
1051
|
-
'?:down|up|over|move|out)|key(?:press|down|up)))$');
|
|
1052
|
-
|
|
1053
|
-
function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
|
|
1054
|
-
const isValueEmpty = !attrValue || /^\s*$/.test(attrValue);
|
|
1055
|
-
if (!isValueEmpty) {
|
|
1056
|
-
return false;
|
|
1057
|
-
}
|
|
1058
|
-
if (typeof options.removeEmptyAttributes === 'function') {
|
|
1059
|
-
return options.removeEmptyAttributes(attrName, tag);
|
|
1060
|
-
}
|
|
1061
|
-
return (tag === 'input' && attrName === 'value') || reEmptyAttribute.test(attrName);
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
function hasAttrName(name, attrs) {
|
|
1065
|
-
for (let i = attrs.length - 1; i >= 0; i--) {
|
|
1066
|
-
if (attrs[i].name === name) {
|
|
1067
|
-
return true;
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
return false;
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
function canRemoveElement(tag, attrs) {
|
|
1074
|
-
switch (tag) {
|
|
1075
|
-
case 'textarea':
|
|
1076
|
-
return false;
|
|
1077
|
-
case 'audio':
|
|
1078
|
-
case 'script':
|
|
1079
|
-
case 'video':
|
|
1080
|
-
if (hasAttrName('src', attrs)) {
|
|
1081
|
-
return false;
|
|
1082
|
-
}
|
|
1083
|
-
break;
|
|
1084
|
-
case 'iframe':
|
|
1085
|
-
if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
|
|
1086
|
-
return false;
|
|
1087
|
-
}
|
|
1088
|
-
break;
|
|
1089
|
-
case 'object':
|
|
1090
|
-
if (hasAttrName('data', attrs)) {
|
|
1091
|
-
return false;
|
|
1092
|
-
}
|
|
1093
|
-
break;
|
|
1094
|
-
case 'applet':
|
|
1095
|
-
if (hasAttrName('code', attrs)) {
|
|
1096
|
-
return false;
|
|
1097
|
-
}
|
|
1098
|
-
break;
|
|
1099
|
-
}
|
|
1100
|
-
return true;
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
/**
|
|
1104
|
-
* @param {string} str - Tag name or HTML-like element spec (e.g., “td” or “<span aria-hidden='true'>”)
|
|
1105
|
-
* @param {MinifierOptions} options - Options object for name normalization
|
|
1106
|
-
* @returns {{tag: string, attrs: Object.<string, string|undefined>|null}|null} Parsed spec or null if invalid
|
|
1107
|
-
*/
|
|
1108
|
-
function parseElementSpec(str, options) {
|
|
1109
|
-
if (typeof str !== 'string') {
|
|
1110
|
-
return null;
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
const trimmed = str.trim();
|
|
1114
|
-
if (!trimmed) {
|
|
1115
|
-
return null;
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
// Simple tag name: “td”
|
|
1119
|
-
if (!/[<>]/.test(trimmed)) {
|
|
1120
|
-
return { tag: options.name(trimmed), attrs: null };
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
// HTML-like markup: “<span aria-hidden='true'>” or “<td></td>”
|
|
1124
|
-
// Extract opening tag using regex
|
|
1125
|
-
const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
|
|
1126
|
-
if (!match) {
|
|
1127
|
-
return null;
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
const tag = options.name(match[1]);
|
|
1131
|
-
const attrString = match[2];
|
|
1132
|
-
|
|
1133
|
-
if (!attrString.trim()) {
|
|
1134
|
-
return { tag, attrs: null };
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
// Parse attributes from string
|
|
1138
|
-
const attrs = {};
|
|
1139
|
-
const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
|
|
1140
|
-
let attrMatch;
|
|
1141
|
-
|
|
1142
|
-
while ((attrMatch = attrRegex.exec(attrString))) {
|
|
1143
|
-
const attrName = options.name(attrMatch[1]);
|
|
1144
|
-
const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
|
|
1145
|
-
// Boolean attributes have no value (undefined)
|
|
1146
|
-
attrs[attrName] = attrValue;
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
return {
|
|
1150
|
-
tag,
|
|
1151
|
-
attrs: Object.keys(attrs).length > 0 ? attrs : null
|
|
1152
|
-
};
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
/**
|
|
1156
|
-
* @param {string[]} input - Array of element specifications from `removeEmptyElementsExcept` option
|
|
1157
|
-
* @param {MinifierOptions} options - Options object for parsing
|
|
1158
|
-
* @returns {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} Array of parsed element specs
|
|
1159
|
-
*/
|
|
1160
|
-
function parseRemoveEmptyElementsExcept(input, options) {
|
|
1161
|
-
if (!Array.isArray(input)) {
|
|
1162
|
-
return [];
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
return input.map(item => {
|
|
1166
|
-
if (typeof item === 'string') {
|
|
1167
|
-
const spec = parseElementSpec(item, options);
|
|
1168
|
-
if (!spec && options.log) {
|
|
1169
|
-
options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
|
|
1170
|
-
}
|
|
1171
|
-
return spec;
|
|
1172
|
-
}
|
|
1173
|
-
if (options.log) {
|
|
1174
|
-
options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
|
|
1175
|
-
}
|
|
1176
|
-
return null;
|
|
1177
|
-
}).filter(Boolean);
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
/**
|
|
1181
|
-
* @param {string} tag - Element tag name
|
|
1182
|
-
* @param {HTMLAttribute[]} attrs - Array of element attributes
|
|
1183
|
-
* @param {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} preserveList - Parsed preserve specs
|
|
1184
|
-
* @returns {boolean} True if the empty element should be preserved
|
|
1185
|
-
*/
|
|
1186
|
-
function shouldPreserveEmptyElement(tag, attrs, preserveList) {
|
|
1187
|
-
for (const spec of preserveList) {
|
|
1188
|
-
// Tag name must match
|
|
1189
|
-
if (spec.tag !== tag) {
|
|
1190
|
-
continue;
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
// If no attributes specified in spec, tag match is enough
|
|
1194
|
-
if (!spec.attrs) {
|
|
1195
|
-
return true;
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
// Check if all specified attributes match
|
|
1199
|
-
const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
|
|
1200
|
-
const attr = attrs.find(a => a.name === name);
|
|
1201
|
-
if (!attr) {
|
|
1202
|
-
return false; // Attribute not present
|
|
1203
|
-
}
|
|
1204
|
-
// Boolean attribute in spec (undefined value) matches if attribute is present
|
|
1205
|
-
if (value === undefined) {
|
|
1206
|
-
return true;
|
|
1207
|
-
}
|
|
1208
|
-
// Valued attribute must match exactly
|
|
1209
|
-
return attr.value === value;
|
|
1210
|
-
});
|
|
1211
|
-
|
|
1212
|
-
if (allAttrsMatch) {
|
|
1213
|
-
return true;
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
return false;
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
function canCollapseWhitespace(tag) {
|
|
1221
|
-
return !/^(?:script|style|pre|textarea)$/.test(tag);
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
function canTrimWhitespace(tag) {
|
|
1225
|
-
return !/^(?:pre|textarea)$/.test(tag);
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
async function normalizeAttr(attr, attrs, tag, options) {
|
|
1229
|
-
const attrName = options.name(attr.name);
|
|
1230
|
-
let attrValue = attr.value;
|
|
1231
|
-
|
|
1232
|
-
if (options.decodeEntities && attrValue) {
|
|
1233
|
-
// Fast path: Only decode when entities are present
|
|
1234
|
-
if (attrValue.indexOf('&') !== -1) {
|
|
1235
|
-
attrValue = decodeHTMLStrict(attrValue);
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
if ((options.removeRedundantAttributes &&
|
|
1240
|
-
isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
|
|
1241
|
-
(options.removeScriptTypeAttributes && tag === 'script' &&
|
|
1242
|
-
attrName === 'type' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue)) ||
|
|
1243
|
-
(options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
|
|
1244
|
-
attrName === 'type' && isStyleLinkTypeAttribute(attrValue))) {
|
|
1245
|
-
return;
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
if (attrValue) {
|
|
1249
|
-
attrValue = await cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML);
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
if (options.removeEmptyAttributes &&
|
|
1253
|
-
canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
|
|
1254
|
-
return;
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
|
|
1258
|
-
attrValue = attrValue.replace(RE_AMP_ENTITY, '&$1');
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
return {
|
|
1262
|
-
attr,
|
|
1263
|
-
name: attrName,
|
|
1264
|
-
value: attrValue
|
|
1265
|
-
};
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
1269
|
-
const attrName = normalized.name;
|
|
1270
|
-
let attrValue = normalized.value;
|
|
1271
|
-
const attr = normalized.attr;
|
|
1272
|
-
let attrQuote = attr.quote;
|
|
1273
|
-
let attrFragment;
|
|
1274
|
-
let emittedAttrValue;
|
|
1275
|
-
|
|
1276
|
-
if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
|
|
1277
|
-
~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
|
|
1278
|
-
// Determine the appropriate quote character
|
|
1279
|
-
if (!options.preventAttributesEscaping) {
|
|
1280
|
-
// Normal mode: choose quotes and escape
|
|
1281
|
-
if (typeof options.quoteCharacter === 'undefined') {
|
|
1282
|
-
// Count quotes in a single pass instead of two regex operations
|
|
1283
|
-
let apos = 0, quot = 0;
|
|
1284
|
-
for (let i = 0; i < attrValue.length; i++) {
|
|
1285
|
-
if (attrValue[i] === "'") apos++;
|
|
1286
|
-
else if (attrValue[i] === '"') quot++;
|
|
1287
|
-
}
|
|
1288
|
-
attrQuote = apos < quot ? '\'' : '"';
|
|
1289
|
-
} else {
|
|
1290
|
-
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
1291
|
-
}
|
|
1292
|
-
if (attrQuote === '"') {
|
|
1293
|
-
attrValue = attrValue.replace(/"/g, '"');
|
|
1294
|
-
} else {
|
|
1295
|
-
attrValue = attrValue.replace(/'/g, ''');
|
|
1296
|
-
}
|
|
1297
|
-
} else {
|
|
1298
|
-
// `preventAttributesEscaping` mode: choose safe quotes but don’t escape
|
|
1299
|
-
// EXCEPT when both quote types are present—then escape to prevent invalid HTML
|
|
1300
|
-
const hasDoubleQuote = attrValue.indexOf('"') !== -1;
|
|
1301
|
-
const hasSingleQuote = attrValue.indexOf("'") !== -1;
|
|
1302
|
-
|
|
1303
|
-
if (hasDoubleQuote && hasSingleQuote) {
|
|
1304
|
-
// Both quote types present: `preventAttributesEscaping` is ignored to ensure valid HTML
|
|
1305
|
-
// Choose the quote type with fewer occurrences and escape the other
|
|
1306
|
-
if (typeof options.quoteCharacter === 'undefined') {
|
|
1307
|
-
let apos = 0, quot = 0;
|
|
1308
|
-
for (let i = 0; i < attrValue.length; i++) {
|
|
1309
|
-
if (attrValue[i] === "'") apos++;
|
|
1310
|
-
else if (attrValue[i] === '"') quot++;
|
|
1311
|
-
}
|
|
1312
|
-
attrQuote = apos < quot ? '\'' : '"';
|
|
1313
|
-
} else {
|
|
1314
|
-
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
1315
|
-
}
|
|
1316
|
-
if (attrQuote === '"') {
|
|
1317
|
-
attrValue = attrValue.replace(/"/g, '"');
|
|
1318
|
-
} else {
|
|
1319
|
-
attrValue = attrValue.replace(/'/g, ''');
|
|
1320
|
-
}
|
|
1321
|
-
} else if (typeof options.quoteCharacter === 'undefined') {
|
|
1322
|
-
// Single or no quote type: Choose safe quote delimiter
|
|
1323
|
-
if (attrQuote === '"' && hasDoubleQuote && !hasSingleQuote) {
|
|
1324
|
-
attrQuote = "'";
|
|
1325
|
-
} else if (attrQuote === "'" && hasSingleQuote && !hasDoubleQuote) {
|
|
1326
|
-
attrQuote = '"';
|
|
1327
|
-
} else if (attrQuote !== '"' && attrQuote !== "'" && attrQuote !== '') {
|
|
1328
|
-
// `attrQuote` is invalid/undefined (not `"`, `'`, or empty string)
|
|
1329
|
-
// Set a safe default based on the value’s content
|
|
1330
|
-
if (hasSingleQuote && !hasDoubleQuote) {
|
|
1331
|
-
attrQuote = '"'; // Value has single quotes, use double quotes as delimiter
|
|
1332
|
-
} else if (hasDoubleQuote && !hasSingleQuote) {
|
|
1333
|
-
attrQuote = "'"; // Value has double quotes, use single quotes as delimiter
|
|
1334
|
-
} else {
|
|
1335
|
-
attrQuote = '"'; // No quotes in value, default to double quotes
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
} else {
|
|
1339
|
-
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
1340
|
-
}
|
|
1341
|
-
}
|
|
1342
|
-
emittedAttrValue = attrQuote + attrValue + attrQuote;
|
|
1343
|
-
if (!isLast && !options.removeTagWhitespace) {
|
|
1344
|
-
emittedAttrValue += ' ';
|
|
1345
|
-
}
|
|
1346
|
-
} else if (isLast && !hasUnarySlash) {
|
|
1347
|
-
// Last attribute in a non-self-closing tag: no space needed
|
|
1348
|
-
emittedAttrValue = attrValue;
|
|
1349
|
-
} else {
|
|
1350
|
-
// Not last attribute, or is a self-closing tag: add space
|
|
1351
|
-
emittedAttrValue = attrValue + ' ';
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
if (typeof attrValue === 'undefined' || (options.collapseBooleanAttributes &&
|
|
1355
|
-
isBooleanAttribute(attrName.toLowerCase(), attrValue.toLowerCase()))) {
|
|
1356
|
-
attrFragment = attrName;
|
|
1357
|
-
if (!isLast) {
|
|
1358
|
-
attrFragment += ' ';
|
|
1359
|
-
}
|
|
1360
|
-
} else {
|
|
1361
|
-
attrFragment = attrName + attr.customAssign + emittedAttrValue;
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
return attr.customOpen + attrFragment + attr.customClose;
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
function identity(value) {
|
|
1368
|
-
return value;
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
function identityAsync(value) {
|
|
1372
|
-
return Promise.resolve(value);
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
function shouldMinifyInnerHTML(options) {
|
|
1376
|
-
return Boolean(
|
|
1377
|
-
options.collapseWhitespace ||
|
|
1378
|
-
options.removeComments ||
|
|
1379
|
-
options.removeOptionalTags ||
|
|
1380
|
-
options.minifyJS !== identity ||
|
|
1381
|
-
options.minifyCSS !== identityAsync ||
|
|
1382
|
-
options.minifyURLs !== identity
|
|
1383
|
-
);
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
/**
|
|
1387
|
-
* @param {Partial<MinifierOptions>} inputOptions - User-provided options
|
|
1388
|
-
* @returns {MinifierOptions} Normalized options with defaults applied
|
|
1389
|
-
*/
|
|
1390
|
-
const processOptions = (inputOptions) => {
|
|
1391
|
-
const options = {
|
|
1392
|
-
name: function (name) {
|
|
1393
|
-
return name.toLowerCase();
|
|
1394
|
-
},
|
|
1395
|
-
canCollapseWhitespace,
|
|
1396
|
-
canTrimWhitespace,
|
|
1397
|
-
continueOnMinifyError: true,
|
|
1398
|
-
html5: true,
|
|
1399
|
-
ignoreCustomComments: [
|
|
1400
|
-
/^!/,
|
|
1401
|
-
/^\s*#/
|
|
1402
|
-
],
|
|
1403
|
-
ignoreCustomFragments: [
|
|
1404
|
-
/<%[\s\S]*?%>/,
|
|
1405
|
-
/<\?[\s\S]*?\?>/
|
|
1406
|
-
],
|
|
1407
|
-
includeAutoGeneratedTags: true,
|
|
1408
|
-
log: identity,
|
|
1409
|
-
minifyCSS: identityAsync,
|
|
1410
|
-
minifyJS: identity,
|
|
1411
|
-
minifyURLs: identity
|
|
1412
|
-
};
|
|
1413
|
-
|
|
1414
|
-
Object.keys(inputOptions).forEach(function (key) {
|
|
1415
|
-
const option = inputOptions[key];
|
|
1416
|
-
|
|
1417
|
-
if (key === 'caseSensitive') {
|
|
1418
|
-
if (option) {
|
|
1419
|
-
options.name = identity;
|
|
1420
|
-
}
|
|
1421
|
-
} else if (key === 'log') {
|
|
1422
|
-
if (typeof option === 'function') {
|
|
1423
|
-
options.log = option;
|
|
1424
|
-
}
|
|
1425
|
-
} else if (key === 'minifyCSS' && typeof option !== 'function') {
|
|
1426
|
-
if (!option) {
|
|
1427
|
-
return;
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
const lightningCssOptions = typeof option === 'object' ? option : {};
|
|
1431
|
-
|
|
1432
|
-
options.minifyCSS = async function (text, type) {
|
|
1433
|
-
// Fast path: Nothing to minify
|
|
1434
|
-
if (!text || !text.trim()) {
|
|
1435
|
-
return text;
|
|
1436
|
-
}
|
|
1437
|
-
text = await replaceAsync(
|
|
1438
|
-
text,
|
|
1439
|
-
/(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
|
|
1440
|
-
async function (match, prefix, dq, sq, unq, suffix) {
|
|
1441
|
-
const quote = dq != null ? '"' : (sq != null ? "'" : '');
|
|
1442
|
-
const url = dq ?? sq ?? unq ?? '';
|
|
1443
|
-
try {
|
|
1444
|
-
const out = await options.minifyURLs(url);
|
|
1445
|
-
return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
|
|
1446
|
-
} catch (err) {
|
|
1447
|
-
if (!options.continueOnMinifyError) {
|
|
1448
|
-
throw err;
|
|
1449
|
-
}
|
|
1450
|
-
options.log && options.log(err);
|
|
1451
|
-
return match;
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
);
|
|
1455
|
-
// Cache key: wrapped content, type, options signature
|
|
1456
|
-
const inputCSS = wrapCSS(text, type);
|
|
1457
|
-
const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
|
|
1458
|
-
// For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
|
|
1459
|
-
const cssKey = inputCSS.length > 2048
|
|
1460
|
-
? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
|
|
1461
|
-
: (inputCSS + '|' + type + '|' + cssSig);
|
|
1462
|
-
|
|
1463
|
-
try {
|
|
1464
|
-
const cached = cssMinifyCache.get(cssKey);
|
|
1465
|
-
if (cached) {
|
|
1466
|
-
return cached;
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
const transformCSS = await getLightningCSS();
|
|
1470
|
-
const result = transformCSS({
|
|
1471
|
-
filename: 'input.css',
|
|
1472
|
-
code: Buffer.from(inputCSS),
|
|
1473
|
-
minify: true,
|
|
1474
|
-
errorRecovery: !!options.continueOnMinifyError,
|
|
1475
|
-
...lightningCssOptions
|
|
1476
|
-
});
|
|
1477
|
-
|
|
1478
|
-
const outputCSS = unwrapCSS(result.code.toString(), type);
|
|
1479
|
-
|
|
1480
|
-
// If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
|
|
1481
|
-
// This preserves:
|
|
1482
|
-
// 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
|
|
1483
|
-
// 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
|
|
1484
|
-
// CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
|
|
1485
|
-
const isCDATA = text.includes('<![CDATA[');
|
|
1486
|
-
const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
|
|
1487
|
-
const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
|
|
1488
|
-
const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
|
|
1489
|
-
|
|
1490
|
-
// Preserve if output is empty and input had template syntax or UIDs
|
|
1491
|
-
// This catches cases where Lightning CSS removed content that should be preserved
|
|
1492
|
-
const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
|
|
1493
|
-
|
|
1494
|
-
cssMinifyCache.set(cssKey, finalOutput);
|
|
1495
|
-
return finalOutput;
|
|
1496
|
-
} catch (err) {
|
|
1497
|
-
cssMinifyCache.delete(cssKey);
|
|
1498
|
-
if (!options.continueOnMinifyError) {
|
|
1499
|
-
throw err;
|
|
1500
|
-
}
|
|
1501
|
-
options.log && options.log(err);
|
|
1502
|
-
return text;
|
|
1503
|
-
}
|
|
1504
|
-
};
|
|
1505
|
-
} else if (key === 'minifyJS' && typeof option !== 'function') {
|
|
1506
|
-
if (!option) {
|
|
1507
|
-
return;
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
const terserOptions = typeof option === 'object' ? option : {};
|
|
1511
|
-
|
|
1512
|
-
terserOptions.parse = {
|
|
1513
|
-
...terserOptions.parse,
|
|
1514
|
-
bare_returns: false
|
|
1515
|
-
};
|
|
1516
|
-
|
|
1517
|
-
options.minifyJS = async function (text, inline) {
|
|
1518
|
-
const start = text.match(/^\s*<!--.*/);
|
|
1519
|
-
const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
|
|
1520
|
-
|
|
1521
|
-
terserOptions.parse.bare_returns = inline;
|
|
1522
|
-
|
|
1523
|
-
let jsKey;
|
|
1524
|
-
try {
|
|
1525
|
-
// Fast path: Avoid invoking Terser for empty/whitespace-only content
|
|
1526
|
-
if (!code || !code.trim()) {
|
|
1527
|
-
return '';
|
|
1528
|
-
}
|
|
1529
|
-
// Cache key: content, inline, options signature (subset)
|
|
1530
|
-
const terserSig = stableStringify({
|
|
1531
|
-
compress: terserOptions.compress,
|
|
1532
|
-
mangle: terserOptions.mangle,
|
|
1533
|
-
ecma: terserOptions.ecma,
|
|
1534
|
-
toplevel: terserOptions.toplevel,
|
|
1535
|
-
module: terserOptions.module,
|
|
1536
|
-
keep_fnames: terserOptions.keep_fnames,
|
|
1537
|
-
format: terserOptions.format,
|
|
1538
|
-
cont: !!options.continueOnMinifyError,
|
|
1539
|
-
});
|
|
1540
|
-
// For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
|
|
1541
|
-
jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
|
|
1542
|
-
const cached = jsMinifyCache.get(jsKey);
|
|
1543
|
-
if (cached) {
|
|
1544
|
-
return await cached;
|
|
1545
|
-
}
|
|
1546
|
-
const inFlight = (async () => {
|
|
1547
|
-
const terser = await getTerser();
|
|
1548
|
-
const result = await terser(code, terserOptions);
|
|
1549
|
-
return result.code.replace(RE_TRAILING_SEMICOLON, '');
|
|
1550
|
-
})();
|
|
1551
|
-
jsMinifyCache.set(jsKey, inFlight);
|
|
1552
|
-
const resolved = await inFlight;
|
|
1553
|
-
jsMinifyCache.set(jsKey, resolved);
|
|
1554
|
-
return resolved;
|
|
1555
|
-
} catch (err) {
|
|
1556
|
-
if (jsKey) jsMinifyCache.delete(jsKey);
|
|
1557
|
-
if (!options.continueOnMinifyError) {
|
|
1558
|
-
throw err;
|
|
1559
|
-
}
|
|
1560
|
-
options.log && options.log(err);
|
|
1561
|
-
return text;
|
|
1562
|
-
}
|
|
1563
|
-
};
|
|
1564
|
-
} else if (key === 'minifyURLs' && typeof option !== 'function') {
|
|
1565
|
-
if (!option) {
|
|
1566
|
-
return;
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
let relateUrlOptions = option;
|
|
1570
|
-
|
|
1571
|
-
if (typeof option === 'string') {
|
|
1572
|
-
relateUrlOptions = { site: option };
|
|
1573
|
-
} else if (typeof option !== 'object') {
|
|
1574
|
-
relateUrlOptions = {};
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
// Cache RelateURL instance for reuse (expensive to create)
|
|
1578
|
-
const relateUrlInstance = new RelateURL(relateUrlOptions.site || '', relateUrlOptions);
|
|
1579
|
-
|
|
1580
|
-
options.minifyURLs = function (text) {
|
|
1581
|
-
// Fast-path: Skip if text doesn’t look like a URL that needs processing
|
|
1582
|
-
// Only process if contains URL-like characters (`/`, `:`, `#`, `?`) or spaces that need encoding
|
|
1583
|
-
if (!/[/:?#\s]/.test(text)) {
|
|
1584
|
-
return text;
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
try {
|
|
1588
|
-
return relateUrlInstance.relate(text);
|
|
1589
|
-
} catch (err) {
|
|
1590
|
-
if (!options.continueOnMinifyError) {
|
|
1591
|
-
throw err;
|
|
1592
|
-
}
|
|
1593
|
-
options.log && options.log(err);
|
|
1594
|
-
return text;
|
|
1595
|
-
}
|
|
1596
|
-
};
|
|
1597
|
-
} else {
|
|
1598
|
-
options[key] = option;
|
|
1599
|
-
}
|
|
1600
|
-
});
|
|
1601
|
-
return options;
|
|
1602
|
-
};
|
|
1603
|
-
|
|
1604
|
-
function uniqueId(value) {
|
|
1605
|
-
let id;
|
|
1606
|
-
do {
|
|
1607
|
-
id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
|
|
1608
|
-
} while (~value.indexOf(id));
|
|
1609
|
-
return id;
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
const specialContentTags = new Set(['script', 'style']);
|
|
1613
|
-
|
|
1614
483
|
async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
|
|
1615
484
|
const attrChains = options.sortAttributes && Object.create(null);
|
|
1616
485
|
const classChain = options.sortClassName && new TokenChain();
|
|
@@ -1682,8 +551,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
1682
551
|
try {
|
|
1683
552
|
await parser.parse();
|
|
1684
553
|
} catch (err) {
|
|
1685
|
-
// If parsing fails during analysis pass, just skip it—we’ll still have
|
|
1686
|
-
// partial frequency data from what we could parse
|
|
554
|
+
// If parsing fails during analysis pass, just skip it—we’ll still have partial frequency data from what we could parse
|
|
1687
555
|
if (!options.continueOnParseError) {
|
|
1688
556
|
throw err;
|
|
1689
557
|
}
|
|
@@ -1692,7 +560,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
1692
560
|
|
|
1693
561
|
// For the first pass, create a copy of options and disable aggressive minification.
|
|
1694
562
|
// Keep attribute transformations (like `removeStyleLinkTypeAttributes`) for accurate analysis.
|
|
1695
|
-
// This is safe because `createSortFns` is called before custom fragment UID markers (uidAttr) are added.
|
|
563
|
+
// This is safe because `createSortFns` is called before custom fragment UID markers (`uidAttr`) are added.
|
|
1696
564
|
// Note: `htmlmin:ignore` UID markers (uidIgnore) already exist and are expanded for analysis.
|
|
1697
565
|
const firstPassOptions = Object.assign({}, options, {
|
|
1698
566
|
// Disable sorting for the analysis pass
|
|
@@ -1735,8 +603,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
1735
603
|
uidReplacePattern.lastIndex = 0;
|
|
1736
604
|
}
|
|
1737
605
|
|
|
1738
|
-
// First pass minification applies attribute transformations
|
|
1739
|
-
// like removeStyleLinkTypeAttributes for accurate frequency analysis
|
|
606
|
+
// First pass minification applies attribute transformations like `removeStyleLinkTypeAttributes` for accurate frequency analysis
|
|
1740
607
|
const firstPassOutput = await minifyHTML(expandedValue, firstPassOptions);
|
|
1741
608
|
|
|
1742
609
|
// For frequency analysis, we need to remove custom fragments temporarily
|
|
@@ -1757,16 +624,30 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
1757
624
|
for (const tag in attrChains) {
|
|
1758
625
|
attrSorters[tag] = attrChains[tag].createSorter();
|
|
1759
626
|
}
|
|
627
|
+
// Memoize sorted attribute orders—attribute sets often repeat in templates
|
|
628
|
+
const attrOrderCache = new LRU(200);
|
|
629
|
+
|
|
1760
630
|
options.sortAttributes = function (tag, attrs) {
|
|
1761
631
|
const sorter = attrSorters[tag];
|
|
1762
632
|
if (sorter) {
|
|
1763
|
-
const attrMap = Object.create(null);
|
|
1764
633
|
const names = attrNames(attrs);
|
|
634
|
+
|
|
635
|
+
// Create order-independent cache key from tag and sorted attribute names
|
|
636
|
+
const cacheKey = tag + ':' + names.slice().sort().join(',');
|
|
637
|
+
let sortedNames = attrOrderCache.get(cacheKey);
|
|
638
|
+
|
|
639
|
+
if (sortedNames === undefined) {
|
|
640
|
+
// Only sort if not in cache—need to clone names since sort mutates in place
|
|
641
|
+
sortedNames = sorter.sort(names.slice());
|
|
642
|
+
attrOrderCache.set(cacheKey, sortedNames);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Apply the sorted order to attrs
|
|
646
|
+
const attrMap = Object.create(null);
|
|
1765
647
|
names.forEach(function (name, index) {
|
|
1766
648
|
(attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
|
|
1767
649
|
});
|
|
1768
|
-
|
|
1769
|
-
sorted.forEach(function (name, index) {
|
|
650
|
+
sortedNames.forEach(function (name, index) {
|
|
1770
651
|
attrs[index] = attrMap[name].shift();
|
|
1771
652
|
});
|
|
1772
653
|
}
|
|
@@ -1867,10 +748,8 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1867
748
|
removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
|
|
1868
749
|
}
|
|
1869
750
|
|
|
1870
|
-
// Temporarily replace ignored chunks with comments,
|
|
1871
|
-
//
|
|
1872
|
-
// For all we care there might be
|
|
1873
|
-
// completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
|
|
751
|
+
// Temporarily replace ignored chunks with comments, so that we don’t have to worry what’s there.
|
|
752
|
+
// For all we care there might be completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
|
|
1874
753
|
value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
|
|
1875
754
|
if (!uidIgnore) {
|
|
1876
755
|
uidIgnore = uniqueId(value);
|
|
@@ -2078,7 +957,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
2078
957
|
|
|
2079
958
|
const parts = [];
|
|
2080
959
|
for (let i = attrs.length, isLast = true; --i >= 0;) {
|
|
2081
|
-
const normalized = await normalizeAttr(attrs[i], attrs, tag, options);
|
|
960
|
+
const normalized = await normalizeAttr(attrs[i], attrs, tag, options, minifyHTML);
|
|
2082
961
|
if (normalized) {
|
|
2083
962
|
parts.push(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
|
|
2084
963
|
isLast = false;
|
|
@@ -2153,7 +1032,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
2153
1032
|
}
|
|
2154
1033
|
|
|
2155
1034
|
if (!preserve) {
|
|
2156
|
-
// Remove last
|
|
1035
|
+
// Remove last element from buffer
|
|
2157
1036
|
removeStartTag();
|
|
2158
1037
|
optionalStartTag = '';
|
|
2159
1038
|
optionalEndTag = '';
|
|
@@ -2237,7 +1116,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
2237
1116
|
}
|
|
2238
1117
|
}
|
|
2239
1118
|
if (specialContentTags.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs))) {
|
|
2240
|
-
text = await processScript(text, options, currentAttrs);
|
|
1119
|
+
text = await processScript(text, options, currentAttrs, minifyHTML);
|
|
2241
1120
|
}
|
|
2242
1121
|
if (isExecutableScript(currentTag, currentAttrs)) {
|
|
2243
1122
|
text = await options.minifyJS(text);
|
|
@@ -2291,7 +1170,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
2291
1170
|
const prefix = nonStandard ? '<!' : '<!--';
|
|
2292
1171
|
const suffix = nonStandard ? '>' : '-->';
|
|
2293
1172
|
if (isConditionalComment(text)) {
|
|
2294
|
-
text = prefix + await cleanConditionalComment(text, options) + suffix;
|
|
1173
|
+
text = prefix + await cleanConditionalComment(text, options, minifyHTML) + suffix;
|
|
2295
1174
|
} else if (options.removeComments) {
|
|
2296
1175
|
if (isIgnoredComment(text, options)) {
|
|
2297
1176
|
text = '<!--' + text + '-->';
|
|
@@ -2478,7 +1357,13 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
|
|
|
2478
1357
|
*/
|
|
2479
1358
|
export const minify = async function (value, options) {
|
|
2480
1359
|
const start = Date.now();
|
|
2481
|
-
options = processOptions(options || {}
|
|
1360
|
+
options = processOptions(options || {}, {
|
|
1361
|
+
getLightningCSS,
|
|
1362
|
+
getTerser,
|
|
1363
|
+
getSwc,
|
|
1364
|
+
cssMinifyCache,
|
|
1365
|
+
jsMinifyCache
|
|
1366
|
+
});
|
|
2482
1367
|
const result = await minifyHTML(value, options);
|
|
2483
1368
|
options.log('minified in: ' + (Date.now() - start) + 'ms');
|
|
2484
1369
|
return result;
|