html-minifier-next 4.12.2 → 4.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +84 -26
  2. package/cli.js +1 -1
  3. package/dist/htmlminifier.cjs +1552 -1320
  4. package/dist/htmlminifier.esm.bundle.js +4204 -3972
  5. package/dist/types/htmlminifier.d.ts +10 -3
  6. package/dist/types/htmlminifier.d.ts.map +1 -1
  7. package/dist/types/htmlparser.d.ts.map +1 -1
  8. package/dist/types/lib/attributes.d.ts +29 -0
  9. package/dist/types/lib/attributes.d.ts.map +1 -0
  10. package/dist/types/lib/constants.d.ts +83 -0
  11. package/dist/types/lib/constants.d.ts.map +1 -0
  12. package/dist/types/lib/content.d.ts +7 -0
  13. package/dist/types/lib/content.d.ts.map +1 -0
  14. package/dist/types/lib/elements.d.ts +39 -0
  15. package/dist/types/lib/elements.d.ts.map +1 -0
  16. package/dist/types/lib/options.d.ts +17 -0
  17. package/dist/types/lib/options.d.ts.map +1 -0
  18. package/dist/types/lib/utils.d.ts +21 -0
  19. package/dist/types/lib/utils.d.ts.map +1 -0
  20. package/dist/types/lib/whitespace.d.ts +7 -0
  21. package/dist/types/lib/whitespace.d.ts.map +1 -0
  22. package/dist/types/presets.d.ts.map +1 -1
  23. package/package.json +10 -1
  24. package/src/htmlminifier.js +114 -1229
  25. package/src/htmlparser.js +11 -11
  26. package/src/lib/attributes.js +511 -0
  27. package/src/lib/constants.js +213 -0
  28. package/src/lib/content.js +105 -0
  29. package/src/lib/elements.js +242 -0
  30. package/src/lib/index.js +20 -0
  31. package/src/lib/options.js +300 -0
  32. package/src/lib/utils.js +90 -0
  33. package/src/lib/whitespace.js +139 -0
  34. package/src/presets.js +0 -1
  35. package/src/tokenchain.js +1 -1
  36. package/dist/types/utils.d.ts +0 -2
  37. package/dist/types/utils.d.ts.map +0 -1
@@ -13,7 +13,8 @@ var RelateURL = require('relateurl');
13
13
  */
14
14
 
15
15
  /*
16
- * // Use like so:
16
+ * Use like so:
17
+ *
17
18
  * HTMLParser(htmlString, {
18
19
  * start: function(tag, attrs, unary) {},
19
20
  * end: function(tag) {},
@@ -32,11 +33,11 @@ class CaseInsensitiveSet extends Set {
32
33
  const singleAttrIdentifier = /([^\s"'<>/=]+)/;
33
34
  const singleAttrAssigns = [/=/];
34
35
  const singleAttrValues = [
35
- // attr value double quotes
36
+ // Attr value double quotes
36
37
  /"([^"]*)"+/.source,
37
- // attr value, single quotes
38
+ // Attr value, single quotes
38
39
  /'([^']*)'+/.source,
39
- // attr value, no quotes
40
+ // Attr value, no quotes
40
41
  /([^ \t\n\f\r"'`=<>]+)/.source
41
42
  ];
42
43
  // https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName
@@ -65,18 +66,17 @@ const empty = new CaseInsensitiveSet(['area', 'base', 'basefont', 'br', 'col', '
65
66
  // Inline elements
66
67
  const inline = new CaseInsensitiveSet(['a', 'abbr', 'acronym', 'applet', 'b', 'basefont', 'bdo', 'big', 'br', 'button', 'cite', 'code', 'del', 'dfn', 'em', 'font', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'map', 'noscript', 'object', 'q', 's', 'samp', 'script', 'select', 'selectedcontent', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'svg', 'textarea', 'tt', 'u', 'var']);
67
68
 
68
- // Elements that you can, intentionally, leave open
69
- // (and which close themselves)
69
+ // Elements that you can, intentionally, leave open (and which close themselves)
70
70
  const closeSelf = new CaseInsensitiveSet(['colgroup', 'dd', 'dt', 'li', 'option', 'p', 'td', 'tfoot', 'th', 'thead', 'tr', 'source']);
71
71
 
72
- // Attributes that have their values filled in disabled='disabled'
72
+ // Attributes that have their values filled in `disabled='disabled'`
73
73
  const fillAttrs = new CaseInsensitiveSet(['checked', 'compact', 'declare', 'defer', 'disabled', 'ismap', 'multiple', 'nohref', 'noresize', 'noshade', 'nowrap', 'readonly', 'selected']);
74
74
 
75
75
  // Special elements (can contain anything)
76
76
  const special = new CaseInsensitiveSet(['script', 'style']);
77
77
 
78
- // HTML elements https://html.spec.whatwg.org/multipage/indices.html#elements-3
79
- // Phrasing Content https://html.spec.whatwg.org/multipage/dom.html#phrasing-content
78
+ // HTML elements, https://html.spec.whatwg.org/multipage/indices.html#elements-3
79
+ // Phrasing content, https://html.spec.whatwg.org/multipage/dom.html#phrasing-content
80
80
  const nonPhrasing = new CaseInsensitiveSet(['address', 'article', 'aside', 'base', 'blockquote', 'body', 'caption', 'col', 'colgroup', 'dd', 'details', 'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'legend', 'li', 'menuitem', 'meta', 'ol', 'optgroup', 'option', 'param', 'rp', 'rt', 'source', 'style', 'summary', 'tbody', 'td', 'tfoot', 'th', 'thead', 'title', 'tr', 'track', 'ul']);
81
81
 
82
82
  const reCache = {};
@@ -198,7 +198,7 @@ class HTMLParser {
198
198
 
199
199
  if (conditionalEnd >= 0) {
200
200
  if (handler.comment) {
201
- await handler.comment(html.substring(2, conditionalEnd + 1), true /* non-standard */);
201
+ await handler.comment(html.substring(2, conditionalEnd + 1), true /* Non-standard */);
202
202
  }
203
203
  advance(conditionalEnd + 2);
204
204
  prevTag = '';
@@ -375,7 +375,7 @@ class HTMLParser {
375
375
  attr = [];
376
376
  attr[0] = fullAttr;
377
377
  attr[baseIndex] = manualMatch[1]; // Attribute name
378
- attr[baseIndex + 1] = '='; // customAssign (falls back to “=” for huge attributes)
378
+ attr[baseIndex + 1] = '='; // `customAssign` (falls back to “=” for huge attributes)
379
379
  const value = input.slice(manualMatch[0].length + 1, closeQuote);
380
380
  // Place value at correct index based on quote type
381
381
  if (quoteChar === '"') {
@@ -681,7 +681,7 @@ class TokenChain {
681
681
  const sorter = new Sorter();
682
682
  sorter.sorterMap = new Map();
683
683
 
684
- // Convert Map entries to array and sort
684
+ // Convert Map entries to array and sort by frequency (descending) then alphabetically
685
685
  const entries = Array.from(this.map.entries()).sort((a, b) => {
686
686
  const m = a[1].arrays.length;
687
687
  const n = b[1].arrays.length;
@@ -729,18 +729,6 @@ class TokenChain {
729
729
  }
730
730
  }
731
731
 
732
- async function replaceAsync(str, regex, asyncFn) {
733
- const promises = [];
734
-
735
- str.replace(regex, (match, ...args) => {
736
- const promise = asyncFn(match, ...args);
737
- promises.push(promise);
738
- });
739
-
740
- const data = await Promise.all(promises);
741
- return str.replace(regex, () => data.shift());
742
- }
743
-
744
732
  /**
745
733
  * Preset configurations for HTML Minifier Next
746
734
  *
@@ -765,7 +753,6 @@ const presets = {
765
753
  useShortDoctype: true
766
754
  },
767
755
  comprehensive: {
768
- // @@ Add `collapseAttributeWhitespace: true` (also add to preset in demo)
769
756
  caseSensitive: true,
770
757
  collapseBooleanAttributes: true,
771
758
  collapseInlineTagWhitespace: true,
@@ -809,407 +796,90 @@ function getPresetNames() {
809
796
  return Object.keys(presets);
810
797
  }
811
798
 
812
- // Lazy-load heavy dependencies only when needed
799
+ // Stringify for options signatures (sorted keys, shallow, nested objects)
813
800
 
814
- let lightningCSSPromise;
815
- async function getLightningCSS() {
816
- if (!lightningCSSPromise) {
817
- lightningCSSPromise = import('lightningcss').then(m => m.transform);
801
+ function stableStringify(obj) {
802
+ if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
803
+ if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
804
+ const keys = Object.keys(obj).sort();
805
+ let out = '{';
806
+ for (let i = 0; i < keys.length; i++) {
807
+ const k = keys[i];
808
+ out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
818
809
  }
819
- return lightningCSSPromise;
810
+ return out + '}';
820
811
  }
821
812
 
822
- let terserPromise;
823
- async function getTerser() {
824
- if (!terserPromise) {
825
- terserPromise = import('terser').then(m => m.minify);
813
+ // LRU cache for strings and promises
814
+
815
+ class LRU {
816
+ constructor(limit = 200) {
817
+ this.limit = limit;
818
+ this.map = new Map();
826
819
  }
827
- return terserPromise;
820
+ get(key) {
821
+ if (this.map.has(key)) {
822
+ const v = this.map.get(key);
823
+ this.map.delete(key);
824
+ this.map.set(key, v);
825
+ return v;
826
+ }
827
+ return undefined;
828
+ }
829
+ set(key, value) {
830
+ if (this.map.has(key)) this.map.delete(key);
831
+ this.map.set(key, value);
832
+ if (this.map.size > this.limit) {
833
+ const first = this.map.keys().next().value;
834
+ this.map.delete(first);
835
+ }
836
+ }
837
+ delete(key) { this.map.delete(key); }
828
838
  }
829
839
 
830
- // Type definitions
840
+ // Unique ID generator
841
+
842
+ function uniqueId(value) {
843
+ let id;
844
+ do {
845
+ id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
846
+ } while (~value.indexOf(id));
847
+ return id;
848
+ }
849
+
850
+ // Identity functions
851
+
852
+ function identity(value) {
853
+ return value;
854
+ }
855
+
856
+ function identityAsync(value) {
857
+ return Promise.resolve(value);
858
+ }
859
+
860
+ // Replace async helper
831
861
 
832
862
  /**
833
- * @typedef {Object} HTMLAttribute
834
- * Representation of an attribute from the HTML parser.
835
- *
836
- * @prop {string} name
837
- * @prop {string} [value]
838
- * @prop {string} [quote]
839
- * @prop {string} [customAssign]
840
- * @prop {string} [customOpen]
841
- * @prop {string} [customClose]
863
+ * Asynchronously replace matches in a string
864
+ * @param {string} str - Input string
865
+ * @param {RegExp} regex - Regular expression with global flag
866
+ * @param {Function} asyncFn - Async function to process each match
867
+ * @returns {Promise<string>} Processed string
842
868
  */
869
+ async function replaceAsync(str, regex, asyncFn) {
870
+ const promises = [];
843
871
 
844
- /**
845
- * @typedef {Object} MinifierOptions
846
- * Options that control how HTML is minified. All of these are optional
847
- * and usually default to a disabled/safe value unless noted.
848
- *
849
- * @prop {(tag: string, attrs: HTMLAttribute[], canCollapseWhitespace: (tag: string) => boolean) => boolean} [canCollapseWhitespace]
850
- * Predicate that determines whether whitespace inside a given element
851
- * can be collapsed.
852
- *
853
- * Default: Built-in `canCollapseWhitespace` function
854
- *
855
- * @prop {(tag: string | null, attrs: HTMLAttribute[] | undefined, canTrimWhitespace: (tag: string) => boolean) => boolean} [canTrimWhitespace]
856
- * Predicate that determines whether leading/trailing whitespace around
857
- * the element may be trimmed.
858
- *
859
- * Default: Built-in `canTrimWhitespace` function
860
- *
861
- * @prop {boolean} [caseSensitive]
862
- * When true, tag and attribute names are treated as case-sensitive.
863
- * Useful for custom HTML tags.
864
- * If false (default) names are lower-cased via the `name` function.
865
- *
866
- * Default: `false`
867
- *
868
- * @prop {boolean} [collapseAttributeWhitespace]
869
- * Collapse multiple whitespace characters within attribute values into a
870
- * single space. Also trims leading and trailing whitespace from attribute
871
- * values. Applied as an early normalization step before special attribute
872
- * handlers (CSS minification, class sorting, etc.) run.
873
- *
874
- * Default: `false`
875
- *
876
- * @prop {boolean} [collapseBooleanAttributes]
877
- * Collapse boolean attributes to their name only (for example
878
- * `disabled="disabled"` → `disabled`).
879
- * See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes
880
- *
881
- * Default: `false`
882
- *
883
- * @prop {boolean} [collapseInlineTagWhitespace]
884
- * When false (default) whitespace around `inline` tags is preserved in
885
- * more cases. When true, whitespace around inline tags may be collapsed.
886
- * Must also enable `collapseWhitespace` to have effect.
887
- *
888
- * Default: `false`
889
- *
890
- * @prop {boolean} [collapseWhitespace]
891
- * Collapse multiple whitespace characters into one where allowed. Also
892
- * controls trimming behaviour in several code paths.
893
- * See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_whitespace
894
- *
895
- * Default: `false`
896
- *
897
- * @prop {boolean} [conservativeCollapse]
898
- * If true, be conservative when collapsing whitespace (preserve more
899
- * whitespace in edge cases). Affects collapse algorithms.
900
- * Must also enable `collapseWhitespace` to have effect.
901
- *
902
- * Default: `false`
903
- *
904
- * @prop {boolean} [continueOnMinifyError]
905
- * When set to `false`, minification errors may throw.
906
- * By default, the minifier will attempt to recover from minification
907
- * errors, or ignore them and preserve the original content.
908
- *
909
- * Default: `true`
910
- *
911
- * @prop {boolean} [continueOnParseError]
912
- * When true, the parser will attempt to continue on recoverable parse
913
- * errors. Otherwise, parsing errors may throw.
914
- *
915
- * Default: `false`
916
- *
917
- * @prop {RegExp[]} [customAttrAssign]
918
- * Array of regexes used to recognise custom attribute assignment
919
- * operators (e.g. `'<div flex?="{{mode != cover}}"></div>'`).
920
- * These are concatenated with the built-in assignment patterns.
921
- *
922
- * Default: `[]`
923
- *
924
- * @prop {RegExp} [customAttrCollapse]
925
- * Regex matching attribute names whose values should be collapsed.
926
- * Basically used to remove newlines and excess spaces inside attribute values,
927
- * e.g. `/ng-class/`.
928
- *
929
- * @prop {[RegExp, RegExp][]} [customAttrSurround]
930
- * Array of `[openRegExp, closeRegExp]` pairs used by the parser to
931
- * detect custom attribute surround patterns (for non-standard syntaxes,
932
- * e.g. `<input {{#if value}}checked="checked"{{/if}}>`).
933
- *
934
- * @prop {RegExp[]} [customEventAttributes]
935
- * Array of regexes used to detect event handler attributes for `minifyJS`
936
- * (e.g. `ng-click`). The default matches standard `on…` event attributes.
937
- *
938
- * Default: `[/^on[a-z]{3,}$/]`
939
- *
940
- * @prop {number} [customFragmentQuantifierLimit]
941
- * Limits the quantifier used when building a safe regex for custom
942
- * fragments to avoid ReDoS. See source use for details.
943
- *
944
- * Default: `200`
945
- *
946
- * @prop {boolean} [decodeEntities]
947
- * When true, decodes HTML entities in text and attributes before
948
- * processing, and re-encodes ambiguous ampersands when outputting.
949
- *
950
- * Default: `false`
951
- *
952
- * @prop {boolean} [html5]
953
- * Parse and emit using HTML5 rules. Set to `false` to use non-HTML5
954
- * parsing behavior.
955
- *
956
- * Default: `true`
957
- *
958
- * @prop {RegExp[]} [ignoreCustomComments]
959
- * Comments matching any pattern in this array of regexes will be
960
- * preserved when `removeComments` is enabled. The default preserves
961
- * “bang” comments and comments starting with `#`.
962
- *
963
- * Default: `[/^!/, /^\s*#/]`
964
- *
965
- * @prop {RegExp[]} [ignoreCustomFragments]
966
- * Array of regexes used to identify fragments that should be
967
- * preserved (for example server templates). These fragments are temporarily
968
- * replaced during minification to avoid corrupting template code.
969
- * The default preserves ASP/PHP-style tags.
970
- *
971
- * Default: `[/<%[\s\S]*?%>/, /<\?[\s\S]*?\?>/]`
972
- *
973
- * @prop {boolean} [includeAutoGeneratedTags]
974
- * If false, tags marked as auto-generated by the parser will be omitted
975
- * from output. Useful to skip injected tags.
976
- *
977
- * Default: `true`
978
- *
979
- * @prop {ArrayLike<string>} [inlineCustomElements]
980
- * Collection of custom element tag names that should be treated as inline
981
- * elements for white-space handling, alongside the built-in inline elements.
982
- *
983
- * Default: `[]`
984
- *
985
- * @prop {boolean} [keepClosingSlash]
986
- * Preserve the trailing slash in self-closing tags when present.
987
- *
988
- * Default: `false`
989
- *
990
- * @prop {(message: unknown) => void} [log]
991
- * Logging function used by the minifier for warnings/errors/info.
992
- * You can directly provide `console.log`, but `message` may also be an `Error`
993
- * object or other non-string value.
994
- *
995
- * Default: `() => {}` (no-op function)
996
- *
997
- * @prop {number} [maxInputLength]
998
- * The maximum allowed input length. Used as a guard against ReDoS via
999
- * pathological inputs. If the input exceeds this length an error is
1000
- * thrown.
1001
- *
1002
- * Default: No limit
1003
- *
1004
- * @prop {number} [maxLineLength]
1005
- * Maximum line length for the output. When set the minifier will wrap
1006
- * output to the given number of characters where possible.
1007
- *
1008
- * Default: No limit
1009
- *
1010
- * @prop {boolean | Partial<import("lightningcss").TransformOptions<import("lightningcss").CustomAtRules>> | ((text: string, type?: string) => Promise<string> | string)} [minifyCSS]
1011
- * When true, enables CSS minification for inline `<style>` tags or
1012
- * `style` attributes. If an object is provided, it is passed to
1013
- * [Lightning CSS](https://www.npmjs.com/package/lightningcss)
1014
- * as transform options. If a function is provided, it will be used to perform
1015
- * custom CSS minification. If disabled, CSS is not minified.
1016
- *
1017
- * Default: `false`
1018
- *
1019
- * @prop {boolean | import("terser").MinifyOptions | ((text: string, inline?: boolean) => Promise<string> | string)} [minifyJS]
1020
- * When true, enables JS minification for `<script>` contents and
1021
- * event handler attributes. If an object is provided, it is passed to
1022
- * [terser](https://www.npmjs.com/package/terser) as minify options.
1023
- * If a function is provided, it will be used to perform
1024
- * custom JS minification. If disabled, JS is not minified.
1025
- *
1026
- * Default: `false`
1027
- *
1028
- * @prop {boolean | string | import("relateurl").Options | ((text: string) => Promise<string> | string)} [minifyURLs]
1029
- * When true, enables URL rewriting/minification. If an object is provided,
1030
- * it is passed to [relateurl](https://www.npmjs.com/package/relateurl)
1031
- * as options. If a string is provided, it is treated as an `{ site: string }`
1032
- * options object. If a function is provided, it will be used to perform
1033
- * custom URL minification. If disabled, URLs are not minified.
1034
- *
1035
- * Default: `false`
1036
- *
1037
- * @prop {(name: string) => string} [name]
1038
- * Function used to normalise tag/attribute names. By default, this lowercases
1039
- * names, unless `caseSensitive` is enabled.
1040
- *
1041
- * Default: `(name) => name.toLowerCase()`,
1042
- * or `(name) => name` (no-op function) if `caseSensitive` is enabled.
1043
- *
1044
- * @prop {boolean} [noNewlinesBeforeTagClose]
1045
- * When wrapping lines, prevent inserting a newline directly before a
1046
- * closing tag (useful to keep tags like `</a>` on the same line).
1047
- *
1048
- * Default: `false`
1049
- *
1050
- * @prop {boolean} [partialMarkup]
1051
- * When true, treat input as a partial HTML fragment rather than a complete
1052
- * document. This preserves stray end tags (closing tags without corresponding
1053
- * opening tags) and prevents auto-closing of unclosed tags at the end of input.
1054
- * Useful for minifying template fragments, SSI includes, or other partial HTML
1055
- * that will be combined with other fragments.
1056
- *
1057
- * Default: `false`
1058
- *
1059
- * @prop {boolean} [preserveLineBreaks]
1060
- * Preserve a single line break at the start/end of text nodes when
1061
- * collapsing/trimming whitespace.
1062
- * Must also enable `collapseWhitespace` to have effect.
1063
- *
1064
- * Default: `false`
1065
- *
1066
- * @prop {boolean} [preventAttributesEscaping]
1067
- * When true, attribute values will not be HTML-escaped (dangerous for
1068
- * untrusted input). By default, attributes are escaped.
1069
- *
1070
- * Default: `false`
1071
- *
1072
- * @prop {boolean} [processConditionalComments]
1073
- * When true, conditional comments (for example `<!--[if IE]> … <![endif]-->`)
1074
- * will have their inner content processed by the minifier.
1075
- * Useful to minify HTML that appears inside conditional comments.
1076
- *
1077
- * Default: `false`
1078
- *
1079
- * @prop {string[]} [processScripts]
1080
- * Array of `type` attribute values for `<script>` elements whose contents
1081
- * should be processed as HTML
1082
- * (e.g. `text/ng-template`, `text/x-handlebars-template`, etc.).
1083
- * When present, the contents of matching script tags are recursively minified,
1084
- * like normal HTML content.
1085
- *
1086
- * Default: `[]`
1087
- *
1088
- * @prop {"\"" | "'"} [quoteCharacter]
1089
- * Preferred quote character for attribute values. If unspecified the
1090
- * minifier picks the safest quote based on the attribute value.
1091
- *
1092
- * Default: Auto-detected
1093
- *
1094
- * @prop {boolean} [removeAttributeQuotes]
1095
- * Remove quotes around attribute values where it is safe to do so.
1096
- * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_attribute_quotes
1097
- *
1098
- * Default: `false`
1099
- *
1100
- * @prop {boolean} [removeComments]
1101
- * Remove HTML comments. Comments that match `ignoreCustomComments` will
1102
- * still be preserved.
1103
- * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_comments
1104
- *
1105
- * Default: `false`
1106
- *
1107
- * @prop {boolean | ((attrName: string, tag: string) => boolean)} [removeEmptyAttributes]
1108
- * If true, removes attributes whose values are empty (some attributes
1109
- * are excluded by name). Can also be a function to customise which empty
1110
- * attributes are removed.
1111
- * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_empty_or_blank_attributes
1112
- *
1113
- * Default: `false`
1114
- *
1115
- * @prop {boolean} [removeEmptyElements]
1116
- * Remove elements that are empty and safe to remove (for example
1117
- * `<script />` without `src`).
1118
- * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_empty_elements
1119
- *
1120
- * Default: `false`
1121
- *
1122
- * @prop {string[]} [removeEmptyElementsExcept]
1123
- * Specifies empty elements to preserve when `removeEmptyElements` is enabled.
1124
- * Has no effect unless `removeEmptyElements: true`.
1125
- *
1126
- * Accepts tag names or HTML-like element specifications:
1127
- *
1128
- * * Tag name only: `["td", "span"]`—preserves all empty elements of these types
1129
- * * With valued attributes: `["<span aria-hidden='true'>"]`—preserves only when attribute values match
1130
- * * With boolean attributes: `["<input disabled>"]`—preserves only when boolean attribute is present
1131
- * * Mixed: `["<button type='button' disabled>"]`—all specified attributes must match
1132
- *
1133
- * Attribute matching:
1134
- *
1135
- * * All specified attributes must be present and match (valued attributes must have exact values)
1136
- * * Additional attributes on the element are allowed
1137
- * * Attribute name matching respects the `caseSensitive` option
1138
- * * Supports double quotes, single quotes, and unquoted attribute values in specifications
1139
- *
1140
- * Limitations:
1141
- *
1142
- * * Self-closing syntax (e.g., `["<span/>"]`) is not supported; use `["span"]` instead
1143
- * * Definitions containing `>` within quoted attribute values (e.g., `["<span title='a>b'>"]`) are not supported
1144
- *
1145
- * Default: `[]`
1146
- *
1147
- * @prop {boolean} [removeOptionalTags]
1148
- * Drop optional start/end tags where the HTML specification permits it
1149
- * (for example `</li>`, optional `<html>` etc.).
1150
- * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_optional_tags
1151
- *
1152
- * Default: `false`
1153
- *
1154
- * @prop {boolean} [removeRedundantAttributes]
1155
- * Remove attributes that are redundant because they match the element’s
1156
- * default values (for example `<button type="submit">`).
1157
- * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_redundant_attributes
1158
- *
1159
- * Default: `false`
1160
- *
1161
- * @prop {boolean} [removeScriptTypeAttributes]
1162
- * Remove `type` attributes from `<script>` when they are unnecessary
1163
- * (e.g. `type="text/javascript"`).
1164
- *
1165
- * Default: `false`
1166
- *
1167
- * @prop {boolean} [removeStyleLinkTypeAttributes]
1168
- * Remove `type` attributes from `<style>` and `<link>` elements when
1169
- * they are unnecessary (e.g. `type="text/css"`).
1170
- *
1171
- * Default: `false`
1172
- *
1173
- * @prop {boolean} [removeTagWhitespace]
1174
- * **Note that this will result in invalid HTML!**
1175
- *
1176
- * When true, extra whitespace between tag name and attributes (or before
1177
- * the closing bracket) will be removed where possible. Affects output spacing
1178
- * such as the space used in the short doctype representation.
1179
- *
1180
- * Default: `false`
1181
- *
1182
- * @prop {boolean | ((tag: string, attrs: HTMLAttribute[]) => void)} [sortAttributes]
1183
- * When true, enables sorting of attributes. If a function is provided it
1184
- * will be used as a custom attribute sorter, which should mutate `attrs`
1185
- * in-place to the desired order. If disabled, the minifier will attempt to
1186
- * preserve the order from the input.
1187
- *
1188
- * Default: `false`
1189
- *
1190
- * @prop {boolean | ((value: string) => string)} [sortClassName]
1191
- * When true, enables sorting of class names inside `class` attributes.
1192
- * If a function is provided it will be used to transform/sort the class
1193
- * name string. If disabled, the minifier will attempt to preserve the
1194
- * class-name order from the input.
1195
- *
1196
- * Default: `false`
1197
- *
1198
- * @prop {boolean} [trimCustomFragments]
1199
- * When true, whitespace around ignored custom fragments may be trimmed
1200
- * more aggressively. This affects how preserved fragments interact with
1201
- * surrounding whitespace collapse.
1202
- *
1203
- * Default: `false`
1204
- *
1205
- * @prop {boolean} [useShortDoctype]
1206
- * Replace the HTML doctype with the short `<!doctype html>` form.
1207
- * See also: https://perfectionkills.com/experimenting-with-html-minifier/#use_short_doctype
1208
- *
1209
- * Default: `false`
1210
- */
872
+ str.replace(regex, (match, ...args) => {
873
+ const promise = asyncFn(match, ...args);
874
+ promises.push(promise);
875
+ });
876
+
877
+ const data = await Promise.all(promises);
878
+ return str.replace(regex, () => data.shift());
879
+ }
880
+
881
+ // RegExp patterns (to avoid repeated allocations in hot paths)
1211
882
 
1212
- // Hoisted, reusable RegExp patterns and tiny helpers to avoid repeated allocations in hot paths
1213
883
  const RE_WS_START = /^[ \n\r\t\f]+/;
1214
884
  const RE_WS_END = /[ \n\r\t\f]+$/;
1215
885
  const RE_ALL_WS_NBSP = /[ \n\r\t\f\xA0]+/g;
@@ -1223,74 +893,194 @@ const RE_CAN_REMOVE_ATTR_QUOTES = /^[^ \t\n\f\r"'`=<>]+$/;
1223
893
  const RE_TRAILING_SEMICOLON = /;$/;
1224
894
  const RE_AMP_ENTITY = /&(#?[0-9a-zA-Z]+;)/g;
1225
895
 
1226
- // Tiny stable stringify for options signatures (sorted keys, shallow, nested objects)
1227
- function stableStringify(obj) {
1228
- if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
1229
- if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
1230
- const keys = Object.keys(obj).sort();
1231
- let out = '{';
1232
- for (let i = 0; i < keys.length; i++) {
1233
- const k = keys[i];
1234
- out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
1235
- }
1236
- return out + '}';
1237
- }
896
+ // Inline element Sets for whitespace handling
1238
897
 
1239
- // Minimal LRU cache for strings and promises
1240
- class LRU {
1241
- constructor(limit = 200) {
1242
- this.limit = limit;
1243
- this.map = new Map();
1244
- }
1245
- get(key) {
1246
- const v = this.map.get(key);
1247
- if (v !== undefined) {
1248
- this.map.delete(key);
1249
- this.map.set(key, v);
1250
- }
1251
- return v;
1252
- }
1253
- set(key, value) {
1254
- if (this.map.has(key)) this.map.delete(key);
1255
- this.map.set(key, value);
1256
- if (this.map.size > this.limit) {
1257
- const first = this.map.keys().next().value;
1258
- this.map.delete(first);
1259
- }
1260
- }
1261
- delete(key) { this.map.delete(key); }
1262
- }
898
+ // Non-empty elements that will maintain whitespace around them
899
+ 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']);
1263
900
 
1264
- // Per-process caches
1265
- const jsMinifyCache = new LRU(200);
1266
- const cssMinifyCache = new LRU(200);
901
+ // Non-empty elements that will maintain whitespace within them
902
+ 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']);
1267
903
 
1268
- const trimWhitespace = str => {
1269
- if (!str) return str;
1270
- // Fast path: If no whitespace at start or end, return early
1271
- if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
1272
- return str;
1273
- }
1274
- return str.replace(RE_WS_START, '').replace(RE_WS_END, '');
1275
- };
904
+ // Elements that will always maintain whitespace around them
905
+ const inlineElementsToKeepWhitespace = new Set(['comment', 'img', 'input', 'wbr']);
1276
906
 
1277
- function collapseWhitespaceAll(str) {
1278
- if (!str) return str;
1279
- // Fast path: If there are no common whitespace characters, return early
1280
- if (!/[ \n\r\t\f\xA0]/.test(str)) {
1281
- return str;
1282
- }
1283
- // Non-breaking space is specifically handled inside the replacer function here:
1284
- return str.replace(RE_ALL_WS_NBSP, function (spaces) {
1285
- return spaces === '\t' ? '\t' : spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
1286
- });
1287
- }
907
+ // Default attribute values
1288
908
 
1289
- function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
909
+ // Default attribute values (could apply to any element)
910
+ const generalDefaults = {
911
+ autocorrect: 'on',
912
+ fetchpriority: 'auto',
913
+ loading: 'eager',
914
+ popovertargetaction: 'toggle'
915
+ };
916
+
917
+ // Tag-specific default attribute values
918
+ const tagDefaults = {
919
+ area: { shape: 'rect' },
920
+ button: { type: 'submit' },
921
+ form: {
922
+ enctype: 'application/x-www-form-urlencoded',
923
+ method: 'get'
924
+ },
925
+ html: { dir: 'ltr' },
926
+ img: { decoding: 'auto' },
927
+ input: {
928
+ colorspace: 'limited-srgb',
929
+ type: 'text'
930
+ },
931
+ marquee: {
932
+ behavior: 'scroll',
933
+ direction: 'left'
934
+ },
935
+ style: { media: 'all' },
936
+ textarea: { wrap: 'soft' },
937
+ track: { kind: 'subtitles' }
938
+ };
939
+
940
+ // Script MIME types
941
+
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
+ ]);
953
+
954
+ const keepScriptsMimetypes = new Set([
955
+ 'module'
956
+ ]);
957
+
958
+ // Boolean attribute Sets
959
+
960
+ 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']);
961
+
962
+ const isBooleanValue = new Set(['true', 'false']);
963
+
964
+ // `srcset` tags
965
+
966
+ const srcsetTags = new Set(['img', 'source']);
967
+
968
+ // JSON script types
969
+
970
+ const jsonScriptTypes = new Set([
971
+ 'application/json',
972
+ 'application/ld+json',
973
+ 'application/manifest+json',
974
+ 'application/vnd.geo+json',
975
+ 'application/problem+json',
976
+ 'application/merge-patch+json',
977
+ 'application/json-patch+json',
978
+ 'importmap',
979
+ 'speculationrules',
980
+ ]);
981
+
982
+ // Tag omission rules and element Sets
983
+
984
+ // Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags with the following extensions:
985
+ // - retain `<body>` if followed by `<noscript>`
986
+ // - `<rb>`, `<rt>`, `<rtc>`, `<rp>` follow HTML Ruby Markup Extensions draft (https://www.w3.org/TR/html-ruby-extensions/)
987
+ // - retain all tags which are adjacent to non-standard HTML tags
988
+
989
+ const optionalStartTags = new Set(['html', 'head', 'body', 'colgroup', 'tbody']);
990
+
991
+ 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']);
992
+
993
+ const headerTags = new Set(['meta', 'link', 'script', 'style', 'template', 'noscript']);
994
+
995
+ const descriptionTags = new Set(['dt', 'dd']);
996
+
997
+ 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']);
998
+
999
+ const pInlineTags = new Set(['a', 'audio', 'del', 'ins', 'map', 'noscript', 'video']);
1000
+
1001
+ const rubyEndTagOmission = new Set(['rb', 'rt', 'rtc', 'rp']); // `</rb>`, `</rt>`, `</rp>` can be omitted if followed by `<rb>`, `<rt>`, `<rtc>`, or `<rp>`
1002
+
1003
+ const rubyRtcEndTagOmission = new Set(['rb', 'rtc']); // `</rtc>` can be omitted if followed by `<rb>` or `<rtc>` (not `<rt>` or `<rp>`)
1004
+
1005
+ const optionTag = new Set(['option', 'optgroup']);
1006
+
1007
+ const tableContentTags = new Set(['tbody', 'tfoot']);
1008
+
1009
+ const tableSectionTags = new Set(['thead', 'tbody', 'tfoot']);
1010
+
1011
+ const cellTags = new Set(['td', 'th']);
1012
+
1013
+ const topLevelTags = new Set(['html', 'head', 'body']);
1014
+
1015
+ const compactTags = new Set(['html', 'body']);
1016
+
1017
+ const looseTags = new Set(['head', 'colgroup', 'caption']);
1018
+
1019
+ const trailingTags = new Set(['dt', 'thead']);
1020
+
1021
+ 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']);
1022
+
1023
+ // Empty attribute regex
1024
+
1025
+ const reEmptyAttribute = new RegExp(
1026
+ '^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
1027
+ '?:down|up|over|move|out)|key(?:press|down|up)))$');
1028
+
1029
+ // Special content elements
1030
+
1031
+ const specialContentTags = new Set(['script', 'style']);
1032
+
1033
+ // Imports
1034
+
1035
+
1036
+ // Trim whitespace
1037
+
1038
+ const trimWhitespace = str => {
1039
+ if (!str) return str;
1040
+ // Fast path: If no whitespace at start or end, return early
1041
+ if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
1042
+ return str;
1043
+ }
1044
+ return str.replace(RE_WS_START, '').replace(RE_WS_END, '');
1045
+ };
1046
+
1047
+ // Collapse all whitespace
1048
+
1049
+ function collapseWhitespaceAll(str) {
1050
+ if (!str) return str;
1051
+ // Fast path: If there are no common whitespace characters, return early
1052
+ if (!/[ \n\r\t\f\xA0]/.test(str)) {
1053
+ return str;
1054
+ }
1055
+ // No-break space is specifically handled inside the replacer function here:
1056
+ return str.replace(RE_ALL_WS_NBSP, function (spaces) {
1057
+ // Preserve standalone tabs
1058
+ if (spaces === '\t') return '\t';
1059
+ // Fast path: No no-break space, common case—just collapse to single space
1060
+ // This avoids the nested regex for the majority of cases
1061
+ if (spaces.indexOf('\xA0') === -1) return ' ';
1062
+ // For no-break space handling, use the original regex approach
1063
+ return spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
1064
+ });
1065
+ }
1066
+
1067
+ // Collapse whitespace with options
1068
+
1069
+ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
1290
1070
  let lineBreakBefore = ''; let lineBreakAfter = '';
1291
1071
 
1292
1072
  if (!str) return str;
1293
1073
 
1074
+ // Fast path: Nothing to do
1075
+ if (!trimLeft && !trimRight && !collapseAll && !options.preserveLineBreaks) {
1076
+ return str;
1077
+ }
1078
+
1079
+ // Fast path: No whitespace at all
1080
+ if (!/[ \n\r\t\f\xA0]/.test(str)) {
1081
+ return str;
1082
+ }
1083
+
1294
1084
  if (options.preserveLineBreaks) {
1295
1085
  str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function () {
1296
1086
  lineBreakBefore = '\n';
@@ -1302,7 +1092,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
1302
1092
  }
1303
1093
 
1304
1094
  if (trimLeft) {
1305
- // Non-breaking space is specifically handled inside the replacer function here:
1095
+ // Non-breaking space is specifically handled inside the replacer function
1306
1096
  str = str.replace(/^[ \n\r\t\f\xA0]+/, function (spaces) {
1307
1097
  const conservative = !lineBreakBefore && options.conservativeCollapse;
1308
1098
  if (conservative && spaces === '\t') {
@@ -1313,7 +1103,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
1313
1103
  }
1314
1104
 
1315
1105
  if (trimRight) {
1316
- // Non-breaking space is specifically handled inside the replacer function here:
1106
+ // Non-breaking space is specifically handled inside the replacer function
1317
1107
  str = str.replace(/[ \n\r\t\f\xA0]+$/, function (spaces) {
1318
1108
  const conservative = !lineBreakAfter && options.conservativeCollapse;
1319
1109
  if (conservative && spaces === '\t') {
@@ -1328,15 +1118,14 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
1328
1118
  str = collapseWhitespaceAll(str);
1329
1119
  }
1330
1120
 
1121
+ // Avoid string concatenation when no line breaks (common case)
1122
+ if (!lineBreakBefore && !lineBreakAfter) return str;
1123
+ if (!lineBreakBefore) return str + lineBreakAfter;
1124
+ if (!lineBreakAfter) return lineBreakBefore + str;
1331
1125
  return lineBreakBefore + str + lineBreakAfter;
1332
1126
  }
1333
1127
 
1334
- // Non-empty elements that will maintain whitespace around them
1335
- 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']);
1336
- // Non-empty elements that will maintain whitespace within them
1337
- 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']);
1338
- // Elements that will always maintain whitespace around them
1339
- const inlineElementsToKeepWhitespace = new Set(['comment', 'img', 'input', 'wbr']);
1128
+ // Collapse whitespace smartly based on surrounding tags
1340
1129
 
1341
1130
  function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements, inlineTextSet) {
1342
1131
  let trimLeft = prevTag && !inlineElementsToKeepWhitespace.has(prevTag);
@@ -1350,510 +1139,576 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements,
1350
1139
  return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag);
1351
1140
  }
1352
1141
 
1353
- function isConditionalComment(text) {
1354
- return RE_CONDITIONAL_COMMENT.test(text);
1142
+ // Collapse/trim whitespace for given tag
1143
+
1144
+ function canCollapseWhitespace(tag) {
1145
+ return !/^(?:script|style|pre|textarea)$/.test(tag);
1355
1146
  }
1356
1147
 
1357
- function isIgnoredComment(text, options) {
1358
- for (let i = 0, len = options.ignoreCustomComments.length; i < len; i++) {
1359
- if (options.ignoreCustomComments[i].test(text)) {
1360
- return true;
1148
+ function canTrimWhitespace(tag) {
1149
+ return !/^(?:pre|textarea)$/.test(tag);
1150
+ }
1151
+
1152
+ // Imports
1153
+
1154
+
1155
+ // CSS processing
1156
+
1157
+ // Wrap CSS declarations for inline styles and media queries
1158
+ // This ensures proper context for CSS minification
1159
+
1160
+ function wrapCSS(text, type) {
1161
+ switch (type) {
1162
+ case 'inline':
1163
+ return '*{' + text + '}';
1164
+ case 'media':
1165
+ return '@media ' + text + '{a{top:0}}';
1166
+ default:
1167
+ return text;
1168
+ }
1169
+ }
1170
+
1171
+ function unwrapCSS(text, type) {
1172
+ let matches;
1173
+ switch (type) {
1174
+ case 'inline':
1175
+ matches = text.match(/^\*\{([\s\S]*)\}$/);
1176
+ break;
1177
+ case 'media':
1178
+ matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/);
1179
+ break;
1180
+ }
1181
+ return matches ? matches[1] : text;
1182
+ }
1183
+
1184
+ async function cleanConditionalComment(comment, options, minifyHTML) {
1185
+ return options.processConditionalComments
1186
+ ? await replaceAsync(comment, /^(\[if\s[^\]]+]>)([\s\S]*?)(<!\[endif])$/, async function (match, prefix, text, suffix) {
1187
+ return prefix + await minifyHTML(text, options, true) + suffix;
1188
+ })
1189
+ : comment;
1190
+ }
1191
+
1192
+ // Script processing
1193
+
1194
+ function minifyJson(text, options) {
1195
+ try {
1196
+ return JSON.stringify(JSON.parse(text));
1197
+ }
1198
+ catch (err) {
1199
+ if (!options.continueOnMinifyError) {
1200
+ throw err;
1361
1201
  }
1202
+ options.log && options.log(err);
1203
+ return text;
1362
1204
  }
1363
- return false;
1364
1205
  }
1365
1206
 
1366
- function isEventAttribute(attrName, options) {
1367
- const patterns = options.customEventAttributes;
1368
- if (patterns) {
1369
- for (let i = patterns.length; i--;) {
1370
- if (patterns[i].test(attrName)) {
1207
+ function hasJsonScriptType(attrs) {
1208
+ for (let i = 0, len = attrs.length; i < len; i++) {
1209
+ const attrName = attrs[i].name.toLowerCase();
1210
+ if (attrName === 'type') {
1211
+ const attrValue = trimWhitespace((attrs[i].value || '').split(/;/, 2)[0]).toLowerCase();
1212
+ if (jsonScriptTypes.has(attrValue)) {
1371
1213
  return true;
1372
1214
  }
1373
1215
  }
1374
- return false;
1375
1216
  }
1376
- return RE_EVENT_ATTR_DEFAULT.test(attrName);
1377
- }
1378
-
1379
- function canRemoveAttributeQuotes(value) {
1380
- // https://mathiasbynens.be/notes/unquoted-attribute-values
1381
- return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
1217
+ return false;
1382
1218
  }
1383
1219
 
1384
- function attributesInclude(attributes, attribute) {
1385
- for (let i = attributes.length; i--;) {
1386
- if (attributes[i].name.toLowerCase() === attribute) {
1387
- return true;
1220
+ async function processScript(text, options, currentAttrs, minifyHTML) {
1221
+ for (let i = 0, len = currentAttrs.length; i < len; i++) {
1222
+ const attrName = currentAttrs[i].name.toLowerCase();
1223
+ if (attrName === 'type') {
1224
+ const rawValue = currentAttrs[i].value;
1225
+ const normalizedValue = trimWhitespace((rawValue || '').split(/;/, 2)[0]).toLowerCase();
1226
+ // Minify JSON script types automatically
1227
+ if (jsonScriptTypes.has(normalizedValue)) {
1228
+ return minifyJson(text, options);
1229
+ }
1230
+ // Process custom script types if specified
1231
+ if (options.processScripts && options.processScripts.indexOf(rawValue) > -1) {
1232
+ return await minifyHTML(text, options);
1233
+ }
1388
1234
  }
1389
1235
  }
1390
- return false;
1236
+ return text;
1391
1237
  }
1392
1238
 
1393
- // Default attribute values (could apply to any element)
1394
- const generalDefaults = {
1395
- autocorrect: 'on',
1396
- fetchpriority: 'auto',
1397
- loading: 'eager',
1398
- popovertargetaction: 'toggle'
1399
- };
1239
+ // Imports
1400
1240
 
1401
- // Tag-specific default attribute values
1402
- const tagDefaults = {
1403
- area: { shape: 'rect' },
1404
- button: { type: 'submit' },
1405
- form: {
1406
- enctype: 'application/x-www-form-urlencoded',
1407
- method: 'get'
1408
- },
1409
- html: { dir: 'ltr' },
1410
- img: { decoding: 'auto' },
1411
- input: {
1412
- colorspace: 'limited-srgb',
1413
- type: 'text'
1414
- },
1415
- marquee: {
1416
- behavior: 'scroll',
1417
- direction: 'left'
1418
- },
1419
- style: { media: 'all' },
1420
- textarea: { wrap: 'soft' },
1421
- track: { kind: 'subtitles' }
1422
- };
1423
1241
 
1424
- function isAttributeRedundant(tag, attrName, attrValue, attrs) {
1425
- attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
1242
+ // Helper functions
1426
1243
 
1427
- // Legacy attributes
1428
- if (tag === 'script' && attrName === 'language' && attrValue === 'javascript') {
1429
- return true;
1430
- }
1431
- if (tag === 'script' && attrName === 'charset' && !attributesInclude(attrs, 'src')) {
1432
- return true;
1433
- }
1434
- if (tag === 'a' && attrName === 'name' && attributesInclude(attrs, 'id')) {
1435
- return true;
1436
- }
1244
+ function shouldMinifyInnerHTML(options) {
1245
+ return Boolean(
1246
+ options.collapseWhitespace ||
1247
+ options.removeComments ||
1248
+ options.removeOptionalTags ||
1249
+ options.minifyJS !== identity ||
1250
+ options.minifyCSS !== identityAsync ||
1251
+ options.minifyURLs !== identity
1252
+ );
1253
+ }
1437
1254
 
1438
- // Check general defaults
1439
- if (generalDefaults[attrName] === attrValue) {
1440
- return true;
1441
- }
1255
+ // Main options processor
1442
1256
 
1443
- // Check tag-specific defaults
1444
- return tagDefaults[tag]?.[attrName] === attrValue;
1445
- }
1257
+ /**
1258
+ * @param {Partial<MinifierOptions>} inputOptions - User-provided options
1259
+ * @param {Object} deps - Dependencies from htmlminifier.js
1260
+ * @param {Function} deps.getLightningCSS - Function to lazily load lightningcss
1261
+ * @param {Function} deps.getTerser - Function to lazily load terser
1262
+ * @param {Function} deps.getSwc - Function to lazily load @swc/core
1263
+ * @param {LRU} deps.cssMinifyCache - CSS minification cache
1264
+ * @param {LRU} deps.jsMinifyCache - JS minification cache
1265
+ * @returns {MinifierOptions} Normalized options with defaults applied
1266
+ */
1267
+ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssMinifyCache, jsMinifyCache } = {}) => {
1268
+ const options = {
1269
+ name: function (name) {
1270
+ return name.toLowerCase();
1271
+ },
1272
+ canCollapseWhitespace,
1273
+ canTrimWhitespace,
1274
+ continueOnMinifyError: true,
1275
+ html5: true,
1276
+ ignoreCustomComments: [
1277
+ /^!/,
1278
+ /^\s*#/
1279
+ ],
1280
+ ignoreCustomFragments: [
1281
+ /<%[\s\S]*?%>/,
1282
+ /<\?[\s\S]*?\?>/
1283
+ ],
1284
+ includeAutoGeneratedTags: true,
1285
+ log: identity,
1286
+ minifyCSS: identityAsync,
1287
+ minifyJS: identity,
1288
+ minifyURLs: identity
1289
+ };
1446
1290
 
1447
- // https://mathiasbynens.be/demo/javascript-mime-type
1448
- // https://developer.mozilla.org/en/docs/Web/HTML/Element/script#attr-type
1449
- const executableScriptsMimetypes = new Set([
1450
- 'text/javascript',
1451
- 'text/ecmascript',
1452
- 'text/jscript',
1453
- 'application/javascript',
1454
- 'application/x-javascript',
1455
- 'application/ecmascript',
1456
- 'module'
1457
- ]);
1291
+ Object.keys(inputOptions).forEach(function (key) {
1292
+ const option = inputOptions[key];
1458
1293
 
1459
- const keepScriptsMimetypes = new Set([
1460
- 'module'
1461
- ]);
1294
+ if (key === 'caseSensitive') {
1295
+ if (option) {
1296
+ options.name = identity;
1297
+ }
1298
+ } else if (key === 'log') {
1299
+ if (typeof option === 'function') {
1300
+ options.log = option;
1301
+ }
1302
+ } else if (key === 'minifyCSS' && typeof option !== 'function') {
1303
+ if (!option) {
1304
+ return;
1305
+ }
1462
1306
 
1463
- function isScriptTypeAttribute(attrValue = '') {
1464
- attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
1465
- return attrValue === '' || executableScriptsMimetypes.has(attrValue);
1466
- }
1307
+ const lightningCssOptions = typeof option === 'object' ? option : {};
1467
1308
 
1468
- function keepScriptTypeAttribute(attrValue = '') {
1469
- attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
1470
- return keepScriptsMimetypes.has(attrValue);
1471
- }
1309
+ options.minifyCSS = async function (text, type) {
1310
+ // Fast path: Nothing to minify
1311
+ if (!text || !text.trim()) {
1312
+ return text;
1313
+ }
1314
+ text = await replaceAsync(
1315
+ text,
1316
+ /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
1317
+ async function (match, prefix, dq, sq, unq, suffix) {
1318
+ const quote = dq != null ? '"' : (sq != null ? "'" : '');
1319
+ const url = dq ?? sq ?? unq ?? '';
1320
+ try {
1321
+ const out = await options.minifyURLs(url);
1322
+ return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
1323
+ } catch (err) {
1324
+ if (!options.continueOnMinifyError) {
1325
+ throw err;
1326
+ }
1327
+ options.log && options.log(err);
1328
+ return match;
1329
+ }
1330
+ }
1331
+ );
1332
+ // Cache key: Wrapped content, type, options signature
1333
+ const inputCSS = wrapCSS(text, type);
1334
+ const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
1335
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
1336
+ const cssKey = inputCSS.length > 2048
1337
+ ? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
1338
+ : (inputCSS + '|' + type + '|' + cssSig);
1472
1339
 
1473
- function isExecutableScript(tag, attrs) {
1474
- if (tag !== 'script') {
1475
- return false;
1476
- }
1477
- for (let i = 0, len = attrs.length; i < len; i++) {
1478
- const attrName = attrs[i].name.toLowerCase();
1479
- if (attrName === 'type') {
1480
- return isScriptTypeAttribute(attrs[i].value);
1481
- }
1482
- }
1483
- return true;
1484
- }
1340
+ try {
1341
+ const cached = cssMinifyCache.get(cssKey);
1342
+ if (cached) {
1343
+ return cached;
1344
+ }
1485
1345
 
1486
- function isStyleLinkTypeAttribute(attrValue = '') {
1487
- attrValue = trimWhitespace(attrValue).toLowerCase();
1488
- return attrValue === '' || attrValue === 'text/css';
1489
- }
1346
+ const transformCSS = await getLightningCSS();
1347
+ const result = transformCSS({
1348
+ filename: 'input.css',
1349
+ code: Buffer.from(inputCSS),
1350
+ minify: true,
1351
+ errorRecovery: !!options.continueOnMinifyError,
1352
+ ...lightningCssOptions
1353
+ });
1490
1354
 
1491
- function isStyleSheet(tag, attrs) {
1492
- if (tag !== 'style') {
1493
- return false;
1494
- }
1495
- for (let i = 0, len = attrs.length; i < len; i++) {
1496
- const attrName = attrs[i].name.toLowerCase();
1497
- if (attrName === 'type') {
1498
- return isStyleLinkTypeAttribute(attrs[i].value);
1499
- }
1500
- }
1501
- return true;
1502
- }
1355
+ const outputCSS = unwrapCSS(result.code.toString(), type);
1503
1356
 
1504
- 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']);
1505
- const isBooleanValue = new Set(['true', 'false']);
1357
+ // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
1358
+ // This preserves:
1359
+ // 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
1360
+ // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
1361
+ // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
1362
+ const isCDATA = text.includes('<![CDATA[');
1363
+ const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
1364
+ const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
1365
+ const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
1506
1366
 
1507
- function isBooleanAttribute(attrName, attrValue) {
1508
- return isSimpleBoolean.has(attrName) || (attrName === 'draggable' && !isBooleanValue.has(attrValue));
1509
- }
1367
+ // Preserve if output is empty and input had template syntax or UIDs
1368
+ // This catches cases where Lightning CSS removed content that should be preserved
1369
+ const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
1510
1370
 
1511
- function isUriTypeAttribute(attrName, tag) {
1512
- return (
1513
- (/^(?:a|area|link|base)$/.test(tag) && attrName === 'href') ||
1514
- (tag === 'img' && /^(?:src|longdesc|usemap)$/.test(attrName)) ||
1515
- (tag === 'object' && /^(?:classid|codebase|data|usemap)$/.test(attrName)) ||
1516
- (tag === 'q' && attrName === 'cite') ||
1517
- (tag === 'blockquote' && attrName === 'cite') ||
1518
- ((tag === 'ins' || tag === 'del') && attrName === 'cite') ||
1519
- (tag === 'form' && attrName === 'action') ||
1520
- (tag === 'input' && (attrName === 'src' || attrName === 'usemap')) ||
1521
- (tag === 'head' && attrName === 'profile') ||
1522
- (tag === 'script' && (attrName === 'src' || attrName === 'for'))
1523
- );
1524
- }
1371
+ cssMinifyCache.set(cssKey, finalOutput);
1372
+ return finalOutput;
1373
+ } catch (err) {
1374
+ cssMinifyCache.delete(cssKey);
1375
+ if (!options.continueOnMinifyError) {
1376
+ throw err;
1377
+ }
1378
+ options.log && options.log(err);
1379
+ return text;
1380
+ }
1381
+ };
1382
+ } else if (key === 'minifyJS' && typeof option !== 'function') {
1383
+ if (!option) {
1384
+ return;
1385
+ }
1525
1386
 
1526
- function isNumberTypeAttribute(attrName, tag) {
1527
- return (
1528
- (/^(?:a|area|object|button)$/.test(tag) && attrName === 'tabindex') ||
1529
- (tag === 'input' && (attrName === 'maxlength' || attrName === 'tabindex')) ||
1530
- (tag === 'select' && (attrName === 'size' || attrName === 'tabindex')) ||
1531
- (tag === 'textarea' && /^(?:rows|cols|tabindex)$/.test(attrName)) ||
1532
- (tag === 'colgroup' && attrName === 'span') ||
1533
- (tag === 'col' && attrName === 'span') ||
1534
- ((tag === 'th' || tag === 'td') && (attrName === 'rowspan' || attrName === 'colspan'))
1535
- );
1536
- }
1387
+ // Parse configuration
1388
+ const config = typeof option === 'object' ? option : {};
1389
+ const engine = (config.engine || 'terser').toLowerCase();
1537
1390
 
1538
- function isLinkType(tag, attrs, value) {
1539
- if (tag !== 'link') return false;
1540
- const needle = String(value).toLowerCase();
1541
- for (let i = 0; i < attrs.length; i++) {
1542
- if (attrs[i].name.toLowerCase() === 'rel') {
1543
- const tokens = String(attrs[i].value).toLowerCase().split(/\s+/);
1544
- if (tokens.includes(needle)) return true;
1545
- }
1546
- }
1547
- return false;
1548
- }
1391
+ // Validate engine
1392
+ const supportedEngines = ['terser', 'swc'];
1393
+ if (!supportedEngines.includes(engine)) {
1394
+ throw new Error(`Unsupported JS minifier engine: "${engine}". Supported engines: ${supportedEngines.join(', ')}`);
1395
+ }
1549
1396
 
1550
- function isMediaQuery(tag, attrs, attrName) {
1551
- return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleSheet(tag, attrs));
1552
- }
1397
+ // Extract engine-specific options (excluding `engine` field itself)
1398
+ const engineOptions = { ...config };
1399
+ delete engineOptions.engine;
1553
1400
 
1554
- const srcsetTags = new Set(['img', 'source']);
1401
+ // Terser options (needed for inline JS and when engine is `terser`)
1402
+ const terserOptions = engine === 'terser' ? engineOptions : {};
1403
+ terserOptions.parse = {
1404
+ ...terserOptions.parse,
1405
+ bare_returns: false
1406
+ };
1555
1407
 
1556
- function isSrcset(attrName, tag) {
1557
- return attrName === 'srcset' && srcsetTags.has(tag);
1558
- }
1408
+ // SWC options (when engine is `swc`)
1409
+ const swcOptions = engine === 'swc' ? engineOptions : {};
1559
1410
 
1560
- async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
1561
- // Apply early whitespace normalization if enabled
1562
- // Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
1563
- if (options.collapseAttributeWhitespace) {
1564
- attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
1565
- }
1411
+ // Pre-compute option signatures once for performance (avoid repeated stringification)
1412
+ const terserSig = stableStringify({
1413
+ ...terserOptions,
1414
+ cont: !!options.continueOnMinifyError
1415
+ });
1416
+ const swcSig = stableStringify({
1417
+ ...swcOptions,
1418
+ cont: !!options.continueOnMinifyError
1419
+ });
1566
1420
 
1567
- if (isEventAttribute(attrName, options)) {
1568
- attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
1569
- return options.minifyJS(attrValue, true);
1570
- } else if (attrName === 'class') {
1571
- attrValue = trimWhitespace(attrValue);
1572
- if (options.sortClassName) {
1573
- attrValue = options.sortClassName(attrValue);
1574
- } else {
1575
- attrValue = collapseWhitespaceAll(attrValue);
1576
- }
1577
- return attrValue;
1578
- } else if (isUriTypeAttribute(attrName, tag)) {
1579
- attrValue = trimWhitespace(attrValue);
1580
- if (isLinkType(tag, attrs, 'canonical')) {
1581
- return attrValue;
1582
- }
1583
- try {
1584
- const out = await options.minifyURLs(attrValue);
1585
- return typeof out === 'string' ? out : attrValue;
1586
- } catch (err) {
1587
- if (!options.continueOnMinifyError) {
1588
- throw err;
1421
+ options.minifyJS = async function (text, inline) {
1422
+ const start = text.match(/^\s*<!--.*/);
1423
+ const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
1424
+
1425
+ // Fast path: Avoid invoking minifier for empty/whitespace-only content
1426
+ if (!code || !code.trim()) {
1427
+ return '';
1428
+ }
1429
+
1430
+ // Hybrid strategy: Always use Terser for inline JS (needs bare returns support)
1431
+ // Use user’s chosen engine for script blocks
1432
+ const useEngine = inline ? 'terser' : engine;
1433
+
1434
+ let jsKey;
1435
+ try {
1436
+ // Select pre-computed signature based on engine
1437
+ const optsSig = useEngine === 'terser' ? terserSig : swcSig;
1438
+
1439
+ // For large inputs, use length and content fingerprint to prevent collisions
1440
+ jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|'))
1441
+ + (inline ? '1' : '0') + '|' + useEngine + '|' + optsSig;
1442
+
1443
+ const cached = jsMinifyCache.get(jsKey);
1444
+ if (cached) {
1445
+ return await cached;
1446
+ }
1447
+
1448
+ const inFlight = (async () => {
1449
+ // Dispatch to appropriate minifier
1450
+ if (useEngine === 'terser') {
1451
+ // Create a copy to avoid mutating shared `terserOptions` (race condition)
1452
+ const terserCallOptions = {
1453
+ ...terserOptions,
1454
+ parse: {
1455
+ ...terserOptions.parse,
1456
+ bare_returns: inline
1457
+ }
1458
+ };
1459
+ const terser = await getTerser();
1460
+ const result = await terser(code, terserCallOptions);
1461
+ return result.code.replace(RE_TRAILING_SEMICOLON, '');
1462
+ } else if (useEngine === 'swc') {
1463
+ const swc = await getSwc();
1464
+ // `swc.minify()` takes compress and mangle directly as options
1465
+ const result = await swc.minify(code, {
1466
+ compress: true,
1467
+ mangle: true,
1468
+ ...swcOptions, // User options override defaults
1469
+ });
1470
+ return result.code.replace(RE_TRAILING_SEMICOLON, '');
1471
+ }
1472
+ throw new Error(`Unknown JS minifier engine: ${useEngine}`);
1473
+ })();
1474
+
1475
+ jsMinifyCache.set(jsKey, inFlight);
1476
+ const resolved = await inFlight;
1477
+ jsMinifyCache.set(jsKey, resolved);
1478
+ return resolved;
1479
+ } catch (err) {
1480
+ if (jsKey) jsMinifyCache.delete(jsKey);
1481
+ if (!options.continueOnMinifyError) {
1482
+ throw err;
1483
+ }
1484
+ options.log && options.log(err);
1485
+ return text;
1486
+ }
1487
+ };
1488
+ } else if (key === 'minifyURLs' && typeof option !== 'function') {
1489
+ if (!option) {
1490
+ return;
1589
1491
  }
1590
- options.log && options.log(err);
1591
- return attrValue;
1592
- }
1593
- } else if (isNumberTypeAttribute(attrName, tag)) {
1594
- return trimWhitespace(attrValue);
1595
- } else if (attrName === 'style') {
1596
- attrValue = trimWhitespace(attrValue);
1597
- if (attrValue) {
1598
- if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
1599
- attrValue = attrValue.replace(/\s*;$/, ';');
1492
+
1493
+ let relateUrlOptions = option;
1494
+
1495
+ if (typeof option === 'string') {
1496
+ relateUrlOptions = { site: option };
1497
+ } else if (typeof option !== 'object') {
1498
+ relateUrlOptions = {};
1600
1499
  }
1601
- attrValue = await options.minifyCSS(attrValue, 'inline');
1602
- }
1603
- return attrValue;
1604
- } else if (isSrcset(attrName, tag)) {
1605
- // https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
1606
- attrValue = (await Promise.all(trimWhitespace(attrValue).split(/\s+,\s*|\s*,\s+/).map(async function (candidate) {
1607
- let url = candidate;
1608
- let descriptor = '';
1609
- const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
1610
- if (match) {
1611
- url = url.slice(0, -match[0].length);
1612
- const num = +match[1].slice(0, -1);
1613
- const suffix = match[1].slice(-1);
1614
- if (num !== 1 || suffix !== 'x') {
1615
- descriptor = ' ' + num + suffix;
1500
+
1501
+ // Cache RelateURL instance for reuse (expensive to create)
1502
+ const relateUrlInstance = new RelateURL(relateUrlOptions.site || '', relateUrlOptions);
1503
+
1504
+ options.minifyURLs = function (text) {
1505
+ // Fast-path: Skip if text doesn’t look like a URL that needs processing
1506
+ // Only process if contains URL-like characters (`/`, `:`, `#`, `?`) or spaces that need encoding
1507
+ if (!/[/:?#\s]/.test(text)) {
1508
+ return text;
1616
1509
  }
1617
- }
1618
- try {
1619
- const out = await options.minifyURLs(url);
1620
- return (typeof out === 'string' ? out : url) + descriptor;
1621
- } catch (err) {
1622
- if (!options.continueOnMinifyError) {
1623
- throw err;
1510
+
1511
+ try {
1512
+ return relateUrlInstance.relate(text);
1513
+ } catch (err) {
1514
+ if (!options.continueOnMinifyError) {
1515
+ throw err;
1516
+ }
1517
+ options.log && options.log(err);
1518
+ return text;
1624
1519
  }
1625
- options.log && options.log(err);
1626
- return url + descriptor;
1627
- }
1628
- }))).join(', ');
1629
- } else if (isMetaViewport(tag, attrs) && attrName === 'content') {
1630
- attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
1631
- // “0.90000” → “0.9”
1632
- // “1.0” → “1”
1633
- // “1.0001” → “1.0001” (unchanged)
1634
- return (+numString).toString();
1635
- });
1636
- } else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
1637
- return collapseWhitespaceAll(attrValue);
1638
- } else if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
1639
- attrValue = trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
1640
- } else if (tag === 'script' && attrName === 'type') {
1641
- attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
1642
- } else if (isMediaQuery(tag, attrs, attrName)) {
1643
- attrValue = trimWhitespace(attrValue);
1644
- return options.minifyCSS(attrValue, 'media');
1645
- } else if (tag === 'iframe' && attrName === 'srcdoc') {
1646
- // Recursively minify HTML content within srcdoc attribute
1647
- // Fast-path: Skip if nothing would change
1648
- if (!shouldMinifyInnerHTML(options)) {
1649
- return attrValue;
1520
+ };
1521
+ } else {
1522
+ options[key] = option;
1650
1523
  }
1651
- return minifyHTMLSelf(attrValue, options, true);
1652
- }
1653
- return attrValue;
1524
+ });
1525
+ return options;
1526
+ };
1527
+
1528
+ // Imports
1529
+
1530
+
1531
+ // Validators
1532
+
1533
+ function isConditionalComment(text) {
1534
+ return RE_CONDITIONAL_COMMENT.test(text);
1654
1535
  }
1655
1536
 
1656
- function isMetaViewport(tag, attrs) {
1657
- if (tag !== 'meta') {
1658
- return false;
1659
- }
1660
- for (let i = 0, len = attrs.length; i < len; i++) {
1661
- if (attrs[i].name === 'name' && attrs[i].value === 'viewport') {
1537
+ function isIgnoredComment(text, options) {
1538
+ for (let i = 0, len = options.ignoreCustomComments.length; i < len; i++) {
1539
+ if (options.ignoreCustomComments[i].test(text)) {
1662
1540
  return true;
1663
1541
  }
1664
1542
  }
1543
+ return false;
1665
1544
  }
1666
1545
 
1667
- function isContentSecurityPolicy(tag, attrs) {
1668
- if (tag !== 'meta') {
1546
+ function isEventAttribute(attrName, options) {
1547
+ const patterns = options.customEventAttributes;
1548
+ if (patterns) {
1549
+ for (let i = patterns.length; i--;) {
1550
+ if (patterns[i].test(attrName)) {
1551
+ return true;
1552
+ }
1553
+ }
1669
1554
  return false;
1670
1555
  }
1671
- for (let i = 0, len = attrs.length; i < len; i++) {
1672
- if (attrs[i].name.toLowerCase() === 'http-equiv' && attrs[i].value.toLowerCase() === 'content-security-policy') {
1556
+ return RE_EVENT_ATTR_DEFAULT.test(attrName);
1557
+ }
1558
+
1559
+ function canRemoveAttributeQuotes(value) {
1560
+ // https://mathiasbynens.be/notes/unquoted-attribute-values
1561
+ return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
1562
+ }
1563
+
1564
+ function attributesInclude(attributes, attribute) {
1565
+ for (let i = attributes.length; i--;) {
1566
+ if (attributes[i].name.toLowerCase() === attribute) {
1673
1567
  return true;
1674
1568
  }
1675
1569
  }
1570
+ return false;
1676
1571
  }
1677
1572
 
1678
- // Wrap CSS declarations for inline styles and media queries
1679
- // This ensures proper context for CSS minification
1680
- function wrapCSS(text, type) {
1681
- switch (type) {
1682
- case 'inline':
1683
- return '*{' + text + '}';
1684
- case 'media':
1685
- return '@media ' + text + '{a{top:0}}';
1686
- default:
1687
- return text;
1573
+ function isAttributeRedundant(tag, attrName, attrValue, attrs) {
1574
+ attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
1575
+
1576
+ // Legacy attributes
1577
+ if (tag === 'script' && attrName === 'language' && attrValue === 'javascript') {
1578
+ return true;
1579
+ }
1580
+ if (tag === 'script' && attrName === 'charset' && !attributesInclude(attrs, 'src')) {
1581
+ return true;
1582
+ }
1583
+ if (tag === 'a' && attrName === 'name' && attributesInclude(attrs, 'id')) {
1584
+ return true;
1688
1585
  }
1689
- }
1690
1586
 
1691
- function unwrapCSS(text, type) {
1692
- let matches;
1693
- switch (type) {
1694
- case 'inline':
1695
- matches = text.match(/^\*\{([\s\S]*)\}$/);
1696
- break;
1697
- case 'media':
1698
- matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/);
1699
- break;
1587
+ // Check general defaults
1588
+ if (generalDefaults[attrName] === attrValue) {
1589
+ return true;
1700
1590
  }
1701
- return matches ? matches[1] : text;
1591
+
1592
+ // Check tag-specific defaults
1593
+ return tagDefaults[tag]?.[attrName] === attrValue;
1702
1594
  }
1703
1595
 
1704
- async function cleanConditionalComment(comment, options) {
1705
- return options.processConditionalComments
1706
- ? await replaceAsync(comment, /^(\[if\s[^\]]+]>)([\s\S]*?)(<!\[endif])$/, async function (match, prefix, text, suffix) {
1707
- return prefix + await minifyHTML(text, options, true) + suffix;
1708
- })
1709
- : comment;
1596
+ function isScriptTypeAttribute(attrValue = '') {
1597
+ attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
1598
+ return attrValue === '' || executableScriptsMimetypes.has(attrValue);
1710
1599
  }
1711
1600
 
1712
- const jsonScriptTypes = new Set([
1713
- 'application/json',
1714
- 'application/ld+json',
1715
- 'application/manifest+json',
1716
- 'application/vnd.geo+json',
1717
- 'application/problem+json',
1718
- 'application/merge-patch+json',
1719
- 'application/json-patch+json',
1720
- 'importmap',
1721
- 'speculationrules',
1722
- ]);
1601
+ function keepScriptTypeAttribute(attrValue = '') {
1602
+ attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
1603
+ return keepScriptsMimetypes.has(attrValue);
1604
+ }
1723
1605
 
1724
- function minifyJson(text, options) {
1725
- try {
1726
- return JSON.stringify(JSON.parse(text));
1606
+ function isExecutableScript(tag, attrs) {
1607
+ if (tag !== 'script') {
1608
+ return false;
1727
1609
  }
1728
- catch (err) {
1729
- if (!options.continueOnMinifyError) {
1730
- throw err;
1610
+ for (let i = 0, len = attrs.length; i < len; i++) {
1611
+ const attrName = attrs[i].name.toLowerCase();
1612
+ if (attrName === 'type') {
1613
+ return isScriptTypeAttribute(attrs[i].value);
1731
1614
  }
1732
- options.log && options.log(err);
1733
- return text;
1734
1615
  }
1616
+ return true;
1735
1617
  }
1736
1618
 
1737
- function hasJsonScriptType(attrs) {
1619
+ function isStyleLinkTypeAttribute(attrValue = '') {
1620
+ attrValue = trimWhitespace(attrValue).toLowerCase();
1621
+ return attrValue === '' || attrValue === 'text/css';
1622
+ }
1623
+
1624
+ function isStyleSheet(tag, attrs) {
1625
+ if (tag !== 'style') {
1626
+ return false;
1627
+ }
1738
1628
  for (let i = 0, len = attrs.length; i < len; i++) {
1739
1629
  const attrName = attrs[i].name.toLowerCase();
1740
1630
  if (attrName === 'type') {
1741
- const attrValue = trimWhitespace((attrs[i].value || '').split(/;/, 2)[0]).toLowerCase();
1742
- if (jsonScriptTypes.has(attrValue)) {
1743
- return true;
1744
- }
1631
+ return isStyleLinkTypeAttribute(attrs[i].value);
1745
1632
  }
1746
1633
  }
1747
- return false;
1634
+ return true;
1748
1635
  }
1749
1636
 
1750
- async function processScript(text, options, currentAttrs) {
1751
- for (let i = 0, len = currentAttrs.length; i < len; i++) {
1752
- const attrName = currentAttrs[i].name.toLowerCase();
1753
- if (attrName === 'type') {
1754
- const rawValue = currentAttrs[i].value;
1755
- const normalizedValue = trimWhitespace((rawValue || '').split(/;/, 2)[0]).toLowerCase();
1756
- // Minify JSON script types automatically
1757
- if (jsonScriptTypes.has(normalizedValue)) {
1758
- return minifyJson(text, options);
1759
- }
1760
- // Process custom script types if specified
1761
- if (options.processScripts && options.processScripts.indexOf(rawValue) > -1) {
1762
- return await minifyHTML(text, options);
1763
- }
1764
- }
1765
- }
1766
- return text;
1637
+ function isBooleanAttribute(attrName, attrValue) {
1638
+ return isSimpleBoolean.has(attrName) || (attrName === 'draggable' && !isBooleanValue.has(attrValue));
1767
1639
  }
1768
1640
 
1769
- // Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags with the following extensions:
1770
- // - retain `<body>` if followed by `<noscript>`
1771
- // - `<rb>`, `<rt>`, `<rtc>`, `<rp>` follow HTML Ruby Markup Extensions draft (https://www.w3.org/TR/html-ruby-extensions/)
1772
- // - retain all tags which are adjacent to non-standard HTML tags
1773
- const optionalStartTags = new Set(['html', 'head', 'body', 'colgroup', 'tbody']);
1774
- 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']);
1775
- const headerTags = new Set(['meta', 'link', 'script', 'style', 'template', 'noscript']);
1776
- const descriptionTags = new Set(['dt', 'dd']);
1777
- 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']);
1778
- const pInlineTags = new Set(['a', 'audio', 'del', 'ins', 'map', 'noscript', 'video']);
1779
- const rubyEndTagOmission = new Set(['rb', 'rt', 'rtc', 'rp']); // `</rb>`, `</rt>`, `</rp>` can be omitted if followed by `<rb>`, `<rt>`, `<rtc>`, or `<rp>`
1780
- const rubyRtcEndTagOmission = new Set(['rb', 'rtc']); // `</rtc>` can be omitted if followed by `<rb>` or `<rtc>` (not `<rt>` or `<rp>`)
1781
- const optionTag = new Set(['option', 'optgroup']);
1782
- const tableContentTags = new Set(['tbody', 'tfoot']);
1783
- const tableSectionTags = new Set(['thead', 'tbody', 'tfoot']);
1784
- const cellTags = new Set(['td', 'th']);
1785
- const topLevelTags = new Set(['html', 'head', 'body']);
1786
- const compactTags = new Set(['html', 'body']);
1787
- const looseTags = new Set(['head', 'colgroup', 'caption']);
1788
- const trailingTags = new Set(['dt', 'thead']);
1789
- 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']);
1641
+ function isUriTypeAttribute(attrName, tag) {
1642
+ return (
1643
+ (/^(?:a|area|link|base)$/.test(tag) && attrName === 'href') ||
1644
+ (tag === 'img' && /^(?:src|longdesc|usemap)$/.test(attrName)) ||
1645
+ (tag === 'object' && /^(?:classid|codebase|data|usemap)$/.test(attrName)) ||
1646
+ (tag === 'q' && attrName === 'cite') ||
1647
+ (tag === 'blockquote' && attrName === 'cite') ||
1648
+ ((tag === 'ins' || tag === 'del') && attrName === 'cite') ||
1649
+ (tag === 'form' && attrName === 'action') ||
1650
+ (tag === 'input' && (attrName === 'src' || attrName === 'usemap')) ||
1651
+ (tag === 'head' && attrName === 'profile') ||
1652
+ (tag === 'script' && (attrName === 'src' || attrName === 'for'))
1653
+ );
1654
+ }
1790
1655
 
1791
- function canRemoveParentTag(optionalStartTag, tag) {
1792
- switch (optionalStartTag) {
1793
- case 'html':
1794
- case 'head':
1795
- return true;
1796
- case 'body':
1797
- return !headerTags.has(tag);
1798
- case 'colgroup':
1799
- return tag === 'col';
1800
- case 'tbody':
1801
- return tag === 'tr';
1656
+ function isNumberTypeAttribute(attrName, tag) {
1657
+ return (
1658
+ (/^(?:a|area|object|button)$/.test(tag) && attrName === 'tabindex') ||
1659
+ (tag === 'input' && (attrName === 'maxlength' || attrName === 'tabindex')) ||
1660
+ (tag === 'select' && (attrName === 'size' || attrName === 'tabindex')) ||
1661
+ (tag === 'textarea' && /^(?:rows|cols|tabindex)$/.test(attrName)) ||
1662
+ (tag === 'colgroup' && attrName === 'span') ||
1663
+ (tag === 'col' && attrName === 'span') ||
1664
+ ((tag === 'th' || tag === 'td') && (attrName === 'rowspan' || attrName === 'colspan'))
1665
+ );
1666
+ }
1667
+
1668
+ function isLinkType(tag, attrs, value) {
1669
+ if (tag !== 'link') return false;
1670
+ const needle = String(value).toLowerCase();
1671
+ for (let i = 0; i < attrs.length; i++) {
1672
+ if (attrs[i].name.toLowerCase() === 'rel') {
1673
+ const tokens = String(attrs[i].value).toLowerCase().split(/\s+/);
1674
+ if (tokens.includes(needle)) return true;
1675
+ }
1802
1676
  }
1803
1677
  return false;
1804
1678
  }
1805
1679
 
1806
- function isStartTagMandatory(optionalEndTag, tag) {
1807
- switch (tag) {
1808
- case 'colgroup':
1809
- return optionalEndTag === 'colgroup';
1810
- case 'tbody':
1811
- return tableSectionTags.has(optionalEndTag);
1680
+ function isMediaQuery(tag, attrs, attrName) {
1681
+ return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleSheet(tag, attrs));
1682
+ }
1683
+
1684
+ function isSrcset(attrName, tag) {
1685
+ return attrName === 'srcset' && srcsetTags.has(tag);
1686
+ }
1687
+
1688
+ function isMetaViewport(tag, attrs) {
1689
+ if (tag !== 'meta') {
1690
+ return false;
1691
+ }
1692
+ for (let i = 0, len = attrs.length; i < len; i++) {
1693
+ if (attrs[i].name === 'name' && attrs[i].value === 'viewport') {
1694
+ return true;
1695
+ }
1812
1696
  }
1813
1697
  return false;
1814
1698
  }
1815
1699
 
1816
- function canRemovePrecedingTag(optionalEndTag, tag) {
1817
- switch (optionalEndTag) {
1818
- case 'html':
1819
- case 'head':
1820
- case 'body':
1821
- case 'colgroup':
1822
- case 'caption':
1700
+ function isContentSecurityPolicy(tag, attrs) {
1701
+ if (tag !== 'meta') {
1702
+ return false;
1703
+ }
1704
+ for (let i = 0, len = attrs.length; i < len; i++) {
1705
+ if (attrs[i].name.toLowerCase() === 'http-equiv' && attrs[i].value.toLowerCase() === 'content-security-policy') {
1823
1706
  return true;
1824
- case 'li':
1825
- case 'optgroup':
1826
- case 'tr':
1827
- return tag === optionalEndTag;
1828
- case 'dt':
1829
- case 'dd':
1830
- return descriptionTags.has(tag);
1831
- case 'p':
1832
- return pBlockTags.has(tag);
1833
- case 'rb':
1834
- case 'rt':
1835
- case 'rp':
1836
- return rubyEndTagOmission.has(tag);
1837
- case 'rtc':
1838
- return rubyRtcEndTagOmission.has(tag);
1839
- case 'option':
1840
- return optionTag.has(tag);
1841
- case 'thead':
1842
- case 'tbody':
1843
- return tableContentTags.has(tag);
1844
- case 'tfoot':
1845
- return tag === 'tbody';
1846
- case 'td':
1847
- case 'th':
1848
- return cellTags.has(tag);
1707
+ }
1849
1708
  }
1850
1709
  return false;
1851
1710
  }
1852
1711
 
1853
- const reEmptyAttribute = new RegExp(
1854
- '^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
1855
- '?:down|up|over|move|out)|key(?:press|down|up)))$');
1856
-
1857
1712
  function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
1858
1713
  const isValueEmpty = !attrValue || /^\s*$/.test(attrValue);
1859
1714
  if (!isValueEmpty) {
@@ -1874,162 +1729,148 @@ function hasAttrName(name, attrs) {
1874
1729
  return false;
1875
1730
  }
1876
1731
 
1877
- function canRemoveElement(tag, attrs) {
1878
- switch (tag) {
1879
- case 'textarea':
1880
- return false;
1881
- case 'audio':
1882
- case 'script':
1883
- case 'video':
1884
- if (hasAttrName('src', attrs)) {
1885
- return false;
1886
- }
1887
- break;
1888
- case 'iframe':
1889
- if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
1890
- return false;
1891
- }
1892
- break;
1893
- case 'object':
1894
- if (hasAttrName('data', attrs)) {
1895
- return false;
1896
- }
1897
- break;
1898
- case 'applet':
1899
- if (hasAttrName('code', attrs)) {
1900
- return false;
1901
- }
1902
- break;
1903
- }
1904
- return true;
1905
- }
1906
-
1907
- /**
1908
- * @param {string} str - Tag name or HTML-like element spec (e.g., “td” or “<span aria-hidden='true'>”)
1909
- * @param {MinifierOptions} options - Options object for name normalization
1910
- * @returns {{tag: string, attrs: Object.<string, string|undefined>|null}|null} Parsed spec or null if invalid
1911
- */
1912
- function parseElementSpec(str, options) {
1913
- if (typeof str !== 'string') {
1914
- return null;
1915
- }
1916
-
1917
- const trimmed = str.trim();
1918
- if (!trimmed) {
1919
- return null;
1920
- }
1921
-
1922
- // Simple tag name: “td”
1923
- if (!/[<>]/.test(trimmed)) {
1924
- return { tag: options.name(trimmed), attrs: null };
1925
- }
1732
+ // Cleaners
1926
1733
 
1927
- // HTML-like markup: “<span aria-hidden='true'>” or “<td></td>”
1928
- // Extract opening tag using regex
1929
- const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
1930
- if (!match) {
1931
- return null;
1734
+ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
1735
+ // Apply early whitespace normalization if enabled
1736
+ // Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
1737
+ if (options.collapseAttributeWhitespace) {
1738
+ attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
1932
1739
  }
1933
1740
 
1934
- const tag = options.name(match[1]);
1935
- const attrString = match[2];
1936
-
1937
- if (!attrString.trim()) {
1938
- return { tag, attrs: null };
1939
- }
1940
-
1941
- // Parse attributes from string
1942
- const attrs = {};
1943
- const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
1944
- let attrMatch;
1945
-
1946
- while ((attrMatch = attrRegex.exec(attrString))) {
1947
- const attrName = options.name(attrMatch[1]);
1948
- const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
1949
- // Boolean attributes have no value (undefined)
1950
- attrs[attrName] = attrValue;
1951
- }
1952
-
1953
- return {
1954
- tag,
1955
- attrs: Object.keys(attrs).length > 0 ? attrs : null
1956
- };
1957
- }
1958
-
1959
- /**
1960
- * @param {string[]} input - Array of element specifications from `removeEmptyElementsExcept` option
1961
- * @param {MinifierOptions} options - Options object for parsing
1962
- * @returns {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} Array of parsed element specs
1963
- */
1964
- function parseRemoveEmptyElementsExcept(input, options) {
1965
- if (!Array.isArray(input)) {
1966
- return [];
1967
- }
1968
-
1969
- return input.map(item => {
1970
- if (typeof item === 'string') {
1971
- const spec = parseElementSpec(item, options);
1972
- if (!spec && options.log) {
1973
- options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
1741
+ if (isEventAttribute(attrName, options)) {
1742
+ attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
1743
+ try {
1744
+ return await options.minifyJS(attrValue, true);
1745
+ } catch (err) {
1746
+ if (!options.continueOnMinifyError) {
1747
+ throw err;
1974
1748
  }
1975
- return spec;
1749
+ options.log && options.log(err);
1750
+ return attrValue;
1976
1751
  }
1977
- if (options.log) {
1978
- options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
1752
+ } else if (attrName === 'class') {
1753
+ attrValue = trimWhitespace(attrValue);
1754
+ if (options.sortClassName) {
1755
+ attrValue = options.sortClassName(attrValue);
1756
+ } else {
1757
+ attrValue = collapseWhitespaceAll(attrValue);
1979
1758
  }
1980
- return null;
1981
- }).filter(Boolean);
1982
- }
1983
-
1984
- /**
1985
- * @param {string} tag - Element tag name
1986
- * @param {HTMLAttribute[]} attrs - Array of element attributes
1987
- * @param {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} preserveList - Parsed preserve specs
1988
- * @returns {boolean} True if the empty element should be preserved
1989
- */
1990
- function shouldPreserveEmptyElement(tag, attrs, preserveList) {
1991
- for (const spec of preserveList) {
1992
- // Tag name must match
1993
- if (spec.tag !== tag) {
1994
- continue;
1759
+ return attrValue;
1760
+ } else if (isUriTypeAttribute(attrName, tag)) {
1761
+ attrValue = trimWhitespace(attrValue);
1762
+ if (isLinkType(tag, attrs, 'canonical')) {
1763
+ return attrValue;
1995
1764
  }
1996
-
1997
- // If no attributes specified in spec, tag match is enough
1998
- if (!spec.attrs) {
1999
- return true;
1765
+ try {
1766
+ const out = await options.minifyURLs(attrValue);
1767
+ return typeof out === 'string' ? out : attrValue;
1768
+ } catch (err) {
1769
+ if (!options.continueOnMinifyError) {
1770
+ throw err;
1771
+ }
1772
+ options.log && options.log(err);
1773
+ return attrValue;
2000
1774
  }
2001
-
2002
- // Check if all specified attributes match
2003
- const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
2004
- const attr = attrs.find(a => a.name === name);
2005
- if (!attr) {
2006
- return false; // Attribute not present
1775
+ } else if (isNumberTypeAttribute(attrName, tag)) {
1776
+ return trimWhitespace(attrValue);
1777
+ } else if (attrName === 'style') {
1778
+ attrValue = trimWhitespace(attrValue);
1779
+ if (attrValue) {
1780
+ if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
1781
+ attrValue = attrValue.replace(/\s*;$/, ';');
2007
1782
  }
2008
- // Boolean attribute in spec (undefined value) matches if attribute is present
2009
- if (value === undefined) {
2010
- return true;
1783
+ try {
1784
+ attrValue = await options.minifyCSS(attrValue, 'inline');
1785
+ } catch (err) {
1786
+ if (!options.continueOnMinifyError) {
1787
+ throw err;
1788
+ }
1789
+ options.log && options.log(err);
2011
1790
  }
2012
- // Valued attribute must match exactly
2013
- return attr.value === value;
1791
+ }
1792
+ return attrValue;
1793
+ } else if (isSrcset(attrName, tag)) {
1794
+ // https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
1795
+ attrValue = (await Promise.all(trimWhitespace(attrValue).split(/\s*,\s*/).map(async function (candidate) {
1796
+ let url = candidate;
1797
+ let descriptor = '';
1798
+ const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
1799
+ if (match) {
1800
+ url = url.slice(0, -match[0].length);
1801
+ const num = +match[1].slice(0, -1);
1802
+ const suffix = match[1].slice(-1);
1803
+ if (num !== 1 || suffix !== 'x') {
1804
+ descriptor = ' ' + num + suffix;
1805
+ }
1806
+ }
1807
+ try {
1808
+ const out = await options.minifyURLs(url);
1809
+ return (typeof out === 'string' ? out : url) + descriptor;
1810
+ } catch (err) {
1811
+ if (!options.continueOnMinifyError) {
1812
+ throw err;
1813
+ }
1814
+ options.log && options.log(err);
1815
+ return url + descriptor;
1816
+ }
1817
+ }))).join(', ');
1818
+ } else if (isMetaViewport(tag, attrs) && attrName === 'content') {
1819
+ attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
1820
+ // 0.90000 → 0.9
1821
+ // 1.0 → 1
1822
+ // 1.0001 → 1.0001 (unchanged)
1823
+ return (+numString).toString();
2014
1824
  });
2015
-
2016
- if (allAttrsMatch) {
2017
- return true;
1825
+ } else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
1826
+ return collapseWhitespaceAll(attrValue);
1827
+ } else if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
1828
+ attrValue = trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
1829
+ } else if (tag === 'script' && attrName === 'type') {
1830
+ attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
1831
+ } else if (isMediaQuery(tag, attrs, attrName)) {
1832
+ attrValue = trimWhitespace(attrValue);
1833
+ try {
1834
+ return await options.minifyCSS(attrValue, 'media');
1835
+ } catch (err) {
1836
+ if (!options.continueOnMinifyError) {
1837
+ throw err;
1838
+ }
1839
+ options.log && options.log(err);
1840
+ return attrValue;
1841
+ }
1842
+ } else if (tag === 'iframe' && attrName === 'srcdoc') {
1843
+ // Recursively minify HTML content within `srcdoc` attribute
1844
+ // Fast-path: Skip if nothing would change
1845
+ if (!shouldMinifyInnerHTML(options)) {
1846
+ return attrValue;
2018
1847
  }
1848
+ return minifyHTMLSelf(attrValue, options, true);
2019
1849
  }
2020
-
2021
- return false;
1850
+ return attrValue;
2022
1851
  }
2023
1852
 
2024
- function canCollapseWhitespace(tag) {
2025
- return !/^(?:script|style|pre|textarea)$/.test(tag);
2026
- }
1853
+ /**
1854
+ * Choose appropriate quote character for an attribute value
1855
+ * @param {string} attrValue - The attribute value
1856
+ * @param {Object} options - Minifier options
1857
+ * @returns {string} The chosen quote character (`"` or `'`)
1858
+ */
1859
+ function chooseAttributeQuote(attrValue, options) {
1860
+ if (typeof options.quoteCharacter !== 'undefined') {
1861
+ return options.quoteCharacter === '\'' ? '\'' : '"';
1862
+ }
2027
1863
 
2028
- function canTrimWhitespace(tag) {
2029
- return !/^(?:pre|textarea)$/.test(tag);
1864
+ // Count quotes in a single pass
1865
+ let apos = 0, quot = 0;
1866
+ for (let i = 0; i < attrValue.length; i++) {
1867
+ if (attrValue[i] === "'") apos++;
1868
+ else if (attrValue[i] === '"') quot++;
1869
+ }
1870
+ return apos < quot ? '\'' : '"';
2030
1871
  }
2031
1872
 
2032
- async function normalizeAttr(attr, attrs, tag, options) {
1873
+ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
2033
1874
  const attrName = options.name(attr.name);
2034
1875
  let attrValue = attr.value;
2035
1876
 
@@ -2041,7 +1882,7 @@ async function normalizeAttr(attr, attrs, tag, options) {
2041
1882
  }
2042
1883
 
2043
1884
  if ((options.removeRedundantAttributes &&
2044
- isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
1885
+ isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
2045
1886
  (options.removeScriptTypeAttributes && tag === 'script' &&
2046
1887
  attrName === 'type' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue)) ||
2047
1888
  (options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
@@ -2078,65 +1919,44 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
2078
1919
  let emittedAttrValue;
2079
1920
 
2080
1921
  if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
2081
- ~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
1922
+ attrValue.indexOf(uidAttr) !== -1 || !canRemoveAttributeQuotes(attrValue))) {
2082
1923
  // Determine the appropriate quote character
2083
1924
  if (!options.preventAttributesEscaping) {
2084
1925
  // Normal mode: choose quotes and escape
2085
- if (typeof options.quoteCharacter === 'undefined') {
2086
- // Count quotes in a single pass instead of two regex operations
2087
- let apos = 0, quot = 0;
2088
- for (let i = 0; i < attrValue.length; i++) {
2089
- if (attrValue[i] === "'") apos++;
2090
- else if (attrValue[i] === '"') quot++;
2091
- }
2092
- attrQuote = apos < quot ? '\'' : '"';
2093
- } else {
2094
- attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
2095
- }
1926
+ attrQuote = chooseAttributeQuote(attrValue, options);
2096
1927
  if (attrQuote === '"') {
2097
1928
  attrValue = attrValue.replace(/"/g, '&#34;');
2098
1929
  } else {
2099
1930
  attrValue = attrValue.replace(/'/g, '&#39;');
2100
1931
  }
2101
1932
  } else {
2102
- // `preventAttributesEscaping` mode: choose safe quotes but dont escape
2103
- // EXCEPT when both quote types are present—then escape to prevent invalid HTML
1933
+ // `preventAttributesEscaping` mode: choose safe quotes but don't escape
1934
+ // except when both quote types are present—then escape to prevent invalid HTML
2104
1935
  const hasDoubleQuote = attrValue.indexOf('"') !== -1;
2105
1936
  const hasSingleQuote = attrValue.indexOf("'") !== -1;
2106
1937
 
1938
+ // Both quote types present: Escaping is required to guarantee valid HTML delimiter matching
2107
1939
  if (hasDoubleQuote && hasSingleQuote) {
2108
- // Both quote types present: `preventAttributesEscaping` is ignored to ensure valid HTML
2109
- // Choose the quote type with fewer occurrences and escape the other
2110
- if (typeof options.quoteCharacter === 'undefined') {
2111
- let apos = 0, quot = 0;
2112
- for (let i = 0; i < attrValue.length; i++) {
2113
- if (attrValue[i] === "'") apos++;
2114
- else if (attrValue[i] === '"') quot++;
2115
- }
2116
- attrQuote = apos < quot ? '\'' : '"';
2117
- } else {
2118
- attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
2119
- }
1940
+ attrQuote = chooseAttributeQuote(attrValue, options);
2120
1941
  if (attrQuote === '"') {
2121
1942
  attrValue = attrValue.replace(/"/g, '&#34;');
2122
1943
  } else {
2123
1944
  attrValue = attrValue.replace(/'/g, '&#39;');
2124
1945
  }
1946
+ // Auto quote selection: Prefer the opposite quote type when value contains one quote type, default to double quotes when none present
2125
1947
  } else if (typeof options.quoteCharacter === 'undefined') {
2126
- // Single or no quote type: Choose safe quote delimiter
2127
1948
  if (attrQuote === '"' && hasDoubleQuote && !hasSingleQuote) {
2128
1949
  attrQuote = "'";
2129
1950
  } else if (attrQuote === "'" && hasSingleQuote && !hasDoubleQuote) {
2130
1951
  attrQuote = '"';
1952
+ // Fallback for invalid/unsupported attrQuote values (not `"`, `'`, or empty string): Choose safe default based on value content
2131
1953
  } else if (attrQuote !== '"' && attrQuote !== "'" && attrQuote !== '') {
2132
- // `attrQuote` is invalid/undefined (not `"`, `'`, or empty string)
2133
- // Set a safe default based on the value’s content
2134
1954
  if (hasSingleQuote && !hasDoubleQuote) {
2135
- attrQuote = '"'; // Value has single quotes, use double quotes as delimiter
1955
+ attrQuote = '"';
2136
1956
  } else if (hasDoubleQuote && !hasSingleQuote) {
2137
- attrQuote = "'"; // Value has double quotes, use single quotes as delimiter
1957
+ attrQuote = "'";
2138
1958
  } else {
2139
- attrQuote = '"'; // No quotes in value, default to double quotes
1959
+ attrQuote = '"';
2140
1960
  }
2141
1961
  }
2142
1962
  } else {
@@ -2156,7 +1976,7 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
2156
1976
  }
2157
1977
 
2158
1978
  if (typeof attrValue === 'undefined' || (options.collapseBooleanAttributes &&
2159
- isBooleanAttribute(attrName.toLowerCase(), attrValue.toLowerCase()))) {
1979
+ isBooleanAttribute(attrName.toLowerCase(), (attrValue || '').toLowerCase()))) {
2160
1980
  attrFragment = attrName;
2161
1981
  if (!isLast) {
2162
1982
  attrFragment += ' ';
@@ -2168,252 +1988,648 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
2168
1988
  return attr.customOpen + attrFragment + attr.customClose;
2169
1989
  }
2170
1990
 
2171
- function identity(value) {
2172
- return value;
1991
+ // Imports
1992
+
1993
+
1994
+ // Tag omission rules
1995
+
1996
+ function canRemoveParentTag(optionalStartTag, tag) {
1997
+ switch (optionalStartTag) {
1998
+ case 'html':
1999
+ case 'head':
2000
+ return true;
2001
+ case 'body':
2002
+ return !headerTags.has(tag);
2003
+ case 'colgroup':
2004
+ return tag === 'col';
2005
+ case 'tbody':
2006
+ return tag === 'tr';
2007
+ }
2008
+ return false;
2173
2009
  }
2174
2010
 
2175
- function identityAsync(value) {
2176
- return Promise.resolve(value);
2011
+ function isStartTagMandatory(optionalEndTag, tag) {
2012
+ switch (tag) {
2013
+ case 'colgroup':
2014
+ return optionalEndTag === 'colgroup';
2015
+ case 'tbody':
2016
+ return tableSectionTags.has(optionalEndTag);
2017
+ }
2018
+ return false;
2177
2019
  }
2178
2020
 
2179
- function shouldMinifyInnerHTML(options) {
2180
- return Boolean(
2181
- options.collapseWhitespace ||
2182
- options.removeComments ||
2183
- options.removeOptionalTags ||
2184
- options.minifyJS !== identity ||
2185
- options.minifyCSS !== identityAsync ||
2186
- options.minifyURLs !== identity
2187
- );
2021
+ function canRemovePrecedingTag(optionalEndTag, tag) {
2022
+ switch (optionalEndTag) {
2023
+ case 'html':
2024
+ case 'head':
2025
+ case 'body':
2026
+ case 'colgroup':
2027
+ case 'caption':
2028
+ return true;
2029
+ case 'li':
2030
+ case 'optgroup':
2031
+ case 'tr':
2032
+ return tag === optionalEndTag;
2033
+ case 'dt':
2034
+ case 'dd':
2035
+ return descriptionTags.has(tag);
2036
+ case 'p':
2037
+ return pBlockTags.has(tag);
2038
+ case 'rb':
2039
+ case 'rt':
2040
+ case 'rp':
2041
+ return rubyEndTagOmission.has(tag);
2042
+ case 'rtc':
2043
+ return rubyRtcEndTagOmission.has(tag);
2044
+ case 'option':
2045
+ return optionTag.has(tag);
2046
+ case 'thead':
2047
+ case 'tbody':
2048
+ return tableContentTags.has(tag);
2049
+ case 'tfoot':
2050
+ return tag === 'tbody';
2051
+ case 'td':
2052
+ case 'th':
2053
+ return cellTags.has(tag);
2054
+ }
2055
+ return false;
2188
2056
  }
2189
2057
 
2190
- /**
2191
- * @param {Partial<MinifierOptions>} inputOptions - User-provided options
2192
- * @returns {MinifierOptions} Normalized options with defaults applied
2058
+ // Element removal logic
2059
+
2060
+ function canRemoveElement(tag, attrs) {
2061
+ switch (tag) {
2062
+ case 'textarea':
2063
+ return false;
2064
+ case 'audio':
2065
+ case 'script':
2066
+ case 'video':
2067
+ if (hasAttrName('src', attrs)) {
2068
+ return false;
2069
+ }
2070
+ break;
2071
+ case 'iframe':
2072
+ if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
2073
+ return false;
2074
+ }
2075
+ break;
2076
+ case 'object':
2077
+ if (hasAttrName('data', attrs)) {
2078
+ return false;
2079
+ }
2080
+ break;
2081
+ case 'applet':
2082
+ if (hasAttrName('code', attrs)) {
2083
+ return false;
2084
+ }
2085
+ break;
2086
+ }
2087
+ return true;
2088
+ }
2089
+
2090
+ /**
2091
+ * @param {string} str - Tag name or HTML-like element spec (e.g., “td” or “<span aria-hidden='true'>”)
2092
+ * @param {MinifierOptions} options - Options object for name normalization
2093
+ * @returns {{tag: string, attrs: Object.<string, string|undefined>|null}|null} Parsed spec or null if invalid
2094
+ */
2095
+ function parseElementSpec(str, options) {
2096
+ if (typeof str !== 'string') {
2097
+ return null;
2098
+ }
2099
+
2100
+ const trimmed = str.trim();
2101
+ if (!trimmed) {
2102
+ return null;
2103
+ }
2104
+
2105
+ // Simple tag name: `td`
2106
+ if (!/[<>]/.test(trimmed)) {
2107
+ return { tag: options.name(trimmed), attrs: null };
2108
+ }
2109
+
2110
+ // HTML-like markup: `<span aria-hidden='true'>` or `<td></td>`
2111
+ // Extract opening tag using regex
2112
+ const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
2113
+ if (!match) {
2114
+ return null;
2115
+ }
2116
+
2117
+ const tag = options.name(match[1]);
2118
+ const attrString = match[2];
2119
+
2120
+ if (!attrString.trim()) {
2121
+ return { tag, attrs: null };
2122
+ }
2123
+
2124
+ // Parse attributes from string
2125
+ const attrs = {};
2126
+ const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
2127
+ let attrMatch;
2128
+
2129
+ while ((attrMatch = attrRegex.exec(attrString))) {
2130
+ const attrName = options.name(attrMatch[1]);
2131
+ const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
2132
+ // Boolean attributes have no value (undefined)
2133
+ attrs[attrName] = attrValue;
2134
+ }
2135
+
2136
+ return {
2137
+ tag,
2138
+ attrs: Object.keys(attrs).length > 0 ? attrs : null
2139
+ };
2140
+ }
2141
+
2142
+ /**
2143
+ * @param {string[]} input - Array of element specifications from `removeEmptyElementsExcept` option
2144
+ * @param {MinifierOptions} options - Options object for parsing
2145
+ * @returns {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} Array of parsed element specs
2146
+ */
2147
+ function parseRemoveEmptyElementsExcept(input, options) {
2148
+ if (!Array.isArray(input)) {
2149
+ return [];
2150
+ }
2151
+
2152
+ return input.map(item => {
2153
+ if (typeof item === 'string') {
2154
+ const spec = parseElementSpec(item, options);
2155
+ if (!spec && options.log) {
2156
+ options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
2157
+ }
2158
+ return spec;
2159
+ }
2160
+ if (options.log) {
2161
+ options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
2162
+ }
2163
+ return null;
2164
+ }).filter(Boolean);
2165
+ }
2166
+
2167
+ /**
2168
+ * @param {string} tag - Element tag name
2169
+ * @param {HTMLAttribute[]} attrs - Array of element attributes
2170
+ * @param {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} preserveList - Parsed preserve specs
2171
+ * @returns {boolean} True if the empty element should be preserved
2172
+ */
2173
+ function shouldPreserveEmptyElement(tag, attrs, preserveList) {
2174
+ for (const spec of preserveList) {
2175
+ // Tag name must match
2176
+ if (spec.tag !== tag) {
2177
+ continue;
2178
+ }
2179
+
2180
+ // If no attributes specified in spec, tag match is enough
2181
+ if (!spec.attrs) {
2182
+ return true;
2183
+ }
2184
+
2185
+ // Check if all specified attributes match
2186
+ const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
2187
+ const attr = attrs.find(a => a.name === name);
2188
+ if (!attr) {
2189
+ return false; // Attribute not present
2190
+ }
2191
+ // Boolean attribute in spec (undefined value) matches if attribute is present
2192
+ if (value === undefined) {
2193
+ return true;
2194
+ }
2195
+ // Valued attribute must match exactly
2196
+ return attr.value === value;
2197
+ });
2198
+
2199
+ if (allAttrsMatch) {
2200
+ return true;
2201
+ }
2202
+ }
2203
+
2204
+ return false;
2205
+ }
2206
+
2207
+ // Imports
2208
+
2209
+
2210
+ // Lazy-load heavy dependencies only when needed
2211
+
2212
+ let lightningCSSPromise;
2213
+ async function getLightningCSS() {
2214
+ if (!lightningCSSPromise) {
2215
+ lightningCSSPromise = import('lightningcss').then(m => m.transform);
2216
+ }
2217
+ return lightningCSSPromise;
2218
+ }
2219
+
2220
+ let terserPromise;
2221
+ async function getTerser() {
2222
+ if (!terserPromise) {
2223
+ terserPromise = import('terser').then(m => m.minify);
2224
+ }
2225
+ return terserPromise;
2226
+ }
2227
+
2228
+ let swcPromise;
2229
+ async function getSwc() {
2230
+ if (!swcPromise) {
2231
+ swcPromise = import('@swc/core')
2232
+ .then(m => m.default || m)
2233
+ .catch(() => {
2234
+ throw new Error(
2235
+ 'The swc minifier requires @swc/core to be installed.\n' +
2236
+ 'Install it with: npm install @swc/core'
2237
+ );
2238
+ });
2239
+ }
2240
+ return swcPromise;
2241
+ }
2242
+
2243
+ // Minification caches
2244
+
2245
+ const cssMinifyCache = new LRU(200);
2246
+ const jsMinifyCache = new LRU(200);
2247
+
2248
+ // Type definitions
2249
+
2250
+ /**
2251
+ * @typedef {Object} HTMLAttribute
2252
+ * Representation of an attribute from the HTML parser.
2253
+ *
2254
+ * @prop {string} name
2255
+ * @prop {string} [value]
2256
+ * @prop {string} [quote]
2257
+ * @prop {string} [customAssign]
2258
+ * @prop {string} [customOpen]
2259
+ * @prop {string} [customClose]
2260
+ */
2261
+
2262
+ /**
2263
+ * @typedef {Object} MinifierOptions
2264
+ * Options that control how HTML is minified. All of these are optional
2265
+ * and usually default to a disabled/safe value unless noted.
2266
+ *
2267
+ * @prop {(tag: string, attrs: HTMLAttribute[], canCollapseWhitespace: (tag: string) => boolean) => boolean} [canCollapseWhitespace]
2268
+ * Predicate that determines whether whitespace inside a given element
2269
+ * can be collapsed.
2270
+ *
2271
+ * Default: Built-in `canCollapseWhitespace` function
2272
+ *
2273
+ * @prop {(tag: string | null, attrs: HTMLAttribute[] | undefined, canTrimWhitespace: (tag: string) => boolean) => boolean} [canTrimWhitespace]
2274
+ * Predicate that determines whether leading/trailing whitespace around
2275
+ * the element may be trimmed.
2276
+ *
2277
+ * Default: Built-in `canTrimWhitespace` function
2278
+ *
2279
+ * @prop {boolean} [caseSensitive]
2280
+ * When true, tag and attribute names are treated as case-sensitive.
2281
+ * Useful for custom HTML tags.
2282
+ * If false (default) names are lower-cased via the `name` function.
2283
+ *
2284
+ * Default: `false`
2285
+ *
2286
+ * @prop {boolean} [collapseAttributeWhitespace]
2287
+ * Collapse multiple whitespace characters within attribute values into a
2288
+ * single space. Also trims leading and trailing whitespace from attribute
2289
+ * values. Applied as an early normalization step before special attribute
2290
+ * handlers (CSS minification, class sorting, etc.) run.
2291
+ *
2292
+ * Default: `false`
2293
+ *
2294
+ * @prop {boolean} [collapseBooleanAttributes]
2295
+ * Collapse boolean attributes to their name only (for example
2296
+ * `disabled="disabled"` → `disabled`).
2297
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes
2298
+ *
2299
+ * Default: `false`
2300
+ *
2301
+ * @prop {boolean} [collapseInlineTagWhitespace]
2302
+ * When false (default) whitespace around `inline` tags is preserved in
2303
+ * more cases. When true, whitespace around inline tags may be collapsed.
2304
+ * Must also enable `collapseWhitespace` to have effect.
2305
+ *
2306
+ * Default: `false`
2307
+ *
2308
+ * @prop {boolean} [collapseWhitespace]
2309
+ * Collapse multiple whitespace characters into one where allowed. Also
2310
+ * controls trimming behaviour in several code paths.
2311
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_whitespace
2312
+ *
2313
+ * Default: `false`
2314
+ *
2315
+ * @prop {boolean} [conservativeCollapse]
2316
+ * If true, be conservative when collapsing whitespace (preserve more
2317
+ * whitespace in edge cases). Affects collapse algorithms.
2318
+ * Must also enable `collapseWhitespace` to have effect.
2319
+ *
2320
+ * Default: `false`
2321
+ *
2322
+ * @prop {boolean} [continueOnMinifyError]
2323
+ * When set to `false`, minification errors may throw.
2324
+ * By default, the minifier will attempt to recover from minification
2325
+ * errors, or ignore them and preserve the original content.
2326
+ *
2327
+ * Default: `true`
2328
+ *
2329
+ * @prop {boolean} [continueOnParseError]
2330
+ * When true, the parser will attempt to continue on recoverable parse
2331
+ * errors. Otherwise, parsing errors may throw.
2332
+ *
2333
+ * Default: `false`
2334
+ *
2335
+ * @prop {RegExp[]} [customAttrAssign]
2336
+ * Array of regexes used to recognise custom attribute assignment
2337
+ * operators (e.g. `'<div flex?="{{mode != cover}}"></div>'`).
2338
+ * These are concatenated with the built-in assignment patterns.
2339
+ *
2340
+ * Default: `[]`
2341
+ *
2342
+ * @prop {RegExp} [customAttrCollapse]
2343
+ * Regex matching attribute names whose values should be collapsed.
2344
+ * Basically used to remove newlines and excess spaces inside attribute values,
2345
+ * e.g. `/ng-class/`.
2346
+ *
2347
+ * @prop {[RegExp, RegExp][]} [customAttrSurround]
2348
+ * Array of `[openRegExp, closeRegExp]` pairs used by the parser to
2349
+ * detect custom attribute surround patterns (for non-standard syntaxes,
2350
+ * e.g. `<input {{#if value}}checked="checked"{{/if}}>`).
2351
+ *
2352
+ * @prop {RegExp[]} [customEventAttributes]
2353
+ * Array of regexes used to detect event handler attributes for `minifyJS`
2354
+ * (e.g. `ng-click`). The default matches standard `on…` event attributes.
2355
+ *
2356
+ * Default: `[/^on[a-z]{3,}$/]`
2357
+ *
2358
+ * @prop {number} [customFragmentQuantifierLimit]
2359
+ * Limits the quantifier used when building a safe regex for custom
2360
+ * fragments to avoid ReDoS. See source use for details.
2361
+ *
2362
+ * Default: `200`
2363
+ *
2364
+ * @prop {boolean} [decodeEntities]
2365
+ * When true, decodes HTML entities in text and attributes before
2366
+ * processing, and re-encodes ambiguous ampersands when outputting.
2367
+ *
2368
+ * Default: `false`
2369
+ *
2370
+ * @prop {boolean} [html5]
2371
+ * Parse and emit using HTML5 rules. Set to `false` to use non-HTML5
2372
+ * parsing behavior.
2373
+ *
2374
+ * Default: `true`
2375
+ *
2376
+ * @prop {RegExp[]} [ignoreCustomComments]
2377
+ * Comments matching any pattern in this array of regexes will be
2378
+ * preserved when `removeComments` is enabled. The default preserves
2379
+ * “bang” comments and comments starting with `#`.
2380
+ *
2381
+ * Default: `[/^!/, /^\s*#/]`
2382
+ *
2383
+ * @prop {RegExp[]} [ignoreCustomFragments]
2384
+ * Array of regexes used to identify fragments that should be
2385
+ * preserved (for example server templates). These fragments are temporarily
2386
+ * replaced during minification to avoid corrupting template code.
2387
+ * The default preserves ASP/PHP-style tags.
2388
+ *
2389
+ * Default: `[/<%[\s\S]*?%>/, /<\?[\s\S]*?\?>/]`
2390
+ *
2391
+ * @prop {boolean} [includeAutoGeneratedTags]
2392
+ * If false, tags marked as auto-generated by the parser will be omitted
2393
+ * from output. Useful to skip injected tags.
2394
+ *
2395
+ * Default: `true`
2396
+ *
2397
+ * @prop {ArrayLike<string>} [inlineCustomElements]
2398
+ * Collection of custom element tag names that should be treated as inline
2399
+ * elements for white-space handling, alongside the built-in inline elements.
2400
+ *
2401
+ * Default: `[]`
2402
+ *
2403
+ * @prop {boolean} [keepClosingSlash]
2404
+ * Preserve the trailing slash in self-closing tags when present.
2405
+ *
2406
+ * Default: `false`
2407
+ *
2408
+ * @prop {(message: unknown) => void} [log]
2409
+ * Logging function used by the minifier for warnings/errors/info.
2410
+ * You can directly provide `console.log`, but `message` may also be an `Error`
2411
+ * object or other non-string value.
2412
+ *
2413
+ * Default: `() => {}` (no-op function)
2414
+ *
2415
+ * @prop {number} [maxInputLength]
2416
+ * The maximum allowed input length. Used as a guard against ReDoS via
2417
+ * pathological inputs. If the input exceeds this length an error is
2418
+ * thrown.
2419
+ *
2420
+ * Default: No limit
2421
+ *
2422
+ * @prop {number} [maxLineLength]
2423
+ * Maximum line length for the output. When set the minifier will wrap
2424
+ * output to the given number of characters where possible.
2425
+ *
2426
+ * Default: No limit
2427
+ *
2428
+ * @prop {boolean | Partial<import("lightningcss").TransformOptions<import("lightningcss").CustomAtRules>> | ((text: string, type?: string) => Promise<string> | string)} [minifyCSS]
2429
+ * When true, enables CSS minification for inline `<style>` tags or
2430
+ * `style` attributes. If an object is provided, it is passed to
2431
+ * [Lightning CSS](https://www.npmjs.com/package/lightningcss)
2432
+ * as transform options. If a function is provided, it will be used to perform
2433
+ * custom CSS minification. If disabled, CSS is not minified.
2434
+ *
2435
+ * Default: `false`
2436
+ *
2437
+ * @prop {boolean | import("terser").MinifyOptions | {engine?: 'terser' | 'swc', [key: string]: any} | ((text: string, inline?: boolean) => Promise<string> | string)} [minifyJS]
2438
+ * When true, enables JS minification for `<script>` contents and
2439
+ * event handler attributes. If an object is provided, it can include:
2440
+ * - `engine`: The minifier to use (`terser` or `swc`). Default: `terser`.
2441
+ * Note: Inline event handlers (e.g., `onclick="…"`) always use Terser
2442
+ * regardless of engine setting, as swc doesn’t support bare return statements.
2443
+ * - Engine-specific options (e.g., Terser options if `engine: 'terser'`,
2444
+ * SWC options if `engine: 'swc'`).
2445
+ * If a function is provided, it will be used to perform
2446
+ * custom JS minification. If disabled, JS is not minified.
2447
+ *
2448
+ * Default: `false`
2449
+ *
2450
+ * @prop {boolean | string | import("relateurl").Options | ((text: string) => Promise<string> | string)} [minifyURLs]
2451
+ * When true, enables URL rewriting/minification. If an object is provided,
2452
+ * it is passed to [relateurl](https://www.npmjs.com/package/relateurl)
2453
+ * as options. If a string is provided, it is treated as an `{ site: string }`
2454
+ * options object. If a function is provided, it will be used to perform
2455
+ * custom URL minification. If disabled, URLs are not minified.
2456
+ *
2457
+ * Default: `false`
2458
+ *
2459
+ * @prop {(name: string) => string} [name]
2460
+ * Function used to normalise tag/attribute names. By default, this lowercases
2461
+ * names, unless `caseSensitive` is enabled.
2462
+ *
2463
+ * Default: `(name) => name.toLowerCase()`,
2464
+ * or `(name) => name` (no-op function) if `caseSensitive` is enabled.
2465
+ *
2466
+ * @prop {boolean} [noNewlinesBeforeTagClose]
2467
+ * When wrapping lines, prevent inserting a newline directly before a
2468
+ * closing tag (useful to keep tags like `</a>` on the same line).
2469
+ *
2470
+ * Default: `false`
2471
+ *
2472
+ * @prop {boolean} [partialMarkup]
2473
+ * When true, treat input as a partial HTML fragment rather than a complete
2474
+ * document. This preserves stray end tags (closing tags without corresponding
2475
+ * opening tags) and prevents auto-closing of unclosed tags at the end of input.
2476
+ * Useful for minifying template fragments, SSI includes, or other partial HTML
2477
+ * that will be combined with other fragments.
2478
+ *
2479
+ * Default: `false`
2480
+ *
2481
+ * @prop {boolean} [preserveLineBreaks]
2482
+ * Preserve a single line break at the start/end of text nodes when
2483
+ * collapsing/trimming whitespace.
2484
+ * Must also enable `collapseWhitespace` to have effect.
2485
+ *
2486
+ * Default: `false`
2487
+ *
2488
+ * @prop {boolean} [preventAttributesEscaping]
2489
+ * When true, attribute values will not be HTML-escaped (dangerous for
2490
+ * untrusted input). By default, attributes are escaped.
2491
+ *
2492
+ * Default: `false`
2493
+ *
2494
+ * @prop {boolean} [processConditionalComments]
2495
+ * When true, conditional comments (for example `<!--[if IE]> … <![endif]-->`)
2496
+ * will have their inner content processed by the minifier.
2497
+ * Useful to minify HTML that appears inside conditional comments.
2498
+ *
2499
+ * Default: `false`
2500
+ *
2501
+ * @prop {string[]} [processScripts]
2502
+ * Array of `type` attribute values for `<script>` elements whose contents
2503
+ * should be processed as HTML
2504
+ * (e.g. `text/ng-template`, `text/x-handlebars-template`, etc.).
2505
+ * When present, the contents of matching script tags are recursively minified,
2506
+ * like normal HTML content.
2507
+ *
2508
+ * Default: `[]`
2509
+ *
2510
+ * @prop {"\"" | "'"} [quoteCharacter]
2511
+ * Preferred quote character for attribute values. If unspecified the
2512
+ * minifier picks the safest quote based on the attribute value.
2513
+ *
2514
+ * Default: Auto-detected
2515
+ *
2516
+ * @prop {boolean} [removeAttributeQuotes]
2517
+ * Remove quotes around attribute values where it is safe to do so.
2518
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_attribute_quotes
2519
+ *
2520
+ * Default: `false`
2521
+ *
2522
+ * @prop {boolean} [removeComments]
2523
+ * Remove HTML comments. Comments that match `ignoreCustomComments` will
2524
+ * still be preserved.
2525
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_comments
2526
+ *
2527
+ * Default: `false`
2528
+ *
2529
+ * @prop {boolean | ((attrName: string, tag: string) => boolean)} [removeEmptyAttributes]
2530
+ * If true, removes attributes whose values are empty (some attributes
2531
+ * are excluded by name). Can also be a function to customise which empty
2532
+ * attributes are removed.
2533
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_empty_or_blank_attributes
2534
+ *
2535
+ * Default: `false`
2536
+ *
2537
+ * @prop {boolean} [removeEmptyElements]
2538
+ * Remove elements that are empty and safe to remove (for example
2539
+ * `<script />` without `src`).
2540
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_empty_elements
2541
+ *
2542
+ * Default: `false`
2543
+ *
2544
+ * @prop {string[]} [removeEmptyElementsExcept]
2545
+ * Specifies empty elements to preserve when `removeEmptyElements` is enabled.
2546
+ * Has no effect unless `removeEmptyElements: true`.
2547
+ *
2548
+ * Accepts tag names or HTML-like element specifications:
2549
+ *
2550
+ * * Tag name only: `["td", "span"]`—preserves all empty elements of these types
2551
+ * * With valued attributes: `["<span aria-hidden='true'>"]`—preserves only when attribute values match
2552
+ * * With boolean attributes: `["<input disabled>"]`—preserves only when boolean attribute is present
2553
+ * * Mixed: `["<button type='button' disabled>"]`—all specified attributes must match
2554
+ *
2555
+ * Attribute matching:
2556
+ *
2557
+ * * All specified attributes must be present and match (valued attributes must have exact values)
2558
+ * * Additional attributes on the element are allowed
2559
+ * * Attribute name matching respects the `caseSensitive` option
2560
+ * * Supports double quotes, single quotes, and unquoted attribute values in specifications
2561
+ *
2562
+ * Limitations:
2563
+ *
2564
+ * * Self-closing syntax (e.g., `["<span/>"]`) is not supported; use `["span"]` instead
2565
+ * * Definitions containing `>` within quoted attribute values (e.g., `["<span title='a>b'>"]`) are not supported
2566
+ *
2567
+ * Default: `[]`
2568
+ *
2569
+ * @prop {boolean} [removeOptionalTags]
2570
+ * Drop optional start/end tags where the HTML specification permits it
2571
+ * (for example `</li>`, optional `<html>` etc.).
2572
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_optional_tags
2573
+ *
2574
+ * Default: `false`
2575
+ *
2576
+ * @prop {boolean} [removeRedundantAttributes]
2577
+ * Remove attributes that are redundant because they match the element’s
2578
+ * default values (for example `<button type="submit">`).
2579
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_redundant_attributes
2580
+ *
2581
+ * Default: `false`
2582
+ *
2583
+ * @prop {boolean} [removeScriptTypeAttributes]
2584
+ * Remove `type` attributes from `<script>` when they are unnecessary
2585
+ * (e.g. `type="text/javascript"`).
2586
+ *
2587
+ * Default: `false`
2588
+ *
2589
+ * @prop {boolean} [removeStyleLinkTypeAttributes]
2590
+ * Remove `type` attributes from `<style>` and `<link>` elements when
2591
+ * they are unnecessary (e.g. `type="text/css"`).
2592
+ *
2593
+ * Default: `false`
2594
+ *
2595
+ * @prop {boolean} [removeTagWhitespace]
2596
+ * **Note that this will result in invalid HTML!**
2597
+ *
2598
+ * When true, extra whitespace between tag name and attributes (or before
2599
+ * the closing bracket) will be removed where possible. Affects output spacing
2600
+ * such as the space used in the short doctype representation.
2601
+ *
2602
+ * Default: `false`
2603
+ *
2604
+ * @prop {boolean | ((tag: string, attrs: HTMLAttribute[]) => void)} [sortAttributes]
2605
+ * When true, enables sorting of attributes. If a function is provided it
2606
+ * will be used as a custom attribute sorter, which should mutate `attrs`
2607
+ * in-place to the desired order. If disabled, the minifier will attempt to
2608
+ * preserve the order from the input.
2609
+ *
2610
+ * Default: `false`
2611
+ *
2612
+ * @prop {boolean | ((value: string) => string)} [sortClassName]
2613
+ * When true, enables sorting of class names inside `class` attributes.
2614
+ * If a function is provided it will be used to transform/sort the class
2615
+ * name string. If disabled, the minifier will attempt to preserve the
2616
+ * class-name order from the input.
2617
+ *
2618
+ * Default: `false`
2619
+ *
2620
+ * @prop {boolean} [trimCustomFragments]
2621
+ * When true, whitespace around ignored custom fragments may be trimmed
2622
+ * more aggressively. This affects how preserved fragments interact with
2623
+ * surrounding whitespace collapse.
2624
+ *
2625
+ * Default: `false`
2626
+ *
2627
+ * @prop {boolean} [useShortDoctype]
2628
+ * Replace the HTML doctype with the short `<!doctype html>` form.
2629
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#use_short_doctype
2630
+ *
2631
+ * Default: `false`
2193
2632
  */
2194
- const processOptions = (inputOptions) => {
2195
- const options = {
2196
- name: function (name) {
2197
- return name.toLowerCase();
2198
- },
2199
- canCollapseWhitespace,
2200
- canTrimWhitespace,
2201
- continueOnMinifyError: true,
2202
- html5: true,
2203
- ignoreCustomComments: [
2204
- /^!/,
2205
- /^\s*#/
2206
- ],
2207
- ignoreCustomFragments: [
2208
- /<%[\s\S]*?%>/,
2209
- /<\?[\s\S]*?\?>/
2210
- ],
2211
- includeAutoGeneratedTags: true,
2212
- log: identity,
2213
- minifyCSS: identityAsync,
2214
- minifyJS: identity,
2215
- minifyURLs: identity
2216
- };
2217
-
2218
- Object.keys(inputOptions).forEach(function (key) {
2219
- const option = inputOptions[key];
2220
-
2221
- if (key === 'caseSensitive') {
2222
- if (option) {
2223
- options.name = identity;
2224
- }
2225
- } else if (key === 'log') {
2226
- if (typeof option === 'function') {
2227
- options.log = option;
2228
- }
2229
- } else if (key === 'minifyCSS' && typeof option !== 'function') {
2230
- if (!option) {
2231
- return;
2232
- }
2233
-
2234
- const lightningCssOptions = typeof option === 'object' ? option : {};
2235
-
2236
- options.minifyCSS = async function (text, type) {
2237
- // Fast path: Nothing to minify
2238
- if (!text || !text.trim()) {
2239
- return text;
2240
- }
2241
- text = await replaceAsync(
2242
- text,
2243
- /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
2244
- async function (match, prefix, dq, sq, unq, suffix) {
2245
- const quote = dq != null ? '"' : (sq != null ? "'" : '');
2246
- const url = dq ?? sq ?? unq ?? '';
2247
- try {
2248
- const out = await options.minifyURLs(url);
2249
- return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
2250
- } catch (err) {
2251
- if (!options.continueOnMinifyError) {
2252
- throw err;
2253
- }
2254
- options.log && options.log(err);
2255
- return match;
2256
- }
2257
- }
2258
- );
2259
- // Cache key: wrapped content, type, options signature
2260
- const inputCSS = wrapCSS(text, type);
2261
- const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
2262
- // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
2263
- const cssKey = inputCSS.length > 2048
2264
- ? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
2265
- : (inputCSS + '|' + type + '|' + cssSig);
2266
-
2267
- try {
2268
- const cached = cssMinifyCache.get(cssKey);
2269
- if (cached) {
2270
- return cached;
2271
- }
2272
-
2273
- const transformCSS = await getLightningCSS();
2274
- const result = transformCSS({
2275
- filename: 'input.css',
2276
- code: Buffer.from(inputCSS),
2277
- minify: true,
2278
- errorRecovery: !!options.continueOnMinifyError,
2279
- ...lightningCssOptions
2280
- });
2281
-
2282
- const outputCSS = unwrapCSS(result.code.toString(), type);
2283
-
2284
- // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
2285
- // This preserves:
2286
- // 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
2287
- // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
2288
- // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
2289
- const isCDATA = text.includes('<![CDATA[');
2290
- const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
2291
- const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
2292
- const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
2293
-
2294
- // Preserve if output is empty and input had template syntax or UIDs
2295
- // This catches cases where Lightning CSS removed content that should be preserved
2296
- const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
2297
-
2298
- cssMinifyCache.set(cssKey, finalOutput);
2299
- return finalOutput;
2300
- } catch (err) {
2301
- cssMinifyCache.delete(cssKey);
2302
- if (!options.continueOnMinifyError) {
2303
- throw err;
2304
- }
2305
- options.log && options.log(err);
2306
- return text;
2307
- }
2308
- };
2309
- } else if (key === 'minifyJS' && typeof option !== 'function') {
2310
- if (!option) {
2311
- return;
2312
- }
2313
-
2314
- const terserOptions = typeof option === 'object' ? option : {};
2315
-
2316
- terserOptions.parse = {
2317
- ...terserOptions.parse,
2318
- bare_returns: false
2319
- };
2320
-
2321
- options.minifyJS = async function (text, inline) {
2322
- const start = text.match(/^\s*<!--.*/);
2323
- const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
2324
-
2325
- terserOptions.parse.bare_returns = inline;
2326
-
2327
- let jsKey;
2328
- try {
2329
- // Fast path: Avoid invoking Terser for empty/whitespace-only content
2330
- if (!code || !code.trim()) {
2331
- return '';
2332
- }
2333
- // Cache key: content, inline, options signature (subset)
2334
- const terserSig = stableStringify({
2335
- compress: terserOptions.compress,
2336
- mangle: terserOptions.mangle,
2337
- ecma: terserOptions.ecma,
2338
- toplevel: terserOptions.toplevel,
2339
- module: terserOptions.module,
2340
- keep_fnames: terserOptions.keep_fnames,
2341
- format: terserOptions.format,
2342
- cont: !!options.continueOnMinifyError,
2343
- });
2344
- // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
2345
- jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
2346
- const cached = jsMinifyCache.get(jsKey);
2347
- if (cached) {
2348
- return await cached;
2349
- }
2350
- const inFlight = (async () => {
2351
- const terser = await getTerser();
2352
- const result = await terser(code, terserOptions);
2353
- return result.code.replace(RE_TRAILING_SEMICOLON, '');
2354
- })();
2355
- jsMinifyCache.set(jsKey, inFlight);
2356
- const resolved = await inFlight;
2357
- jsMinifyCache.set(jsKey, resolved);
2358
- return resolved;
2359
- } catch (err) {
2360
- if (jsKey) jsMinifyCache.delete(jsKey);
2361
- if (!options.continueOnMinifyError) {
2362
- throw err;
2363
- }
2364
- options.log && options.log(err);
2365
- return text;
2366
- }
2367
- };
2368
- } else if (key === 'minifyURLs' && typeof option !== 'function') {
2369
- if (!option) {
2370
- return;
2371
- }
2372
-
2373
- let relateUrlOptions = option;
2374
-
2375
- if (typeof option === 'string') {
2376
- relateUrlOptions = { site: option };
2377
- } else if (typeof option !== 'object') {
2378
- relateUrlOptions = {};
2379
- }
2380
-
2381
- // Cache RelateURL instance for reuse (expensive to create)
2382
- const relateUrlInstance = new RelateURL(relateUrlOptions.site || '', relateUrlOptions);
2383
-
2384
- options.minifyURLs = function (text) {
2385
- // Fast-path: Skip if text doesn’t look like a URL that needs processing
2386
- // Only process if contains URL-like characters (`/`, `:`, `#`, `?`) or spaces that need encoding
2387
- if (!/[/:?#\s]/.test(text)) {
2388
- return text;
2389
- }
2390
-
2391
- try {
2392
- return relateUrlInstance.relate(text);
2393
- } catch (err) {
2394
- if (!options.continueOnMinifyError) {
2395
- throw err;
2396
- }
2397
- options.log && options.log(err);
2398
- return text;
2399
- }
2400
- };
2401
- } else {
2402
- options[key] = option;
2403
- }
2404
- });
2405
- return options;
2406
- };
2407
-
2408
- function uniqueId(value) {
2409
- let id;
2410
- do {
2411
- id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
2412
- } while (~value.indexOf(id));
2413
- return id;
2414
- }
2415
-
2416
- const specialContentTags = new Set(['script', 'style']);
2417
2633
 
2418
2634
  async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
2419
2635
  const attrChains = options.sortAttributes && Object.create(null);
@@ -2486,8 +2702,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
2486
2702
  try {
2487
2703
  await parser.parse();
2488
2704
  } catch (err) {
2489
- // If parsing fails during analysis pass, just skip it—we’ll still have
2490
- // partial frequency data from what we could parse
2705
+ // If parsing fails during analysis pass, just skip it—we’ll still have partial frequency data from what we could parse
2491
2706
  if (!options.continueOnParseError) {
2492
2707
  throw err;
2493
2708
  }
@@ -2496,7 +2711,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
2496
2711
 
2497
2712
  // For the first pass, create a copy of options and disable aggressive minification.
2498
2713
  // Keep attribute transformations (like `removeStyleLinkTypeAttributes`) for accurate analysis.
2499
- // This is safe because `createSortFns` is called before custom fragment UID markers (uidAttr) are added.
2714
+ // This is safe because `createSortFns` is called before custom fragment UID markers (`uidAttr`) are added.
2500
2715
  // Note: `htmlmin:ignore` UID markers (uidIgnore) already exist and are expanded for analysis.
2501
2716
  const firstPassOptions = Object.assign({}, options, {
2502
2717
  // Disable sorting for the analysis pass
@@ -2539,8 +2754,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
2539
2754
  uidReplacePattern.lastIndex = 0;
2540
2755
  }
2541
2756
 
2542
- // First pass minification applies attribute transformations
2543
- // like removeStyleLinkTypeAttributes for accurate frequency analysis
2757
+ // First pass minification applies attribute transformations like `removeStyleLinkTypeAttributes` for accurate frequency analysis
2544
2758
  const firstPassOutput = await minifyHTML(expandedValue, firstPassOptions);
2545
2759
 
2546
2760
  // For frequency analysis, we need to remove custom fragments temporarily
@@ -2561,16 +2775,30 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
2561
2775
  for (const tag in attrChains) {
2562
2776
  attrSorters[tag] = attrChains[tag].createSorter();
2563
2777
  }
2778
+ // Memoize sorted attribute orders—attribute sets often repeat in templates
2779
+ const attrOrderCache = new LRU(200);
2780
+
2564
2781
  options.sortAttributes = function (tag, attrs) {
2565
2782
  const sorter = attrSorters[tag];
2566
2783
  if (sorter) {
2567
- const attrMap = Object.create(null);
2568
2784
  const names = attrNames(attrs);
2785
+
2786
+ // Create order-independent cache key from tag and sorted attribute names
2787
+ const cacheKey = tag + ':' + names.slice().sort().join(',');
2788
+ let sortedNames = attrOrderCache.get(cacheKey);
2789
+
2790
+ if (sortedNames === undefined) {
2791
+ // Only sort if not in cache—need to clone names since sort mutates in place
2792
+ sortedNames = sorter.sort(names.slice());
2793
+ attrOrderCache.set(cacheKey, sortedNames);
2794
+ }
2795
+
2796
+ // Apply the sorted order to attrs
2797
+ const attrMap = Object.create(null);
2569
2798
  names.forEach(function (name, index) {
2570
2799
  (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
2571
2800
  });
2572
- const sorted = sorter.sort(names);
2573
- sorted.forEach(function (name, index) {
2801
+ sortedNames.forEach(function (name, index) {
2574
2802
  attrs[index] = attrMap[name].shift();
2575
2803
  });
2576
2804
  }
@@ -2671,10 +2899,8 @@ async function minifyHTML(value, options, partialMarkup) {
2671
2899
  removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
2672
2900
  }
2673
2901
 
2674
- // Temporarily replace ignored chunks with comments,
2675
- // so that we don’t have to worry what’s there.
2676
- // For all we care there might be
2677
- // completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
2902
+ // Temporarily replace ignored chunks with comments, so that we don’t have to worry what’s there.
2903
+ // For all we care there might be completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
2678
2904
  value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
2679
2905
  if (!uidIgnore) {
2680
2906
  uidIgnore = uniqueId(value);
@@ -2882,7 +3108,7 @@ async function minifyHTML(value, options, partialMarkup) {
2882
3108
 
2883
3109
  const parts = [];
2884
3110
  for (let i = attrs.length, isLast = true; --i >= 0;) {
2885
- const normalized = await normalizeAttr(attrs[i], attrs, tag, options);
3111
+ const normalized = await normalizeAttr(attrs[i], attrs, tag, options, minifyHTML);
2886
3112
  if (normalized) {
2887
3113
  parts.push(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
2888
3114
  isLast = false;
@@ -2957,7 +3183,7 @@ async function minifyHTML(value, options, partialMarkup) {
2957
3183
  }
2958
3184
 
2959
3185
  if (!preserve) {
2960
- // Remove last element from buffer
3186
+ // Remove last element from buffer
2961
3187
  removeStartTag();
2962
3188
  optionalStartTag = '';
2963
3189
  optionalEndTag = '';
@@ -3041,7 +3267,7 @@ async function minifyHTML(value, options, partialMarkup) {
3041
3267
  }
3042
3268
  }
3043
3269
  if (specialContentTags.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs))) {
3044
- text = await processScript(text, options, currentAttrs);
3270
+ text = await processScript(text, options, currentAttrs, minifyHTML);
3045
3271
  }
3046
3272
  if (isExecutableScript(currentTag, currentAttrs)) {
3047
3273
  text = await options.minifyJS(text);
@@ -3095,7 +3321,7 @@ async function minifyHTML(value, options, partialMarkup) {
3095
3321
  const prefix = nonStandard ? '<!' : '<!--';
3096
3322
  const suffix = nonStandard ? '>' : '-->';
3097
3323
  if (isConditionalComment(text)) {
3098
- text = prefix + await cleanConditionalComment(text, options) + suffix;
3324
+ text = prefix + await cleanConditionalComment(text, options, minifyHTML) + suffix;
3099
3325
  } else if (options.removeComments) {
3100
3326
  if (isIgnoredComment(text, options)) {
3101
3327
  text = '<!--' + text + '-->';
@@ -3282,7 +3508,13 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
3282
3508
  */
3283
3509
  const minify = async function (value, options) {
3284
3510
  const start = Date.now();
3285
- options = processOptions(options || {});
3511
+ options = processOptions(options || {}, {
3512
+ getLightningCSS,
3513
+ getTerser,
3514
+ getSwc,
3515
+ cssMinifyCache,
3516
+ jsMinifyCache
3517
+ });
3286
3518
  const result = await minifyHTML(value, options);
3287
3519
  options.log('minified in: ' + (Date.now() - start) + 'ms');
3288
3520
  return result;