html-minifier-next 4.12.1 → 4.13.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 +23 -21
- package/dist/htmlminifier.cjs +1477 -1310
- package/dist/htmlminifier.esm.bundle.js +4136 -3969
- 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 +1 -1
- package/src/htmlminifier.js +91 -1223
- 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 +252 -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,11 @@ async function getTerser() {
|
|
|
23
74
|
return terserPromise;
|
|
24
75
|
}
|
|
25
76
|
|
|
77
|
+
// Minification caches
|
|
78
|
+
|
|
79
|
+
const cssMinifyCache = new LRU(200);
|
|
80
|
+
const jsMinifyCache = new LRU(200);
|
|
81
|
+
|
|
26
82
|
// Type definitions
|
|
27
83
|
|
|
28
84
|
/**
|
|
@@ -405,1209 +461,6 @@ async function getTerser() {
|
|
|
405
461
|
* Default: `false`
|
|
406
462
|
*/
|
|
407
463
|
|
|
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
|
-
'importmap',
|
|
914
|
-
'speculationrules',
|
|
915
|
-
]);
|
|
916
|
-
|
|
917
|
-
function minifyJson(text, options) {
|
|
918
|
-
try {
|
|
919
|
-
return JSON.stringify(JSON.parse(text));
|
|
920
|
-
}
|
|
921
|
-
catch (err) {
|
|
922
|
-
if (!options.continueOnMinifyError) {
|
|
923
|
-
throw err;
|
|
924
|
-
}
|
|
925
|
-
options.log && options.log(err);
|
|
926
|
-
return text;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
function hasJsonScriptType(attrs) {
|
|
931
|
-
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
932
|
-
const attrName = attrs[i].name.toLowerCase();
|
|
933
|
-
if (attrName === 'type') {
|
|
934
|
-
const attrValue = trimWhitespace((attrs[i].value || '').split(/;/, 2)[0]).toLowerCase();
|
|
935
|
-
if (jsonScriptTypes.has(attrValue)) {
|
|
936
|
-
return true;
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
return false;
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
async function processScript(text, options, currentAttrs) {
|
|
944
|
-
for (let i = 0, len = currentAttrs.length; i < len; i++) {
|
|
945
|
-
const attrName = currentAttrs[i].name.toLowerCase();
|
|
946
|
-
if (attrName === 'type') {
|
|
947
|
-
const rawValue = currentAttrs[i].value;
|
|
948
|
-
const normalizedValue = trimWhitespace((rawValue || '').split(/;/, 2)[0]).toLowerCase();
|
|
949
|
-
// Minify JSON script types automatically
|
|
950
|
-
if (jsonScriptTypes.has(normalizedValue)) {
|
|
951
|
-
return minifyJson(text, options);
|
|
952
|
-
}
|
|
953
|
-
// Process custom script types if specified
|
|
954
|
-
if (options.processScripts && options.processScripts.indexOf(rawValue) > -1) {
|
|
955
|
-
return await minifyHTML(text, options);
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
return text;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
// Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags with the following extensions:
|
|
963
|
-
// - retain `<body>` if followed by `<noscript>`
|
|
964
|
-
// - `<rb>`, `<rt>`, `<rtc>`, `<rp>` follow HTML Ruby Markup Extensions draft (https://www.w3.org/TR/html-ruby-extensions/)
|
|
965
|
-
// - retain all tags which are adjacent to non-standard HTML tags
|
|
966
|
-
const optionalStartTags = new Set(['html', 'head', 'body', 'colgroup', 'tbody']);
|
|
967
|
-
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']);
|
|
968
|
-
const headerTags = new Set(['meta', 'link', 'script', 'style', 'template', 'noscript']);
|
|
969
|
-
const descriptionTags = new Set(['dt', 'dd']);
|
|
970
|
-
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']);
|
|
971
|
-
const pInlineTags = new Set(['a', 'audio', 'del', 'ins', 'map', 'noscript', 'video']);
|
|
972
|
-
const rubyEndTagOmission = new Set(['rb', 'rt', 'rtc', 'rp']); // `</rb>`, `</rt>`, `</rp>` can be omitted if followed by `<rb>`, `<rt>`, `<rtc>`, or `<rp>`
|
|
973
|
-
const rubyRtcEndTagOmission = new Set(['rb', 'rtc']); // `</rtc>` can be omitted if followed by `<rb>` or `<rtc>` (not `<rt>` or `<rp>`)
|
|
974
|
-
const optionTag = new Set(['option', 'optgroup']);
|
|
975
|
-
const tableContentTags = new Set(['tbody', 'tfoot']);
|
|
976
|
-
const tableSectionTags = new Set(['thead', 'tbody', 'tfoot']);
|
|
977
|
-
const cellTags = new Set(['td', 'th']);
|
|
978
|
-
const topLevelTags = new Set(['html', 'head', 'body']);
|
|
979
|
-
const compactTags = new Set(['html', 'body']);
|
|
980
|
-
const looseTags = new Set(['head', 'colgroup', 'caption']);
|
|
981
|
-
const trailingTags = new Set(['dt', 'thead']);
|
|
982
|
-
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']);
|
|
983
|
-
|
|
984
|
-
function canRemoveParentTag(optionalStartTag, tag) {
|
|
985
|
-
switch (optionalStartTag) {
|
|
986
|
-
case 'html':
|
|
987
|
-
case 'head':
|
|
988
|
-
return true;
|
|
989
|
-
case 'body':
|
|
990
|
-
return !headerTags.has(tag);
|
|
991
|
-
case 'colgroup':
|
|
992
|
-
return tag === 'col';
|
|
993
|
-
case 'tbody':
|
|
994
|
-
return tag === 'tr';
|
|
995
|
-
}
|
|
996
|
-
return false;
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
function isStartTagMandatory(optionalEndTag, tag) {
|
|
1000
|
-
switch (tag) {
|
|
1001
|
-
case 'colgroup':
|
|
1002
|
-
return optionalEndTag === 'colgroup';
|
|
1003
|
-
case 'tbody':
|
|
1004
|
-
return tableSectionTags.has(optionalEndTag);
|
|
1005
|
-
}
|
|
1006
|
-
return false;
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
function canRemovePrecedingTag(optionalEndTag, tag) {
|
|
1010
|
-
switch (optionalEndTag) {
|
|
1011
|
-
case 'html':
|
|
1012
|
-
case 'head':
|
|
1013
|
-
case 'body':
|
|
1014
|
-
case 'colgroup':
|
|
1015
|
-
case 'caption':
|
|
1016
|
-
return true;
|
|
1017
|
-
case 'li':
|
|
1018
|
-
case 'optgroup':
|
|
1019
|
-
case 'tr':
|
|
1020
|
-
return tag === optionalEndTag;
|
|
1021
|
-
case 'dt':
|
|
1022
|
-
case 'dd':
|
|
1023
|
-
return descriptionTags.has(tag);
|
|
1024
|
-
case 'p':
|
|
1025
|
-
return pBlockTags.has(tag);
|
|
1026
|
-
case 'rb':
|
|
1027
|
-
case 'rt':
|
|
1028
|
-
case 'rp':
|
|
1029
|
-
return rubyEndTagOmission.has(tag);
|
|
1030
|
-
case 'rtc':
|
|
1031
|
-
return rubyRtcEndTagOmission.has(tag);
|
|
1032
|
-
case 'option':
|
|
1033
|
-
return optionTag.has(tag);
|
|
1034
|
-
case 'thead':
|
|
1035
|
-
case 'tbody':
|
|
1036
|
-
return tableContentTags.has(tag);
|
|
1037
|
-
case 'tfoot':
|
|
1038
|
-
return tag === 'tbody';
|
|
1039
|
-
case 'td':
|
|
1040
|
-
case 'th':
|
|
1041
|
-
return cellTags.has(tag);
|
|
1042
|
-
}
|
|
1043
|
-
return false;
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
const reEmptyAttribute = new RegExp(
|
|
1047
|
-
'^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
|
|
1048
|
-
'?:down|up|over|move|out)|key(?:press|down|up)))$');
|
|
1049
|
-
|
|
1050
|
-
function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
|
|
1051
|
-
const isValueEmpty = !attrValue || /^\s*$/.test(attrValue);
|
|
1052
|
-
if (!isValueEmpty) {
|
|
1053
|
-
return false;
|
|
1054
|
-
}
|
|
1055
|
-
if (typeof options.removeEmptyAttributes === 'function') {
|
|
1056
|
-
return options.removeEmptyAttributes(attrName, tag);
|
|
1057
|
-
}
|
|
1058
|
-
return (tag === 'input' && attrName === 'value') || reEmptyAttribute.test(attrName);
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
function hasAttrName(name, attrs) {
|
|
1062
|
-
for (let i = attrs.length - 1; i >= 0; i--) {
|
|
1063
|
-
if (attrs[i].name === name) {
|
|
1064
|
-
return true;
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
return false;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
function canRemoveElement(tag, attrs) {
|
|
1071
|
-
switch (tag) {
|
|
1072
|
-
case 'textarea':
|
|
1073
|
-
return false;
|
|
1074
|
-
case 'audio':
|
|
1075
|
-
case 'script':
|
|
1076
|
-
case 'video':
|
|
1077
|
-
if (hasAttrName('src', attrs)) {
|
|
1078
|
-
return false;
|
|
1079
|
-
}
|
|
1080
|
-
break;
|
|
1081
|
-
case 'iframe':
|
|
1082
|
-
if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
|
|
1083
|
-
return false;
|
|
1084
|
-
}
|
|
1085
|
-
break;
|
|
1086
|
-
case 'object':
|
|
1087
|
-
if (hasAttrName('data', attrs)) {
|
|
1088
|
-
return false;
|
|
1089
|
-
}
|
|
1090
|
-
break;
|
|
1091
|
-
case 'applet':
|
|
1092
|
-
if (hasAttrName('code', attrs)) {
|
|
1093
|
-
return false;
|
|
1094
|
-
}
|
|
1095
|
-
break;
|
|
1096
|
-
}
|
|
1097
|
-
return true;
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
/**
|
|
1101
|
-
* @param {string} str - Tag name or HTML-like element spec (e.g., “td” or “<span aria-hidden='true'>”)
|
|
1102
|
-
* @param {MinifierOptions} options - Options object for name normalization
|
|
1103
|
-
* @returns {{tag: string, attrs: Object.<string, string|undefined>|null}|null} Parsed spec or null if invalid
|
|
1104
|
-
*/
|
|
1105
|
-
function parseElementSpec(str, options) {
|
|
1106
|
-
if (typeof str !== 'string') {
|
|
1107
|
-
return null;
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
const trimmed = str.trim();
|
|
1111
|
-
if (!trimmed) {
|
|
1112
|
-
return null;
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
// Simple tag name: “td”
|
|
1116
|
-
if (!/[<>]/.test(trimmed)) {
|
|
1117
|
-
return { tag: options.name(trimmed), attrs: null };
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
// HTML-like markup: “<span aria-hidden='true'>” or “<td></td>”
|
|
1121
|
-
// Extract opening tag using regex
|
|
1122
|
-
const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
|
|
1123
|
-
if (!match) {
|
|
1124
|
-
return null;
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
const tag = options.name(match[1]);
|
|
1128
|
-
const attrString = match[2];
|
|
1129
|
-
|
|
1130
|
-
if (!attrString.trim()) {
|
|
1131
|
-
return { tag, attrs: null };
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
// Parse attributes from string
|
|
1135
|
-
const attrs = {};
|
|
1136
|
-
const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
|
|
1137
|
-
let attrMatch;
|
|
1138
|
-
|
|
1139
|
-
while ((attrMatch = attrRegex.exec(attrString))) {
|
|
1140
|
-
const attrName = options.name(attrMatch[1]);
|
|
1141
|
-
const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
|
|
1142
|
-
// Boolean attributes have no value (undefined)
|
|
1143
|
-
attrs[attrName] = attrValue;
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
return {
|
|
1147
|
-
tag,
|
|
1148
|
-
attrs: Object.keys(attrs).length > 0 ? attrs : null
|
|
1149
|
-
};
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
/**
|
|
1153
|
-
* @param {string[]} input - Array of element specifications from `removeEmptyElementsExcept` option
|
|
1154
|
-
* @param {MinifierOptions} options - Options object for parsing
|
|
1155
|
-
* @returns {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} Array of parsed element specs
|
|
1156
|
-
*/
|
|
1157
|
-
function parseRemoveEmptyElementsExcept(input, options) {
|
|
1158
|
-
if (!Array.isArray(input)) {
|
|
1159
|
-
return [];
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
return input.map(item => {
|
|
1163
|
-
if (typeof item === 'string') {
|
|
1164
|
-
const spec = parseElementSpec(item, options);
|
|
1165
|
-
if (!spec && options.log) {
|
|
1166
|
-
options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
|
|
1167
|
-
}
|
|
1168
|
-
return spec;
|
|
1169
|
-
}
|
|
1170
|
-
if (options.log) {
|
|
1171
|
-
options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
|
|
1172
|
-
}
|
|
1173
|
-
return null;
|
|
1174
|
-
}).filter(Boolean);
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
/**
|
|
1178
|
-
* @param {string} tag - Element tag name
|
|
1179
|
-
* @param {HTMLAttribute[]} attrs - Array of element attributes
|
|
1180
|
-
* @param {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} preserveList - Parsed preserve specs
|
|
1181
|
-
* @returns {boolean} True if the empty element should be preserved
|
|
1182
|
-
*/
|
|
1183
|
-
function shouldPreserveEmptyElement(tag, attrs, preserveList) {
|
|
1184
|
-
for (const spec of preserveList) {
|
|
1185
|
-
// Tag name must match
|
|
1186
|
-
if (spec.tag !== tag) {
|
|
1187
|
-
continue;
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
// If no attributes specified in spec, tag match is enough
|
|
1191
|
-
if (!spec.attrs) {
|
|
1192
|
-
return true;
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
// Check if all specified attributes match
|
|
1196
|
-
const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
|
|
1197
|
-
const attr = attrs.find(a => a.name === name);
|
|
1198
|
-
if (!attr) {
|
|
1199
|
-
return false; // Attribute not present
|
|
1200
|
-
}
|
|
1201
|
-
// Boolean attribute in spec (undefined value) matches if attribute is present
|
|
1202
|
-
if (value === undefined) {
|
|
1203
|
-
return true;
|
|
1204
|
-
}
|
|
1205
|
-
// Valued attribute must match exactly
|
|
1206
|
-
return attr.value === value;
|
|
1207
|
-
});
|
|
1208
|
-
|
|
1209
|
-
if (allAttrsMatch) {
|
|
1210
|
-
return true;
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
return false;
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
function canCollapseWhitespace(tag) {
|
|
1218
|
-
return !/^(?:script|style|pre|textarea)$/.test(tag);
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
function canTrimWhitespace(tag) {
|
|
1222
|
-
return !/^(?:pre|textarea)$/.test(tag);
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
async function normalizeAttr(attr, attrs, tag, options) {
|
|
1226
|
-
const attrName = options.name(attr.name);
|
|
1227
|
-
let attrValue = attr.value;
|
|
1228
|
-
|
|
1229
|
-
if (options.decodeEntities && attrValue) {
|
|
1230
|
-
// Fast path: Only decode when entities are present
|
|
1231
|
-
if (attrValue.indexOf('&') !== -1) {
|
|
1232
|
-
attrValue = decodeHTMLStrict(attrValue);
|
|
1233
|
-
}
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
if ((options.removeRedundantAttributes &&
|
|
1237
|
-
isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
|
|
1238
|
-
(options.removeScriptTypeAttributes && tag === 'script' &&
|
|
1239
|
-
attrName === 'type' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue)) ||
|
|
1240
|
-
(options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
|
|
1241
|
-
attrName === 'type' && isStyleLinkTypeAttribute(attrValue))) {
|
|
1242
|
-
return;
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
if (attrValue) {
|
|
1246
|
-
attrValue = await cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML);
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
if (options.removeEmptyAttributes &&
|
|
1250
|
-
canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
|
|
1251
|
-
return;
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
|
|
1255
|
-
attrValue = attrValue.replace(RE_AMP_ENTITY, '&$1');
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
return {
|
|
1259
|
-
attr,
|
|
1260
|
-
name: attrName,
|
|
1261
|
-
value: attrValue
|
|
1262
|
-
};
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
1266
|
-
const attrName = normalized.name;
|
|
1267
|
-
let attrValue = normalized.value;
|
|
1268
|
-
const attr = normalized.attr;
|
|
1269
|
-
let attrQuote = attr.quote;
|
|
1270
|
-
let attrFragment;
|
|
1271
|
-
let emittedAttrValue;
|
|
1272
|
-
|
|
1273
|
-
if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
|
|
1274
|
-
~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
|
|
1275
|
-
// Determine the appropriate quote character
|
|
1276
|
-
if (!options.preventAttributesEscaping) {
|
|
1277
|
-
// Normal mode: choose quotes and escape
|
|
1278
|
-
if (typeof options.quoteCharacter === 'undefined') {
|
|
1279
|
-
// Count quotes in a single pass instead of two regex operations
|
|
1280
|
-
let apos = 0, quot = 0;
|
|
1281
|
-
for (let i = 0; i < attrValue.length; i++) {
|
|
1282
|
-
if (attrValue[i] === "'") apos++;
|
|
1283
|
-
else if (attrValue[i] === '"') quot++;
|
|
1284
|
-
}
|
|
1285
|
-
attrQuote = apos < quot ? '\'' : '"';
|
|
1286
|
-
} else {
|
|
1287
|
-
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
1288
|
-
}
|
|
1289
|
-
if (attrQuote === '"') {
|
|
1290
|
-
attrValue = attrValue.replace(/"/g, '"');
|
|
1291
|
-
} else {
|
|
1292
|
-
attrValue = attrValue.replace(/'/g, ''');
|
|
1293
|
-
}
|
|
1294
|
-
} else {
|
|
1295
|
-
// `preventAttributesEscaping` mode: choose safe quotes but don’t escape
|
|
1296
|
-
// EXCEPT when both quote types are present—then escape to prevent invalid HTML
|
|
1297
|
-
const hasDoubleQuote = attrValue.indexOf('"') !== -1;
|
|
1298
|
-
const hasSingleQuote = attrValue.indexOf("'") !== -1;
|
|
1299
|
-
|
|
1300
|
-
if (hasDoubleQuote && hasSingleQuote) {
|
|
1301
|
-
// Both quote types present: `preventAttributesEscaping` is ignored to ensure valid HTML
|
|
1302
|
-
// Choose the quote type with fewer occurrences and escape the other
|
|
1303
|
-
if (typeof options.quoteCharacter === 'undefined') {
|
|
1304
|
-
let apos = 0, quot = 0;
|
|
1305
|
-
for (let i = 0; i < attrValue.length; i++) {
|
|
1306
|
-
if (attrValue[i] === "'") apos++;
|
|
1307
|
-
else if (attrValue[i] === '"') quot++;
|
|
1308
|
-
}
|
|
1309
|
-
attrQuote = apos < quot ? '\'' : '"';
|
|
1310
|
-
} else {
|
|
1311
|
-
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
1312
|
-
}
|
|
1313
|
-
if (attrQuote === '"') {
|
|
1314
|
-
attrValue = attrValue.replace(/"/g, '"');
|
|
1315
|
-
} else {
|
|
1316
|
-
attrValue = attrValue.replace(/'/g, ''');
|
|
1317
|
-
}
|
|
1318
|
-
} else if (typeof options.quoteCharacter === 'undefined') {
|
|
1319
|
-
// Single or no quote type: Choose safe quote delimiter
|
|
1320
|
-
if (attrQuote === '"' && hasDoubleQuote && !hasSingleQuote) {
|
|
1321
|
-
attrQuote = "'";
|
|
1322
|
-
} else if (attrQuote === "'" && hasSingleQuote && !hasDoubleQuote) {
|
|
1323
|
-
attrQuote = '"';
|
|
1324
|
-
} else if (attrQuote !== '"' && attrQuote !== "'" && attrQuote !== '') {
|
|
1325
|
-
// `attrQuote` is invalid/undefined (not `"`, `'`, or empty string)
|
|
1326
|
-
// Set a safe default based on the value’s content
|
|
1327
|
-
if (hasSingleQuote && !hasDoubleQuote) {
|
|
1328
|
-
attrQuote = '"'; // Value has single quotes, use double quotes as delimiter
|
|
1329
|
-
} else if (hasDoubleQuote && !hasSingleQuote) {
|
|
1330
|
-
attrQuote = "'"; // Value has double quotes, use single quotes as delimiter
|
|
1331
|
-
} else {
|
|
1332
|
-
attrQuote = '"'; // No quotes in value, default to double quotes
|
|
1333
|
-
}
|
|
1334
|
-
}
|
|
1335
|
-
} else {
|
|
1336
|
-
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
emittedAttrValue = attrQuote + attrValue + attrQuote;
|
|
1340
|
-
if (!isLast && !options.removeTagWhitespace) {
|
|
1341
|
-
emittedAttrValue += ' ';
|
|
1342
|
-
}
|
|
1343
|
-
} else if (isLast && !hasUnarySlash) {
|
|
1344
|
-
// Last attribute in a non-self-closing tag: no space needed
|
|
1345
|
-
emittedAttrValue = attrValue;
|
|
1346
|
-
} else {
|
|
1347
|
-
// Not last attribute, or is a self-closing tag: add space
|
|
1348
|
-
emittedAttrValue = attrValue + ' ';
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
if (typeof attrValue === 'undefined' || (options.collapseBooleanAttributes &&
|
|
1352
|
-
isBooleanAttribute(attrName.toLowerCase(), attrValue.toLowerCase()))) {
|
|
1353
|
-
attrFragment = attrName;
|
|
1354
|
-
if (!isLast) {
|
|
1355
|
-
attrFragment += ' ';
|
|
1356
|
-
}
|
|
1357
|
-
} else {
|
|
1358
|
-
attrFragment = attrName + attr.customAssign + emittedAttrValue;
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
return attr.customOpen + attrFragment + attr.customClose;
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
function identity(value) {
|
|
1365
|
-
return value;
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
function identityAsync(value) {
|
|
1369
|
-
return Promise.resolve(value);
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
function shouldMinifyInnerHTML(options) {
|
|
1373
|
-
return Boolean(
|
|
1374
|
-
options.collapseWhitespace ||
|
|
1375
|
-
options.removeComments ||
|
|
1376
|
-
options.removeOptionalTags ||
|
|
1377
|
-
options.minifyJS !== identity ||
|
|
1378
|
-
options.minifyCSS !== identityAsync ||
|
|
1379
|
-
options.minifyURLs !== identity
|
|
1380
|
-
);
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
/**
|
|
1384
|
-
* @param {Partial<MinifierOptions>} inputOptions - User-provided options
|
|
1385
|
-
* @returns {MinifierOptions} Normalized options with defaults applied
|
|
1386
|
-
*/
|
|
1387
|
-
const processOptions = (inputOptions) => {
|
|
1388
|
-
const options = {
|
|
1389
|
-
name: function (name) {
|
|
1390
|
-
return name.toLowerCase();
|
|
1391
|
-
},
|
|
1392
|
-
canCollapseWhitespace,
|
|
1393
|
-
canTrimWhitespace,
|
|
1394
|
-
continueOnMinifyError: true,
|
|
1395
|
-
html5: true,
|
|
1396
|
-
ignoreCustomComments: [
|
|
1397
|
-
/^!/,
|
|
1398
|
-
/^\s*#/
|
|
1399
|
-
],
|
|
1400
|
-
ignoreCustomFragments: [
|
|
1401
|
-
/<%[\s\S]*?%>/,
|
|
1402
|
-
/<\?[\s\S]*?\?>/
|
|
1403
|
-
],
|
|
1404
|
-
includeAutoGeneratedTags: true,
|
|
1405
|
-
log: identity,
|
|
1406
|
-
minifyCSS: identityAsync,
|
|
1407
|
-
minifyJS: identity,
|
|
1408
|
-
minifyURLs: identity
|
|
1409
|
-
};
|
|
1410
|
-
|
|
1411
|
-
Object.keys(inputOptions).forEach(function (key) {
|
|
1412
|
-
const option = inputOptions[key];
|
|
1413
|
-
|
|
1414
|
-
if (key === 'caseSensitive') {
|
|
1415
|
-
if (option) {
|
|
1416
|
-
options.name = identity;
|
|
1417
|
-
}
|
|
1418
|
-
} else if (key === 'log') {
|
|
1419
|
-
if (typeof option === 'function') {
|
|
1420
|
-
options.log = option;
|
|
1421
|
-
}
|
|
1422
|
-
} else if (key === 'minifyCSS' && typeof option !== 'function') {
|
|
1423
|
-
if (!option) {
|
|
1424
|
-
return;
|
|
1425
|
-
}
|
|
1426
|
-
|
|
1427
|
-
const lightningCssOptions = typeof option === 'object' ? option : {};
|
|
1428
|
-
|
|
1429
|
-
options.minifyCSS = async function (text, type) {
|
|
1430
|
-
// Fast path: Nothing to minify
|
|
1431
|
-
if (!text || !text.trim()) {
|
|
1432
|
-
return text;
|
|
1433
|
-
}
|
|
1434
|
-
text = await replaceAsync(
|
|
1435
|
-
text,
|
|
1436
|
-
/(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
|
|
1437
|
-
async function (match, prefix, dq, sq, unq, suffix) {
|
|
1438
|
-
const quote = dq != null ? '"' : (sq != null ? "'" : '');
|
|
1439
|
-
const url = dq ?? sq ?? unq ?? '';
|
|
1440
|
-
try {
|
|
1441
|
-
const out = await options.minifyURLs(url);
|
|
1442
|
-
return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
|
|
1443
|
-
} catch (err) {
|
|
1444
|
-
if (!options.continueOnMinifyError) {
|
|
1445
|
-
throw err;
|
|
1446
|
-
}
|
|
1447
|
-
options.log && options.log(err);
|
|
1448
|
-
return match;
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
);
|
|
1452
|
-
// Cache key: wrapped content, type, options signature
|
|
1453
|
-
const inputCSS = wrapCSS(text, type);
|
|
1454
|
-
const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
|
|
1455
|
-
// For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
|
|
1456
|
-
const cssKey = inputCSS.length > 2048
|
|
1457
|
-
? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
|
|
1458
|
-
: (inputCSS + '|' + type + '|' + cssSig);
|
|
1459
|
-
|
|
1460
|
-
try {
|
|
1461
|
-
const cached = cssMinifyCache.get(cssKey);
|
|
1462
|
-
if (cached) {
|
|
1463
|
-
return cached;
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
const transformCSS = await getLightningCSS();
|
|
1467
|
-
const result = transformCSS({
|
|
1468
|
-
filename: 'input.css',
|
|
1469
|
-
code: Buffer.from(inputCSS),
|
|
1470
|
-
minify: true,
|
|
1471
|
-
errorRecovery: !!options.continueOnMinifyError,
|
|
1472
|
-
...lightningCssOptions
|
|
1473
|
-
});
|
|
1474
|
-
|
|
1475
|
-
const outputCSS = unwrapCSS(result.code.toString(), type);
|
|
1476
|
-
|
|
1477
|
-
// If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
|
|
1478
|
-
// This preserves:
|
|
1479
|
-
// 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
|
|
1480
|
-
// 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
|
|
1481
|
-
// CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
|
|
1482
|
-
const isCDATA = text.includes('<![CDATA[');
|
|
1483
|
-
const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
|
|
1484
|
-
const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
|
|
1485
|
-
const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
|
|
1486
|
-
|
|
1487
|
-
// Preserve if output is empty and input had template syntax or UIDs
|
|
1488
|
-
// This catches cases where Lightning CSS removed content that should be preserved
|
|
1489
|
-
const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
|
|
1490
|
-
|
|
1491
|
-
cssMinifyCache.set(cssKey, finalOutput);
|
|
1492
|
-
return finalOutput;
|
|
1493
|
-
} catch (err) {
|
|
1494
|
-
cssMinifyCache.delete(cssKey);
|
|
1495
|
-
if (!options.continueOnMinifyError) {
|
|
1496
|
-
throw err;
|
|
1497
|
-
}
|
|
1498
|
-
options.log && options.log(err);
|
|
1499
|
-
return text;
|
|
1500
|
-
}
|
|
1501
|
-
};
|
|
1502
|
-
} else if (key === 'minifyJS' && typeof option !== 'function') {
|
|
1503
|
-
if (!option) {
|
|
1504
|
-
return;
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
const terserOptions = typeof option === 'object' ? option : {};
|
|
1508
|
-
|
|
1509
|
-
terserOptions.parse = {
|
|
1510
|
-
...terserOptions.parse,
|
|
1511
|
-
bare_returns: false
|
|
1512
|
-
};
|
|
1513
|
-
|
|
1514
|
-
options.minifyJS = async function (text, inline) {
|
|
1515
|
-
const start = text.match(/^\s*<!--.*/);
|
|
1516
|
-
const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
|
|
1517
|
-
|
|
1518
|
-
terserOptions.parse.bare_returns = inline;
|
|
1519
|
-
|
|
1520
|
-
let jsKey;
|
|
1521
|
-
try {
|
|
1522
|
-
// Fast path: Avoid invoking Terser for empty/whitespace-only content
|
|
1523
|
-
if (!code || !code.trim()) {
|
|
1524
|
-
return '';
|
|
1525
|
-
}
|
|
1526
|
-
// Cache key: content, inline, options signature (subset)
|
|
1527
|
-
const terserSig = stableStringify({
|
|
1528
|
-
compress: terserOptions.compress,
|
|
1529
|
-
mangle: terserOptions.mangle,
|
|
1530
|
-
ecma: terserOptions.ecma,
|
|
1531
|
-
toplevel: terserOptions.toplevel,
|
|
1532
|
-
module: terserOptions.module,
|
|
1533
|
-
keep_fnames: terserOptions.keep_fnames,
|
|
1534
|
-
format: terserOptions.format,
|
|
1535
|
-
cont: !!options.continueOnMinifyError,
|
|
1536
|
-
});
|
|
1537
|
-
// For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
|
|
1538
|
-
jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
|
|
1539
|
-
const cached = jsMinifyCache.get(jsKey);
|
|
1540
|
-
if (cached) {
|
|
1541
|
-
return await cached;
|
|
1542
|
-
}
|
|
1543
|
-
const inFlight = (async () => {
|
|
1544
|
-
const terser = await getTerser();
|
|
1545
|
-
const result = await terser(code, terserOptions);
|
|
1546
|
-
return result.code.replace(RE_TRAILING_SEMICOLON, '');
|
|
1547
|
-
})();
|
|
1548
|
-
jsMinifyCache.set(jsKey, inFlight);
|
|
1549
|
-
const resolved = await inFlight;
|
|
1550
|
-
jsMinifyCache.set(jsKey, resolved);
|
|
1551
|
-
return resolved;
|
|
1552
|
-
} catch (err) {
|
|
1553
|
-
if (jsKey) jsMinifyCache.delete(jsKey);
|
|
1554
|
-
if (!options.continueOnMinifyError) {
|
|
1555
|
-
throw err;
|
|
1556
|
-
}
|
|
1557
|
-
options.log && options.log(err);
|
|
1558
|
-
return text;
|
|
1559
|
-
}
|
|
1560
|
-
};
|
|
1561
|
-
} else if (key === 'minifyURLs' && typeof option !== 'function') {
|
|
1562
|
-
if (!option) {
|
|
1563
|
-
return;
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
let relateUrlOptions = option;
|
|
1567
|
-
|
|
1568
|
-
if (typeof option === 'string') {
|
|
1569
|
-
relateUrlOptions = { site: option };
|
|
1570
|
-
} else if (typeof option !== 'object') {
|
|
1571
|
-
relateUrlOptions = {};
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
// Cache RelateURL instance for reuse (expensive to create)
|
|
1575
|
-
const relateUrlInstance = new RelateURL(relateUrlOptions.site || '', relateUrlOptions);
|
|
1576
|
-
|
|
1577
|
-
options.minifyURLs = function (text) {
|
|
1578
|
-
// Fast-path: Skip if text doesn’t look like a URL that needs processing
|
|
1579
|
-
// Only process if contains URL-like characters (`/`, `:`, `#`, `?`) or spaces that need encoding
|
|
1580
|
-
if (!/[/:?#\s]/.test(text)) {
|
|
1581
|
-
return text;
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
try {
|
|
1585
|
-
return relateUrlInstance.relate(text);
|
|
1586
|
-
} catch (err) {
|
|
1587
|
-
if (!options.continueOnMinifyError) {
|
|
1588
|
-
throw err;
|
|
1589
|
-
}
|
|
1590
|
-
options.log && options.log(err);
|
|
1591
|
-
return text;
|
|
1592
|
-
}
|
|
1593
|
-
};
|
|
1594
|
-
} else {
|
|
1595
|
-
options[key] = option;
|
|
1596
|
-
}
|
|
1597
|
-
});
|
|
1598
|
-
return options;
|
|
1599
|
-
};
|
|
1600
|
-
|
|
1601
|
-
function uniqueId(value) {
|
|
1602
|
-
let id;
|
|
1603
|
-
do {
|
|
1604
|
-
id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
|
|
1605
|
-
} while (~value.indexOf(id));
|
|
1606
|
-
return id;
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
const specialContentTags = new Set(['script', 'style']);
|
|
1610
|
-
|
|
1611
464
|
async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
|
|
1612
465
|
const attrChains = options.sortAttributes && Object.create(null);
|
|
1613
466
|
const classChain = options.sortClassName && new TokenChain();
|
|
@@ -1679,8 +532,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
1679
532
|
try {
|
|
1680
533
|
await parser.parse();
|
|
1681
534
|
} catch (err) {
|
|
1682
|
-
// If parsing fails during analysis pass, just skip it—we’ll still have
|
|
1683
|
-
// partial frequency data from what we could parse
|
|
535
|
+
// If parsing fails during analysis pass, just skip it—we’ll still have partial frequency data from what we could parse
|
|
1684
536
|
if (!options.continueOnParseError) {
|
|
1685
537
|
throw err;
|
|
1686
538
|
}
|
|
@@ -1689,7 +541,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
1689
541
|
|
|
1690
542
|
// For the first pass, create a copy of options and disable aggressive minification.
|
|
1691
543
|
// Keep attribute transformations (like `removeStyleLinkTypeAttributes`) for accurate analysis.
|
|
1692
|
-
// This is safe because `createSortFns` is called before custom fragment UID markers (uidAttr) are added.
|
|
544
|
+
// This is safe because `createSortFns` is called before custom fragment UID markers (`uidAttr`) are added.
|
|
1693
545
|
// Note: `htmlmin:ignore` UID markers (uidIgnore) already exist and are expanded for analysis.
|
|
1694
546
|
const firstPassOptions = Object.assign({}, options, {
|
|
1695
547
|
// Disable sorting for the analysis pass
|
|
@@ -1732,8 +584,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
1732
584
|
uidReplacePattern.lastIndex = 0;
|
|
1733
585
|
}
|
|
1734
586
|
|
|
1735
|
-
// First pass minification applies attribute transformations
|
|
1736
|
-
// like removeStyleLinkTypeAttributes for accurate frequency analysis
|
|
587
|
+
// First pass minification applies attribute transformations like `removeStyleLinkTypeAttributes` for accurate frequency analysis
|
|
1737
588
|
const firstPassOutput = await minifyHTML(expandedValue, firstPassOptions);
|
|
1738
589
|
|
|
1739
590
|
// For frequency analysis, we need to remove custom fragments temporarily
|
|
@@ -1754,16 +605,30 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
1754
605
|
for (const tag in attrChains) {
|
|
1755
606
|
attrSorters[tag] = attrChains[tag].createSorter();
|
|
1756
607
|
}
|
|
608
|
+
// Memoize sorted attribute orders—attribute sets often repeat in templates
|
|
609
|
+
const attrOrderCache = new LRU(200);
|
|
610
|
+
|
|
1757
611
|
options.sortAttributes = function (tag, attrs) {
|
|
1758
612
|
const sorter = attrSorters[tag];
|
|
1759
613
|
if (sorter) {
|
|
1760
|
-
const attrMap = Object.create(null);
|
|
1761
614
|
const names = attrNames(attrs);
|
|
615
|
+
|
|
616
|
+
// Create order-independent cache key from tag and sorted attribute names
|
|
617
|
+
const cacheKey = tag + ':' + names.slice().sort().join(',');
|
|
618
|
+
let sortedNames = attrOrderCache.get(cacheKey);
|
|
619
|
+
|
|
620
|
+
if (sortedNames === undefined) {
|
|
621
|
+
// Only sort if not in cache—need to clone names since sort mutates in place
|
|
622
|
+
sortedNames = sorter.sort(names.slice());
|
|
623
|
+
attrOrderCache.set(cacheKey, sortedNames);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Apply the sorted order to attrs
|
|
627
|
+
const attrMap = Object.create(null);
|
|
1762
628
|
names.forEach(function (name, index) {
|
|
1763
629
|
(attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
|
|
1764
630
|
});
|
|
1765
|
-
|
|
1766
|
-
sorted.forEach(function (name, index) {
|
|
631
|
+
sortedNames.forEach(function (name, index) {
|
|
1767
632
|
attrs[index] = attrMap[name].shift();
|
|
1768
633
|
});
|
|
1769
634
|
}
|
|
@@ -1864,10 +729,8 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1864
729
|
removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
|
|
1865
730
|
}
|
|
1866
731
|
|
|
1867
|
-
// Temporarily replace ignored chunks with comments,
|
|
1868
|
-
//
|
|
1869
|
-
// For all we care there might be
|
|
1870
|
-
// completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
|
|
732
|
+
// Temporarily replace ignored chunks with comments, so that we don’t have to worry what’s there.
|
|
733
|
+
// For all we care there might be completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
|
|
1871
734
|
value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
|
|
1872
735
|
if (!uidIgnore) {
|
|
1873
736
|
uidIgnore = uniqueId(value);
|
|
@@ -2075,7 +938,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
2075
938
|
|
|
2076
939
|
const parts = [];
|
|
2077
940
|
for (let i = attrs.length, isLast = true; --i >= 0;) {
|
|
2078
|
-
const normalized = await normalizeAttr(attrs[i], attrs, tag, options);
|
|
941
|
+
const normalized = await normalizeAttr(attrs[i], attrs, tag, options, minifyHTML);
|
|
2079
942
|
if (normalized) {
|
|
2080
943
|
parts.push(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
|
|
2081
944
|
isLast = false;
|
|
@@ -2150,7 +1013,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
2150
1013
|
}
|
|
2151
1014
|
|
|
2152
1015
|
if (!preserve) {
|
|
2153
|
-
// Remove last
|
|
1016
|
+
// Remove last element from buffer
|
|
2154
1017
|
removeStartTag();
|
|
2155
1018
|
optionalStartTag = '';
|
|
2156
1019
|
optionalEndTag = '';
|
|
@@ -2234,7 +1097,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
2234
1097
|
}
|
|
2235
1098
|
}
|
|
2236
1099
|
if (specialContentTags.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs))) {
|
|
2237
|
-
text = await processScript(text, options, currentAttrs);
|
|
1100
|
+
text = await processScript(text, options, currentAttrs, minifyHTML);
|
|
2238
1101
|
}
|
|
2239
1102
|
if (isExecutableScript(currentTag, currentAttrs)) {
|
|
2240
1103
|
text = await options.minifyJS(text);
|
|
@@ -2288,7 +1151,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
2288
1151
|
const prefix = nonStandard ? '<!' : '<!--';
|
|
2289
1152
|
const suffix = nonStandard ? '>' : '-->';
|
|
2290
1153
|
if (isConditionalComment(text)) {
|
|
2291
|
-
text = prefix + await cleanConditionalComment(text, options) + suffix;
|
|
1154
|
+
text = prefix + await cleanConditionalComment(text, options, minifyHTML) + suffix;
|
|
2292
1155
|
} else if (options.removeComments) {
|
|
2293
1156
|
if (isIgnoredComment(text, options)) {
|
|
2294
1157
|
text = '<!--' + text + '-->';
|
|
@@ -2475,7 +1338,12 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
|
|
|
2475
1338
|
*/
|
|
2476
1339
|
export const minify = async function (value, options) {
|
|
2477
1340
|
const start = Date.now();
|
|
2478
|
-
options = processOptions(options || {}
|
|
1341
|
+
options = processOptions(options || {}, {
|
|
1342
|
+
getLightningCSS,
|
|
1343
|
+
getTerser,
|
|
1344
|
+
cssMinifyCache,
|
|
1345
|
+
jsMinifyCache
|
|
1346
|
+
});
|
|
2479
1347
|
const result = await minifyHTML(value, options);
|
|
2480
1348
|
options.log('minified in: ' + (Date.now() - start) + 'ms');
|
|
2481
1349
|
return result;
|