html-minifier-next 4.12.2 → 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.
@@ -1,10 +1,61 @@
1
- import { decodeHTMLStrict, decodeHTML } from 'entities';
2
- import RelateURL from 'relateurl';
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,1212 +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
- '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, '&amp;$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, '&#34;');
1294
- } else {
1295
- attrValue = attrValue.replace(/'/g, '&#39;');
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, '&#34;');
1318
- } else {
1319
- attrValue = attrValue.replace(/'/g, '&#39;');
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
464
  async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
1615
465
  const attrChains = options.sortAttributes && Object.create(null);
1616
466
  const classChain = options.sortClassName && new TokenChain();
@@ -1682,8 +532,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
1682
532
  try {
1683
533
  await parser.parse();
1684
534
  } 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
535
+ // If parsing fails during analysis pass, just skip it—we’ll still have partial frequency data from what we could parse
1687
536
  if (!options.continueOnParseError) {
1688
537
  throw err;
1689
538
  }
@@ -1692,7 +541,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
1692
541
 
1693
542
  // For the first pass, create a copy of options and disable aggressive minification.
1694
543
  // Keep attribute transformations (like `removeStyleLinkTypeAttributes`) for accurate analysis.
1695
- // 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.
1696
545
  // Note: `htmlmin:ignore` UID markers (uidIgnore) already exist and are expanded for analysis.
1697
546
  const firstPassOptions = Object.assign({}, options, {
1698
547
  // Disable sorting for the analysis pass
@@ -1735,8 +584,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
1735
584
  uidReplacePattern.lastIndex = 0;
1736
585
  }
1737
586
 
1738
- // First pass minification applies attribute transformations
1739
- // like removeStyleLinkTypeAttributes for accurate frequency analysis
587
+ // First pass minification applies attribute transformations like `removeStyleLinkTypeAttributes` for accurate frequency analysis
1740
588
  const firstPassOutput = await minifyHTML(expandedValue, firstPassOptions);
1741
589
 
1742
590
  // For frequency analysis, we need to remove custom fragments temporarily
@@ -1757,16 +605,30 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
1757
605
  for (const tag in attrChains) {
1758
606
  attrSorters[tag] = attrChains[tag].createSorter();
1759
607
  }
608
+ // Memoize sorted attribute orders—attribute sets often repeat in templates
609
+ const attrOrderCache = new LRU(200);
610
+
1760
611
  options.sortAttributes = function (tag, attrs) {
1761
612
  const sorter = attrSorters[tag];
1762
613
  if (sorter) {
1763
- const attrMap = Object.create(null);
1764
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);
1765
628
  names.forEach(function (name, index) {
1766
629
  (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
1767
630
  });
1768
- const sorted = sorter.sort(names);
1769
- sorted.forEach(function (name, index) {
631
+ sortedNames.forEach(function (name, index) {
1770
632
  attrs[index] = attrMap[name].shift();
1771
633
  });
1772
634
  }
@@ -1867,10 +729,8 @@ async function minifyHTML(value, options, partialMarkup) {
1867
729
  removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
1868
730
  }
1869
731
 
1870
- // Temporarily replace ignored chunks with comments,
1871
- // so that we don’t have to worry what’s there.
1872
- // For all we care there might be
1873
- // 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
1874
734
  value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
1875
735
  if (!uidIgnore) {
1876
736
  uidIgnore = uniqueId(value);
@@ -2078,7 +938,7 @@ async function minifyHTML(value, options, partialMarkup) {
2078
938
 
2079
939
  const parts = [];
2080
940
  for (let i = attrs.length, isLast = true; --i >= 0;) {
2081
- const normalized = await normalizeAttr(attrs[i], attrs, tag, options);
941
+ const normalized = await normalizeAttr(attrs[i], attrs, tag, options, minifyHTML);
2082
942
  if (normalized) {
2083
943
  parts.push(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
2084
944
  isLast = false;
@@ -2153,7 +1013,7 @@ async function minifyHTML(value, options, partialMarkup) {
2153
1013
  }
2154
1014
 
2155
1015
  if (!preserve) {
2156
- // Remove last element from buffer
1016
+ // Remove last element from buffer
2157
1017
  removeStartTag();
2158
1018
  optionalStartTag = '';
2159
1019
  optionalEndTag = '';
@@ -2237,7 +1097,7 @@ async function minifyHTML(value, options, partialMarkup) {
2237
1097
  }
2238
1098
  }
2239
1099
  if (specialContentTags.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs))) {
2240
- text = await processScript(text, options, currentAttrs);
1100
+ text = await processScript(text, options, currentAttrs, minifyHTML);
2241
1101
  }
2242
1102
  if (isExecutableScript(currentTag, currentAttrs)) {
2243
1103
  text = await options.minifyJS(text);
@@ -2291,7 +1151,7 @@ async function minifyHTML(value, options, partialMarkup) {
2291
1151
  const prefix = nonStandard ? '<!' : '<!--';
2292
1152
  const suffix = nonStandard ? '>' : '-->';
2293
1153
  if (isConditionalComment(text)) {
2294
- text = prefix + await cleanConditionalComment(text, options) + suffix;
1154
+ text = prefix + await cleanConditionalComment(text, options, minifyHTML) + suffix;
2295
1155
  } else if (options.removeComments) {
2296
1156
  if (isIgnoredComment(text, options)) {
2297
1157
  text = '<!--' + text + '-->';
@@ -2478,7 +1338,12 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
2478
1338
  */
2479
1339
  export const minify = async function (value, options) {
2480
1340
  const start = Date.now();
2481
- options = processOptions(options || {});
1341
+ options = processOptions(options || {}, {
1342
+ getLightningCSS,
1343
+ getTerser,
1344
+ cssMinifyCache,
1345
+ jsMinifyCache
1346
+ });
2482
1347
  const result = await minifyHTML(value, options);
2483
1348
  options.log('minified in: ' + (Date.now() - start) + 'ms');
2484
1349
  return result;