html-minifier-next 4.8.0 → 4.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,10 +2,8 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
- var lightningcss = require('lightningcss');
6
5
  var entities = require('entities');
7
6
  var RelateURL = require('relateurl');
8
- var terser = require('terser');
9
7
 
10
8
  async function replaceAsync(str, regex, asyncFn) {
11
9
  const promises = [];
@@ -86,6 +84,14 @@ const nonPhrasing = new CaseInsensitiveSet(['address', 'article', 'aside', 'base
86
84
 
87
85
  const reCache = {};
88
86
 
87
+ // Pre-compiled regexes for common special elements (`script`, `style`, `noscript`)
88
+ // These are used frequently and pre-compiling them avoids regex creation overhead
89
+ const preCompiledStackedTags = {
90
+ 'script': /([\s\S]*?)<\/script[^>]*>/i,
91
+ 'style': /([\s\S]*?)<\/style[^>]*>/i,
92
+ 'noscript': /([\s\S]*?)<\/noscript[^>]*>/i
93
+ };
94
+
89
95
  function attrForHandler(handler) {
90
96
  let pattern = singleAttrIdentifier.source +
91
97
  '(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
@@ -234,7 +240,8 @@ class HTMLParser {
234
240
  prevTag = '';
235
241
  } else {
236
242
  const stackedTag = lastTag.toLowerCase();
237
- const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)</' + stackedTag + '[^>]*>', 'i'));
243
+ // Use pre-compiled regex for common tags (`script`, `style`, `noscript`) to avoid regex creation overhead
244
+ const reStackedTag = preCompiledStackedTags[stackedTag] || reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)</' + stackedTag + '[^>]*>', 'i'));
238
245
 
239
246
  html = await replaceAsync(html, reStackedTag, async (_, text) => {
240
247
  if (stackedTag !== 'script' && stackedTag !== 'style' && stackedTag !== 'noscript') {
@@ -704,1802 +711,37 @@ function getPresetNames() {
704
711
  return Object.keys(presets);
705
712
  }
706
713
 
707
- // Hoisted, reusable RegExp patterns and tiny helpers to avoid repeated allocations in hot paths
708
- const RE_WS_START = /^[ \n\r\t\f]+/;
709
- const RE_WS_END = /[ \n\r\t\f]+$/;
710
- const RE_ALL_WS_NBSP = /[ \n\r\t\f\xA0]+/g;
711
- const RE_NBSP_LEADING_GROUP = /(^|\xA0+)[^\xA0]+/g;
712
- const RE_NBSP_LEAD_GROUP = /(\xA0+)[^\xA0]+/g;
713
- const RE_NBSP_TRAILING_GROUP = /[^\xA0]+(\xA0+)/g;
714
- const RE_NBSP_TRAILING_STRIP = /[^\xA0]+$/;
715
- const RE_CONDITIONAL_COMMENT = /^\[if\s[^\]]+]|\[endif]$/;
716
- const RE_EVENT_ATTR_DEFAULT = /^on[a-z]{3,}$/;
717
- const RE_CAN_REMOVE_ATTR_QUOTES = /^[^ \t\n\f\r"'`=<>]+$/;
718
- const RE_TRAILING_SEMICOLON = /;$/;
719
- const RE_AMP_ENTITY = /&(#?[0-9a-zA-Z]+;)/g;
720
-
721
- // Tiny stable stringify for options signatures (sorted keys, shallow, nested objects)
722
- function stableStringify(obj) {
723
- if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
724
- if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
725
- const keys = Object.keys(obj).sort();
726
- let out = '{';
727
- for (let i = 0; i < keys.length; i++) {
728
- const k = keys[i];
729
- out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
730
- }
731
- return out + '}';
732
- }
733
-
734
- // Minimal LRU cache for strings and promises
735
- class LRU {
736
- constructor(limit = 200) {
737
- this.limit = limit;
738
- this.map = new Map();
739
- }
740
- get(key) {
741
- const v = this.map.get(key);
742
- if (v !== undefined) {
743
- this.map.delete(key);
744
- this.map.set(key, v);
745
- }
746
- return v;
747
- }
748
- set(key, value) {
749
- if (this.map.has(key)) this.map.delete(key);
750
- this.map.set(key, value);
751
- if (this.map.size > this.limit) {
752
- const first = this.map.keys().next().value;
753
- this.map.delete(first);
754
- }
755
- }
756
- delete(key) { this.map.delete(key); }
757
- }
758
-
759
- // Per-process caches
760
- const jsMinifyCache = new LRU(200);
761
- const cssMinifyCache = new LRU(200);
762
-
763
- const trimWhitespace = str => {
764
- if (!str) return str;
765
- // Fast path: if no whitespace at start or end, return early
766
- if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
767
- return str;
768
- }
769
- return str.replace(RE_WS_START, '').replace(RE_WS_END, '');
770
- };
771
-
772
- function collapseWhitespaceAll(str) {
773
- if (!str) return str;
774
- // Fast path: if there are no common whitespace characters, return early
775
- if (!/[ \n\r\t\f\xA0]/.test(str)) {
776
- return str;
777
- }
778
- // Non-breaking space is specifically handled inside the replacer function here:
779
- return str.replace(RE_ALL_WS_NBSP, function (spaces) {
780
- return spaces === '\t' ? '\t' : spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
781
- });
782
- }
783
-
784
- function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
785
- let lineBreakBefore = ''; let lineBreakAfter = '';
786
-
787
- if (!str) return str;
788
-
789
- if (options.preserveLineBreaks) {
790
- str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function () {
791
- lineBreakBefore = '\n';
792
- return '';
793
- }).replace(/[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*$/, function () {
794
- lineBreakAfter = '\n';
795
- return '';
796
- });
797
- }
798
-
799
- if (trimLeft) {
800
- // Non-breaking space is specifically handled inside the replacer function here:
801
- str = str.replace(/^[ \n\r\t\f\xA0]+/, function (spaces) {
802
- const conservative = !lineBreakBefore && options.conservativeCollapse;
803
- if (conservative && spaces === '\t') {
804
- return '\t';
805
- }
806
- return spaces.replace(/^[^\xA0]+/, '').replace(RE_NBSP_LEAD_GROUP, '$1 ') || (conservative ? ' ' : '');
807
- });
808
- }
809
-
810
- if (trimRight) {
811
- // Non-breaking space is specifically handled inside the replacer function here:
812
- str = str.replace(/[ \n\r\t\f\xA0]+$/, function (spaces) {
813
- const conservative = !lineBreakAfter && options.conservativeCollapse;
814
- if (conservative && spaces === '\t') {
815
- return '\t';
816
- }
817
- return spaces.replace(RE_NBSP_TRAILING_GROUP, ' $1').replace(RE_NBSP_TRAILING_STRIP, '') || (conservative ? ' ' : '');
818
- });
819
- }
820
-
821
- if (collapseAll) {
822
- // Strip non-space whitespace then compress spaces to one
823
- str = collapseWhitespaceAll(str);
824
- }
825
-
826
- return lineBreakBefore + str + lineBreakAfter;
827
- }
828
-
829
- // Non-empty elements that will maintain whitespace around them
830
- const inlineElementsToKeepWhitespaceAround = ['a', 'abbr', 'acronym', 'b', 'bdi', 'bdo', 'big', 'button', 'cite', 'code', 'del', 'dfn', 'em', 'font', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'mark', 'math', 'meter', 'nobr', 'object', 'output', 'progress', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'select', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'svg', 'textarea', 'time', 'tt', 'u', 'var', 'wbr'];
831
- // Non-empty elements that will maintain whitespace within them
832
- 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']);
833
- // Elements that will always maintain whitespace around them
834
- const inlineElementsToKeepWhitespace = new Set(['comment', 'img', 'input', 'wbr']);
835
-
836
- function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements, inlineTextSet) {
837
- let trimLeft = prevTag && !inlineElementsToKeepWhitespace.has(prevTag);
838
- if (trimLeft && !options.collapseInlineTagWhitespace) {
839
- trimLeft = prevTag.charAt(0) === '/' ? !inlineElements.has(prevTag.slice(1)) : !inlineTextSet.has(prevTag);
840
- }
841
- let trimRight = nextTag && !inlineElementsToKeepWhitespace.has(nextTag);
842
- if (trimRight && !options.collapseInlineTagWhitespace) {
843
- trimRight = nextTag.charAt(0) === '/' ? !inlineTextSet.has(nextTag.slice(1)) : !inlineElements.has(nextTag);
844
- }
845
- return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag);
846
- }
847
-
848
- function isConditionalComment(text) {
849
- return RE_CONDITIONAL_COMMENT.test(text);
850
- }
851
-
852
- function isIgnoredComment(text, options) {
853
- for (let i = 0, len = options.ignoreCustomComments.length; i < len; i++) {
854
- if (options.ignoreCustomComments[i].test(text)) {
855
- return true;
856
- }
857
- }
858
- return false;
859
- }
860
-
861
- function isEventAttribute(attrName, options) {
862
- const patterns = options.customEventAttributes;
863
- if (patterns) {
864
- for (let i = patterns.length; i--;) {
865
- if (patterns[i].test(attrName)) {
866
- return true;
867
- }
868
- }
869
- return false;
870
- }
871
- return RE_EVENT_ATTR_DEFAULT.test(attrName);
872
- }
873
-
874
- function canRemoveAttributeQuotes(value) {
875
- // https://mathiasbynens.be/notes/unquoted-attribute-values
876
- return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
877
- }
714
+ // Lazy-load heavy dependencies only when needed
878
715
 
879
- function attributesInclude(attributes, attribute) {
880
- for (let i = attributes.length; i--;) {
881
- if (attributes[i].name.toLowerCase() === attribute) {
882
- return true;
883
- }
716
+ let lightningCSSPromise;
717
+ async function getLightningCSS() {
718
+ if (!lightningCSSPromise) {
719
+ lightningCSSPromise = import('lightningcss').then(m => m.transform);
884
720
  }
885
- return false;
721
+ return lightningCSSPromise;
886
722
  }
887
723
 
888
- // Default attribute values (could apply to any element)
889
- const generalDefaults = {
890
- autocorrect: 'on',
891
- fetchpriority: 'auto',
892
- loading: 'eager',
893
- popovertargetaction: 'toggle'
894
- };
895
-
896
- // Tag-specific default attribute values
897
- const tagDefaults = {
898
- area: { shape: 'rect' },
899
- button: { type: 'submit' },
900
- form: {
901
- enctype: 'application/x-www-form-urlencoded',
902
- method: 'get'
903
- },
904
- html: { dir: 'ltr' },
905
- img: { decoding: 'auto' },
906
- input: {
907
- colorspace: 'limited-srgb',
908
- type: 'text'
909
- },
910
- marquee: {
911
- behavior: 'scroll',
912
- direction: 'left'
913
- },
914
- style: { media: 'all' },
915
- textarea: { wrap: 'soft' },
916
- track: { kind: 'subtitles' }
917
- };
918
-
919
- function isAttributeRedundant(tag, attrName, attrValue, attrs) {
920
- attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
921
-
922
- // Legacy attributes
923
- if (tag === 'script' && attrName === 'language' && attrValue === 'javascript') {
924
- return true;
925
- }
926
- if (tag === 'script' && attrName === 'charset' && !attributesInclude(attrs, 'src')) {
927
- return true;
928
- }
929
- if (tag === 'a' && attrName === 'name' && attributesInclude(attrs, 'id')) {
930
- return true;
931
- }
932
-
933
- // Check general defaults
934
- if (generalDefaults[attrName] === attrValue) {
935
- return true;
724
+ let terserPromise;
725
+ async function getTerser() {
726
+ if (!terserPromise) {
727
+ terserPromise = import('terser').then(m => m.minify);
936
728
  }
937
-
938
- // Check tag-specific defaults
939
- return tagDefaults[tag]?.[attrName] === attrValue;
729
+ return terserPromise;
940
730
  }
941
731
 
942
- // https://mathiasbynens.be/demo/javascript-mime-type
943
- // https://developer.mozilla.org/en/docs/Web/HTML/Element/script#attr-type
944
- const executableScriptsMimetypes = new Set([
945
- 'text/javascript',
946
- 'text/ecmascript',
947
- 'text/jscript',
948
- 'application/javascript',
949
- 'application/x-javascript',
950
- 'application/ecmascript',
951
- 'module'
952
- ]);
732
+ // Type definitions
953
733
 
954
- const keepScriptsMimetypes = new Set([
955
- 'module'
956
- ]);
957
-
958
- function isScriptTypeAttribute(attrValue = '') {
959
- attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
960
- return attrValue === '' || executableScriptsMimetypes.has(attrValue);
961
- }
962
-
963
- function keepScriptTypeAttribute(attrValue = '') {
964
- attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
965
- return keepScriptsMimetypes.has(attrValue);
966
- }
967
-
968
- function isExecutableScript(tag, attrs) {
969
- if (tag !== 'script') {
970
- return false;
971
- }
972
- for (let i = 0, len = attrs.length; i < len; i++) {
973
- const attrName = attrs[i].name.toLowerCase();
974
- if (attrName === 'type') {
975
- return isScriptTypeAttribute(attrs[i].value);
976
- }
977
- }
978
- return true;
979
- }
980
-
981
- function isStyleLinkTypeAttribute(attrValue = '') {
982
- attrValue = trimWhitespace(attrValue).toLowerCase();
983
- return attrValue === '' || attrValue === 'text/css';
984
- }
985
-
986
- function isStyleSheet(tag, attrs) {
987
- if (tag !== 'style') {
988
- return false;
989
- }
990
- for (let i = 0, len = attrs.length; i < len; i++) {
991
- const attrName = attrs[i].name.toLowerCase();
992
- if (attrName === 'type') {
993
- return isStyleLinkTypeAttribute(attrs[i].value);
994
- }
995
- }
996
- return true;
997
- }
998
-
999
- 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']);
1000
- const isBooleanValue = new Set(['true', 'false']);
1001
-
1002
- function isBooleanAttribute(attrName, attrValue) {
1003
- return isSimpleBoolean.has(attrName) || (attrName === 'draggable' && !isBooleanValue.has(attrValue));
1004
- }
1005
-
1006
- function isUriTypeAttribute(attrName, tag) {
1007
- return (
1008
- (/^(?:a|area|link|base)$/.test(tag) && attrName === 'href') ||
1009
- (tag === 'img' && /^(?:src|longdesc|usemap)$/.test(attrName)) ||
1010
- (tag === 'object' && /^(?:classid|codebase|data|usemap)$/.test(attrName)) ||
1011
- (tag === 'q' && attrName === 'cite') ||
1012
- (tag === 'blockquote' && attrName === 'cite') ||
1013
- ((tag === 'ins' || tag === 'del') && attrName === 'cite') ||
1014
- (tag === 'form' && attrName === 'action') ||
1015
- (tag === 'input' && (attrName === 'src' || attrName === 'usemap')) ||
1016
- (tag === 'head' && attrName === 'profile') ||
1017
- (tag === 'script' && (attrName === 'src' || attrName === 'for'))
1018
- );
1019
- }
1020
-
1021
- function isNumberTypeAttribute(attrName, tag) {
1022
- return (
1023
- (/^(?:a|area|object|button)$/.test(tag) && attrName === 'tabindex') ||
1024
- (tag === 'input' && (attrName === 'maxlength' || attrName === 'tabindex')) ||
1025
- (tag === 'select' && (attrName === 'size' || attrName === 'tabindex')) ||
1026
- (tag === 'textarea' && /^(?:rows|cols|tabindex)$/.test(attrName)) ||
1027
- (tag === 'colgroup' && attrName === 'span') ||
1028
- (tag === 'col' && attrName === 'span') ||
1029
- ((tag === 'th' || tag === 'td') && (attrName === 'rowspan' || attrName === 'colspan'))
1030
- );
1031
- }
1032
-
1033
- function isLinkType(tag, attrs, value) {
1034
- if (tag !== 'link') return false;
1035
- const needle = String(value).toLowerCase();
1036
- for (let i = 0; i < attrs.length; i++) {
1037
- if (attrs[i].name.toLowerCase() === 'rel') {
1038
- const tokens = String(attrs[i].value).toLowerCase().split(/\s+/);
1039
- if (tokens.includes(needle)) return true;
1040
- }
1041
- }
1042
- return false;
1043
- }
1044
-
1045
- function isMediaQuery(tag, attrs, attrName) {
1046
- return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleSheet(tag, attrs));
1047
- }
1048
-
1049
- const srcsetTags = new Set(['img', 'source']);
1050
-
1051
- function isSrcset(attrName, tag) {
1052
- return attrName === 'srcset' && srcsetTags.has(tag);
1053
- }
1054
-
1055
- async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
1056
- if (isEventAttribute(attrName, options)) {
1057
- attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
1058
- return options.minifyJS(attrValue, true);
1059
- } else if (attrName === 'class') {
1060
- attrValue = trimWhitespace(attrValue);
1061
- if (options.sortClassName) {
1062
- attrValue = options.sortClassName(attrValue);
1063
- } else {
1064
- attrValue = collapseWhitespaceAll(attrValue);
1065
- }
1066
- return attrValue;
1067
- } else if (isUriTypeAttribute(attrName, tag)) {
1068
- attrValue = trimWhitespace(attrValue);
1069
- if (isLinkType(tag, attrs, 'canonical')) {
1070
- return attrValue;
1071
- }
1072
- try {
1073
- const out = await options.minifyURLs(attrValue);
1074
- return typeof out === 'string' ? out : attrValue;
1075
- } catch (err) {
1076
- if (!options.continueOnMinifyError) {
1077
- throw err;
1078
- }
1079
- options.log && options.log(err);
1080
- return attrValue;
1081
- }
1082
- } else if (isNumberTypeAttribute(attrName, tag)) {
1083
- return trimWhitespace(attrValue);
1084
- } else if (attrName === 'style') {
1085
- attrValue = trimWhitespace(attrValue);
1086
- if (attrValue) {
1087
- if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
1088
- attrValue = attrValue.replace(/\s*;$/, ';');
1089
- }
1090
- attrValue = await options.minifyCSS(attrValue, 'inline');
1091
- }
1092
- return attrValue;
1093
- } else if (isSrcset(attrName, tag)) {
1094
- // https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
1095
- attrValue = (await Promise.all(trimWhitespace(attrValue).split(/\s+,\s*|\s*,\s+/).map(async function (candidate) {
1096
- let url = candidate;
1097
- let descriptor = '';
1098
- const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
1099
- if (match) {
1100
- url = url.slice(0, -match[0].length);
1101
- const num = +match[1].slice(0, -1);
1102
- const suffix = match[1].slice(-1);
1103
- if (num !== 1 || suffix !== 'x') {
1104
- descriptor = ' ' + num + suffix;
1105
- }
1106
- }
1107
- try {
1108
- const out = await options.minifyURLs(url);
1109
- return (typeof out === 'string' ? out : url) + descriptor;
1110
- } catch (err) {
1111
- if (!options.continueOnMinifyError) {
1112
- throw err;
1113
- }
1114
- options.log && options.log(err);
1115
- return url + descriptor;
1116
- }
1117
- }))).join(', ');
1118
- } else if (isMetaViewport(tag, attrs) && attrName === 'content') {
1119
- attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
1120
- // “0.90000” → “0.9”
1121
- // “1.0” → “1”
1122
- // “1.0001” → “1.0001” (unchanged)
1123
- return (+numString).toString();
1124
- });
1125
- } else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
1126
- return collapseWhitespaceAll(attrValue);
1127
- } else if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
1128
- attrValue = trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
1129
- } else if (tag === 'script' && attrName === 'type') {
1130
- attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
1131
- } else if (isMediaQuery(tag, attrs, attrName)) {
1132
- attrValue = trimWhitespace(attrValue);
1133
- return options.minifyCSS(attrValue, 'media');
1134
- } else if (tag === 'iframe' && attrName === 'srcdoc') {
1135
- // Recursively minify HTML content within srcdoc attribute
1136
- // Fast-path: skip if nothing would change
1137
- if (!shouldMinifyInnerHTML(options)) {
1138
- return attrValue;
1139
- }
1140
- return minifyHTMLSelf(attrValue, options, true);
1141
- }
1142
- return attrValue;
1143
- }
1144
-
1145
- function isMetaViewport(tag, attrs) {
1146
- if (tag !== 'meta') {
1147
- return false;
1148
- }
1149
- for (let i = 0, len = attrs.length; i < len; i++) {
1150
- if (attrs[i].name === 'name' && attrs[i].value === 'viewport') {
1151
- return true;
1152
- }
1153
- }
1154
- }
1155
-
1156
- function isContentSecurityPolicy(tag, attrs) {
1157
- if (tag !== 'meta') {
1158
- return false;
1159
- }
1160
- for (let i = 0, len = attrs.length; i < len; i++) {
1161
- if (attrs[i].name.toLowerCase() === 'http-equiv' && attrs[i].value.toLowerCase() === 'content-security-policy') {
1162
- return true;
1163
- }
1164
- }
1165
- }
1166
-
1167
- // Wrap CSS declarations for inline styles and media queries
1168
- // This ensures proper context for CSS minification
1169
- function wrapCSS(text, type) {
1170
- switch (type) {
1171
- case 'inline':
1172
- return '*{' + text + '}';
1173
- case 'media':
1174
- return '@media ' + text + '{a{top:0}}';
1175
- default:
1176
- return text;
1177
- }
1178
- }
1179
-
1180
- function unwrapCSS(text, type) {
1181
- let matches;
1182
- switch (type) {
1183
- case 'inline':
1184
- matches = text.match(/^\*\{([\s\S]*)\}$/);
1185
- break;
1186
- case 'media':
1187
- matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/);
1188
- break;
1189
- }
1190
- return matches ? matches[1] : text;
1191
- }
1192
-
1193
- async function cleanConditionalComment(comment, options) {
1194
- return options.processConditionalComments
1195
- ? await replaceAsync(comment, /^(\[if\s[^\]]+]>)([\s\S]*?)(<!\[endif])$/, async function (match, prefix, text, suffix) {
1196
- return prefix + await minifyHTML(text, options, true) + suffix;
1197
- })
1198
- : comment;
1199
- }
1200
-
1201
- const jsonScriptTypes = new Set([
1202
- 'application/json',
1203
- 'application/ld+json',
1204
- 'application/manifest+json',
1205
- 'application/vnd.geo+json',
1206
- 'importmap',
1207
- 'speculationrules',
1208
- ]);
1209
-
1210
- function minifyJson(text, options) {
1211
- try {
1212
- return JSON.stringify(JSON.parse(text));
1213
- }
1214
- catch (err) {
1215
- if (!options.continueOnMinifyError) {
1216
- throw err;
1217
- }
1218
- options.log && options.log(err);
1219
- return text;
1220
- }
1221
- }
1222
-
1223
- function hasJsonScriptType(attrs) {
1224
- for (let i = 0, len = attrs.length; i < len; i++) {
1225
- const attrName = attrs[i].name.toLowerCase();
1226
- if (attrName === 'type') {
1227
- const attrValue = trimWhitespace((attrs[i].value || '').split(/;/, 2)[0]).toLowerCase();
1228
- if (jsonScriptTypes.has(attrValue)) {
1229
- return true;
1230
- }
1231
- }
1232
- }
1233
- return false;
1234
- }
1235
-
1236
- async function processScript(text, options, currentAttrs) {
1237
- for (let i = 0, len = currentAttrs.length; i < len; i++) {
1238
- const attrName = currentAttrs[i].name.toLowerCase();
1239
- if (attrName === 'type') {
1240
- const rawValue = currentAttrs[i].value;
1241
- const normalizedValue = trimWhitespace((rawValue || '').split(/;/, 2)[0]).toLowerCase();
1242
- // Minify JSON script types automatically
1243
- if (jsonScriptTypes.has(normalizedValue)) {
1244
- return minifyJson(text, options);
1245
- }
1246
- // Process custom script types if specified
1247
- if (options.processScripts && options.processScripts.indexOf(rawValue) > -1) {
1248
- return await minifyHTML(text, options);
1249
- }
1250
- }
1251
- }
1252
- return text;
1253
- }
1254
-
1255
- // Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags with the following extensions:
1256
- // - retain <body> if followed by <noscript>
1257
- // - <rb>, <rt>, <rtc>, <rp> follow HTML Ruby Markup Extensions draft (https://www.w3.org/TR/html-ruby-extensions/)
1258
- // - retain all tags which are adjacent to non-standard HTML tags
1259
- const optionalStartTags = new Set(['html', 'head', 'body', 'colgroup', 'tbody']);
1260
- 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']);
1261
- const headerTags = new Set(['meta', 'link', 'script', 'style', 'template', 'noscript']);
1262
- const descriptionTags = new Set(['dt', 'dd']);
1263
- 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']);
1264
- const pInlineTags = new Set(['a', 'audio', 'del', 'ins', 'map', 'noscript', 'video']);
1265
- const rubyEndTagOmission = new Set(['rb', 'rt', 'rtc', 'rp']); // </rb>, </rt>, </rp> can be omitted if followed by <rb>, <rt>, <rtc>, or <rp>
1266
- const rubyRtcEndTagOmission = new Set(['rb', 'rtc']); // </rtc> can be omitted if followed by <rb> or <rtc> (not <rt> or <rp>)
1267
- const optionTag = new Set(['option', 'optgroup']);
1268
- const tableContentTags = new Set(['tbody', 'tfoot']);
1269
- const tableSectionTags = new Set(['thead', 'tbody', 'tfoot']);
1270
- const cellTags = new Set(['td', 'th']);
1271
- const topLevelTags = new Set(['html', 'head', 'body']);
1272
- const compactTags = new Set(['html', 'body']);
1273
- const looseTags = new Set(['head', 'colgroup', 'caption']);
1274
- const trailingTags = new Set(['dt', 'thead']);
1275
- 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']);
1276
-
1277
- function canRemoveParentTag(optionalStartTag, tag) {
1278
- switch (optionalStartTag) {
1279
- case 'html':
1280
- case 'head':
1281
- return true;
1282
- case 'body':
1283
- return !headerTags.has(tag);
1284
- case 'colgroup':
1285
- return tag === 'col';
1286
- case 'tbody':
1287
- return tag === 'tr';
1288
- }
1289
- return false;
1290
- }
1291
-
1292
- function isStartTagMandatory(optionalEndTag, tag) {
1293
- switch (tag) {
1294
- case 'colgroup':
1295
- return optionalEndTag === 'colgroup';
1296
- case 'tbody':
1297
- return tableSectionTags.has(optionalEndTag);
1298
- }
1299
- return false;
1300
- }
1301
-
1302
- function canRemovePrecedingTag(optionalEndTag, tag) {
1303
- switch (optionalEndTag) {
1304
- case 'html':
1305
- case 'head':
1306
- case 'body':
1307
- case 'colgroup':
1308
- case 'caption':
1309
- return true;
1310
- case 'li':
1311
- case 'optgroup':
1312
- case 'tr':
1313
- return tag === optionalEndTag;
1314
- case 'dt':
1315
- case 'dd':
1316
- return descriptionTags.has(tag);
1317
- case 'p':
1318
- return pBlockTags.has(tag);
1319
- case 'rb':
1320
- case 'rt':
1321
- case 'rp':
1322
- return rubyEndTagOmission.has(tag);
1323
- case 'rtc':
1324
- return rubyRtcEndTagOmission.has(tag);
1325
- case 'option':
1326
- return optionTag.has(tag);
1327
- case 'thead':
1328
- case 'tbody':
1329
- return tableContentTags.has(tag);
1330
- case 'tfoot':
1331
- return tag === 'tbody';
1332
- case 'td':
1333
- case 'th':
1334
- return cellTags.has(tag);
1335
- }
1336
- return false;
1337
- }
1338
-
1339
- const reEmptyAttribute = new RegExp(
1340
- '^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
1341
- '?:down|up|over|move|out)|key(?:press|down|up)))$');
1342
-
1343
- function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
1344
- const isValueEmpty = !attrValue || /^\s*$/.test(attrValue);
1345
- if (!isValueEmpty) {
1346
- return false;
1347
- }
1348
- if (typeof options.removeEmptyAttributes === 'function') {
1349
- return options.removeEmptyAttributes(attrName, tag);
1350
- }
1351
- return (tag === 'input' && attrName === 'value') || reEmptyAttribute.test(attrName);
1352
- }
1353
-
1354
- function hasAttrName(name, attrs) {
1355
- for (let i = attrs.length - 1; i >= 0; i--) {
1356
- if (attrs[i].name === name) {
1357
- return true;
1358
- }
1359
- }
1360
- return false;
1361
- }
1362
-
1363
- function canRemoveElement(tag, attrs) {
1364
- switch (tag) {
1365
- case 'textarea':
1366
- return false;
1367
- case 'audio':
1368
- case 'script':
1369
- case 'video':
1370
- if (hasAttrName('src', attrs)) {
1371
- return false;
1372
- }
1373
- break;
1374
- case 'iframe':
1375
- if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
1376
- return false;
1377
- }
1378
- break;
1379
- case 'object':
1380
- if (hasAttrName('data', attrs)) {
1381
- return false;
1382
- }
1383
- break;
1384
- case 'applet':
1385
- if (hasAttrName('code', attrs)) {
1386
- return false;
1387
- }
1388
- break;
1389
- }
1390
- return true;
1391
- }
1392
-
1393
- function parseElementSpec(str, options) {
1394
- if (typeof str !== 'string') {
1395
- return null;
1396
- }
1397
-
1398
- const trimmed = str.trim();
1399
- if (!trimmed) {
1400
- return null;
1401
- }
1402
-
1403
- // Simple tag name: “td”
1404
- if (!/[<>]/.test(trimmed)) {
1405
- return { tag: options.name(trimmed), attrs: null };
1406
- }
1407
-
1408
- // HTML-like markup: “<span aria-hidden='true'>” or “<td></td>”
1409
- // Extract opening tag using regex
1410
- const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
1411
- if (!match) {
1412
- return null;
1413
- }
1414
-
1415
- const tag = options.name(match[1]);
1416
- const attrString = match[2];
1417
-
1418
- if (!attrString.trim()) {
1419
- return { tag, attrs: null };
1420
- }
1421
-
1422
- // Parse attributes from string
1423
- const attrs = {};
1424
- const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
1425
- let attrMatch;
1426
-
1427
- while ((attrMatch = attrRegex.exec(attrString))) {
1428
- const attrName = options.name(attrMatch[1]);
1429
- const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
1430
- // Boolean attributes have no value (undefined)
1431
- attrs[attrName] = attrValue;
1432
- }
1433
-
1434
- return {
1435
- tag,
1436
- attrs: Object.keys(attrs).length > 0 ? attrs : null
1437
- };
1438
- }
1439
-
1440
- function parseRemoveEmptyElementsExcept(input, options) {
1441
- if (!Array.isArray(input)) {
1442
- return [];
1443
- }
1444
-
1445
- return input.map(item => {
1446
- if (typeof item === 'string') {
1447
- const spec = parseElementSpec(item, options);
1448
- if (!spec && options.log) {
1449
- options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
1450
- }
1451
- return spec;
1452
- }
1453
- if (options.log) {
1454
- options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
1455
- }
1456
- return null;
1457
- }).filter(Boolean);
1458
- }
1459
-
1460
- function shouldPreserveEmptyElement(tag, attrs, preserveList) {
1461
- for (const spec of preserveList) {
1462
- // Tag name must match
1463
- if (spec.tag !== tag) {
1464
- continue;
1465
- }
1466
-
1467
- // If no attributes specified in spec, tag match is enough
1468
- if (!spec.attrs) {
1469
- return true;
1470
- }
1471
-
1472
- // Check if all specified attributes match
1473
- const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
1474
- const attr = attrs.find(a => a.name === name);
1475
- if (!attr) {
1476
- return false; // Attribute not present
1477
- }
1478
- // Boolean attribute in spec (undefined value) matches if attribute is present
1479
- if (value === undefined) {
1480
- return true;
1481
- }
1482
- // Valued attribute must match exactly
1483
- return attr.value === value;
1484
- });
1485
-
1486
- if (allAttrsMatch) {
1487
- return true;
1488
- }
1489
- }
1490
-
1491
- return false;
1492
- }
1493
-
1494
- function canCollapseWhitespace(tag) {
1495
- return !/^(?:script|style|pre|textarea)$/.test(tag);
1496
- }
1497
-
1498
- function canTrimWhitespace(tag) {
1499
- return !/^(?:pre|textarea)$/.test(tag);
1500
- }
1501
-
1502
- async function normalizeAttr(attr, attrs, tag, options) {
1503
- const attrName = options.name(attr.name);
1504
- let attrValue = attr.value;
1505
-
1506
- if (options.decodeEntities && attrValue) {
1507
- // Fast path: only decode when entities are present
1508
- if (attrValue.indexOf('&') !== -1) {
1509
- attrValue = entities.decodeHTMLStrict(attrValue);
1510
- }
1511
- }
1512
-
1513
- if ((options.removeRedundantAttributes &&
1514
- isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
1515
- (options.removeScriptTypeAttributes && tag === 'script' &&
1516
- attrName === 'type' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue)) ||
1517
- (options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
1518
- attrName === 'type' && isStyleLinkTypeAttribute(attrValue))) {
1519
- return;
1520
- }
1521
-
1522
- if (attrValue) {
1523
- attrValue = await cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML);
1524
- }
1525
-
1526
- if (options.removeEmptyAttributes &&
1527
- canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
1528
- return;
1529
- }
1530
-
1531
- if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
1532
- attrValue = attrValue.replace(RE_AMP_ENTITY, '&amp;$1');
1533
- }
1534
-
1535
- return {
1536
- attr,
1537
- name: attrName,
1538
- value: attrValue
1539
- };
1540
- }
1541
-
1542
- function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
1543
- const attrName = normalized.name;
1544
- let attrValue = normalized.value;
1545
- const attr = normalized.attr;
1546
- let attrQuote = attr.quote;
1547
- let attrFragment;
1548
- let emittedAttrValue;
1549
-
1550
- if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
1551
- ~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
1552
- if (!options.preventAttributesEscaping) {
1553
- if (typeof options.quoteCharacter === 'undefined') {
1554
- const apos = (attrValue.match(/'/g) || []).length;
1555
- const quot = (attrValue.match(/"/g) || []).length;
1556
- attrQuote = apos < quot ? '\'' : '"';
1557
- } else {
1558
- attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
1559
- }
1560
- if (attrQuote === '"') {
1561
- attrValue = attrValue.replace(/"/g, '&#34;');
1562
- } else {
1563
- attrValue = attrValue.replace(/'/g, '&#39;');
1564
- }
1565
- }
1566
- emittedAttrValue = attrQuote + attrValue + attrQuote;
1567
- if (!isLast && !options.removeTagWhitespace) {
1568
- emittedAttrValue += ' ';
1569
- }
1570
- } else if (isLast && !hasUnarySlash && !/\/$/.test(attrValue)) {
1571
- // Make sure trailing slash is not interpreted as HTML self-closing tag
1572
- emittedAttrValue = attrValue;
1573
- } else {
1574
- emittedAttrValue = attrValue + ' ';
1575
- }
1576
-
1577
- if (typeof attrValue === 'undefined' || (options.collapseBooleanAttributes &&
1578
- isBooleanAttribute(attrName.toLowerCase(), attrValue.toLowerCase()))) {
1579
- attrFragment = attrName;
1580
- if (!isLast) {
1581
- attrFragment += ' ';
1582
- }
1583
- } else {
1584
- attrFragment = attrName + attr.customAssign + emittedAttrValue;
1585
- }
1586
-
1587
- return attr.customOpen + attrFragment + attr.customClose;
1588
- }
1589
-
1590
- function identity(value) {
1591
- return value;
1592
- }
1593
-
1594
- function identityAsync(value) {
1595
- return Promise.resolve(value);
1596
- }
1597
-
1598
- function shouldMinifyInnerHTML(options) {
1599
- return Boolean(
1600
- options.collapseWhitespace ||
1601
- options.removeComments ||
1602
- options.removeOptionalTags ||
1603
- options.minifyJS !== identity ||
1604
- options.minifyCSS !== identityAsync ||
1605
- options.minifyURLs !== identity
1606
- );
1607
- }
1608
-
1609
- const processOptions = (inputOptions) => {
1610
- const options = {
1611
- name: function (name) {
1612
- return name.toLowerCase();
1613
- },
1614
- canCollapseWhitespace,
1615
- canTrimWhitespace,
1616
- continueOnMinifyError: true,
1617
- html5: true,
1618
- ignoreCustomComments: [
1619
- /^!/,
1620
- /^\s*#/
1621
- ],
1622
- ignoreCustomFragments: [
1623
- /<%[\s\S]*?%>/,
1624
- /<\?[\s\S]*?\?>/
1625
- ],
1626
- includeAutoGeneratedTags: true,
1627
- log: identity,
1628
- minifyCSS: identityAsync,
1629
- minifyJS: identity,
1630
- minifyURLs: identity
1631
- };
1632
-
1633
- Object.keys(inputOptions).forEach(function (key) {
1634
- const option = inputOptions[key];
1635
-
1636
- if (key === 'caseSensitive') {
1637
- if (option) {
1638
- options.name = identity;
1639
- }
1640
- } else if (key === 'log') {
1641
- if (typeof option === 'function') {
1642
- options.log = option;
1643
- }
1644
- } else if (key === 'minifyCSS' && typeof option !== 'function') {
1645
- if (!option) {
1646
- return;
1647
- }
1648
-
1649
- const lightningCssOptions = typeof option === 'object' ? option : {};
1650
-
1651
- options.minifyCSS = async function (text, type) {
1652
- // Fast path: nothing to minify
1653
- if (!text || !text.trim()) {
1654
- return text;
1655
- }
1656
- text = await replaceAsync(
1657
- text,
1658
- /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
1659
- async function (match, prefix, dq, sq, unq, suffix) {
1660
- const quote = dq != null ? '"' : (sq != null ? "'" : '');
1661
- const url = dq ?? sq ?? unq ?? '';
1662
- try {
1663
- const out = await options.minifyURLs(url);
1664
- return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
1665
- } catch (err) {
1666
- if (!options.continueOnMinifyError) {
1667
- throw err;
1668
- }
1669
- options.log && options.log(err);
1670
- return match;
1671
- }
1672
- }
1673
- );
1674
- // Cache key: wrapped content, type, options signature
1675
- const inputCSS = wrapCSS(text, type);
1676
- const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
1677
- // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
1678
- const cssKey = inputCSS.length > 2048
1679
- ? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
1680
- : (inputCSS + '|' + type + '|' + cssSig);
1681
-
1682
- try {
1683
- const cached = cssMinifyCache.get(cssKey);
1684
- if (cached) {
1685
- return cached;
1686
- }
1687
-
1688
- const result = lightningcss.transform({
1689
- filename: 'input.css',
1690
- code: Buffer.from(inputCSS),
1691
- minify: true,
1692
- errorRecovery: !!options.continueOnMinifyError,
1693
- ...lightningCssOptions
1694
- });
1695
-
1696
- const outputCSS = unwrapCSS(result.code.toString(), type);
1697
-
1698
- // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
1699
- // This preserves:
1700
- // 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
1701
- // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
1702
- // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
1703
- const isCDATA = text.includes('<![CDATA[');
1704
- const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
1705
- const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
1706
- const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
1707
-
1708
- // Preserve if output is empty and input had template syntax or UIDs
1709
- // This catches cases where Lightning CSS removed content that should be preserved
1710
- const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
1711
-
1712
- cssMinifyCache.set(cssKey, finalOutput);
1713
- return finalOutput;
1714
- } catch (err) {
1715
- cssMinifyCache.delete(cssKey);
1716
- if (!options.continueOnMinifyError) {
1717
- throw err;
1718
- }
1719
- options.log && options.log(err);
1720
- return text;
1721
- }
1722
- };
1723
- } else if (key === 'minifyJS' && typeof option !== 'function') {
1724
- if (!option) {
1725
- return;
1726
- }
1727
-
1728
- const terserOptions = typeof option === 'object' ? option : {};
1729
-
1730
- terserOptions.parse = {
1731
- ...terserOptions.parse,
1732
- bare_returns: false
1733
- };
1734
-
1735
- options.minifyJS = async function (text, inline) {
1736
- const start = text.match(/^\s*<!--.*/);
1737
- const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
1738
-
1739
- terserOptions.parse.bare_returns = inline;
1740
-
1741
- let jsKey;
1742
- try {
1743
- // Fast path: avoid invoking Terser for empty/whitespace-only content
1744
- if (!code || !code.trim()) {
1745
- return '';
1746
- }
1747
- // Cache key: content, inline, options signature (subset)
1748
- const terserSig = stableStringify({
1749
- compress: terserOptions.compress,
1750
- mangle: terserOptions.mangle,
1751
- ecma: terserOptions.ecma,
1752
- toplevel: terserOptions.toplevel,
1753
- module: terserOptions.module,
1754
- keep_fnames: terserOptions.keep_fnames,
1755
- format: terserOptions.format,
1756
- cont: !!options.continueOnMinifyError,
1757
- });
1758
- // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
1759
- jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
1760
- const cached = jsMinifyCache.get(jsKey);
1761
- if (cached) {
1762
- return await cached;
1763
- }
1764
- const inFlight = (async () => {
1765
- const result = await terser.minify(code, terserOptions);
1766
- return result.code.replace(RE_TRAILING_SEMICOLON, '');
1767
- })();
1768
- jsMinifyCache.set(jsKey, inFlight);
1769
- const resolved = await inFlight;
1770
- jsMinifyCache.set(jsKey, resolved);
1771
- return resolved;
1772
- } catch (err) {
1773
- if (jsKey) jsMinifyCache.delete(jsKey);
1774
- if (!options.continueOnMinifyError) {
1775
- throw err;
1776
- }
1777
- options.log && options.log(err);
1778
- return text;
1779
- }
1780
- };
1781
- } else if (key === 'minifyURLs' && typeof option !== 'function') {
1782
- if (!option) {
1783
- return;
1784
- }
1785
-
1786
- let relateUrlOptions = option;
1787
-
1788
- if (typeof option === 'string') {
1789
- relateUrlOptions = { site: option };
1790
- } else if (typeof option !== 'object') {
1791
- relateUrlOptions = {};
1792
- }
1793
-
1794
- options.minifyURLs = function (text) {
1795
- try {
1796
- return RelateURL.relate(text, relateUrlOptions);
1797
- } catch (err) {
1798
- if (!options.continueOnMinifyError) {
1799
- throw err;
1800
- }
1801
- options.log && options.log(err);
1802
- return text;
1803
- }
1804
- };
1805
- } else {
1806
- options[key] = option;
1807
- }
1808
- });
1809
- return options;
1810
- };
1811
-
1812
- function uniqueId(value) {
1813
- let id;
1814
- do {
1815
- id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
1816
- } while (~value.indexOf(id));
1817
- return id;
1818
- }
1819
-
1820
- const specialContentTags = new Set(['script', 'style']);
1821
-
1822
- async function createSortFns(value, options, uidIgnore, uidAttr) {
1823
- const attrChains = options.sortAttributes && Object.create(null);
1824
- const classChain = options.sortClassName && new TokenChain();
1825
-
1826
- function attrNames(attrs) {
1827
- return attrs.map(function (attr) {
1828
- return options.name(attr.name);
1829
- });
1830
- }
1831
-
1832
- function shouldSkipUID(token, uid) {
1833
- return !uid || token.indexOf(uid) === -1;
1834
- }
1835
-
1836
- function shouldSkipUIDs(token) {
1837
- return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
1838
- }
1839
-
1840
- async function scan(input) {
1841
- let currentTag, currentType;
1842
- const parser = new HTMLParser(input, {
1843
- start: function (tag, attrs) {
1844
- if (attrChains) {
1845
- if (!attrChains[tag]) {
1846
- attrChains[tag] = new TokenChain();
1847
- }
1848
- attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
1849
- }
1850
- for (let i = 0, len = attrs.length; i < len; i++) {
1851
- const attr = attrs[i];
1852
- if (classChain && attr.value && options.name(attr.name) === 'class') {
1853
- classChain.add(trimWhitespace(attr.value).split(/[ \t\n\f\r]+/).filter(shouldSkipUIDs));
1854
- } else if (options.processScripts && attr.name.toLowerCase() === 'type') {
1855
- currentTag = tag;
1856
- currentType = attr.value;
1857
- }
1858
- }
1859
- },
1860
- end: function () {
1861
- currentTag = '';
1862
- },
1863
- chars: async function (text) {
1864
- // Only recursively scan HTML content, not JSON-LD or other non-HTML script types
1865
- // `scan()` is for analyzing HTML attribute order, not for parsing JSON
1866
- if (options.processScripts && specialContentTags.has(currentTag) &&
1867
- options.processScripts.indexOf(currentType) > -1 &&
1868
- currentType === 'text/html') {
1869
- await scan(text);
1870
- }
1871
- }
1872
- });
1873
-
1874
- await parser.parse();
1875
- }
1876
-
1877
- const log = options.log;
1878
- options.log = identity;
1879
- options.sortAttributes = false;
1880
- options.sortClassName = false;
1881
- const firstPassOutput = await minifyHTML(value, options);
1882
- await scan(firstPassOutput);
1883
- options.log = log;
1884
- if (attrChains) {
1885
- const attrSorters = Object.create(null);
1886
- for (const tag in attrChains) {
1887
- attrSorters[tag] = attrChains[tag].createSorter();
1888
- }
1889
- options.sortAttributes = function (tag, attrs) {
1890
- const sorter = attrSorters[tag];
1891
- if (sorter) {
1892
- const attrMap = Object.create(null);
1893
- const names = attrNames(attrs);
1894
- names.forEach(function (name, index) {
1895
- (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
1896
- });
1897
- sorter.sort(names).forEach(function (name, index) {
1898
- attrs[index] = attrMap[name].shift();
1899
- });
1900
- }
1901
- };
1902
- }
1903
- if (classChain) {
1904
- const sorter = classChain.createSorter();
1905
- options.sortClassName = function (value) {
1906
- return sorter.sort(value.split(/[ \n\f\r]+/)).join(' ');
1907
- };
1908
- }
1909
- }
1910
-
1911
- async function minifyHTML(value, options, partialMarkup) {
1912
- // Check input length limitation to prevent ReDoS attacks
1913
- if (options.maxInputLength && value.length > options.maxInputLength) {
1914
- throw new Error(`Input length (${value.length}) exceeds maximum allowed length (${options.maxInputLength})`);
1915
- }
1916
-
1917
- if (options.collapseWhitespace) {
1918
- value = collapseWhitespace(value, options, true, true);
1919
- }
1920
-
1921
- const buffer = [];
1922
- let charsPrevTag;
1923
- let currentChars = '';
1924
- let hasChars;
1925
- let currentTag = '';
1926
- let currentAttrs = [];
1927
- const stackNoTrimWhitespace = [];
1928
- const stackNoCollapseWhitespace = [];
1929
- let optionalStartTag = '';
1930
- let optionalEndTag = '';
1931
- const ignoredMarkupChunks = [];
1932
- const ignoredCustomMarkupChunks = [];
1933
- let uidIgnore;
1934
- let uidAttr;
1935
- let uidPattern;
1936
- // Create inline tags/text sets with custom elements
1937
- const customElementsInput = options.inlineCustomElements ?? [];
1938
- const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
1939
- const normalizedCustomElements = customElementsArr.map(name => options.name(name));
1940
- const inlineTextSet = new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements]);
1941
- const inlineElements = new Set([...inlineElementsToKeepWhitespaceAround, ...normalizedCustomElements]);
1942
-
1943
- // Parse `removeEmptyElementsExcept` option
1944
- let removeEmptyElementsExcept;
1945
- if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
1946
- if (options.log) {
1947
- options.log('Warning: "removeEmptyElementsExcept" option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
1948
- }
1949
- removeEmptyElementsExcept = [];
1950
- } else {
1951
- removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
1952
- }
1953
-
1954
- // Temporarily replace ignored chunks with comments,
1955
- // so that we don’t have to worry what’s there.
1956
- // For all we care there might be
1957
- // completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
1958
- value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
1959
- if (!uidIgnore) {
1960
- uidIgnore = uniqueId(value);
1961
- const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
1962
- if (options.ignoreCustomComments) {
1963
- options.ignoreCustomComments = options.ignoreCustomComments.slice();
1964
- } else {
1965
- options.ignoreCustomComments = [];
1966
- }
1967
- options.ignoreCustomComments.push(pattern);
1968
- }
1969
- const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
1970
- ignoredMarkupChunks.push(group1);
1971
- return token;
1972
- });
1973
-
1974
- const customFragments = options.ignoreCustomFragments.map(function (re) {
1975
- return re.source;
1976
- });
1977
- if (customFragments.length) {
1978
- // Warn about potential ReDoS if custom fragments use unlimited quantifiers
1979
- for (let i = 0; i < customFragments.length; i++) {
1980
- if (/[*+]/.test(customFragments[i])) {
1981
- options.log('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
1982
- break;
1983
- }
1984
- }
1985
-
1986
- // Safe approach: Use bounded quantifiers instead of unlimited ones to prevent ReDoS
1987
- const maxQuantifier = options.customFragmentQuantifierLimit || 200;
1988
- const whitespacePattern = `\\s{0,${maxQuantifier}}`;
1989
-
1990
- // Use bounded quantifiers to prevent ReDoS - this approach prevents exponential backtracking
1991
- const reCustomIgnore = new RegExp(
1992
- whitespacePattern + '(?:' + customFragments.join('|') + '){1,' + maxQuantifier + '}' + whitespacePattern,
1993
- 'g'
1994
- );
1995
- // Temporarily replace custom ignored fragments with unique attributes
1996
- value = value.replace(reCustomIgnore, function (match) {
1997
- if (!uidAttr) {
1998
- uidAttr = uniqueId(value);
1999
- uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
2000
-
2001
- if (options.minifyCSS) {
2002
- options.minifyCSS = (function (fn) {
2003
- return function (text, type) {
2004
- text = text.replace(uidPattern, function (match, prefix, index) {
2005
- const chunks = ignoredCustomMarkupChunks[+index];
2006
- return chunks[1] + uidAttr + index + uidAttr + chunks[2];
2007
- });
2008
-
2009
- return fn(text, type);
2010
- };
2011
- })(options.minifyCSS);
2012
- }
2013
-
2014
- if (options.minifyJS) {
2015
- options.minifyJS = (function (fn) {
2016
- return function (text, type) {
2017
- return fn(text.replace(uidPattern, function (match, prefix, index) {
2018
- const chunks = ignoredCustomMarkupChunks[+index];
2019
- return chunks[1] + uidAttr + index + uidAttr + chunks[2];
2020
- }), type);
2021
- };
2022
- })(options.minifyJS);
2023
- }
2024
- }
2025
-
2026
- const token = uidAttr + ignoredCustomMarkupChunks.length + uidAttr;
2027
- ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
2028
- return '\t' + token + '\t';
2029
- });
2030
- }
2031
-
2032
- if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
2033
- (options.sortClassName && typeof options.sortClassName !== 'function')) {
2034
- await createSortFns(value, options, uidIgnore, uidAttr);
2035
- }
2036
-
2037
- function _canCollapseWhitespace(tag, attrs) {
2038
- return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
2039
- }
2040
-
2041
- function _canTrimWhitespace(tag, attrs) {
2042
- return options.canTrimWhitespace(tag, attrs, canTrimWhitespace);
2043
- }
2044
-
2045
- function removeStartTag() {
2046
- let index = buffer.length - 1;
2047
- while (index > 0 && !/^<[^/!]/.test(buffer[index])) {
2048
- index--;
2049
- }
2050
- buffer.length = Math.max(0, index);
2051
- }
2052
-
2053
- function removeEndTag() {
2054
- let index = buffer.length - 1;
2055
- while (index > 0 && !/^<\//.test(buffer[index])) {
2056
- index--;
2057
- }
2058
- buffer.length = Math.max(0, index);
2059
- }
2060
-
2061
- // Look for trailing whitespaces, bypass any inline tags
2062
- function trimTrailingWhitespace(index, nextTag) {
2063
- for (let endTag = null; index >= 0 && _canTrimWhitespace(endTag); index--) {
2064
- const str = buffer[index];
2065
- const match = str.match(/^<\/([\w:-]+)>$/);
2066
- if (match) {
2067
- endTag = match[1];
2068
- } else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, null, nextTag, options, inlineElements, inlineTextSet))) {
2069
- break;
2070
- }
2071
- }
2072
- }
2073
-
2074
- // Look for trailing whitespaces from previously processed text
2075
- // which may not be trimmed due to a following comment or an empty
2076
- // element which has now been removed
2077
- function squashTrailingWhitespace(nextTag) {
2078
- let charsIndex = buffer.length - 1;
2079
- if (buffer.length > 1) {
2080
- const item = buffer[buffer.length - 1];
2081
- if (/^(?:<!|$)/.test(item) && item.indexOf(uidIgnore) === -1) {
2082
- charsIndex--;
2083
- }
2084
- }
2085
- trimTrailingWhitespace(charsIndex, nextTag);
2086
- }
2087
-
2088
- const parser = new HTMLParser(value, {
2089
- partialMarkup: partialMarkup ?? options.partialMarkup,
2090
- continueOnParseError: options.continueOnParseError,
2091
- customAttrAssign: options.customAttrAssign,
2092
- customAttrSurround: options.customAttrSurround,
2093
- html5: options.html5,
2094
-
2095
- start: async function (tag, attrs, unary, unarySlash, autoGenerated) {
2096
- if (tag.toLowerCase() === 'svg') {
2097
- options = Object.create(options);
2098
- options.caseSensitive = true;
2099
- options.keepClosingSlash = true;
2100
- options.name = identity;
2101
- }
2102
- tag = options.name(tag);
2103
- currentTag = tag;
2104
- charsPrevTag = tag;
2105
- if (!inlineTextSet.has(tag)) {
2106
- currentChars = '';
2107
- }
2108
- hasChars = false;
2109
- currentAttrs = attrs;
2110
-
2111
- let optional = options.removeOptionalTags;
2112
- if (optional) {
2113
- const htmlTag = htmlTags.has(tag);
2114
- // <html> may be omitted if first thing inside is not a comment
2115
- // <head> may be omitted if first thing inside is an element
2116
- // <body> may be omitted if first thing inside is not space, comment, <meta>, <link>, <script>, <style> or <template>
2117
- // <colgroup> may be omitted if first thing inside is <col>
2118
- // <tbody> may be omitted if first thing inside is <tr>
2119
- if (htmlTag && canRemoveParentTag(optionalStartTag, tag)) {
2120
- removeStartTag();
2121
- }
2122
- optionalStartTag = '';
2123
- // End-tag-followed-by-start-tag omission rules
2124
- if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag)) {
2125
- removeEndTag();
2126
- // <colgroup> cannot be omitted if preceding </colgroup> is omitted
2127
- // <tbody> cannot be omitted if preceding </tbody>, </thead> or </tfoot> is omitted
2128
- optional = !isStartTagMandatory(optionalEndTag, tag);
2129
- }
2130
- optionalEndTag = '';
2131
- }
2132
-
2133
- // Set whitespace flags for nested tags (e.g., <code> within a <pre>)
2134
- if (options.collapseWhitespace) {
2135
- if (!stackNoTrimWhitespace.length) {
2136
- squashTrailingWhitespace(tag);
2137
- }
2138
- if (!unary) {
2139
- if (!_canTrimWhitespace(tag, attrs) || stackNoTrimWhitespace.length) {
2140
- stackNoTrimWhitespace.push(tag);
2141
- }
2142
- if (!_canCollapseWhitespace(tag, attrs) || stackNoCollapseWhitespace.length) {
2143
- stackNoCollapseWhitespace.push(tag);
2144
- }
2145
- }
2146
- }
2147
-
2148
- const openTag = '<' + tag;
2149
- const hasUnarySlash = unarySlash && options.keepClosingSlash;
2150
-
2151
- buffer.push(openTag);
2152
-
2153
- if (options.sortAttributes) {
2154
- options.sortAttributes(tag, attrs);
2155
- }
2156
-
2157
- const parts = [];
2158
- for (let i = attrs.length, isLast = true; --i >= 0;) {
2159
- const normalized = await normalizeAttr(attrs[i], attrs, tag, options);
2160
- if (normalized) {
2161
- parts.unshift(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
2162
- isLast = false;
2163
- }
2164
- }
2165
- if (parts.length > 0) {
2166
- buffer.push(' ');
2167
- buffer.push.apply(buffer, parts);
2168
- } else if (optional && optionalStartTags.has(tag)) {
2169
- // Start tag must never be omitted if it has any attributes
2170
- optionalStartTag = tag;
2171
- }
2172
-
2173
- buffer.push(buffer.pop() + (hasUnarySlash ? '/' : '') + '>');
2174
-
2175
- if (autoGenerated && !options.includeAutoGeneratedTags) {
2176
- removeStartTag();
2177
- optionalStartTag = '';
2178
- }
2179
- },
2180
- end: function (tag, attrs, autoGenerated) {
2181
- if (tag.toLowerCase() === 'svg') {
2182
- options = Object.getPrototypeOf(options);
2183
- }
2184
- tag = options.name(tag);
2185
-
2186
- // Check if current tag is in a whitespace stack
2187
- if (options.collapseWhitespace) {
2188
- if (stackNoTrimWhitespace.length) {
2189
- if (tag === stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1]) {
2190
- stackNoTrimWhitespace.pop();
2191
- }
2192
- } else {
2193
- squashTrailingWhitespace('/' + tag);
2194
- }
2195
- if (stackNoCollapseWhitespace.length &&
2196
- tag === stackNoCollapseWhitespace[stackNoCollapseWhitespace.length - 1]) {
2197
- stackNoCollapseWhitespace.pop();
2198
- }
2199
- }
2200
-
2201
- let isElementEmpty = false;
2202
- if (tag === currentTag) {
2203
- currentTag = '';
2204
- isElementEmpty = !hasChars;
2205
- }
2206
-
2207
- if (options.removeOptionalTags) {
2208
- // <html>, <head> or <body> may be omitted if the element is empty
2209
- if (isElementEmpty && topLevelTags.has(optionalStartTag)) {
2210
- removeStartTag();
2211
- }
2212
- optionalStartTag = '';
2213
- // </html> or </body> may be omitted if not followed by comment
2214
- // </head> may be omitted if not followed by space or comment
2215
- // </p> may be omitted if no more content in non-</a> parent
2216
- // except for </dt> or </thead>, end tags may be omitted if no more content in parent element
2217
- if (htmlTags.has(tag) && optionalEndTag && !trailingTags.has(optionalEndTag) && (optionalEndTag !== 'p' || !pInlineTags.has(tag))) {
2218
- removeEndTag();
2219
- }
2220
- optionalEndTag = optionalEndTags.has(tag) ? tag : '';
2221
- }
2222
-
2223
- if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
2224
- let preserve = false;
2225
- if (removeEmptyElementsExcept.length) {
2226
- // Normalize attribute names for comparison with specs
2227
- const normalizedAttrs = attrs.map(attr => ({ ...attr, name: options.name(attr.name) }));
2228
- preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
2229
- }
2230
-
2231
- if (!preserve) {
2232
- // Remove last “element” from buffer
2233
- removeStartTag();
2234
- optionalStartTag = '';
2235
- optionalEndTag = '';
2236
- } else {
2237
- // Preserve the element - add closing tag
2238
- if (autoGenerated && !options.includeAutoGeneratedTags) {
2239
- optionalEndTag = '';
2240
- } else {
2241
- buffer.push('</' + tag + '>');
2242
- }
2243
- charsPrevTag = '/' + tag;
2244
- if (!inlineElements.has(tag)) {
2245
- currentChars = '';
2246
- } else if (isElementEmpty) {
2247
- currentChars += '|';
2248
- }
2249
- }
2250
- } else {
2251
- if (autoGenerated && !options.includeAutoGeneratedTags) {
2252
- optionalEndTag = '';
2253
- } else {
2254
- buffer.push('</' + tag + '>');
2255
- }
2256
- charsPrevTag = '/' + tag;
2257
- if (!inlineElements.has(tag)) {
2258
- currentChars = '';
2259
- } else if (isElementEmpty) {
2260
- currentChars += '|';
2261
- }
2262
- }
2263
- },
2264
- chars: async function (text, prevTag, nextTag) {
2265
- prevTag = prevTag === '' ? 'comment' : prevTag;
2266
- nextTag = nextTag === '' ? 'comment' : nextTag;
2267
- if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
2268
- if (text.indexOf('&') !== -1) {
2269
- text = entities.decodeHTML(text);
2270
- }
2271
- }
2272
- if (options.collapseWhitespace) {
2273
- if (!stackNoTrimWhitespace.length) {
2274
- if (prevTag === 'comment') {
2275
- const prevComment = buffer[buffer.length - 1];
2276
- if (prevComment.indexOf(uidIgnore) === -1) {
2277
- if (!prevComment) {
2278
- prevTag = charsPrevTag;
2279
- }
2280
- if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
2281
- const charsIndex = buffer.length - 2;
2282
- buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
2283
- text = trailingSpaces + text;
2284
- return '';
2285
- });
2286
- }
2287
- }
2288
- }
2289
- if (prevTag) {
2290
- if (prevTag === '/nobr' || prevTag === 'wbr') {
2291
- if (/^\s/.test(text)) {
2292
- let tagIndex = buffer.length - 1;
2293
- while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
2294
- tagIndex--;
2295
- }
2296
- trimTrailingWhitespace(tagIndex - 1, 'br');
2297
- }
2298
- } else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
2299
- text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
2300
- }
2301
- }
2302
- if (prevTag || nextTag) {
2303
- text = collapseWhitespaceSmart(text, prevTag, nextTag, options, inlineElements, inlineTextSet);
2304
- } else {
2305
- text = collapseWhitespace(text, options, true, true);
2306
- }
2307
- if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
2308
- trimTrailingWhitespace(buffer.length - 1, nextTag);
2309
- }
2310
- }
2311
- if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
2312
- text = collapseWhitespace(text, options, false, false, true);
2313
- }
2314
- }
2315
- if (specialContentTags.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs))) {
2316
- text = await processScript(text, options, currentAttrs);
2317
- }
2318
- if (isExecutableScript(currentTag, currentAttrs)) {
2319
- text = await options.minifyJS(text);
2320
- }
2321
- if (isStyleSheet(currentTag, currentAttrs)) {
2322
- text = await options.minifyCSS(text);
2323
- }
2324
- if (options.removeOptionalTags && text) {
2325
- // <html> may be omitted if first thing inside is not a comment
2326
- // <body> may be omitted if first thing inside is not space, comment, <meta>, <link>, <script>, <style> or <template>
2327
- if (optionalStartTag === 'html' || (optionalStartTag === 'body' && !/^\s/.test(text))) {
2328
- removeStartTag();
2329
- }
2330
- optionalStartTag = '';
2331
- // </html> or </body> may be omitted if not followed by comment
2332
- // </head>, </colgroup> or </caption> may be omitted if not followed by space or comment
2333
- if (compactTags.has(optionalEndTag) || (looseTags.has(optionalEndTag) && !/^\s/.test(text))) {
2334
- removeEndTag();
2335
- }
2336
- // Don’t reset optionalEndTag if text is only whitespace and will be collapsed (not conservatively)
2337
- if (!/^\s+$/.test(text) || !options.collapseWhitespace || options.conservativeCollapse) {
2338
- optionalEndTag = '';
2339
- }
2340
- }
2341
- charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
2342
- if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
2343
- // Escape any `&` symbols that start either:
2344
- // 1) a legacy named character reference (i.e., one that doesn’t end with `;`)
2345
- // 2) or any other character reference (i.e., one that does end with `;`)
2346
- // Note that `&` can be escaped as `&amp`, without the semi-colon.
2347
- // https://mathiasbynens.be/notes/ambiguous-ampersands
2348
- if (text.indexOf('&') !== -1) {
2349
- text = text.replace(/&((?:Iacute|aacute|uacute|plusmn|Otilde|otilde|agrave|Agrave|Yacute|yacute|Oslash|oslash|atilde|Atilde|brvbar|ccedil|Ccedil|Ograve|curren|divide|eacute|Eacute|ograve|Oacute|egrave|Egrave|Ugrave|frac12|frac14|frac34|ugrave|oacute|iacute|Ntilde|ntilde|Uacute|middot|igrave|Igrave|iquest|Aacute|cedil|laquo|micro|iexcl|Icirc|icirc|acirc|Ucirc|Ecirc|ocirc|Ocirc|ecirc|ucirc|Aring|aring|AElig|aelig|acute|pound|raquo|Acirc|times|THORN|szlig|thorn|COPY|auml|ordf|ordm|Uuml|macr|uuml|Auml|ouml|Ouml|para|nbsp|euml|quot|QUOT|Euml|yuml|cent|sect|copy|sup1|sup2|sup3|iuml|Iuml|ETH|shy|reg|not|yen|amp|AMP|REG|uml|eth|deg|gt|GT|LT|lt)(?!;)|(?:#?[0-9a-zA-Z]+;))/g, '&amp$1');
2350
- }
2351
- if (text.indexOf('<') !== -1) {
2352
- text = text.replace(/</g, '&lt;');
2353
- }
2354
- }
2355
- if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
2356
- text = text.replace(uidPattern, function (match, prefix, index) {
2357
- return ignoredCustomMarkupChunks[+index][0];
2358
- });
2359
- }
2360
- currentChars += text;
2361
- if (text) {
2362
- hasChars = true;
2363
- }
2364
- buffer.push(text);
2365
- },
2366
- comment: async function (text, nonStandard) {
2367
- const prefix = nonStandard ? '<!' : '<!--';
2368
- const suffix = nonStandard ? '>' : '-->';
2369
- if (isConditionalComment(text)) {
2370
- text = prefix + await cleanConditionalComment(text, options) + suffix;
2371
- } else if (options.removeComments) {
2372
- if (isIgnoredComment(text, options)) {
2373
- text = '<!--' + text + '-->';
2374
- } else {
2375
- text = '';
2376
- }
2377
- } else {
2378
- text = prefix + text + suffix;
2379
- }
2380
- if (options.removeOptionalTags && text) {
2381
- // Preceding comments suppress tag omissions
2382
- optionalStartTag = '';
2383
- optionalEndTag = '';
2384
- }
2385
- buffer.push(text);
2386
- },
2387
- doctype: function (doctype) {
2388
- buffer.push(options.useShortDoctype
2389
- ? '<!doctype' +
2390
- (options.removeTagWhitespace ? '' : ' ') + 'html>'
2391
- : collapseWhitespaceAll(doctype));
2392
- }
2393
- });
2394
-
2395
- await parser.parse();
2396
-
2397
- if (options.removeOptionalTags) {
2398
- // <html> may be omitted if first thing inside is not a comment
2399
- // <head> or <body> may be omitted if empty
2400
- if (topLevelTags.has(optionalStartTag)) {
2401
- removeStartTag();
2402
- }
2403
- // except for </dt> or </thead>, end tags may be omitted if no more content in parent element
2404
- if (optionalEndTag && !trailingTags.has(optionalEndTag)) {
2405
- removeEndTag();
2406
- }
2407
- }
2408
- if (options.collapseWhitespace) {
2409
- squashTrailingWhitespace('br');
2410
- }
2411
-
2412
- return joinResultSegments(buffer, options, uidPattern
2413
- ? function (str) {
2414
- return str.replace(uidPattern, function (match, prefix, index, suffix) {
2415
- let chunk = ignoredCustomMarkupChunks[+index][0];
2416
- if (options.collapseWhitespace) {
2417
- if (prefix !== '\t') {
2418
- chunk = prefix + chunk;
2419
- }
2420
- if (suffix !== '\t') {
2421
- chunk += suffix;
2422
- }
2423
- return collapseWhitespace(chunk, {
2424
- preserveLineBreaks: options.preserveLineBreaks,
2425
- conservativeCollapse: !options.trimCustomFragments
2426
- }, /^[ \n\r\t\f]/.test(chunk), /[ \n\r\t\f]$/.test(chunk));
2427
- }
2428
- return chunk;
2429
- });
2430
- }
2431
- : identity, uidIgnore
2432
- ? function (str) {
2433
- return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (match, index) {
2434
- return ignoredMarkupChunks[+index];
2435
- });
2436
- }
2437
- : identity);
2438
- }
2439
-
2440
- function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
2441
- let str;
2442
- const maxLineLength = options.maxLineLength;
2443
- const noNewlinesBeforeTagClose = options.noNewlinesBeforeTagClose;
2444
-
2445
- if (maxLineLength) {
2446
- let line = ''; const lines = [];
2447
- while (results.length) {
2448
- const len = line.length;
2449
- const end = results[0].indexOf('\n');
2450
- const isClosingTag = Boolean(results[0].match(endTag));
2451
- const shouldKeepSameLine = noNewlinesBeforeTagClose && isClosingTag;
2452
-
2453
- if (end < 0) {
2454
- line += restoreIgnore(restoreCustom(results.shift()));
2455
- } else {
2456
- line += restoreIgnore(restoreCustom(results[0].slice(0, end)));
2457
- results[0] = results[0].slice(end + 1);
2458
- }
2459
- if (len > 0 && line.length > maxLineLength && !shouldKeepSameLine) {
2460
- lines.push(line.slice(0, len));
2461
- line = line.slice(len);
2462
- } else if (end >= 0) {
2463
- lines.push(line);
2464
- line = '';
2465
- }
2466
- }
2467
- if (line) {
2468
- lines.push(line);
2469
- }
2470
- str = lines.join('\n');
2471
- } else {
2472
- str = restoreIgnore(restoreCustom(results.join('')));
2473
- }
2474
- return options.collapseWhitespace ? collapseWhitespace(str, options, true, true) : str;
2475
- }
2476
-
2477
- /**
2478
- * @param {string} value
2479
- * @param {MinifierOptions} [options]
2480
- * @returns {Promise<string>}
2481
- */
2482
- const minify = async function (value, options) {
2483
- const start = Date.now();
2484
- options = processOptions(options || {});
2485
- const result = await minifyHTML(value, options);
2486
- options.log('minified in: ' + (Date.now() - start) + 'ms');
2487
- return result;
2488
- };
2489
-
2490
- var htmlminifier = { minify, presets, getPreset, getPresetNames };
2491
-
2492
- /**
2493
- * @typedef {Object} HTMLAttribute
2494
- * Representation of an attribute from the HTML parser.
2495
- *
2496
- * @prop {string} name
2497
- * @prop {string} [value]
2498
- * @prop {string} [quote]
2499
- * @prop {string} [customAssign]
2500
- * @prop {string} [customOpen]
2501
- * @prop {string} [customClose]
2502
- */
734
+ /**
735
+ * @typedef {Object} HTMLAttribute
736
+ * Representation of an attribute from the HTML parser.
737
+ *
738
+ * @prop {string} name
739
+ * @prop {string} [value]
740
+ * @prop {string} [quote]
741
+ * @prop {string} [customAssign]
742
+ * @prop {string} [customOpen]
743
+ * @prop {string} [customClose]
744
+ */
2503
745
 
2504
746
  /**
2505
747
  * @typedef {Object} MinifierOptions
@@ -2804,7 +1046,7 @@ var htmlminifier = { minify, presets, getPreset, getPresetNames };
2804
1046
  * Default: `false`
2805
1047
  *
2806
1048
  * @prop {boolean} [removeRedundantAttributes]
2807
- * Remove attributes that are redundant because they match the element's
1049
+ * Remove attributes that are redundant because they match the elements
2808
1050
  * default values (for example `<button type="submit">`).
2809
1051
  * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_redundant_attributes
2810
1052
  *
@@ -2861,6 +1103,1829 @@ var htmlminifier = { minify, presets, getPreset, getPresetNames };
2861
1103
  * Default: `false`
2862
1104
  */
2863
1105
 
1106
+ // Hoisted, reusable RegExp patterns and tiny helpers to avoid repeated allocations in hot paths
1107
+ const RE_WS_START = /^[ \n\r\t\f]+/;
1108
+ const RE_WS_END = /[ \n\r\t\f]+$/;
1109
+ const RE_ALL_WS_NBSP = /[ \n\r\t\f\xA0]+/g;
1110
+ const RE_NBSP_LEADING_GROUP = /(^|\xA0+)[^\xA0]+/g;
1111
+ const RE_NBSP_LEAD_GROUP = /(\xA0+)[^\xA0]+/g;
1112
+ const RE_NBSP_TRAILING_GROUP = /[^\xA0]+(\xA0+)/g;
1113
+ const RE_NBSP_TRAILING_STRIP = /[^\xA0]+$/;
1114
+ const RE_CONDITIONAL_COMMENT = /^\[if\s[^\]]+]|\[endif]$/;
1115
+ const RE_EVENT_ATTR_DEFAULT = /^on[a-z]{3,}$/;
1116
+ const RE_CAN_REMOVE_ATTR_QUOTES = /^[^ \t\n\f\r"'`=<>]+$/;
1117
+ const RE_TRAILING_SEMICOLON = /;$/;
1118
+ const RE_AMP_ENTITY = /&(#?[0-9a-zA-Z]+;)/g;
1119
+
1120
+ // Tiny stable stringify for options signatures (sorted keys, shallow, nested objects)
1121
+ function stableStringify(obj) {
1122
+ if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
1123
+ if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
1124
+ const keys = Object.keys(obj).sort();
1125
+ let out = '{';
1126
+ for (let i = 0; i < keys.length; i++) {
1127
+ const k = keys[i];
1128
+ out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
1129
+ }
1130
+ return out + '}';
1131
+ }
1132
+
1133
+ // Minimal LRU cache for strings and promises
1134
+ class LRU {
1135
+ constructor(limit = 200) {
1136
+ this.limit = limit;
1137
+ this.map = new Map();
1138
+ }
1139
+ get(key) {
1140
+ const v = this.map.get(key);
1141
+ if (v !== undefined) {
1142
+ this.map.delete(key);
1143
+ this.map.set(key, v);
1144
+ }
1145
+ return v;
1146
+ }
1147
+ set(key, value) {
1148
+ if (this.map.has(key)) this.map.delete(key);
1149
+ this.map.set(key, value);
1150
+ if (this.map.size > this.limit) {
1151
+ const first = this.map.keys().next().value;
1152
+ this.map.delete(first);
1153
+ }
1154
+ }
1155
+ delete(key) { this.map.delete(key); }
1156
+ }
1157
+
1158
+ // Per-process caches
1159
+ const jsMinifyCache = new LRU(200);
1160
+ const cssMinifyCache = new LRU(200);
1161
+
1162
+ const trimWhitespace = str => {
1163
+ if (!str) return str;
1164
+ // Fast path: if no whitespace at start or end, return early
1165
+ if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
1166
+ return str;
1167
+ }
1168
+ return str.replace(RE_WS_START, '').replace(RE_WS_END, '');
1169
+ };
1170
+
1171
+ function collapseWhitespaceAll(str) {
1172
+ if (!str) return str;
1173
+ // Fast path: if there are no common whitespace characters, return early
1174
+ if (!/[ \n\r\t\f\xA0]/.test(str)) {
1175
+ return str;
1176
+ }
1177
+ // Non-breaking space is specifically handled inside the replacer function here:
1178
+ return str.replace(RE_ALL_WS_NBSP, function (spaces) {
1179
+ return spaces === '\t' ? '\t' : spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
1180
+ });
1181
+ }
1182
+
1183
+ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
1184
+ let lineBreakBefore = ''; let lineBreakAfter = '';
1185
+
1186
+ if (!str) return str;
1187
+
1188
+ if (options.preserveLineBreaks) {
1189
+ str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function () {
1190
+ lineBreakBefore = '\n';
1191
+ return '';
1192
+ }).replace(/[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*$/, function () {
1193
+ lineBreakAfter = '\n';
1194
+ return '';
1195
+ });
1196
+ }
1197
+
1198
+ if (trimLeft) {
1199
+ // Non-breaking space is specifically handled inside the replacer function here:
1200
+ str = str.replace(/^[ \n\r\t\f\xA0]+/, function (spaces) {
1201
+ const conservative = !lineBreakBefore && options.conservativeCollapse;
1202
+ if (conservative && spaces === '\t') {
1203
+ return '\t';
1204
+ }
1205
+ return spaces.replace(/^[^\xA0]+/, '').replace(RE_NBSP_LEAD_GROUP, '$1 ') || (conservative ? ' ' : '');
1206
+ });
1207
+ }
1208
+
1209
+ if (trimRight) {
1210
+ // Non-breaking space is specifically handled inside the replacer function here:
1211
+ str = str.replace(/[ \n\r\t\f\xA0]+$/, function (spaces) {
1212
+ const conservative = !lineBreakAfter && options.conservativeCollapse;
1213
+ if (conservative && spaces === '\t') {
1214
+ return '\t';
1215
+ }
1216
+ return spaces.replace(RE_NBSP_TRAILING_GROUP, ' $1').replace(RE_NBSP_TRAILING_STRIP, '') || (conservative ? ' ' : '');
1217
+ });
1218
+ }
1219
+
1220
+ if (collapseAll) {
1221
+ // Strip non-space whitespace then compress spaces to one
1222
+ str = collapseWhitespaceAll(str);
1223
+ }
1224
+
1225
+ return lineBreakBefore + str + lineBreakAfter;
1226
+ }
1227
+
1228
+ // Non-empty elements that will maintain whitespace around them
1229
+ 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']);
1230
+ // Non-empty elements that will maintain whitespace within them
1231
+ 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']);
1232
+ // Elements that will always maintain whitespace around them
1233
+ const inlineElementsToKeepWhitespace = new Set(['comment', 'img', 'input', 'wbr']);
1234
+
1235
+ function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements, inlineTextSet) {
1236
+ let trimLeft = prevTag && !inlineElementsToKeepWhitespace.has(prevTag);
1237
+ if (trimLeft && !options.collapseInlineTagWhitespace) {
1238
+ trimLeft = prevTag.charAt(0) === '/' ? !inlineElements.has(prevTag.slice(1)) : !inlineTextSet.has(prevTag);
1239
+ }
1240
+ let trimRight = nextTag && !inlineElementsToKeepWhitespace.has(nextTag);
1241
+ if (trimRight && !options.collapseInlineTagWhitespace) {
1242
+ trimRight = nextTag.charAt(0) === '/' ? !inlineTextSet.has(nextTag.slice(1)) : !inlineElements.has(nextTag);
1243
+ }
1244
+ return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag);
1245
+ }
1246
+
1247
+ function isConditionalComment(text) {
1248
+ return RE_CONDITIONAL_COMMENT.test(text);
1249
+ }
1250
+
1251
+ function isIgnoredComment(text, options) {
1252
+ for (let i = 0, len = options.ignoreCustomComments.length; i < len; i++) {
1253
+ if (options.ignoreCustomComments[i].test(text)) {
1254
+ return true;
1255
+ }
1256
+ }
1257
+ return false;
1258
+ }
1259
+
1260
+ function isEventAttribute(attrName, options) {
1261
+ const patterns = options.customEventAttributes;
1262
+ if (patterns) {
1263
+ for (let i = patterns.length; i--;) {
1264
+ if (patterns[i].test(attrName)) {
1265
+ return true;
1266
+ }
1267
+ }
1268
+ return false;
1269
+ }
1270
+ return RE_EVENT_ATTR_DEFAULT.test(attrName);
1271
+ }
1272
+
1273
+ function canRemoveAttributeQuotes(value) {
1274
+ // https://mathiasbynens.be/notes/unquoted-attribute-values
1275
+ return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
1276
+ }
1277
+
1278
+ function attributesInclude(attributes, attribute) {
1279
+ for (let i = attributes.length; i--;) {
1280
+ if (attributes[i].name.toLowerCase() === attribute) {
1281
+ return true;
1282
+ }
1283
+ }
1284
+ return false;
1285
+ }
1286
+
1287
+ // Default attribute values (could apply to any element)
1288
+ const generalDefaults = {
1289
+ autocorrect: 'on',
1290
+ fetchpriority: 'auto',
1291
+ loading: 'eager',
1292
+ popovertargetaction: 'toggle'
1293
+ };
1294
+
1295
+ // Tag-specific default attribute values
1296
+ const tagDefaults = {
1297
+ area: { shape: 'rect' },
1298
+ button: { type: 'submit' },
1299
+ form: {
1300
+ enctype: 'application/x-www-form-urlencoded',
1301
+ method: 'get'
1302
+ },
1303
+ html: { dir: 'ltr' },
1304
+ img: { decoding: 'auto' },
1305
+ input: {
1306
+ colorspace: 'limited-srgb',
1307
+ type: 'text'
1308
+ },
1309
+ marquee: {
1310
+ behavior: 'scroll',
1311
+ direction: 'left'
1312
+ },
1313
+ style: { media: 'all' },
1314
+ textarea: { wrap: 'soft' },
1315
+ track: { kind: 'subtitles' }
1316
+ };
1317
+
1318
+ function isAttributeRedundant(tag, attrName, attrValue, attrs) {
1319
+ attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
1320
+
1321
+ // Legacy attributes
1322
+ if (tag === 'script' && attrName === 'language' && attrValue === 'javascript') {
1323
+ return true;
1324
+ }
1325
+ if (tag === 'script' && attrName === 'charset' && !attributesInclude(attrs, 'src')) {
1326
+ return true;
1327
+ }
1328
+ if (tag === 'a' && attrName === 'name' && attributesInclude(attrs, 'id')) {
1329
+ return true;
1330
+ }
1331
+
1332
+ // Check general defaults
1333
+ if (generalDefaults[attrName] === attrValue) {
1334
+ return true;
1335
+ }
1336
+
1337
+ // Check tag-specific defaults
1338
+ return tagDefaults[tag]?.[attrName] === attrValue;
1339
+ }
1340
+
1341
+ // https://mathiasbynens.be/demo/javascript-mime-type
1342
+ // https://developer.mozilla.org/en/docs/Web/HTML/Element/script#attr-type
1343
+ const executableScriptsMimetypes = new Set([
1344
+ 'text/javascript',
1345
+ 'text/ecmascript',
1346
+ 'text/jscript',
1347
+ 'application/javascript',
1348
+ 'application/x-javascript',
1349
+ 'application/ecmascript',
1350
+ 'module'
1351
+ ]);
1352
+
1353
+ const keepScriptsMimetypes = new Set([
1354
+ 'module'
1355
+ ]);
1356
+
1357
+ function isScriptTypeAttribute(attrValue = '') {
1358
+ attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
1359
+ return attrValue === '' || executableScriptsMimetypes.has(attrValue);
1360
+ }
1361
+
1362
+ function keepScriptTypeAttribute(attrValue = '') {
1363
+ attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
1364
+ return keepScriptsMimetypes.has(attrValue);
1365
+ }
1366
+
1367
+ function isExecutableScript(tag, attrs) {
1368
+ if (tag !== 'script') {
1369
+ return false;
1370
+ }
1371
+ for (let i = 0, len = attrs.length; i < len; i++) {
1372
+ const attrName = attrs[i].name.toLowerCase();
1373
+ if (attrName === 'type') {
1374
+ return isScriptTypeAttribute(attrs[i].value);
1375
+ }
1376
+ }
1377
+ return true;
1378
+ }
1379
+
1380
+ function isStyleLinkTypeAttribute(attrValue = '') {
1381
+ attrValue = trimWhitespace(attrValue).toLowerCase();
1382
+ return attrValue === '' || attrValue === 'text/css';
1383
+ }
1384
+
1385
+ function isStyleSheet(tag, attrs) {
1386
+ if (tag !== 'style') {
1387
+ return false;
1388
+ }
1389
+ for (let i = 0, len = attrs.length; i < len; i++) {
1390
+ const attrName = attrs[i].name.toLowerCase();
1391
+ if (attrName === 'type') {
1392
+ return isStyleLinkTypeAttribute(attrs[i].value);
1393
+ }
1394
+ }
1395
+ return true;
1396
+ }
1397
+
1398
+ 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']);
1399
+ const isBooleanValue = new Set(['true', 'false']);
1400
+
1401
+ function isBooleanAttribute(attrName, attrValue) {
1402
+ return isSimpleBoolean.has(attrName) || (attrName === 'draggable' && !isBooleanValue.has(attrValue));
1403
+ }
1404
+
1405
+ function isUriTypeAttribute(attrName, tag) {
1406
+ return (
1407
+ (/^(?:a|area|link|base)$/.test(tag) && attrName === 'href') ||
1408
+ (tag === 'img' && /^(?:src|longdesc|usemap)$/.test(attrName)) ||
1409
+ (tag === 'object' && /^(?:classid|codebase|data|usemap)$/.test(attrName)) ||
1410
+ (tag === 'q' && attrName === 'cite') ||
1411
+ (tag === 'blockquote' && attrName === 'cite') ||
1412
+ ((tag === 'ins' || tag === 'del') && attrName === 'cite') ||
1413
+ (tag === 'form' && attrName === 'action') ||
1414
+ (tag === 'input' && (attrName === 'src' || attrName === 'usemap')) ||
1415
+ (tag === 'head' && attrName === 'profile') ||
1416
+ (tag === 'script' && (attrName === 'src' || attrName === 'for'))
1417
+ );
1418
+ }
1419
+
1420
+ function isNumberTypeAttribute(attrName, tag) {
1421
+ return (
1422
+ (/^(?:a|area|object|button)$/.test(tag) && attrName === 'tabindex') ||
1423
+ (tag === 'input' && (attrName === 'maxlength' || attrName === 'tabindex')) ||
1424
+ (tag === 'select' && (attrName === 'size' || attrName === 'tabindex')) ||
1425
+ (tag === 'textarea' && /^(?:rows|cols|tabindex)$/.test(attrName)) ||
1426
+ (tag === 'colgroup' && attrName === 'span') ||
1427
+ (tag === 'col' && attrName === 'span') ||
1428
+ ((tag === 'th' || tag === 'td') && (attrName === 'rowspan' || attrName === 'colspan'))
1429
+ );
1430
+ }
1431
+
1432
+ function isLinkType(tag, attrs, value) {
1433
+ if (tag !== 'link') return false;
1434
+ const needle = String(value).toLowerCase();
1435
+ for (let i = 0; i < attrs.length; i++) {
1436
+ if (attrs[i].name.toLowerCase() === 'rel') {
1437
+ const tokens = String(attrs[i].value).toLowerCase().split(/\s+/);
1438
+ if (tokens.includes(needle)) return true;
1439
+ }
1440
+ }
1441
+ return false;
1442
+ }
1443
+
1444
+ function isMediaQuery(tag, attrs, attrName) {
1445
+ return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleSheet(tag, attrs));
1446
+ }
1447
+
1448
+ const srcsetTags = new Set(['img', 'source']);
1449
+
1450
+ function isSrcset(attrName, tag) {
1451
+ return attrName === 'srcset' && srcsetTags.has(tag);
1452
+ }
1453
+
1454
+ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
1455
+ if (isEventAttribute(attrName, options)) {
1456
+ attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
1457
+ return options.minifyJS(attrValue, true);
1458
+ } else if (attrName === 'class') {
1459
+ attrValue = trimWhitespace(attrValue);
1460
+ if (options.sortClassName) {
1461
+ attrValue = options.sortClassName(attrValue);
1462
+ } else {
1463
+ attrValue = collapseWhitespaceAll(attrValue);
1464
+ }
1465
+ return attrValue;
1466
+ } else if (isUriTypeAttribute(attrName, tag)) {
1467
+ attrValue = trimWhitespace(attrValue);
1468
+ if (isLinkType(tag, attrs, 'canonical')) {
1469
+ return attrValue;
1470
+ }
1471
+ try {
1472
+ const out = await options.minifyURLs(attrValue);
1473
+ return typeof out === 'string' ? out : attrValue;
1474
+ } catch (err) {
1475
+ if (!options.continueOnMinifyError) {
1476
+ throw err;
1477
+ }
1478
+ options.log && options.log(err);
1479
+ return attrValue;
1480
+ }
1481
+ } else if (isNumberTypeAttribute(attrName, tag)) {
1482
+ return trimWhitespace(attrValue);
1483
+ } else if (attrName === 'style') {
1484
+ attrValue = trimWhitespace(attrValue);
1485
+ if (attrValue) {
1486
+ if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
1487
+ attrValue = attrValue.replace(/\s*;$/, ';');
1488
+ }
1489
+ attrValue = await options.minifyCSS(attrValue, 'inline');
1490
+ }
1491
+ return attrValue;
1492
+ } else if (isSrcset(attrName, tag)) {
1493
+ // https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
1494
+ attrValue = (await Promise.all(trimWhitespace(attrValue).split(/\s+,\s*|\s*,\s+/).map(async function (candidate) {
1495
+ let url = candidate;
1496
+ let descriptor = '';
1497
+ const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
1498
+ if (match) {
1499
+ url = url.slice(0, -match[0].length);
1500
+ const num = +match[1].slice(0, -1);
1501
+ const suffix = match[1].slice(-1);
1502
+ if (num !== 1 || suffix !== 'x') {
1503
+ descriptor = ' ' + num + suffix;
1504
+ }
1505
+ }
1506
+ try {
1507
+ const out = await options.minifyURLs(url);
1508
+ return (typeof out === 'string' ? out : url) + descriptor;
1509
+ } catch (err) {
1510
+ if (!options.continueOnMinifyError) {
1511
+ throw err;
1512
+ }
1513
+ options.log && options.log(err);
1514
+ return url + descriptor;
1515
+ }
1516
+ }))).join(', ');
1517
+ } else if (isMetaViewport(tag, attrs) && attrName === 'content') {
1518
+ attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
1519
+ // “0.90000” → “0.9”
1520
+ // “1.0” → “1”
1521
+ // “1.0001” → “1.0001” (unchanged)
1522
+ return (+numString).toString();
1523
+ });
1524
+ } else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
1525
+ return collapseWhitespaceAll(attrValue);
1526
+ } else if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
1527
+ attrValue = trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
1528
+ } else if (tag === 'script' && attrName === 'type') {
1529
+ attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
1530
+ } else if (isMediaQuery(tag, attrs, attrName)) {
1531
+ attrValue = trimWhitespace(attrValue);
1532
+ return options.minifyCSS(attrValue, 'media');
1533
+ } else if (tag === 'iframe' && attrName === 'srcdoc') {
1534
+ // Recursively minify HTML content within srcdoc attribute
1535
+ // Fast-path: skip if nothing would change
1536
+ if (!shouldMinifyInnerHTML(options)) {
1537
+ return attrValue;
1538
+ }
1539
+ return minifyHTMLSelf(attrValue, options, true);
1540
+ }
1541
+ return attrValue;
1542
+ }
1543
+
1544
+ function isMetaViewport(tag, attrs) {
1545
+ if (tag !== 'meta') {
1546
+ return false;
1547
+ }
1548
+ for (let i = 0, len = attrs.length; i < len; i++) {
1549
+ if (attrs[i].name === 'name' && attrs[i].value === 'viewport') {
1550
+ return true;
1551
+ }
1552
+ }
1553
+ }
1554
+
1555
+ function isContentSecurityPolicy(tag, attrs) {
1556
+ if (tag !== 'meta') {
1557
+ return false;
1558
+ }
1559
+ for (let i = 0, len = attrs.length; i < len; i++) {
1560
+ if (attrs[i].name.toLowerCase() === 'http-equiv' && attrs[i].value.toLowerCase() === 'content-security-policy') {
1561
+ return true;
1562
+ }
1563
+ }
1564
+ }
1565
+
1566
+ // Wrap CSS declarations for inline styles and media queries
1567
+ // This ensures proper context for CSS minification
1568
+ function wrapCSS(text, type) {
1569
+ switch (type) {
1570
+ case 'inline':
1571
+ return '*{' + text + '}';
1572
+ case 'media':
1573
+ return '@media ' + text + '{a{top:0}}';
1574
+ default:
1575
+ return text;
1576
+ }
1577
+ }
1578
+
1579
+ function unwrapCSS(text, type) {
1580
+ let matches;
1581
+ switch (type) {
1582
+ case 'inline':
1583
+ matches = text.match(/^\*\{([\s\S]*)\}$/);
1584
+ break;
1585
+ case 'media':
1586
+ matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/);
1587
+ break;
1588
+ }
1589
+ return matches ? matches[1] : text;
1590
+ }
1591
+
1592
+ async function cleanConditionalComment(comment, options) {
1593
+ return options.processConditionalComments
1594
+ ? await replaceAsync(comment, /^(\[if\s[^\]]+]>)([\s\S]*?)(<!\[endif])$/, async function (match, prefix, text, suffix) {
1595
+ return prefix + await minifyHTML(text, options, true) + suffix;
1596
+ })
1597
+ : comment;
1598
+ }
1599
+
1600
+ const jsonScriptTypes = new Set([
1601
+ 'application/json',
1602
+ 'application/ld+json',
1603
+ 'application/manifest+json',
1604
+ 'application/vnd.geo+json',
1605
+ 'importmap',
1606
+ 'speculationrules',
1607
+ ]);
1608
+
1609
+ function minifyJson(text, options) {
1610
+ try {
1611
+ return JSON.stringify(JSON.parse(text));
1612
+ }
1613
+ catch (err) {
1614
+ if (!options.continueOnMinifyError) {
1615
+ throw err;
1616
+ }
1617
+ options.log && options.log(err);
1618
+ return text;
1619
+ }
1620
+ }
1621
+
1622
+ function hasJsonScriptType(attrs) {
1623
+ for (let i = 0, len = attrs.length; i < len; i++) {
1624
+ const attrName = attrs[i].name.toLowerCase();
1625
+ if (attrName === 'type') {
1626
+ const attrValue = trimWhitespace((attrs[i].value || '').split(/;/, 2)[0]).toLowerCase();
1627
+ if (jsonScriptTypes.has(attrValue)) {
1628
+ return true;
1629
+ }
1630
+ }
1631
+ }
1632
+ return false;
1633
+ }
1634
+
1635
+ async function processScript(text, options, currentAttrs) {
1636
+ for (let i = 0, len = currentAttrs.length; i < len; i++) {
1637
+ const attrName = currentAttrs[i].name.toLowerCase();
1638
+ if (attrName === 'type') {
1639
+ const rawValue = currentAttrs[i].value;
1640
+ const normalizedValue = trimWhitespace((rawValue || '').split(/;/, 2)[0]).toLowerCase();
1641
+ // Minify JSON script types automatically
1642
+ if (jsonScriptTypes.has(normalizedValue)) {
1643
+ return minifyJson(text, options);
1644
+ }
1645
+ // Process custom script types if specified
1646
+ if (options.processScripts && options.processScripts.indexOf(rawValue) > -1) {
1647
+ return await minifyHTML(text, options);
1648
+ }
1649
+ }
1650
+ }
1651
+ return text;
1652
+ }
1653
+
1654
+ // Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags with the following extensions:
1655
+ // - retain `<body>` if followed by `<noscript>`
1656
+ // - `<rb>`, `<rt>`, `<rtc>`, `<rp>` follow HTML Ruby Markup Extensions draft (https://www.w3.org/TR/html-ruby-extensions/)
1657
+ // - retain all tags which are adjacent to non-standard HTML tags
1658
+ const optionalStartTags = new Set(['html', 'head', 'body', 'colgroup', 'tbody']);
1659
+ 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']);
1660
+ const headerTags = new Set(['meta', 'link', 'script', 'style', 'template', 'noscript']);
1661
+ const descriptionTags = new Set(['dt', 'dd']);
1662
+ 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']);
1663
+ const pInlineTags = new Set(['a', 'audio', 'del', 'ins', 'map', 'noscript', 'video']);
1664
+ const rubyEndTagOmission = new Set(['rb', 'rt', 'rtc', 'rp']); // `</rb>`, `</rt>`, `</rp>` can be omitted if followed by `<rb>`, `<rt>`, `<rtc>`, or `<rp>`
1665
+ const rubyRtcEndTagOmission = new Set(['rb', 'rtc']); // `</rtc>` can be omitted if followed by `<rb>` or `<rtc>` (not `<rt>` or `<rp>`)
1666
+ const optionTag = new Set(['option', 'optgroup']);
1667
+ const tableContentTags = new Set(['tbody', 'tfoot']);
1668
+ const tableSectionTags = new Set(['thead', 'tbody', 'tfoot']);
1669
+ const cellTags = new Set(['td', 'th']);
1670
+ const topLevelTags = new Set(['html', 'head', 'body']);
1671
+ const compactTags = new Set(['html', 'body']);
1672
+ const looseTags = new Set(['head', 'colgroup', 'caption']);
1673
+ const trailingTags = new Set(['dt', 'thead']);
1674
+ 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']);
1675
+
1676
+ function canRemoveParentTag(optionalStartTag, tag) {
1677
+ switch (optionalStartTag) {
1678
+ case 'html':
1679
+ case 'head':
1680
+ return true;
1681
+ case 'body':
1682
+ return !headerTags.has(tag);
1683
+ case 'colgroup':
1684
+ return tag === 'col';
1685
+ case 'tbody':
1686
+ return tag === 'tr';
1687
+ }
1688
+ return false;
1689
+ }
1690
+
1691
+ function isStartTagMandatory(optionalEndTag, tag) {
1692
+ switch (tag) {
1693
+ case 'colgroup':
1694
+ return optionalEndTag === 'colgroup';
1695
+ case 'tbody':
1696
+ return tableSectionTags.has(optionalEndTag);
1697
+ }
1698
+ return false;
1699
+ }
1700
+
1701
+ function canRemovePrecedingTag(optionalEndTag, tag) {
1702
+ switch (optionalEndTag) {
1703
+ case 'html':
1704
+ case 'head':
1705
+ case 'body':
1706
+ case 'colgroup':
1707
+ case 'caption':
1708
+ return true;
1709
+ case 'li':
1710
+ case 'optgroup':
1711
+ case 'tr':
1712
+ return tag === optionalEndTag;
1713
+ case 'dt':
1714
+ case 'dd':
1715
+ return descriptionTags.has(tag);
1716
+ case 'p':
1717
+ return pBlockTags.has(tag);
1718
+ case 'rb':
1719
+ case 'rt':
1720
+ case 'rp':
1721
+ return rubyEndTagOmission.has(tag);
1722
+ case 'rtc':
1723
+ return rubyRtcEndTagOmission.has(tag);
1724
+ case 'option':
1725
+ return optionTag.has(tag);
1726
+ case 'thead':
1727
+ case 'tbody':
1728
+ return tableContentTags.has(tag);
1729
+ case 'tfoot':
1730
+ return tag === 'tbody';
1731
+ case 'td':
1732
+ case 'th':
1733
+ return cellTags.has(tag);
1734
+ }
1735
+ return false;
1736
+ }
1737
+
1738
+ const reEmptyAttribute = new RegExp(
1739
+ '^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
1740
+ '?:down|up|over|move|out)|key(?:press|down|up)))$');
1741
+
1742
+ function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
1743
+ const isValueEmpty = !attrValue || /^\s*$/.test(attrValue);
1744
+ if (!isValueEmpty) {
1745
+ return false;
1746
+ }
1747
+ if (typeof options.removeEmptyAttributes === 'function') {
1748
+ return options.removeEmptyAttributes(attrName, tag);
1749
+ }
1750
+ return (tag === 'input' && attrName === 'value') || reEmptyAttribute.test(attrName);
1751
+ }
1752
+
1753
+ function hasAttrName(name, attrs) {
1754
+ for (let i = attrs.length - 1; i >= 0; i--) {
1755
+ if (attrs[i].name === name) {
1756
+ return true;
1757
+ }
1758
+ }
1759
+ return false;
1760
+ }
1761
+
1762
+ function canRemoveElement(tag, attrs) {
1763
+ switch (tag) {
1764
+ case 'textarea':
1765
+ return false;
1766
+ case 'audio':
1767
+ case 'script':
1768
+ case 'video':
1769
+ if (hasAttrName('src', attrs)) {
1770
+ return false;
1771
+ }
1772
+ break;
1773
+ case 'iframe':
1774
+ if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
1775
+ return false;
1776
+ }
1777
+ break;
1778
+ case 'object':
1779
+ if (hasAttrName('data', attrs)) {
1780
+ return false;
1781
+ }
1782
+ break;
1783
+ case 'applet':
1784
+ if (hasAttrName('code', attrs)) {
1785
+ return false;
1786
+ }
1787
+ break;
1788
+ }
1789
+ return true;
1790
+ }
1791
+
1792
+ /**
1793
+ * @param {string} str - Tag name or HTML-like element spec (e.g., “td” or “<span aria-hidden='true'>”)
1794
+ * @param {MinifierOptions} options - Options object for name normalization
1795
+ * @returns {{tag: string, attrs: Object.<string, string|undefined>|null}|null} Parsed spec or null if invalid
1796
+ */
1797
+ function parseElementSpec(str, options) {
1798
+ if (typeof str !== 'string') {
1799
+ return null;
1800
+ }
1801
+
1802
+ const trimmed = str.trim();
1803
+ if (!trimmed) {
1804
+ return null;
1805
+ }
1806
+
1807
+ // Simple tag name: “td”
1808
+ if (!/[<>]/.test(trimmed)) {
1809
+ return { tag: options.name(trimmed), attrs: null };
1810
+ }
1811
+
1812
+ // HTML-like markup: “<span aria-hidden='true'>” or “<td></td>”
1813
+ // Extract opening tag using regex
1814
+ const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
1815
+ if (!match) {
1816
+ return null;
1817
+ }
1818
+
1819
+ const tag = options.name(match[1]);
1820
+ const attrString = match[2];
1821
+
1822
+ if (!attrString.trim()) {
1823
+ return { tag, attrs: null };
1824
+ }
1825
+
1826
+ // Parse attributes from string
1827
+ const attrs = {};
1828
+ const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
1829
+ let attrMatch;
1830
+
1831
+ while ((attrMatch = attrRegex.exec(attrString))) {
1832
+ const attrName = options.name(attrMatch[1]);
1833
+ const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
1834
+ // Boolean attributes have no value (undefined)
1835
+ attrs[attrName] = attrValue;
1836
+ }
1837
+
1838
+ return {
1839
+ tag,
1840
+ attrs: Object.keys(attrs).length > 0 ? attrs : null
1841
+ };
1842
+ }
1843
+
1844
+ /**
1845
+ * @param {string[]} input - Array of element specifications from `removeEmptyElementsExcept` option
1846
+ * @param {MinifierOptions} options - Options object for parsing
1847
+ * @returns {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} Array of parsed element specs
1848
+ */
1849
+ function parseRemoveEmptyElementsExcept(input, options) {
1850
+ if (!Array.isArray(input)) {
1851
+ return [];
1852
+ }
1853
+
1854
+ return input.map(item => {
1855
+ if (typeof item === 'string') {
1856
+ const spec = parseElementSpec(item, options);
1857
+ if (!spec && options.log) {
1858
+ options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
1859
+ }
1860
+ return spec;
1861
+ }
1862
+ if (options.log) {
1863
+ options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
1864
+ }
1865
+ return null;
1866
+ }).filter(Boolean);
1867
+ }
1868
+
1869
+ /**
1870
+ * @param {string} tag - Element tag name
1871
+ * @param {HTMLAttribute[]} attrs - Array of element attributes
1872
+ * @param {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} preserveList - Parsed preserve specs
1873
+ * @returns {boolean} True if the empty element should be preserved
1874
+ */
1875
+ function shouldPreserveEmptyElement(tag, attrs, preserveList) {
1876
+ for (const spec of preserveList) {
1877
+ // Tag name must match
1878
+ if (spec.tag !== tag) {
1879
+ continue;
1880
+ }
1881
+
1882
+ // If no attributes specified in spec, tag match is enough
1883
+ if (!spec.attrs) {
1884
+ return true;
1885
+ }
1886
+
1887
+ // Check if all specified attributes match
1888
+ const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
1889
+ const attr = attrs.find(a => a.name === name);
1890
+ if (!attr) {
1891
+ return false; // Attribute not present
1892
+ }
1893
+ // Boolean attribute in spec (undefined value) matches if attribute is present
1894
+ if (value === undefined) {
1895
+ return true;
1896
+ }
1897
+ // Valued attribute must match exactly
1898
+ return attr.value === value;
1899
+ });
1900
+
1901
+ if (allAttrsMatch) {
1902
+ return true;
1903
+ }
1904
+ }
1905
+
1906
+ return false;
1907
+ }
1908
+
1909
+ function canCollapseWhitespace(tag) {
1910
+ return !/^(?:script|style|pre|textarea)$/.test(tag);
1911
+ }
1912
+
1913
+ function canTrimWhitespace(tag) {
1914
+ return !/^(?:pre|textarea)$/.test(tag);
1915
+ }
1916
+
1917
+ async function normalizeAttr(attr, attrs, tag, options) {
1918
+ const attrName = options.name(attr.name);
1919
+ let attrValue = attr.value;
1920
+
1921
+ if (options.decodeEntities && attrValue) {
1922
+ // Fast path: only decode when entities are present
1923
+ if (attrValue.indexOf('&') !== -1) {
1924
+ attrValue = entities.decodeHTMLStrict(attrValue);
1925
+ }
1926
+ }
1927
+
1928
+ if ((options.removeRedundantAttributes &&
1929
+ isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
1930
+ (options.removeScriptTypeAttributes && tag === 'script' &&
1931
+ attrName === 'type' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue)) ||
1932
+ (options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
1933
+ attrName === 'type' && isStyleLinkTypeAttribute(attrValue))) {
1934
+ return;
1935
+ }
1936
+
1937
+ if (attrValue) {
1938
+ attrValue = await cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML);
1939
+ }
1940
+
1941
+ if (options.removeEmptyAttributes &&
1942
+ canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
1943
+ return;
1944
+ }
1945
+
1946
+ if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
1947
+ attrValue = attrValue.replace(RE_AMP_ENTITY, '&amp;$1');
1948
+ }
1949
+
1950
+ return {
1951
+ attr,
1952
+ name: attrName,
1953
+ value: attrValue
1954
+ };
1955
+ }
1956
+
1957
+ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
1958
+ const attrName = normalized.name;
1959
+ let attrValue = normalized.value;
1960
+ const attr = normalized.attr;
1961
+ let attrQuote = attr.quote;
1962
+ let attrFragment;
1963
+ let emittedAttrValue;
1964
+
1965
+ if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
1966
+ ~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
1967
+ if (!options.preventAttributesEscaping) {
1968
+ if (typeof options.quoteCharacter === 'undefined') {
1969
+ // Count quotes in a single pass instead of two regex operations
1970
+ let apos = 0, quot = 0;
1971
+ for (let i = 0; i < attrValue.length; i++) {
1972
+ if (attrValue[i] === "'") apos++;
1973
+ else if (attrValue[i] === '"') quot++;
1974
+ }
1975
+ attrQuote = apos < quot ? '\'' : '"';
1976
+ } else {
1977
+ attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
1978
+ }
1979
+ if (attrQuote === '"') {
1980
+ attrValue = attrValue.replace(/"/g, '&#34;');
1981
+ } else {
1982
+ attrValue = attrValue.replace(/'/g, '&#39;');
1983
+ }
1984
+ }
1985
+ emittedAttrValue = attrQuote + attrValue + attrQuote;
1986
+ if (!isLast && !options.removeTagWhitespace) {
1987
+ emittedAttrValue += ' ';
1988
+ }
1989
+ } else if (isLast && !hasUnarySlash && !/\/$/.test(attrValue)) {
1990
+ // Make sure trailing slash is not interpreted as HTML self-closing tag
1991
+ emittedAttrValue = attrValue;
1992
+ } else {
1993
+ emittedAttrValue = attrValue + ' ';
1994
+ }
1995
+
1996
+ if (typeof attrValue === 'undefined' || (options.collapseBooleanAttributes &&
1997
+ isBooleanAttribute(attrName.toLowerCase(), attrValue.toLowerCase()))) {
1998
+ attrFragment = attrName;
1999
+ if (!isLast) {
2000
+ attrFragment += ' ';
2001
+ }
2002
+ } else {
2003
+ attrFragment = attrName + attr.customAssign + emittedAttrValue;
2004
+ }
2005
+
2006
+ return attr.customOpen + attrFragment + attr.customClose;
2007
+ }
2008
+
2009
+ function identity(value) {
2010
+ return value;
2011
+ }
2012
+
2013
+ function identityAsync(value) {
2014
+ return Promise.resolve(value);
2015
+ }
2016
+
2017
+ function shouldMinifyInnerHTML(options) {
2018
+ return Boolean(
2019
+ options.collapseWhitespace ||
2020
+ options.removeComments ||
2021
+ options.removeOptionalTags ||
2022
+ options.minifyJS !== identity ||
2023
+ options.minifyCSS !== identityAsync ||
2024
+ options.minifyURLs !== identity
2025
+ );
2026
+ }
2027
+
2028
+ /**
2029
+ * @param {Partial<MinifierOptions>} inputOptions - User-provided options
2030
+ * @returns {MinifierOptions} Normalized options with defaults applied
2031
+ */
2032
+ const processOptions = (inputOptions) => {
2033
+ const options = {
2034
+ name: function (name) {
2035
+ return name.toLowerCase();
2036
+ },
2037
+ canCollapseWhitespace,
2038
+ canTrimWhitespace,
2039
+ continueOnMinifyError: true,
2040
+ html5: true,
2041
+ ignoreCustomComments: [
2042
+ /^!/,
2043
+ /^\s*#/
2044
+ ],
2045
+ ignoreCustomFragments: [
2046
+ /<%[\s\S]*?%>/,
2047
+ /<\?[\s\S]*?\?>/
2048
+ ],
2049
+ includeAutoGeneratedTags: true,
2050
+ log: identity,
2051
+ minifyCSS: identityAsync,
2052
+ minifyJS: identity,
2053
+ minifyURLs: identity
2054
+ };
2055
+
2056
+ Object.keys(inputOptions).forEach(function (key) {
2057
+ const option = inputOptions[key];
2058
+
2059
+ if (key === 'caseSensitive') {
2060
+ if (option) {
2061
+ options.name = identity;
2062
+ }
2063
+ } else if (key === 'log') {
2064
+ if (typeof option === 'function') {
2065
+ options.log = option;
2066
+ }
2067
+ } else if (key === 'minifyCSS' && typeof option !== 'function') {
2068
+ if (!option) {
2069
+ return;
2070
+ }
2071
+
2072
+ const lightningCssOptions = typeof option === 'object' ? option : {};
2073
+
2074
+ options.minifyCSS = async function (text, type) {
2075
+ // Fast path: nothing to minify
2076
+ if (!text || !text.trim()) {
2077
+ return text;
2078
+ }
2079
+ text = await replaceAsync(
2080
+ text,
2081
+ /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
2082
+ async function (match, prefix, dq, sq, unq, suffix) {
2083
+ const quote = dq != null ? '"' : (sq != null ? "'" : '');
2084
+ const url = dq ?? sq ?? unq ?? '';
2085
+ try {
2086
+ const out = await options.minifyURLs(url);
2087
+ return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
2088
+ } catch (err) {
2089
+ if (!options.continueOnMinifyError) {
2090
+ throw err;
2091
+ }
2092
+ options.log && options.log(err);
2093
+ return match;
2094
+ }
2095
+ }
2096
+ );
2097
+ // Cache key: wrapped content, type, options signature
2098
+ const inputCSS = wrapCSS(text, type);
2099
+ const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
2100
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
2101
+ const cssKey = inputCSS.length > 2048
2102
+ ? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
2103
+ : (inputCSS + '|' + type + '|' + cssSig);
2104
+
2105
+ try {
2106
+ const cached = cssMinifyCache.get(cssKey);
2107
+ if (cached) {
2108
+ return cached;
2109
+ }
2110
+
2111
+ const transformCSS = await getLightningCSS();
2112
+ const result = transformCSS({
2113
+ filename: 'input.css',
2114
+ code: Buffer.from(inputCSS),
2115
+ minify: true,
2116
+ errorRecovery: !!options.continueOnMinifyError,
2117
+ ...lightningCssOptions
2118
+ });
2119
+
2120
+ const outputCSS = unwrapCSS(result.code.toString(), type);
2121
+
2122
+ // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
2123
+ // This preserves:
2124
+ // 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
2125
+ // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
2126
+ // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
2127
+ const isCDATA = text.includes('<![CDATA[');
2128
+ const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
2129
+ const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
2130
+ const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
2131
+
2132
+ // Preserve if output is empty and input had template syntax or UIDs
2133
+ // This catches cases where Lightning CSS removed content that should be preserved
2134
+ const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
2135
+
2136
+ cssMinifyCache.set(cssKey, finalOutput);
2137
+ return finalOutput;
2138
+ } catch (err) {
2139
+ cssMinifyCache.delete(cssKey);
2140
+ if (!options.continueOnMinifyError) {
2141
+ throw err;
2142
+ }
2143
+ options.log && options.log(err);
2144
+ return text;
2145
+ }
2146
+ };
2147
+ } else if (key === 'minifyJS' && typeof option !== 'function') {
2148
+ if (!option) {
2149
+ return;
2150
+ }
2151
+
2152
+ const terserOptions = typeof option === 'object' ? option : {};
2153
+
2154
+ terserOptions.parse = {
2155
+ ...terserOptions.parse,
2156
+ bare_returns: false
2157
+ };
2158
+
2159
+ options.minifyJS = async function (text, inline) {
2160
+ const start = text.match(/^\s*<!--.*/);
2161
+ const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
2162
+
2163
+ terserOptions.parse.bare_returns = inline;
2164
+
2165
+ let jsKey;
2166
+ try {
2167
+ // Fast path: avoid invoking Terser for empty/whitespace-only content
2168
+ if (!code || !code.trim()) {
2169
+ return '';
2170
+ }
2171
+ // Cache key: content, inline, options signature (subset)
2172
+ const terserSig = stableStringify({
2173
+ compress: terserOptions.compress,
2174
+ mangle: terserOptions.mangle,
2175
+ ecma: terserOptions.ecma,
2176
+ toplevel: terserOptions.toplevel,
2177
+ module: terserOptions.module,
2178
+ keep_fnames: terserOptions.keep_fnames,
2179
+ format: terserOptions.format,
2180
+ cont: !!options.continueOnMinifyError,
2181
+ });
2182
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
2183
+ jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
2184
+ const cached = jsMinifyCache.get(jsKey);
2185
+ if (cached) {
2186
+ return await cached;
2187
+ }
2188
+ const inFlight = (async () => {
2189
+ const terser = await getTerser();
2190
+ const result = await terser(code, terserOptions);
2191
+ return result.code.replace(RE_TRAILING_SEMICOLON, '');
2192
+ })();
2193
+ jsMinifyCache.set(jsKey, inFlight);
2194
+ const resolved = await inFlight;
2195
+ jsMinifyCache.set(jsKey, resolved);
2196
+ return resolved;
2197
+ } catch (err) {
2198
+ if (jsKey) jsMinifyCache.delete(jsKey);
2199
+ if (!options.continueOnMinifyError) {
2200
+ throw err;
2201
+ }
2202
+ options.log && options.log(err);
2203
+ return text;
2204
+ }
2205
+ };
2206
+ } else if (key === 'minifyURLs' && typeof option !== 'function') {
2207
+ if (!option) {
2208
+ return;
2209
+ }
2210
+
2211
+ let relateUrlOptions = option;
2212
+
2213
+ if (typeof option === 'string') {
2214
+ relateUrlOptions = { site: option };
2215
+ } else if (typeof option !== 'object') {
2216
+ relateUrlOptions = {};
2217
+ }
2218
+
2219
+ options.minifyURLs = function (text) {
2220
+ try {
2221
+ return RelateURL.relate(text, relateUrlOptions);
2222
+ } catch (err) {
2223
+ if (!options.continueOnMinifyError) {
2224
+ throw err;
2225
+ }
2226
+ options.log && options.log(err);
2227
+ return text;
2228
+ }
2229
+ };
2230
+ } else {
2231
+ options[key] = option;
2232
+ }
2233
+ });
2234
+ return options;
2235
+ };
2236
+
2237
+ function uniqueId(value) {
2238
+ let id;
2239
+ do {
2240
+ id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
2241
+ } while (~value.indexOf(id));
2242
+ return id;
2243
+ }
2244
+
2245
+ const specialContentTags = new Set(['script', 'style']);
2246
+
2247
+ async function createSortFns(value, options, uidIgnore, uidAttr) {
2248
+ const attrChains = options.sortAttributes && Object.create(null);
2249
+ const classChain = options.sortClassName && new TokenChain();
2250
+
2251
+ function attrNames(attrs) {
2252
+ return attrs.map(function (attr) {
2253
+ return options.name(attr.name);
2254
+ });
2255
+ }
2256
+
2257
+ function shouldSkipUID(token, uid) {
2258
+ return !uid || token.indexOf(uid) === -1;
2259
+ }
2260
+
2261
+ function shouldSkipUIDs(token) {
2262
+ return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
2263
+ }
2264
+
2265
+ async function scan(input) {
2266
+ let currentTag, currentType;
2267
+ const parser = new HTMLParser(input, {
2268
+ start: function (tag, attrs) {
2269
+ if (attrChains) {
2270
+ if (!attrChains[tag]) {
2271
+ attrChains[tag] = new TokenChain();
2272
+ }
2273
+ attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
2274
+ }
2275
+ for (let i = 0, len = attrs.length; i < len; i++) {
2276
+ const attr = attrs[i];
2277
+ if (classChain && attr.value && options.name(attr.name) === 'class') {
2278
+ classChain.add(trimWhitespace(attr.value).split(/[ \t\n\f\r]+/).filter(shouldSkipUIDs));
2279
+ } else if (options.processScripts && attr.name.toLowerCase() === 'type') {
2280
+ currentTag = tag;
2281
+ currentType = attr.value;
2282
+ }
2283
+ }
2284
+ },
2285
+ end: function () {
2286
+ currentTag = '';
2287
+ },
2288
+ chars: async function (text) {
2289
+ // Only recursively scan HTML content, not JSON-LD or other non-HTML script types
2290
+ // `scan()` is for analyzing HTML attribute order, not for parsing JSON
2291
+ if (options.processScripts && specialContentTags.has(currentTag) &&
2292
+ options.processScripts.indexOf(currentType) > -1 &&
2293
+ currentType === 'text/html') {
2294
+ await scan(text);
2295
+ }
2296
+ }
2297
+ });
2298
+
2299
+ await parser.parse();
2300
+ }
2301
+
2302
+ const log = options.log;
2303
+ options.log = identity;
2304
+ options.sortAttributes = false;
2305
+ options.sortClassName = false;
2306
+ const firstPassOutput = await minifyHTML(value, options);
2307
+ await scan(firstPassOutput);
2308
+ options.log = log;
2309
+ if (attrChains) {
2310
+ const attrSorters = Object.create(null);
2311
+ for (const tag in attrChains) {
2312
+ attrSorters[tag] = attrChains[tag].createSorter();
2313
+ }
2314
+ options.sortAttributes = function (tag, attrs) {
2315
+ const sorter = attrSorters[tag];
2316
+ if (sorter) {
2317
+ const attrMap = Object.create(null);
2318
+ const names = attrNames(attrs);
2319
+ names.forEach(function (name, index) {
2320
+ (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
2321
+ });
2322
+ sorter.sort(names).forEach(function (name, index) {
2323
+ attrs[index] = attrMap[name].shift();
2324
+ });
2325
+ }
2326
+ };
2327
+ }
2328
+ if (classChain) {
2329
+ const sorter = classChain.createSorter();
2330
+ options.sortClassName = function (value) {
2331
+ return sorter.sort(value.split(/[ \n\f\r]+/)).join(' ');
2332
+ };
2333
+ }
2334
+ }
2335
+
2336
+ /**
2337
+ * @param {string} value - HTML content to minify
2338
+ * @param {MinifierOptions} options - Normalized minification options
2339
+ * @param {boolean} [partialMarkup] - Whether treating input as partial markup
2340
+ * @returns {Promise<string>} Minified HTML
2341
+ */
2342
+ async function minifyHTML(value, options, partialMarkup) {
2343
+ // Check input length limitation to prevent ReDoS attacks
2344
+ if (options.maxInputLength && value.length > options.maxInputLength) {
2345
+ throw new Error(`Input length (${value.length}) exceeds maximum allowed length (${options.maxInputLength})`);
2346
+ }
2347
+
2348
+ if (options.collapseWhitespace) {
2349
+ value = collapseWhitespace(value, options, true, true);
2350
+ }
2351
+
2352
+ const buffer = [];
2353
+ let charsPrevTag;
2354
+ let currentChars = '';
2355
+ let hasChars;
2356
+ let currentTag = '';
2357
+ let currentAttrs = [];
2358
+ const stackNoTrimWhitespace = [];
2359
+ const stackNoCollapseWhitespace = [];
2360
+ let optionalStartTag = '';
2361
+ let optionalEndTag = '';
2362
+ const ignoredMarkupChunks = [];
2363
+ const ignoredCustomMarkupChunks = [];
2364
+ let uidIgnore;
2365
+ let uidAttr;
2366
+ let uidPattern;
2367
+ // Create inline tags/text sets with custom elements
2368
+ const customElementsInput = options.inlineCustomElements ?? [];
2369
+ const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
2370
+ const normalizedCustomElements = customElementsArr.map(name => options.name(name));
2371
+ // Fast path: reuse base Sets if no custom elements
2372
+ const inlineTextSet = normalizedCustomElements.length
2373
+ ? new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements])
2374
+ : inlineElementsToKeepWhitespaceWithin;
2375
+ const inlineElements = normalizedCustomElements.length
2376
+ ? new Set([...inlineElementsToKeepWhitespaceAround, ...normalizedCustomElements])
2377
+ : inlineElementsToKeepWhitespaceAround;
2378
+
2379
+ // Parse `removeEmptyElementsExcept` option
2380
+ let removeEmptyElementsExcept;
2381
+ if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
2382
+ if (options.log) {
2383
+ options.log('Warning: “removeEmptyElementsExcept” option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
2384
+ }
2385
+ removeEmptyElementsExcept = [];
2386
+ } else {
2387
+ removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
2388
+ }
2389
+
2390
+ // Temporarily replace ignored chunks with comments,
2391
+ // so that we don’t have to worry what’s there.
2392
+ // For all we care there might be
2393
+ // completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
2394
+ value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
2395
+ if (!uidIgnore) {
2396
+ uidIgnore = uniqueId(value);
2397
+ const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
2398
+ if (options.ignoreCustomComments) {
2399
+ options.ignoreCustomComments = options.ignoreCustomComments.slice();
2400
+ } else {
2401
+ options.ignoreCustomComments = [];
2402
+ }
2403
+ options.ignoreCustomComments.push(pattern);
2404
+ }
2405
+ const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
2406
+ ignoredMarkupChunks.push(group1);
2407
+ return token;
2408
+ });
2409
+
2410
+ const customFragments = options.ignoreCustomFragments.map(function (re) {
2411
+ return re.source;
2412
+ });
2413
+ if (customFragments.length) {
2414
+ // Warn about potential ReDoS if custom fragments use unlimited quantifiers
2415
+ for (let i = 0; i < customFragments.length; i++) {
2416
+ if (/[*+]/.test(customFragments[i])) {
2417
+ options.log('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
2418
+ break;
2419
+ }
2420
+ }
2421
+
2422
+ // Safe approach: Use bounded quantifiers instead of unlimited ones to prevent ReDoS
2423
+ const maxQuantifier = options.customFragmentQuantifierLimit || 200;
2424
+ const whitespacePattern = `\\s{0,${maxQuantifier}}`;
2425
+
2426
+ // Use bounded quantifiers to prevent ReDoS—this approach prevents exponential backtracking
2427
+ const reCustomIgnore = new RegExp(
2428
+ whitespacePattern + '(?:' + customFragments.join('|') + '){1,' + maxQuantifier + '}' + whitespacePattern,
2429
+ 'g'
2430
+ );
2431
+ // Temporarily replace custom ignored fragments with unique attributes
2432
+ value = value.replace(reCustomIgnore, function (match) {
2433
+ if (!uidAttr) {
2434
+ uidAttr = uniqueId(value);
2435
+ uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
2436
+
2437
+ if (options.minifyCSS) {
2438
+ options.minifyCSS = (function (fn) {
2439
+ return function (text, type) {
2440
+ text = text.replace(uidPattern, function (match, prefix, index) {
2441
+ const chunks = ignoredCustomMarkupChunks[+index];
2442
+ return chunks[1] + uidAttr + index + uidAttr + chunks[2];
2443
+ });
2444
+
2445
+ return fn(text, type);
2446
+ };
2447
+ })(options.minifyCSS);
2448
+ }
2449
+
2450
+ if (options.minifyJS) {
2451
+ options.minifyJS = (function (fn) {
2452
+ return function (text, type) {
2453
+ return fn(text.replace(uidPattern, function (match, prefix, index) {
2454
+ const chunks = ignoredCustomMarkupChunks[+index];
2455
+ return chunks[1] + uidAttr + index + uidAttr + chunks[2];
2456
+ }), type);
2457
+ };
2458
+ })(options.minifyJS);
2459
+ }
2460
+ }
2461
+
2462
+ const token = uidAttr + ignoredCustomMarkupChunks.length + uidAttr;
2463
+ ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
2464
+ return '\t' + token + '\t';
2465
+ });
2466
+ }
2467
+
2468
+ if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
2469
+ (options.sortClassName && typeof options.sortClassName !== 'function')) {
2470
+ await createSortFns(value, options, uidIgnore, uidAttr);
2471
+ }
2472
+
2473
+ function _canCollapseWhitespace(tag, attrs) {
2474
+ return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
2475
+ }
2476
+
2477
+ function _canTrimWhitespace(tag, attrs) {
2478
+ return options.canTrimWhitespace(tag, attrs, canTrimWhitespace);
2479
+ }
2480
+
2481
+ function removeStartTag() {
2482
+ let index = buffer.length - 1;
2483
+ while (index > 0 && !/^<[^/!]/.test(buffer[index])) {
2484
+ index--;
2485
+ }
2486
+ buffer.length = Math.max(0, index);
2487
+ }
2488
+
2489
+ function removeEndTag() {
2490
+ let index = buffer.length - 1;
2491
+ while (index > 0 && !/^<\//.test(buffer[index])) {
2492
+ index--;
2493
+ }
2494
+ buffer.length = Math.max(0, index);
2495
+ }
2496
+
2497
+ // Look for trailing whitespaces, bypass any inline tags
2498
+ function trimTrailingWhitespace(index, nextTag) {
2499
+ for (let endTag = null; index >= 0 && _canTrimWhitespace(endTag); index--) {
2500
+ const str = buffer[index];
2501
+ const match = str.match(/^<\/([\w:-]+)>$/);
2502
+ if (match) {
2503
+ endTag = match[1];
2504
+ } else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, null, nextTag, options, inlineElements, inlineTextSet))) {
2505
+ break;
2506
+ }
2507
+ }
2508
+ }
2509
+
2510
+ // Look for trailing whitespaces from previously processed text
2511
+ // which may not be trimmed due to a following comment or an empty
2512
+ // element which has now been removed
2513
+ function squashTrailingWhitespace(nextTag) {
2514
+ let charsIndex = buffer.length - 1;
2515
+ if (buffer.length > 1) {
2516
+ const item = buffer[buffer.length - 1];
2517
+ if (/^(?:<!|$)/.test(item) && item.indexOf(uidIgnore) === -1) {
2518
+ charsIndex--;
2519
+ }
2520
+ }
2521
+ trimTrailingWhitespace(charsIndex, nextTag);
2522
+ }
2523
+
2524
+ const parser = new HTMLParser(value, {
2525
+ partialMarkup: partialMarkup ?? options.partialMarkup,
2526
+ continueOnParseError: options.continueOnParseError,
2527
+ customAttrAssign: options.customAttrAssign,
2528
+ customAttrSurround: options.customAttrSurround,
2529
+ html5: options.html5,
2530
+
2531
+ start: async function (tag, attrs, unary, unarySlash, autoGenerated) {
2532
+ if (tag.toLowerCase() === 'svg') {
2533
+ options = Object.create(options);
2534
+ options.caseSensitive = true;
2535
+ options.keepClosingSlash = true;
2536
+ options.name = identity;
2537
+ }
2538
+ tag = options.name(tag);
2539
+ currentTag = tag;
2540
+ charsPrevTag = tag;
2541
+ if (!inlineTextSet.has(tag)) {
2542
+ currentChars = '';
2543
+ }
2544
+ hasChars = false;
2545
+ currentAttrs = attrs;
2546
+
2547
+ let optional = options.removeOptionalTags;
2548
+ if (optional) {
2549
+ const htmlTag = htmlTags.has(tag);
2550
+ // `<html>` may be omitted if first thing inside is not a comment
2551
+ // `<head>` may be omitted if first thing inside is an element
2552
+ // `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`, `<script>`, <`style>`, or `<template>`
2553
+ // `<colgroup>` may be omitted if first thing inside is `<col>`
2554
+ // `<tbody>` may be omitted if first thing inside is `<tr>`
2555
+ if (htmlTag && canRemoveParentTag(optionalStartTag, tag)) {
2556
+ removeStartTag();
2557
+ }
2558
+ optionalStartTag = '';
2559
+ // End-tag-followed-by-start-tag omission rules
2560
+ if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag)) {
2561
+ removeEndTag();
2562
+ // `<colgroup>` cannot be omitted if preceding `</colgroup>` is omitted
2563
+ // `<tbody>` cannot be omitted if preceding `</tbody>`, `</thead>`, or `</tfoot>` is omitted
2564
+ optional = !isStartTagMandatory(optionalEndTag, tag);
2565
+ }
2566
+ optionalEndTag = '';
2567
+ }
2568
+
2569
+ // Set whitespace flags for nested tags (e.g., <code> within a <pre>)
2570
+ if (options.collapseWhitespace) {
2571
+ if (!stackNoTrimWhitespace.length) {
2572
+ squashTrailingWhitespace(tag);
2573
+ }
2574
+ if (!unary) {
2575
+ if (!_canTrimWhitespace(tag, attrs) || stackNoTrimWhitespace.length) {
2576
+ stackNoTrimWhitespace.push(tag);
2577
+ }
2578
+ if (!_canCollapseWhitespace(tag, attrs) || stackNoCollapseWhitespace.length) {
2579
+ stackNoCollapseWhitespace.push(tag);
2580
+ }
2581
+ }
2582
+ }
2583
+
2584
+ const openTag = '<' + tag;
2585
+ const hasUnarySlash = unarySlash && options.keepClosingSlash;
2586
+
2587
+ buffer.push(openTag);
2588
+
2589
+ if (options.sortAttributes) {
2590
+ options.sortAttributes(tag, attrs);
2591
+ }
2592
+
2593
+ const parts = [];
2594
+ for (let i = attrs.length, isLast = true; --i >= 0;) {
2595
+ const normalized = await normalizeAttr(attrs[i], attrs, tag, options);
2596
+ if (normalized) {
2597
+ parts.push(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
2598
+ isLast = false;
2599
+ }
2600
+ }
2601
+ parts.reverse();
2602
+ if (parts.length > 0) {
2603
+ buffer.push(' ');
2604
+ buffer.push.apply(buffer, parts);
2605
+ } else if (optional && optionalStartTags.has(tag)) {
2606
+ // Start tag must never be omitted if it has any attributes
2607
+ optionalStartTag = tag;
2608
+ }
2609
+
2610
+ buffer.push(buffer.pop() + (hasUnarySlash ? '/' : '') + '>');
2611
+
2612
+ if (autoGenerated && !options.includeAutoGeneratedTags) {
2613
+ removeStartTag();
2614
+ optionalStartTag = '';
2615
+ }
2616
+ },
2617
+ end: function (tag, attrs, autoGenerated) {
2618
+ if (tag.toLowerCase() === 'svg') {
2619
+ options = Object.getPrototypeOf(options);
2620
+ }
2621
+ tag = options.name(tag);
2622
+
2623
+ // Check if current tag is in a whitespace stack
2624
+ if (options.collapseWhitespace) {
2625
+ if (stackNoTrimWhitespace.length) {
2626
+ if (tag === stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1]) {
2627
+ stackNoTrimWhitespace.pop();
2628
+ }
2629
+ } else {
2630
+ squashTrailingWhitespace('/' + tag);
2631
+ }
2632
+ if (stackNoCollapseWhitespace.length &&
2633
+ tag === stackNoCollapseWhitespace[stackNoCollapseWhitespace.length - 1]) {
2634
+ stackNoCollapseWhitespace.pop();
2635
+ }
2636
+ }
2637
+
2638
+ let isElementEmpty = false;
2639
+ if (tag === currentTag) {
2640
+ currentTag = '';
2641
+ isElementEmpty = !hasChars;
2642
+ }
2643
+
2644
+ if (options.removeOptionalTags) {
2645
+ // `<html>`, `<head>` or `<body>` may be omitted if the element is empty
2646
+ if (isElementEmpty && topLevelTags.has(optionalStartTag)) {
2647
+ removeStartTag();
2648
+ }
2649
+ optionalStartTag = '';
2650
+ // `</html>` or `</body>` may be omitted if not followed by comment
2651
+ // `</head>` may be omitted if not followed by space or comment
2652
+ // `</p>` may be omitted if no more content in non-`</a>` parent
2653
+ // except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent element
2654
+ if (htmlTags.has(tag) && optionalEndTag && !trailingTags.has(optionalEndTag) && (optionalEndTag !== 'p' || !pInlineTags.has(tag))) {
2655
+ removeEndTag();
2656
+ }
2657
+ optionalEndTag = optionalEndTags.has(tag) ? tag : '';
2658
+ }
2659
+
2660
+ if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
2661
+ let preserve = false;
2662
+ if (removeEmptyElementsExcept.length) {
2663
+ // Normalize attribute names for comparison with specs
2664
+ const normalizedAttrs = attrs.map(attr => ({ ...attr, name: options.name(attr.name) }));
2665
+ preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
2666
+ }
2667
+
2668
+ if (!preserve) {
2669
+ // Remove last “element” from buffer
2670
+ removeStartTag();
2671
+ optionalStartTag = '';
2672
+ optionalEndTag = '';
2673
+ } else {
2674
+ // Preserve the element—add closing tag
2675
+ if (autoGenerated && !options.includeAutoGeneratedTags) {
2676
+ optionalEndTag = '';
2677
+ } else {
2678
+ buffer.push('</' + tag + '>');
2679
+ }
2680
+ charsPrevTag = '/' + tag;
2681
+ if (!inlineElements.has(tag)) {
2682
+ currentChars = '';
2683
+ } else if (isElementEmpty) {
2684
+ currentChars += '|';
2685
+ }
2686
+ }
2687
+ } else {
2688
+ if (autoGenerated && !options.includeAutoGeneratedTags) {
2689
+ optionalEndTag = '';
2690
+ } else {
2691
+ buffer.push('</' + tag + '>');
2692
+ }
2693
+ charsPrevTag = '/' + tag;
2694
+ if (!inlineElements.has(tag)) {
2695
+ currentChars = '';
2696
+ } else if (isElementEmpty) {
2697
+ currentChars += '|';
2698
+ }
2699
+ }
2700
+ },
2701
+ chars: async function (text, prevTag, nextTag) {
2702
+ prevTag = prevTag === '' ? 'comment' : prevTag;
2703
+ nextTag = nextTag === '' ? 'comment' : nextTag;
2704
+ if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
2705
+ if (text.indexOf('&') !== -1) {
2706
+ text = entities.decodeHTML(text);
2707
+ }
2708
+ }
2709
+ if (options.collapseWhitespace) {
2710
+ if (!stackNoTrimWhitespace.length) {
2711
+ if (prevTag === 'comment') {
2712
+ const prevComment = buffer[buffer.length - 1];
2713
+ if (prevComment.indexOf(uidIgnore) === -1) {
2714
+ if (!prevComment) {
2715
+ prevTag = charsPrevTag;
2716
+ }
2717
+ if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
2718
+ const charsIndex = buffer.length - 2;
2719
+ buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
2720
+ text = trailingSpaces + text;
2721
+ return '';
2722
+ });
2723
+ }
2724
+ }
2725
+ }
2726
+ if (prevTag) {
2727
+ if (prevTag === '/nobr' || prevTag === 'wbr') {
2728
+ if (/^\s/.test(text)) {
2729
+ let tagIndex = buffer.length - 1;
2730
+ while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
2731
+ tagIndex--;
2732
+ }
2733
+ trimTrailingWhitespace(tagIndex - 1, 'br');
2734
+ }
2735
+ } else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
2736
+ text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
2737
+ }
2738
+ }
2739
+ if (prevTag || nextTag) {
2740
+ text = collapseWhitespaceSmart(text, prevTag, nextTag, options, inlineElements, inlineTextSet);
2741
+ } else {
2742
+ text = collapseWhitespace(text, options, true, true);
2743
+ }
2744
+ if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
2745
+ trimTrailingWhitespace(buffer.length - 1, nextTag);
2746
+ }
2747
+ }
2748
+ if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
2749
+ text = collapseWhitespace(text, options, false, false, true);
2750
+ }
2751
+ }
2752
+ if (specialContentTags.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs))) {
2753
+ text = await processScript(text, options, currentAttrs);
2754
+ }
2755
+ if (isExecutableScript(currentTag, currentAttrs)) {
2756
+ text = await options.minifyJS(text);
2757
+ }
2758
+ if (isStyleSheet(currentTag, currentAttrs)) {
2759
+ text = await options.minifyCSS(text);
2760
+ }
2761
+ if (options.removeOptionalTags && text) {
2762
+ // `<html>` may be omitted if first thing inside is not a comment
2763
+ // `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`, `<script>`, `<style>`, or `<template>`
2764
+ if (optionalStartTag === 'html' || (optionalStartTag === 'body' && !/^\s/.test(text))) {
2765
+ removeStartTag();
2766
+ }
2767
+ optionalStartTag = '';
2768
+ // `</html>` or `</body>` may be omitted if not followed by comment
2769
+ // `</head>`, `</colgroup>`, or `</caption>` may be omitted if not followed by space or comment
2770
+ if (compactTags.has(optionalEndTag) || (looseTags.has(optionalEndTag) && !/^\s/.test(text))) {
2771
+ removeEndTag();
2772
+ }
2773
+ // Don’t reset optionalEndTag if text is only whitespace and will be collapsed (not conservatively)
2774
+ if (!/^\s+$/.test(text) || !options.collapseWhitespace || options.conservativeCollapse) {
2775
+ optionalEndTag = '';
2776
+ }
2777
+ }
2778
+ charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
2779
+ if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
2780
+ // Escape any `&` symbols that start either:
2781
+ // 1) a legacy named character reference (i.e., one that doesn’t end with `;`)
2782
+ // 2) or any other character reference (i.e., one that does end with `;`)
2783
+ // Note that `&` can be escaped as `&amp`, without the semi-colon.
2784
+ // https://mathiasbynens.be/notes/ambiguous-ampersands
2785
+ if (text.indexOf('&') !== -1) {
2786
+ text = text.replace(/&((?:Iacute|aacute|uacute|plusmn|Otilde|otilde|agrave|Agrave|Yacute|yacute|Oslash|oslash|atilde|Atilde|brvbar|ccedil|Ccedil|Ograve|curren|divide|eacute|Eacute|ograve|Oacute|egrave|Egrave|Ugrave|frac12|frac14|frac34|ugrave|oacute|iacute|Ntilde|ntilde|Uacute|middot|igrave|Igrave|iquest|Aacute|cedil|laquo|micro|iexcl|Icirc|icirc|acirc|Ucirc|Ecirc|ocirc|Ocirc|ecirc|ucirc|Aring|aring|AElig|aelig|acute|pound|raquo|Acirc|times|THORN|szlig|thorn|COPY|auml|ordf|ordm|Uuml|macr|uuml|Auml|ouml|Ouml|para|nbsp|euml|quot|QUOT|Euml|yuml|cent|sect|copy|sup1|sup2|sup3|iuml|Iuml|ETH|shy|reg|not|yen|amp|AMP|REG|uml|eth|deg|gt|GT|LT|lt)(?!;)|(?:#?[0-9a-zA-Z]+;))/g, '&amp$1');
2787
+ }
2788
+ if (text.indexOf('<') !== -1) {
2789
+ text = text.replace(/</g, '&lt;');
2790
+ }
2791
+ }
2792
+ if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
2793
+ text = text.replace(uidPattern, function (match, prefix, index) {
2794
+ return ignoredCustomMarkupChunks[+index][0];
2795
+ });
2796
+ }
2797
+ currentChars += text;
2798
+ if (text) {
2799
+ hasChars = true;
2800
+ }
2801
+ buffer.push(text);
2802
+ },
2803
+ comment: async function (text, nonStandard) {
2804
+ const prefix = nonStandard ? '<!' : '<!--';
2805
+ const suffix = nonStandard ? '>' : '-->';
2806
+ if (isConditionalComment(text)) {
2807
+ text = prefix + await cleanConditionalComment(text, options) + suffix;
2808
+ } else if (options.removeComments) {
2809
+ if (isIgnoredComment(text, options)) {
2810
+ text = '<!--' + text + '-->';
2811
+ } else {
2812
+ text = '';
2813
+ }
2814
+ } else {
2815
+ text = prefix + text + suffix;
2816
+ }
2817
+ if (options.removeOptionalTags && text) {
2818
+ // Preceding comments suppress tag omissions
2819
+ optionalStartTag = '';
2820
+ optionalEndTag = '';
2821
+ }
2822
+ buffer.push(text);
2823
+ },
2824
+ doctype: function (doctype) {
2825
+ buffer.push(options.useShortDoctype
2826
+ ? '<!doctype' +
2827
+ (options.removeTagWhitespace ? '' : ' ') + 'html>'
2828
+ : collapseWhitespaceAll(doctype));
2829
+ }
2830
+ });
2831
+
2832
+ await parser.parse();
2833
+
2834
+ if (options.removeOptionalTags) {
2835
+ // `<html>` may be omitted if first thing inside is not a comment
2836
+ // `<head>` or `<body>` may be omitted if empty
2837
+ if (topLevelTags.has(optionalStartTag)) {
2838
+ removeStartTag();
2839
+ }
2840
+ // except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent element
2841
+ if (optionalEndTag && !trailingTags.has(optionalEndTag)) {
2842
+ removeEndTag();
2843
+ }
2844
+ }
2845
+ if (options.collapseWhitespace) {
2846
+ squashTrailingWhitespace('br');
2847
+ }
2848
+
2849
+ return joinResultSegments(buffer, options, uidPattern
2850
+ ? function (str) {
2851
+ return str.replace(uidPattern, function (match, prefix, index, suffix) {
2852
+ let chunk = ignoredCustomMarkupChunks[+index][0];
2853
+ if (options.collapseWhitespace) {
2854
+ if (prefix !== '\t') {
2855
+ chunk = prefix + chunk;
2856
+ }
2857
+ if (suffix !== '\t') {
2858
+ chunk += suffix;
2859
+ }
2860
+ return collapseWhitespace(chunk, {
2861
+ preserveLineBreaks: options.preserveLineBreaks,
2862
+ conservativeCollapse: !options.trimCustomFragments
2863
+ }, /^[ \n\r\t\f]/.test(chunk), /[ \n\r\t\f]$/.test(chunk));
2864
+ }
2865
+ return chunk;
2866
+ });
2867
+ }
2868
+ : identity, uidIgnore
2869
+ ? function (str) {
2870
+ return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (match, index) {
2871
+ return ignoredMarkupChunks[+index];
2872
+ });
2873
+ }
2874
+ : identity);
2875
+ }
2876
+
2877
+ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
2878
+ let str;
2879
+ const maxLineLength = options.maxLineLength;
2880
+ const noNewlinesBeforeTagClose = options.noNewlinesBeforeTagClose;
2881
+
2882
+ if (maxLineLength) {
2883
+ let line = ''; const lines = [];
2884
+ while (results.length) {
2885
+ const len = line.length;
2886
+ const end = results[0].indexOf('\n');
2887
+ const isClosingTag = Boolean(results[0].match(endTag));
2888
+ const shouldKeepSameLine = noNewlinesBeforeTagClose && isClosingTag;
2889
+
2890
+ if (end < 0) {
2891
+ line += restoreIgnore(restoreCustom(results.shift()));
2892
+ } else {
2893
+ line += restoreIgnore(restoreCustom(results[0].slice(0, end)));
2894
+ results[0] = results[0].slice(end + 1);
2895
+ }
2896
+ if (len > 0 && line.length > maxLineLength && !shouldKeepSameLine) {
2897
+ lines.push(line.slice(0, len));
2898
+ line = line.slice(len);
2899
+ } else if (end >= 0) {
2900
+ lines.push(line);
2901
+ line = '';
2902
+ }
2903
+ }
2904
+ if (line) {
2905
+ lines.push(line);
2906
+ }
2907
+ str = lines.join('\n');
2908
+ } else {
2909
+ str = restoreIgnore(restoreCustom(results.join('')));
2910
+ }
2911
+ return options.collapseWhitespace ? collapseWhitespace(str, options, true, true) : str;
2912
+ }
2913
+
2914
+ /**
2915
+ * @param {string} value
2916
+ * @param {MinifierOptions} [options]
2917
+ * @returns {Promise<string>}
2918
+ */
2919
+ const minify = async function (value, options) {
2920
+ const start = Date.now();
2921
+ options = processOptions(options || {});
2922
+ const result = await minifyHTML(value, options);
2923
+ options.log('minified in: ' + (Date.now() - start) + 'ms');
2924
+ return result;
2925
+ };
2926
+
2927
+ var htmlminifier = { minify, presets, getPreset, getPresetNames };
2928
+
2864
2929
  exports.default = htmlminifier;
2865
2930
  exports.getPreset = getPreset;
2866
2931
  exports.getPresetNames = getPresetNames;