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.
@@ -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,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, '&amp;$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, '&#34;');
1291
- } else {
1292
- attrValue = attrValue.replace(/'/g, '&#39;');
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, '&#34;');
1315
- } else {
1316
- attrValue = attrValue.replace(/'/g, '&#39;');
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
- const sorted = sorter.sort(names);
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
- // so that we don’t have to worry what’s there.
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 element from buffer
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;