html-minifier-next 4.12.1 → 4.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, '');
904
+ // Elements that will always maintain whitespace around them
905
+ const inlineElementsToKeepWhitespace = new Set(['comment', 'img', 'input', 'wbr']);
906
+
907
+ // Default attribute values
908
+
909
+ // Default attribute values (could apply to any element)
910
+ const generalDefaults = {
911
+ autocorrect: 'on',
912
+ fetchpriority: 'auto',
913
+ loading: 'eager',
914
+ popovertargetaction: 'toggle'
1275
915
  };
1276
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
+
1277
1049
  function collapseWhitespaceAll(str) {
1278
1050
  if (!str) return str;
1279
1051
  // Fast path: If there are no common whitespace characters, return early
1280
1052
  if (!/[ \n\r\t\f\xA0]/.test(str)) {
1281
1053
  return str;
1282
1054
  }
1283
- // Non-breaking space is specifically handled inside the replacer function here:
1055
+ // No-break space is specifically handled inside the replacer function here:
1284
1056
  return str.replace(RE_ALL_WS_NBSP, function (spaces) {
1285
- return spaces === '\t' ? '\t' : spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
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 ');
1286
1064
  });
1287
1065
  }
1288
1066
 
1067
+ // Collapse whitespace with options
1068
+
1289
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,507 +1139,528 @@ 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
-
1424
- function isAttributeRedundant(tag, attrName, attrValue, attrs) {
1425
- attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
1426
-
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
- }
1437
1241
 
1438
- // Check general defaults
1439
- if (generalDefaults[attrName] === attrValue) {
1440
- return true;
1441
- }
1242
+ // Helper functions
1442
1243
 
1443
- // Check tag-specific defaults
1444
- return tagDefaults[tag]?.[attrName] === attrValue;
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
+ );
1445
1253
  }
1446
1254
 
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
- ]);
1255
+ // Main options processor
1458
1256
 
1459
- const keepScriptsMimetypes = new Set([
1460
- 'module'
1461
- ]);
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 {LRU} deps.cssMinifyCache - CSS minification cache
1263
+ * @param {LRU} deps.jsMinifyCache - JS minification cache
1264
+ * @returns {MinifierOptions} Normalized options with defaults applied
1265
+ */
1266
+ const processOptions = (inputOptions, { getLightningCSS, getTerser, cssMinifyCache, jsMinifyCache } = {}) => {
1267
+ const options = {
1268
+ name: function (name) {
1269
+ return name.toLowerCase();
1270
+ },
1271
+ canCollapseWhitespace,
1272
+ canTrimWhitespace,
1273
+ continueOnMinifyError: true,
1274
+ html5: true,
1275
+ ignoreCustomComments: [
1276
+ /^!/,
1277
+ /^\s*#/
1278
+ ],
1279
+ ignoreCustomFragments: [
1280
+ /<%[\s\S]*?%>/,
1281
+ /<\?[\s\S]*?\?>/
1282
+ ],
1283
+ includeAutoGeneratedTags: true,
1284
+ log: identity,
1285
+ minifyCSS: identityAsync,
1286
+ minifyJS: identity,
1287
+ minifyURLs: identity
1288
+ };
1462
1289
 
1463
- function isScriptTypeAttribute(attrValue = '') {
1464
- attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
1465
- return attrValue === '' || executableScriptsMimetypes.has(attrValue);
1466
- }
1290
+ Object.keys(inputOptions).forEach(function (key) {
1291
+ const option = inputOptions[key];
1467
1292
 
1468
- function keepScriptTypeAttribute(attrValue = '') {
1469
- attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
1470
- return keepScriptsMimetypes.has(attrValue);
1471
- }
1293
+ if (key === 'caseSensitive') {
1294
+ if (option) {
1295
+ options.name = identity;
1296
+ }
1297
+ } else if (key === 'log') {
1298
+ if (typeof option === 'function') {
1299
+ options.log = option;
1300
+ }
1301
+ } else if (key === 'minifyCSS' && typeof option !== 'function') {
1302
+ if (!option) {
1303
+ return;
1304
+ }
1472
1305
 
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
- }
1306
+ const lightningCssOptions = typeof option === 'object' ? option : {};
1485
1307
 
1486
- function isStyleLinkTypeAttribute(attrValue = '') {
1487
- attrValue = trimWhitespace(attrValue).toLowerCase();
1488
- return attrValue === '' || attrValue === 'text/css';
1489
- }
1308
+ options.minifyCSS = async function (text, type) {
1309
+ // Fast path: Nothing to minify
1310
+ if (!text || !text.trim()) {
1311
+ return text;
1312
+ }
1313
+ text = await replaceAsync(
1314
+ text,
1315
+ /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
1316
+ async function (match, prefix, dq, sq, unq, suffix) {
1317
+ const quote = dq != null ? '"' : (sq != null ? "'" : '');
1318
+ const url = dq ?? sq ?? unq ?? '';
1319
+ try {
1320
+ const out = await options.minifyURLs(url);
1321
+ return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
1322
+ } catch (err) {
1323
+ if (!options.continueOnMinifyError) {
1324
+ throw err;
1325
+ }
1326
+ options.log && options.log(err);
1327
+ return match;
1328
+ }
1329
+ }
1330
+ );
1331
+ // Cache key: Wrapped content, type, options signature
1332
+ const inputCSS = wrapCSS(text, type);
1333
+ const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
1334
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
1335
+ const cssKey = inputCSS.length > 2048
1336
+ ? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
1337
+ : (inputCSS + '|' + type + '|' + cssSig);
1490
1338
 
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
- }
1339
+ try {
1340
+ const cached = cssMinifyCache.get(cssKey);
1341
+ if (cached) {
1342
+ return cached;
1343
+ }
1503
1344
 
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']);
1345
+ const transformCSS = await getLightningCSS();
1346
+ const result = transformCSS({
1347
+ filename: 'input.css',
1348
+ code: Buffer.from(inputCSS),
1349
+ minify: true,
1350
+ errorRecovery: !!options.continueOnMinifyError,
1351
+ ...lightningCssOptions
1352
+ });
1506
1353
 
1507
- function isBooleanAttribute(attrName, attrValue) {
1508
- return isSimpleBoolean.has(attrName) || (attrName === 'draggable' && !isBooleanValue.has(attrValue));
1509
- }
1354
+ const outputCSS = unwrapCSS(result.code.toString(), type);
1510
1355
 
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
- }
1356
+ // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
1357
+ // This preserves:
1358
+ // 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
1359
+ // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
1360
+ // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
1361
+ const isCDATA = text.includes('<![CDATA[');
1362
+ const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
1363
+ const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
1364
+ const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
1525
1365
 
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
- }
1366
+ // Preserve if output is empty and input had template syntax or UIDs
1367
+ // This catches cases where Lightning CSS removed content that should be preserved
1368
+ const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
1537
1369
 
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
- }
1370
+ cssMinifyCache.set(cssKey, finalOutput);
1371
+ return finalOutput;
1372
+ } catch (err) {
1373
+ cssMinifyCache.delete(cssKey);
1374
+ if (!options.continueOnMinifyError) {
1375
+ throw err;
1376
+ }
1377
+ options.log && options.log(err);
1378
+ return text;
1379
+ }
1380
+ };
1381
+ } else if (key === 'minifyJS' && typeof option !== 'function') {
1382
+ if (!option) {
1383
+ return;
1384
+ }
1549
1385
 
1550
- function isMediaQuery(tag, attrs, attrName) {
1551
- return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleSheet(tag, attrs));
1552
- }
1386
+ const terserOptions = typeof option === 'object' ? option : {};
1553
1387
 
1554
- const srcsetTags = new Set(['img', 'source']);
1388
+ terserOptions.parse = {
1389
+ ...terserOptions.parse,
1390
+ bare_returns: false
1391
+ };
1555
1392
 
1556
- function isSrcset(attrName, tag) {
1557
- return attrName === 'srcset' && srcsetTags.has(tag);
1558
- }
1393
+ options.minifyJS = async function (text, inline) {
1394
+ const start = text.match(/^\s*<!--.*/);
1395
+ const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
1559
1396
 
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
- }
1397
+ terserOptions.parse.bare_returns = inline;
1566
1398
 
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;
1399
+ let jsKey;
1400
+ try {
1401
+ // Fast path: Avoid invoking Terser for empty/whitespace-only content
1402
+ if (!code || !code.trim()) {
1403
+ return '';
1404
+ }
1405
+ // Cache key: content, inline, options signature (subset)
1406
+ const terserSig = stableStringify({
1407
+ compress: terserOptions.compress,
1408
+ mangle: terserOptions.mangle,
1409
+ ecma: terserOptions.ecma,
1410
+ toplevel: terserOptions.toplevel,
1411
+ module: terserOptions.module,
1412
+ keep_fnames: terserOptions.keep_fnames,
1413
+ format: terserOptions.format,
1414
+ cont: !!options.continueOnMinifyError,
1415
+ });
1416
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
1417
+ jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
1418
+ const cached = jsMinifyCache.get(jsKey);
1419
+ if (cached) {
1420
+ return await cached;
1421
+ }
1422
+ const inFlight = (async () => {
1423
+ const terser = await getTerser();
1424
+ const result = await terser(code, terserOptions);
1425
+ return result.code.replace(RE_TRAILING_SEMICOLON, '');
1426
+ })();
1427
+ jsMinifyCache.set(jsKey, inFlight);
1428
+ const resolved = await inFlight;
1429
+ jsMinifyCache.set(jsKey, resolved);
1430
+ return resolved;
1431
+ } catch (err) {
1432
+ if (jsKey) jsMinifyCache.delete(jsKey);
1433
+ if (!options.continueOnMinifyError) {
1434
+ throw err;
1435
+ }
1436
+ options.log && options.log(err);
1437
+ return text;
1438
+ }
1439
+ };
1440
+ } else if (key === 'minifyURLs' && typeof option !== 'function') {
1441
+ if (!option) {
1442
+ return;
1589
1443
  }
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*;$/, ';');
1444
+
1445
+ let relateUrlOptions = option;
1446
+
1447
+ if (typeof option === 'string') {
1448
+ relateUrlOptions = { site: option };
1449
+ } else if (typeof option !== 'object') {
1450
+ relateUrlOptions = {};
1600
1451
  }
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;
1452
+
1453
+ // Cache RelateURL instance for reuse (expensive to create)
1454
+ const relateUrlInstance = new RelateURL(relateUrlOptions.site || '', relateUrlOptions);
1455
+
1456
+ options.minifyURLs = function (text) {
1457
+ // Fast-path: Skip if text doesn’t look like a URL that needs processing
1458
+ // Only process if contains URL-like characters (`/`, `:`, `#`, `?`) or spaces that need encoding
1459
+ if (!/[/:?#\s]/.test(text)) {
1460
+ return text;
1616
1461
  }
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;
1462
+
1463
+ try {
1464
+ return relateUrlInstance.relate(text);
1465
+ } catch (err) {
1466
+ if (!options.continueOnMinifyError) {
1467
+ throw err;
1468
+ }
1469
+ options.log && options.log(err);
1470
+ return text;
1624
1471
  }
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;
1472
+ };
1473
+ } else {
1474
+ options[key] = option;
1650
1475
  }
1651
- return minifyHTMLSelf(attrValue, options, true);
1652
- }
1653
- return attrValue;
1476
+ });
1477
+ return options;
1478
+ };
1479
+
1480
+ // Imports
1481
+
1482
+
1483
+ // Validators
1484
+
1485
+ function isConditionalComment(text) {
1486
+ return RE_CONDITIONAL_COMMENT.test(text);
1654
1487
  }
1655
1488
 
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') {
1489
+ function isIgnoredComment(text, options) {
1490
+ for (let i = 0, len = options.ignoreCustomComments.length; i < len; i++) {
1491
+ if (options.ignoreCustomComments[i].test(text)) {
1662
1492
  return true;
1663
1493
  }
1664
1494
  }
1495
+ return false;
1665
1496
  }
1666
1497
 
1667
- function isContentSecurityPolicy(tag, attrs) {
1668
- if (tag !== 'meta') {
1498
+ function isEventAttribute(attrName, options) {
1499
+ const patterns = options.customEventAttributes;
1500
+ if (patterns) {
1501
+ for (let i = patterns.length; i--;) {
1502
+ if (patterns[i].test(attrName)) {
1503
+ return true;
1504
+ }
1505
+ }
1669
1506
  return false;
1670
1507
  }
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') {
1508
+ return RE_EVENT_ATTR_DEFAULT.test(attrName);
1509
+ }
1510
+
1511
+ function canRemoveAttributeQuotes(value) {
1512
+ // https://mathiasbynens.be/notes/unquoted-attribute-values
1513
+ return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
1514
+ }
1515
+
1516
+ function attributesInclude(attributes, attribute) {
1517
+ for (let i = attributes.length; i--;) {
1518
+ if (attributes[i].name.toLowerCase() === attribute) {
1673
1519
  return true;
1674
1520
  }
1675
1521
  }
1522
+ return false;
1676
1523
  }
1677
1524
 
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;
1525
+ function isAttributeRedundant(tag, attrName, attrValue, attrs) {
1526
+ attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
1527
+
1528
+ // Legacy attributes
1529
+ if (tag === 'script' && attrName === 'language' && attrValue === 'javascript') {
1530
+ return true;
1531
+ }
1532
+ if (tag === 'script' && attrName === 'charset' && !attributesInclude(attrs, 'src')) {
1533
+ return true;
1534
+ }
1535
+ if (tag === 'a' && attrName === 'name' && attributesInclude(attrs, 'id')) {
1536
+ return true;
1688
1537
  }
1689
- }
1690
1538
 
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;
1539
+ // Check general defaults
1540
+ if (generalDefaults[attrName] === attrValue) {
1541
+ return true;
1700
1542
  }
1701
- return matches ? matches[1] : text;
1543
+
1544
+ // Check tag-specific defaults
1545
+ return tagDefaults[tag]?.[attrName] === attrValue;
1702
1546
  }
1703
1547
 
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;
1548
+ function isScriptTypeAttribute(attrValue = '') {
1549
+ attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
1550
+ return attrValue === '' || executableScriptsMimetypes.has(attrValue);
1710
1551
  }
1711
1552
 
1712
- const jsonScriptTypes = new Set([
1713
- 'application/json',
1714
- 'application/ld+json',
1715
- 'application/manifest+json',
1716
- 'application/vnd.geo+json',
1717
- 'importmap',
1718
- 'speculationrules',
1719
- ]);
1553
+ function keepScriptTypeAttribute(attrValue = '') {
1554
+ attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
1555
+ return keepScriptsMimetypes.has(attrValue);
1556
+ }
1720
1557
 
1721
- function minifyJson(text, options) {
1722
- try {
1723
- return JSON.stringify(JSON.parse(text));
1558
+ function isExecutableScript(tag, attrs) {
1559
+ if (tag !== 'script') {
1560
+ return false;
1724
1561
  }
1725
- catch (err) {
1726
- if (!options.continueOnMinifyError) {
1727
- throw err;
1562
+ for (let i = 0, len = attrs.length; i < len; i++) {
1563
+ const attrName = attrs[i].name.toLowerCase();
1564
+ if (attrName === 'type') {
1565
+ return isScriptTypeAttribute(attrs[i].value);
1728
1566
  }
1729
- options.log && options.log(err);
1730
- return text;
1731
1567
  }
1568
+ return true;
1732
1569
  }
1733
1570
 
1734
- function hasJsonScriptType(attrs) {
1571
+ function isStyleLinkTypeAttribute(attrValue = '') {
1572
+ attrValue = trimWhitespace(attrValue).toLowerCase();
1573
+ return attrValue === '' || attrValue === 'text/css';
1574
+ }
1575
+
1576
+ function isStyleSheet(tag, attrs) {
1577
+ if (tag !== 'style') {
1578
+ return false;
1579
+ }
1735
1580
  for (let i = 0, len = attrs.length; i < len; i++) {
1736
1581
  const attrName = attrs[i].name.toLowerCase();
1737
1582
  if (attrName === 'type') {
1738
- const attrValue = trimWhitespace((attrs[i].value || '').split(/;/, 2)[0]).toLowerCase();
1739
- if (jsonScriptTypes.has(attrValue)) {
1740
- return true;
1741
- }
1583
+ return isStyleLinkTypeAttribute(attrs[i].value);
1742
1584
  }
1743
1585
  }
1744
- return false;
1586
+ return true;
1745
1587
  }
1746
1588
 
1747
- async function processScript(text, options, currentAttrs) {
1748
- for (let i = 0, len = currentAttrs.length; i < len; i++) {
1749
- const attrName = currentAttrs[i].name.toLowerCase();
1750
- if (attrName === 'type') {
1751
- const rawValue = currentAttrs[i].value;
1752
- const normalizedValue = trimWhitespace((rawValue || '').split(/;/, 2)[0]).toLowerCase();
1753
- // Minify JSON script types automatically
1754
- if (jsonScriptTypes.has(normalizedValue)) {
1755
- return minifyJson(text, options);
1756
- }
1757
- // Process custom script types if specified
1758
- if (options.processScripts && options.processScripts.indexOf(rawValue) > -1) {
1759
- return await minifyHTML(text, options);
1760
- }
1761
- }
1762
- }
1763
- return text;
1589
+ function isBooleanAttribute(attrName, attrValue) {
1590
+ return isSimpleBoolean.has(attrName) || (attrName === 'draggable' && !isBooleanValue.has(attrValue));
1764
1591
  }
1765
1592
 
1766
- // Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags with the following extensions:
1767
- // - retain `<body>` if followed by `<noscript>`
1768
- // - `<rb>`, `<rt>`, `<rtc>`, `<rp>` follow HTML Ruby Markup Extensions draft (https://www.w3.org/TR/html-ruby-extensions/)
1769
- // - retain all tags which are adjacent to non-standard HTML tags
1770
- const optionalStartTags = new Set(['html', 'head', 'body', 'colgroup', 'tbody']);
1771
- 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']);
1772
- const headerTags = new Set(['meta', 'link', 'script', 'style', 'template', 'noscript']);
1773
- const descriptionTags = new Set(['dt', 'dd']);
1774
- 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']);
1775
- const pInlineTags = new Set(['a', 'audio', 'del', 'ins', 'map', 'noscript', 'video']);
1776
- const rubyEndTagOmission = new Set(['rb', 'rt', 'rtc', 'rp']); // `</rb>`, `</rt>`, `</rp>` can be omitted if followed by `<rb>`, `<rt>`, `<rtc>`, or `<rp>`
1777
- const rubyRtcEndTagOmission = new Set(['rb', 'rtc']); // `</rtc>` can be omitted if followed by `<rb>` or `<rtc>` (not `<rt>` or `<rp>`)
1778
- const optionTag = new Set(['option', 'optgroup']);
1779
- const tableContentTags = new Set(['tbody', 'tfoot']);
1780
- const tableSectionTags = new Set(['thead', 'tbody', 'tfoot']);
1781
- const cellTags = new Set(['td', 'th']);
1782
- const topLevelTags = new Set(['html', 'head', 'body']);
1783
- const compactTags = new Set(['html', 'body']);
1784
- const looseTags = new Set(['head', 'colgroup', 'caption']);
1785
- const trailingTags = new Set(['dt', 'thead']);
1786
- 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']);
1593
+ function isUriTypeAttribute(attrName, tag) {
1594
+ return (
1595
+ (/^(?:a|area|link|base)$/.test(tag) && attrName === 'href') ||
1596
+ (tag === 'img' && /^(?:src|longdesc|usemap)$/.test(attrName)) ||
1597
+ (tag === 'object' && /^(?:classid|codebase|data|usemap)$/.test(attrName)) ||
1598
+ (tag === 'q' && attrName === 'cite') ||
1599
+ (tag === 'blockquote' && attrName === 'cite') ||
1600
+ ((tag === 'ins' || tag === 'del') && attrName === 'cite') ||
1601
+ (tag === 'form' && attrName === 'action') ||
1602
+ (tag === 'input' && (attrName === 'src' || attrName === 'usemap')) ||
1603
+ (tag === 'head' && attrName === 'profile') ||
1604
+ (tag === 'script' && (attrName === 'src' || attrName === 'for'))
1605
+ );
1606
+ }
1787
1607
 
1788
- function canRemoveParentTag(optionalStartTag, tag) {
1789
- switch (optionalStartTag) {
1790
- case 'html':
1791
- case 'head':
1792
- return true;
1793
- case 'body':
1794
- return !headerTags.has(tag);
1795
- case 'colgroup':
1796
- return tag === 'col';
1797
- case 'tbody':
1798
- return tag === 'tr';
1608
+ function isNumberTypeAttribute(attrName, tag) {
1609
+ return (
1610
+ (/^(?:a|area|object|button)$/.test(tag) && attrName === 'tabindex') ||
1611
+ (tag === 'input' && (attrName === 'maxlength' || attrName === 'tabindex')) ||
1612
+ (tag === 'select' && (attrName === 'size' || attrName === 'tabindex')) ||
1613
+ (tag === 'textarea' && /^(?:rows|cols|tabindex)$/.test(attrName)) ||
1614
+ (tag === 'colgroup' && attrName === 'span') ||
1615
+ (tag === 'col' && attrName === 'span') ||
1616
+ ((tag === 'th' || tag === 'td') && (attrName === 'rowspan' || attrName === 'colspan'))
1617
+ );
1618
+ }
1619
+
1620
+ function isLinkType(tag, attrs, value) {
1621
+ if (tag !== 'link') return false;
1622
+ const needle = String(value).toLowerCase();
1623
+ for (let i = 0; i < attrs.length; i++) {
1624
+ if (attrs[i].name.toLowerCase() === 'rel') {
1625
+ const tokens = String(attrs[i].value).toLowerCase().split(/\s+/);
1626
+ if (tokens.includes(needle)) return true;
1627
+ }
1799
1628
  }
1800
1629
  return false;
1801
1630
  }
1802
1631
 
1803
- function isStartTagMandatory(optionalEndTag, tag) {
1804
- switch (tag) {
1805
- case 'colgroup':
1806
- return optionalEndTag === 'colgroup';
1807
- case 'tbody':
1808
- return tableSectionTags.has(optionalEndTag);
1632
+ function isMediaQuery(tag, attrs, attrName) {
1633
+ return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleSheet(tag, attrs));
1634
+ }
1635
+
1636
+ function isSrcset(attrName, tag) {
1637
+ return attrName === 'srcset' && srcsetTags.has(tag);
1638
+ }
1639
+
1640
+ function isMetaViewport(tag, attrs) {
1641
+ if (tag !== 'meta') {
1642
+ return false;
1643
+ }
1644
+ for (let i = 0, len = attrs.length; i < len; i++) {
1645
+ if (attrs[i].name === 'name' && attrs[i].value === 'viewport') {
1646
+ return true;
1647
+ }
1809
1648
  }
1810
1649
  return false;
1811
1650
  }
1812
1651
 
1813
- function canRemovePrecedingTag(optionalEndTag, tag) {
1814
- switch (optionalEndTag) {
1815
- case 'html':
1816
- case 'head':
1817
- case 'body':
1818
- case 'colgroup':
1819
- case 'caption':
1652
+ function isContentSecurityPolicy(tag, attrs) {
1653
+ if (tag !== 'meta') {
1654
+ return false;
1655
+ }
1656
+ for (let i = 0, len = attrs.length; i < len; i++) {
1657
+ if (attrs[i].name.toLowerCase() === 'http-equiv' && attrs[i].value.toLowerCase() === 'content-security-policy') {
1820
1658
  return true;
1821
- case 'li':
1822
- case 'optgroup':
1823
- case 'tr':
1824
- return tag === optionalEndTag;
1825
- case 'dt':
1826
- case 'dd':
1827
- return descriptionTags.has(tag);
1828
- case 'p':
1829
- return pBlockTags.has(tag);
1830
- case 'rb':
1831
- case 'rt':
1832
- case 'rp':
1833
- return rubyEndTagOmission.has(tag);
1834
- case 'rtc':
1835
- return rubyRtcEndTagOmission.has(tag);
1836
- case 'option':
1837
- return optionTag.has(tag);
1838
- case 'thead':
1839
- case 'tbody':
1840
- return tableContentTags.has(tag);
1841
- case 'tfoot':
1842
- return tag === 'tbody';
1843
- case 'td':
1844
- case 'th':
1845
- return cellTags.has(tag);
1659
+ }
1846
1660
  }
1847
1661
  return false;
1848
1662
  }
1849
1663
 
1850
- const reEmptyAttribute = new RegExp(
1851
- '^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
1852
- '?:down|up|over|move|out)|key(?:press|down|up)))$');
1853
-
1854
1664
  function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
1855
1665
  const isValueEmpty = !attrValue || /^\s*$/.test(attrValue);
1856
1666
  if (!isValueEmpty) {
@@ -1871,162 +1681,148 @@ function hasAttrName(name, attrs) {
1871
1681
  return false;
1872
1682
  }
1873
1683
 
1874
- function canRemoveElement(tag, attrs) {
1875
- switch (tag) {
1876
- case 'textarea':
1877
- return false;
1878
- case 'audio':
1879
- case 'script':
1880
- case 'video':
1881
- if (hasAttrName('src', attrs)) {
1882
- return false;
1684
+ // Cleaners
1685
+
1686
+ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
1687
+ // Apply early whitespace normalization if enabled
1688
+ // Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
1689
+ if (options.collapseAttributeWhitespace) {
1690
+ attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
1691
+ }
1692
+
1693
+ if (isEventAttribute(attrName, options)) {
1694
+ attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
1695
+ try {
1696
+ return await options.minifyJS(attrValue, true);
1697
+ } catch (err) {
1698
+ if (!options.continueOnMinifyError) {
1699
+ throw err;
1883
1700
  }
1884
- break;
1885
- case 'iframe':
1886
- if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
1887
- return false;
1701
+ options.log && options.log(err);
1702
+ return attrValue;
1703
+ }
1704
+ } else if (attrName === 'class') {
1705
+ attrValue = trimWhitespace(attrValue);
1706
+ if (options.sortClassName) {
1707
+ attrValue = options.sortClassName(attrValue);
1708
+ } else {
1709
+ attrValue = collapseWhitespaceAll(attrValue);
1710
+ }
1711
+ return attrValue;
1712
+ } else if (isUriTypeAttribute(attrName, tag)) {
1713
+ attrValue = trimWhitespace(attrValue);
1714
+ if (isLinkType(tag, attrs, 'canonical')) {
1715
+ return attrValue;
1716
+ }
1717
+ try {
1718
+ const out = await options.minifyURLs(attrValue);
1719
+ return typeof out === 'string' ? out : attrValue;
1720
+ } catch (err) {
1721
+ if (!options.continueOnMinifyError) {
1722
+ throw err;
1888
1723
  }
1889
- break;
1890
- case 'object':
1891
- if (hasAttrName('data', attrs)) {
1892
- return false;
1724
+ options.log && options.log(err);
1725
+ return attrValue;
1726
+ }
1727
+ } else if (isNumberTypeAttribute(attrName, tag)) {
1728
+ return trimWhitespace(attrValue);
1729
+ } else if (attrName === 'style') {
1730
+ attrValue = trimWhitespace(attrValue);
1731
+ if (attrValue) {
1732
+ if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
1733
+ attrValue = attrValue.replace(/\s*;$/, ';');
1893
1734
  }
1894
- break;
1895
- case 'applet':
1896
- if (hasAttrName('code', attrs)) {
1897
- return false;
1735
+ try {
1736
+ attrValue = await options.minifyCSS(attrValue, 'inline');
1737
+ } catch (err) {
1738
+ if (!options.continueOnMinifyError) {
1739
+ throw err;
1740
+ }
1741
+ options.log && options.log(err);
1898
1742
  }
1899
- break;
1900
- }
1901
- return true;
1902
- }
1903
-
1904
- /**
1905
- * @param {string} str - Tag name or HTML-like element spec (e.g., “td” or “<span aria-hidden='true'>”)
1906
- * @param {MinifierOptions} options - Options object for name normalization
1907
- * @returns {{tag: string, attrs: Object.<string, string|undefined>|null}|null} Parsed spec or null if invalid
1908
- */
1909
- function parseElementSpec(str, options) {
1910
- if (typeof str !== 'string') {
1911
- return null;
1912
- }
1913
-
1914
- const trimmed = str.trim();
1915
- if (!trimmed) {
1916
- return null;
1917
- }
1918
-
1919
- // Simple tag name: “td”
1920
- if (!/[<>]/.test(trimmed)) {
1921
- return { tag: options.name(trimmed), attrs: null };
1922
- }
1923
-
1924
- // HTML-like markup: “<span aria-hidden='true'>” or “<td></td>”
1925
- // Extract opening tag using regex
1926
- const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
1927
- if (!match) {
1928
- return null;
1929
- }
1930
-
1931
- const tag = options.name(match[1]);
1932
- const attrString = match[2];
1933
-
1934
- if (!attrString.trim()) {
1935
- return { tag, attrs: null };
1936
- }
1937
-
1938
- // Parse attributes from string
1939
- const attrs = {};
1940
- const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
1941
- let attrMatch;
1942
-
1943
- while ((attrMatch = attrRegex.exec(attrString))) {
1944
- const attrName = options.name(attrMatch[1]);
1945
- const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
1946
- // Boolean attributes have no value (undefined)
1947
- attrs[attrName] = attrValue;
1948
- }
1949
-
1950
- return {
1951
- tag,
1952
- attrs: Object.keys(attrs).length > 0 ? attrs : null
1953
- };
1954
- }
1955
-
1956
- /**
1957
- * @param {string[]} input - Array of element specifications from `removeEmptyElementsExcept` option
1958
- * @param {MinifierOptions} options - Options object for parsing
1959
- * @returns {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} Array of parsed element specs
1960
- */
1961
- function parseRemoveEmptyElementsExcept(input, options) {
1962
- if (!Array.isArray(input)) {
1963
- return [];
1964
- }
1965
-
1966
- return input.map(item => {
1967
- if (typeof item === 'string') {
1968
- const spec = parseElementSpec(item, options);
1969
- if (!spec && options.log) {
1970
- options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
1971
- }
1972
- return spec;
1973
- }
1974
- if (options.log) {
1975
- options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
1976
- }
1977
- return null;
1978
- }).filter(Boolean);
1979
- }
1980
-
1981
- /**
1982
- * @param {string} tag - Element tag name
1983
- * @param {HTMLAttribute[]} attrs - Array of element attributes
1984
- * @param {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} preserveList - Parsed preserve specs
1985
- * @returns {boolean} True if the empty element should be preserved
1986
- */
1987
- function shouldPreserveEmptyElement(tag, attrs, preserveList) {
1988
- for (const spec of preserveList) {
1989
- // Tag name must match
1990
- if (spec.tag !== tag) {
1991
- continue;
1992
- }
1993
-
1994
- // If no attributes specified in spec, tag match is enough
1995
- if (!spec.attrs) {
1996
- return true;
1997
1743
  }
1998
-
1999
- // Check if all specified attributes match
2000
- const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
2001
- const attr = attrs.find(a => a.name === name);
2002
- if (!attr) {
2003
- return false; // Attribute not present
1744
+ return attrValue;
1745
+ } else if (isSrcset(attrName, tag)) {
1746
+ // https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
1747
+ attrValue = (await Promise.all(trimWhitespace(attrValue).split(/\s*,\s*/).map(async function (candidate) {
1748
+ let url = candidate;
1749
+ let descriptor = '';
1750
+ const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
1751
+ if (match) {
1752
+ url = url.slice(0, -match[0].length);
1753
+ const num = +match[1].slice(0, -1);
1754
+ const suffix = match[1].slice(-1);
1755
+ if (num !== 1 || suffix !== 'x') {
1756
+ descriptor = ' ' + num + suffix;
1757
+ }
2004
1758
  }
2005
- // Boolean attribute in spec (undefined value) matches if attribute is present
2006
- if (value === undefined) {
2007
- return true;
1759
+ try {
1760
+ const out = await options.minifyURLs(url);
1761
+ return (typeof out === 'string' ? out : url) + descriptor;
1762
+ } catch (err) {
1763
+ if (!options.continueOnMinifyError) {
1764
+ throw err;
1765
+ }
1766
+ options.log && options.log(err);
1767
+ return url + descriptor;
2008
1768
  }
2009
- // Valued attribute must match exactly
2010
- return attr.value === value;
1769
+ }))).join(', ');
1770
+ } else if (isMetaViewport(tag, attrs) && attrName === 'content') {
1771
+ attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
1772
+ // 0.90000 → 0.9
1773
+ // 1.0 → 1
1774
+ // 1.0001 → 1.0001 (unchanged)
1775
+ return (+numString).toString();
2011
1776
  });
2012
-
2013
- if (allAttrsMatch) {
2014
- return true;
1777
+ } else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
1778
+ return collapseWhitespaceAll(attrValue);
1779
+ } else if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
1780
+ attrValue = trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
1781
+ } else if (tag === 'script' && attrName === 'type') {
1782
+ attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
1783
+ } else if (isMediaQuery(tag, attrs, attrName)) {
1784
+ attrValue = trimWhitespace(attrValue);
1785
+ try {
1786
+ return await options.minifyCSS(attrValue, 'media');
1787
+ } catch (err) {
1788
+ if (!options.continueOnMinifyError) {
1789
+ throw err;
1790
+ }
1791
+ options.log && options.log(err);
1792
+ return attrValue;
2015
1793
  }
1794
+ } else if (tag === 'iframe' && attrName === 'srcdoc') {
1795
+ // Recursively minify HTML content within `srcdoc` attribute
1796
+ // Fast-path: Skip if nothing would change
1797
+ if (!shouldMinifyInnerHTML(options)) {
1798
+ return attrValue;
1799
+ }
1800
+ return minifyHTMLSelf(attrValue, options, true);
2016
1801
  }
2017
-
2018
- return false;
1802
+ return attrValue;
2019
1803
  }
2020
1804
 
2021
- function canCollapseWhitespace(tag) {
2022
- return !/^(?:script|style|pre|textarea)$/.test(tag);
2023
- }
1805
+ /**
1806
+ * Choose appropriate quote character for an attribute value
1807
+ * @param {string} attrValue - The attribute value
1808
+ * @param {Object} options - Minifier options
1809
+ * @returns {string} The chosen quote character (`"` or `'`)
1810
+ */
1811
+ function chooseAttributeQuote(attrValue, options) {
1812
+ if (typeof options.quoteCharacter !== 'undefined') {
1813
+ return options.quoteCharacter === '\'' ? '\'' : '"';
1814
+ }
2024
1815
 
2025
- function canTrimWhitespace(tag) {
2026
- return !/^(?:pre|textarea)$/.test(tag);
1816
+ // Count quotes in a single pass
1817
+ let apos = 0, quot = 0;
1818
+ for (let i = 0; i < attrValue.length; i++) {
1819
+ if (attrValue[i] === "'") apos++;
1820
+ else if (attrValue[i] === '"') quot++;
1821
+ }
1822
+ return apos < quot ? '\'' : '"';
2027
1823
  }
2028
1824
 
2029
- async function normalizeAttr(attr, attrs, tag, options) {
1825
+ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
2030
1826
  const attrName = options.name(attr.name);
2031
1827
  let attrValue = attr.value;
2032
1828
 
@@ -2038,7 +1834,7 @@ async function normalizeAttr(attr, attrs, tag, options) {
2038
1834
  }
2039
1835
 
2040
1836
  if ((options.removeRedundantAttributes &&
2041
- isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
1837
+ isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
2042
1838
  (options.removeScriptTypeAttributes && tag === 'script' &&
2043
1839
  attrName === 'type' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue)) ||
2044
1840
  (options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
@@ -2075,65 +1871,44 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
2075
1871
  let emittedAttrValue;
2076
1872
 
2077
1873
  if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
2078
- ~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
1874
+ attrValue.indexOf(uidAttr) !== -1 || !canRemoveAttributeQuotes(attrValue))) {
2079
1875
  // Determine the appropriate quote character
2080
1876
  if (!options.preventAttributesEscaping) {
2081
1877
  // Normal mode: choose quotes and escape
2082
- if (typeof options.quoteCharacter === 'undefined') {
2083
- // Count quotes in a single pass instead of two regex operations
2084
- let apos = 0, quot = 0;
2085
- for (let i = 0; i < attrValue.length; i++) {
2086
- if (attrValue[i] === "'") apos++;
2087
- else if (attrValue[i] === '"') quot++;
2088
- }
2089
- attrQuote = apos < quot ? '\'' : '"';
2090
- } else {
2091
- attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
2092
- }
1878
+ attrQuote = chooseAttributeQuote(attrValue, options);
2093
1879
  if (attrQuote === '"') {
2094
1880
  attrValue = attrValue.replace(/"/g, '&#34;');
2095
1881
  } else {
2096
1882
  attrValue = attrValue.replace(/'/g, '&#39;');
2097
1883
  }
2098
1884
  } else {
2099
- // `preventAttributesEscaping` mode: choose safe quotes but dont escape
2100
- // EXCEPT when both quote types are present—then escape to prevent invalid HTML
1885
+ // `preventAttributesEscaping` mode: choose safe quotes but don't escape
1886
+ // except when both quote types are present—then escape to prevent invalid HTML
2101
1887
  const hasDoubleQuote = attrValue.indexOf('"') !== -1;
2102
1888
  const hasSingleQuote = attrValue.indexOf("'") !== -1;
2103
1889
 
1890
+ // Both quote types present: Escaping is required to guarantee valid HTML delimiter matching
2104
1891
  if (hasDoubleQuote && hasSingleQuote) {
2105
- // Both quote types present: `preventAttributesEscaping` is ignored to ensure valid HTML
2106
- // Choose the quote type with fewer occurrences and escape the other
2107
- if (typeof options.quoteCharacter === 'undefined') {
2108
- let apos = 0, quot = 0;
2109
- for (let i = 0; i < attrValue.length; i++) {
2110
- if (attrValue[i] === "'") apos++;
2111
- else if (attrValue[i] === '"') quot++;
2112
- }
2113
- attrQuote = apos < quot ? '\'' : '"';
2114
- } else {
2115
- attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
2116
- }
1892
+ attrQuote = chooseAttributeQuote(attrValue, options);
2117
1893
  if (attrQuote === '"') {
2118
1894
  attrValue = attrValue.replace(/"/g, '&#34;');
2119
1895
  } else {
2120
1896
  attrValue = attrValue.replace(/'/g, '&#39;');
2121
1897
  }
1898
+ // Auto quote selection: Prefer the opposite quote type when value contains one quote type, default to double quotes when none present
2122
1899
  } else if (typeof options.quoteCharacter === 'undefined') {
2123
- // Single or no quote type: Choose safe quote delimiter
2124
1900
  if (attrQuote === '"' && hasDoubleQuote && !hasSingleQuote) {
2125
1901
  attrQuote = "'";
2126
1902
  } else if (attrQuote === "'" && hasSingleQuote && !hasDoubleQuote) {
2127
1903
  attrQuote = '"';
1904
+ // Fallback for invalid/unsupported attrQuote values (not `"`, `'`, or empty string): Choose safe default based on value content
2128
1905
  } else if (attrQuote !== '"' && attrQuote !== "'" && attrQuote !== '') {
2129
- // `attrQuote` is invalid/undefined (not `"`, `'`, or empty string)
2130
- // Set a safe default based on the value’s content
2131
1906
  if (hasSingleQuote && !hasDoubleQuote) {
2132
- attrQuote = '"'; // Value has single quotes, use double quotes as delimiter
1907
+ attrQuote = '"';
2133
1908
  } else if (hasDoubleQuote && !hasSingleQuote) {
2134
- attrQuote = "'"; // Value has double quotes, use single quotes as delimiter
1909
+ attrQuote = "'";
2135
1910
  } else {
2136
- attrQuote = '"'; // No quotes in value, default to double quotes
1911
+ attrQuote = '"';
2137
1912
  }
2138
1913
  }
2139
1914
  } else {
@@ -2153,7 +1928,7 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
2153
1928
  }
2154
1929
 
2155
1930
  if (typeof attrValue === 'undefined' || (options.collapseBooleanAttributes &&
2156
- isBooleanAttribute(attrName.toLowerCase(), attrValue.toLowerCase()))) {
1931
+ isBooleanAttribute(attrName.toLowerCase(), (attrValue || '').toLowerCase()))) {
2157
1932
  attrFragment = attrName;
2158
1933
  if (!isLast) {
2159
1934
  attrFragment += ' ';
@@ -2165,252 +1940,629 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
2165
1940
  return attr.customOpen + attrFragment + attr.customClose;
2166
1941
  }
2167
1942
 
2168
- function identity(value) {
2169
- return value;
1943
+ // Imports
1944
+
1945
+
1946
+ // Tag omission rules
1947
+
1948
+ function canRemoveParentTag(optionalStartTag, tag) {
1949
+ switch (optionalStartTag) {
1950
+ case 'html':
1951
+ case 'head':
1952
+ return true;
1953
+ case 'body':
1954
+ return !headerTags.has(tag);
1955
+ case 'colgroup':
1956
+ return tag === 'col';
1957
+ case 'tbody':
1958
+ return tag === 'tr';
1959
+ }
1960
+ return false;
2170
1961
  }
2171
1962
 
2172
- function identityAsync(value) {
2173
- return Promise.resolve(value);
1963
+ function isStartTagMandatory(optionalEndTag, tag) {
1964
+ switch (tag) {
1965
+ case 'colgroup':
1966
+ return optionalEndTag === 'colgroup';
1967
+ case 'tbody':
1968
+ return tableSectionTags.has(optionalEndTag);
1969
+ }
1970
+ return false;
2174
1971
  }
2175
1972
 
2176
- function shouldMinifyInnerHTML(options) {
2177
- return Boolean(
2178
- options.collapseWhitespace ||
2179
- options.removeComments ||
2180
- options.removeOptionalTags ||
2181
- options.minifyJS !== identity ||
2182
- options.minifyCSS !== identityAsync ||
2183
- options.minifyURLs !== identity
2184
- );
1973
+ function canRemovePrecedingTag(optionalEndTag, tag) {
1974
+ switch (optionalEndTag) {
1975
+ case 'html':
1976
+ case 'head':
1977
+ case 'body':
1978
+ case 'colgroup':
1979
+ case 'caption':
1980
+ return true;
1981
+ case 'li':
1982
+ case 'optgroup':
1983
+ case 'tr':
1984
+ return tag === optionalEndTag;
1985
+ case 'dt':
1986
+ case 'dd':
1987
+ return descriptionTags.has(tag);
1988
+ case 'p':
1989
+ return pBlockTags.has(tag);
1990
+ case 'rb':
1991
+ case 'rt':
1992
+ case 'rp':
1993
+ return rubyEndTagOmission.has(tag);
1994
+ case 'rtc':
1995
+ return rubyRtcEndTagOmission.has(tag);
1996
+ case 'option':
1997
+ return optionTag.has(tag);
1998
+ case 'thead':
1999
+ case 'tbody':
2000
+ return tableContentTags.has(tag);
2001
+ case 'tfoot':
2002
+ return tag === 'tbody';
2003
+ case 'td':
2004
+ case 'th':
2005
+ return cellTags.has(tag);
2006
+ }
2007
+ return false;
2185
2008
  }
2186
2009
 
2187
- /**
2188
- * @param {Partial<MinifierOptions>} inputOptions - User-provided options
2189
- * @returns {MinifierOptions} Normalized options with defaults applied
2010
+ // Element removal logic
2011
+
2012
+ function canRemoveElement(tag, attrs) {
2013
+ switch (tag) {
2014
+ case 'textarea':
2015
+ return false;
2016
+ case 'audio':
2017
+ case 'script':
2018
+ case 'video':
2019
+ if (hasAttrName('src', attrs)) {
2020
+ return false;
2021
+ }
2022
+ break;
2023
+ case 'iframe':
2024
+ if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
2025
+ return false;
2026
+ }
2027
+ break;
2028
+ case 'object':
2029
+ if (hasAttrName('data', attrs)) {
2030
+ return false;
2031
+ }
2032
+ break;
2033
+ case 'applet':
2034
+ if (hasAttrName('code', attrs)) {
2035
+ return false;
2036
+ }
2037
+ break;
2038
+ }
2039
+ return true;
2040
+ }
2041
+
2042
+ /**
2043
+ * @param {string} str - Tag name or HTML-like element spec (e.g., “td” or “<span aria-hidden='true'>”)
2044
+ * @param {MinifierOptions} options - Options object for name normalization
2045
+ * @returns {{tag: string, attrs: Object.<string, string|undefined>|null}|null} Parsed spec or null if invalid
2046
+ */
2047
+ function parseElementSpec(str, options) {
2048
+ if (typeof str !== 'string') {
2049
+ return null;
2050
+ }
2051
+
2052
+ const trimmed = str.trim();
2053
+ if (!trimmed) {
2054
+ return null;
2055
+ }
2056
+
2057
+ // Simple tag name: `td`
2058
+ if (!/[<>]/.test(trimmed)) {
2059
+ return { tag: options.name(trimmed), attrs: null };
2060
+ }
2061
+
2062
+ // HTML-like markup: `<span aria-hidden='true'>` or `<td></td>`
2063
+ // Extract opening tag using regex
2064
+ const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
2065
+ if (!match) {
2066
+ return null;
2067
+ }
2068
+
2069
+ const tag = options.name(match[1]);
2070
+ const attrString = match[2];
2071
+
2072
+ if (!attrString.trim()) {
2073
+ return { tag, attrs: null };
2074
+ }
2075
+
2076
+ // Parse attributes from string
2077
+ const attrs = {};
2078
+ const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
2079
+ let attrMatch;
2080
+
2081
+ while ((attrMatch = attrRegex.exec(attrString))) {
2082
+ const attrName = options.name(attrMatch[1]);
2083
+ const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
2084
+ // Boolean attributes have no value (undefined)
2085
+ attrs[attrName] = attrValue;
2086
+ }
2087
+
2088
+ return {
2089
+ tag,
2090
+ attrs: Object.keys(attrs).length > 0 ? attrs : null
2091
+ };
2092
+ }
2093
+
2094
+ /**
2095
+ * @param {string[]} input - Array of element specifications from `removeEmptyElementsExcept` option
2096
+ * @param {MinifierOptions} options - Options object for parsing
2097
+ * @returns {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} Array of parsed element specs
2098
+ */
2099
+ function parseRemoveEmptyElementsExcept(input, options) {
2100
+ if (!Array.isArray(input)) {
2101
+ return [];
2102
+ }
2103
+
2104
+ return input.map(item => {
2105
+ if (typeof item === 'string') {
2106
+ const spec = parseElementSpec(item, options);
2107
+ if (!spec && options.log) {
2108
+ options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
2109
+ }
2110
+ return spec;
2111
+ }
2112
+ if (options.log) {
2113
+ options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
2114
+ }
2115
+ return null;
2116
+ }).filter(Boolean);
2117
+ }
2118
+
2119
+ /**
2120
+ * @param {string} tag - Element tag name
2121
+ * @param {HTMLAttribute[]} attrs - Array of element attributes
2122
+ * @param {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} preserveList - Parsed preserve specs
2123
+ * @returns {boolean} True if the empty element should be preserved
2124
+ */
2125
+ function shouldPreserveEmptyElement(tag, attrs, preserveList) {
2126
+ for (const spec of preserveList) {
2127
+ // Tag name must match
2128
+ if (spec.tag !== tag) {
2129
+ continue;
2130
+ }
2131
+
2132
+ // If no attributes specified in spec, tag match is enough
2133
+ if (!spec.attrs) {
2134
+ return true;
2135
+ }
2136
+
2137
+ // Check if all specified attributes match
2138
+ const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
2139
+ const attr = attrs.find(a => a.name === name);
2140
+ if (!attr) {
2141
+ return false; // Attribute not present
2142
+ }
2143
+ // Boolean attribute in spec (undefined value) matches if attribute is present
2144
+ if (value === undefined) {
2145
+ return true;
2146
+ }
2147
+ // Valued attribute must match exactly
2148
+ return attr.value === value;
2149
+ });
2150
+
2151
+ if (allAttrsMatch) {
2152
+ return true;
2153
+ }
2154
+ }
2155
+
2156
+ return false;
2157
+ }
2158
+
2159
+ // Imports
2160
+
2161
+
2162
+ // Lazy-load heavy dependencies only when needed
2163
+
2164
+ let lightningCSSPromise;
2165
+ async function getLightningCSS() {
2166
+ if (!lightningCSSPromise) {
2167
+ lightningCSSPromise = import('lightningcss').then(m => m.transform);
2168
+ }
2169
+ return lightningCSSPromise;
2170
+ }
2171
+
2172
+ let terserPromise;
2173
+ async function getTerser() {
2174
+ if (!terserPromise) {
2175
+ terserPromise = import('terser').then(m => m.minify);
2176
+ }
2177
+ return terserPromise;
2178
+ }
2179
+
2180
+ // Minification caches
2181
+
2182
+ const cssMinifyCache = new LRU(200);
2183
+ const jsMinifyCache = new LRU(200);
2184
+
2185
+ // Type definitions
2186
+
2187
+ /**
2188
+ * @typedef {Object} HTMLAttribute
2189
+ * Representation of an attribute from the HTML parser.
2190
+ *
2191
+ * @prop {string} name
2192
+ * @prop {string} [value]
2193
+ * @prop {string} [quote]
2194
+ * @prop {string} [customAssign]
2195
+ * @prop {string} [customOpen]
2196
+ * @prop {string} [customClose]
2197
+ */
2198
+
2199
+ /**
2200
+ * @typedef {Object} MinifierOptions
2201
+ * Options that control how HTML is minified. All of these are optional
2202
+ * and usually default to a disabled/safe value unless noted.
2203
+ *
2204
+ * @prop {(tag: string, attrs: HTMLAttribute[], canCollapseWhitespace: (tag: string) => boolean) => boolean} [canCollapseWhitespace]
2205
+ * Predicate that determines whether whitespace inside a given element
2206
+ * can be collapsed.
2207
+ *
2208
+ * Default: Built-in `canCollapseWhitespace` function
2209
+ *
2210
+ * @prop {(tag: string | null, attrs: HTMLAttribute[] | undefined, canTrimWhitespace: (tag: string) => boolean) => boolean} [canTrimWhitespace]
2211
+ * Predicate that determines whether leading/trailing whitespace around
2212
+ * the element may be trimmed.
2213
+ *
2214
+ * Default: Built-in `canTrimWhitespace` function
2215
+ *
2216
+ * @prop {boolean} [caseSensitive]
2217
+ * When true, tag and attribute names are treated as case-sensitive.
2218
+ * Useful for custom HTML tags.
2219
+ * If false (default) names are lower-cased via the `name` function.
2220
+ *
2221
+ * Default: `false`
2222
+ *
2223
+ * @prop {boolean} [collapseAttributeWhitespace]
2224
+ * Collapse multiple whitespace characters within attribute values into a
2225
+ * single space. Also trims leading and trailing whitespace from attribute
2226
+ * values. Applied as an early normalization step before special attribute
2227
+ * handlers (CSS minification, class sorting, etc.) run.
2228
+ *
2229
+ * Default: `false`
2230
+ *
2231
+ * @prop {boolean} [collapseBooleanAttributes]
2232
+ * Collapse boolean attributes to their name only (for example
2233
+ * `disabled="disabled"` → `disabled`).
2234
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes
2235
+ *
2236
+ * Default: `false`
2237
+ *
2238
+ * @prop {boolean} [collapseInlineTagWhitespace]
2239
+ * When false (default) whitespace around `inline` tags is preserved in
2240
+ * more cases. When true, whitespace around inline tags may be collapsed.
2241
+ * Must also enable `collapseWhitespace` to have effect.
2242
+ *
2243
+ * Default: `false`
2244
+ *
2245
+ * @prop {boolean} [collapseWhitespace]
2246
+ * Collapse multiple whitespace characters into one where allowed. Also
2247
+ * controls trimming behaviour in several code paths.
2248
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_whitespace
2249
+ *
2250
+ * Default: `false`
2251
+ *
2252
+ * @prop {boolean} [conservativeCollapse]
2253
+ * If true, be conservative when collapsing whitespace (preserve more
2254
+ * whitespace in edge cases). Affects collapse algorithms.
2255
+ * Must also enable `collapseWhitespace` to have effect.
2256
+ *
2257
+ * Default: `false`
2258
+ *
2259
+ * @prop {boolean} [continueOnMinifyError]
2260
+ * When set to `false`, minification errors may throw.
2261
+ * By default, the minifier will attempt to recover from minification
2262
+ * errors, or ignore them and preserve the original content.
2263
+ *
2264
+ * Default: `true`
2265
+ *
2266
+ * @prop {boolean} [continueOnParseError]
2267
+ * When true, the parser will attempt to continue on recoverable parse
2268
+ * errors. Otherwise, parsing errors may throw.
2269
+ *
2270
+ * Default: `false`
2271
+ *
2272
+ * @prop {RegExp[]} [customAttrAssign]
2273
+ * Array of regexes used to recognise custom attribute assignment
2274
+ * operators (e.g. `'<div flex?="{{mode != cover}}"></div>'`).
2275
+ * These are concatenated with the built-in assignment patterns.
2276
+ *
2277
+ * Default: `[]`
2278
+ *
2279
+ * @prop {RegExp} [customAttrCollapse]
2280
+ * Regex matching attribute names whose values should be collapsed.
2281
+ * Basically used to remove newlines and excess spaces inside attribute values,
2282
+ * e.g. `/ng-class/`.
2283
+ *
2284
+ * @prop {[RegExp, RegExp][]} [customAttrSurround]
2285
+ * Array of `[openRegExp, closeRegExp]` pairs used by the parser to
2286
+ * detect custom attribute surround patterns (for non-standard syntaxes,
2287
+ * e.g. `<input {{#if value}}checked="checked"{{/if}}>`).
2288
+ *
2289
+ * @prop {RegExp[]} [customEventAttributes]
2290
+ * Array of regexes used to detect event handler attributes for `minifyJS`
2291
+ * (e.g. `ng-click`). The default matches standard `on…` event attributes.
2292
+ *
2293
+ * Default: `[/^on[a-z]{3,}$/]`
2294
+ *
2295
+ * @prop {number} [customFragmentQuantifierLimit]
2296
+ * Limits the quantifier used when building a safe regex for custom
2297
+ * fragments to avoid ReDoS. See source use for details.
2298
+ *
2299
+ * Default: `200`
2300
+ *
2301
+ * @prop {boolean} [decodeEntities]
2302
+ * When true, decodes HTML entities in text and attributes before
2303
+ * processing, and re-encodes ambiguous ampersands when outputting.
2304
+ *
2305
+ * Default: `false`
2306
+ *
2307
+ * @prop {boolean} [html5]
2308
+ * Parse and emit using HTML5 rules. Set to `false` to use non-HTML5
2309
+ * parsing behavior.
2310
+ *
2311
+ * Default: `true`
2312
+ *
2313
+ * @prop {RegExp[]} [ignoreCustomComments]
2314
+ * Comments matching any pattern in this array of regexes will be
2315
+ * preserved when `removeComments` is enabled. The default preserves
2316
+ * “bang” comments and comments starting with `#`.
2317
+ *
2318
+ * Default: `[/^!/, /^\s*#/]`
2319
+ *
2320
+ * @prop {RegExp[]} [ignoreCustomFragments]
2321
+ * Array of regexes used to identify fragments that should be
2322
+ * preserved (for example server templates). These fragments are temporarily
2323
+ * replaced during minification to avoid corrupting template code.
2324
+ * The default preserves ASP/PHP-style tags.
2325
+ *
2326
+ * Default: `[/<%[\s\S]*?%>/, /<\?[\s\S]*?\?>/]`
2327
+ *
2328
+ * @prop {boolean} [includeAutoGeneratedTags]
2329
+ * If false, tags marked as auto-generated by the parser will be omitted
2330
+ * from output. Useful to skip injected tags.
2331
+ *
2332
+ * Default: `true`
2333
+ *
2334
+ * @prop {ArrayLike<string>} [inlineCustomElements]
2335
+ * Collection of custom element tag names that should be treated as inline
2336
+ * elements for white-space handling, alongside the built-in inline elements.
2337
+ *
2338
+ * Default: `[]`
2339
+ *
2340
+ * @prop {boolean} [keepClosingSlash]
2341
+ * Preserve the trailing slash in self-closing tags when present.
2342
+ *
2343
+ * Default: `false`
2344
+ *
2345
+ * @prop {(message: unknown) => void} [log]
2346
+ * Logging function used by the minifier for warnings/errors/info.
2347
+ * You can directly provide `console.log`, but `message` may also be an `Error`
2348
+ * object or other non-string value.
2349
+ *
2350
+ * Default: `() => {}` (no-op function)
2351
+ *
2352
+ * @prop {number} [maxInputLength]
2353
+ * The maximum allowed input length. Used as a guard against ReDoS via
2354
+ * pathological inputs. If the input exceeds this length an error is
2355
+ * thrown.
2356
+ *
2357
+ * Default: No limit
2358
+ *
2359
+ * @prop {number} [maxLineLength]
2360
+ * Maximum line length for the output. When set the minifier will wrap
2361
+ * output to the given number of characters where possible.
2362
+ *
2363
+ * Default: No limit
2364
+ *
2365
+ * @prop {boolean | Partial<import("lightningcss").TransformOptions<import("lightningcss").CustomAtRules>> | ((text: string, type?: string) => Promise<string> | string)} [minifyCSS]
2366
+ * When true, enables CSS minification for inline `<style>` tags or
2367
+ * `style` attributes. If an object is provided, it is passed to
2368
+ * [Lightning CSS](https://www.npmjs.com/package/lightningcss)
2369
+ * as transform options. If a function is provided, it will be used to perform
2370
+ * custom CSS minification. If disabled, CSS is not minified.
2371
+ *
2372
+ * Default: `false`
2373
+ *
2374
+ * @prop {boolean | import("terser").MinifyOptions | ((text: string, inline?: boolean) => Promise<string> | string)} [minifyJS]
2375
+ * When true, enables JS minification for `<script>` contents and
2376
+ * event handler attributes. If an object is provided, it is passed to
2377
+ * [terser](https://www.npmjs.com/package/terser) as minify options.
2378
+ * If a function is provided, it will be used to perform
2379
+ * custom JS minification. If disabled, JS is not minified.
2380
+ *
2381
+ * Default: `false`
2382
+ *
2383
+ * @prop {boolean | string | import("relateurl").Options | ((text: string) => Promise<string> | string)} [minifyURLs]
2384
+ * When true, enables URL rewriting/minification. If an object is provided,
2385
+ * it is passed to [relateurl](https://www.npmjs.com/package/relateurl)
2386
+ * as options. If a string is provided, it is treated as an `{ site: string }`
2387
+ * options object. If a function is provided, it will be used to perform
2388
+ * custom URL minification. If disabled, URLs are not minified.
2389
+ *
2390
+ * Default: `false`
2391
+ *
2392
+ * @prop {(name: string) => string} [name]
2393
+ * Function used to normalise tag/attribute names. By default, this lowercases
2394
+ * names, unless `caseSensitive` is enabled.
2395
+ *
2396
+ * Default: `(name) => name.toLowerCase()`,
2397
+ * or `(name) => name` (no-op function) if `caseSensitive` is enabled.
2398
+ *
2399
+ * @prop {boolean} [noNewlinesBeforeTagClose]
2400
+ * When wrapping lines, prevent inserting a newline directly before a
2401
+ * closing tag (useful to keep tags like `</a>` on the same line).
2402
+ *
2403
+ * Default: `false`
2404
+ *
2405
+ * @prop {boolean} [partialMarkup]
2406
+ * When true, treat input as a partial HTML fragment rather than a complete
2407
+ * document. This preserves stray end tags (closing tags without corresponding
2408
+ * opening tags) and prevents auto-closing of unclosed tags at the end of input.
2409
+ * Useful for minifying template fragments, SSI includes, or other partial HTML
2410
+ * that will be combined with other fragments.
2411
+ *
2412
+ * Default: `false`
2413
+ *
2414
+ * @prop {boolean} [preserveLineBreaks]
2415
+ * Preserve a single line break at the start/end of text nodes when
2416
+ * collapsing/trimming whitespace.
2417
+ * Must also enable `collapseWhitespace` to have effect.
2418
+ *
2419
+ * Default: `false`
2420
+ *
2421
+ * @prop {boolean} [preventAttributesEscaping]
2422
+ * When true, attribute values will not be HTML-escaped (dangerous for
2423
+ * untrusted input). By default, attributes are escaped.
2424
+ *
2425
+ * Default: `false`
2426
+ *
2427
+ * @prop {boolean} [processConditionalComments]
2428
+ * When true, conditional comments (for example `<!--[if IE]> … <![endif]-->`)
2429
+ * will have their inner content processed by the minifier.
2430
+ * Useful to minify HTML that appears inside conditional comments.
2431
+ *
2432
+ * Default: `false`
2433
+ *
2434
+ * @prop {string[]} [processScripts]
2435
+ * Array of `type` attribute values for `<script>` elements whose contents
2436
+ * should be processed as HTML
2437
+ * (e.g. `text/ng-template`, `text/x-handlebars-template`, etc.).
2438
+ * When present, the contents of matching script tags are recursively minified,
2439
+ * like normal HTML content.
2440
+ *
2441
+ * Default: `[]`
2442
+ *
2443
+ * @prop {"\"" | "'"} [quoteCharacter]
2444
+ * Preferred quote character for attribute values. If unspecified the
2445
+ * minifier picks the safest quote based on the attribute value.
2446
+ *
2447
+ * Default: Auto-detected
2448
+ *
2449
+ * @prop {boolean} [removeAttributeQuotes]
2450
+ * Remove quotes around attribute values where it is safe to do so.
2451
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_attribute_quotes
2452
+ *
2453
+ * Default: `false`
2454
+ *
2455
+ * @prop {boolean} [removeComments]
2456
+ * Remove HTML comments. Comments that match `ignoreCustomComments` will
2457
+ * still be preserved.
2458
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_comments
2459
+ *
2460
+ * Default: `false`
2461
+ *
2462
+ * @prop {boolean | ((attrName: string, tag: string) => boolean)} [removeEmptyAttributes]
2463
+ * If true, removes attributes whose values are empty (some attributes
2464
+ * are excluded by name). Can also be a function to customise which empty
2465
+ * attributes are removed.
2466
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_empty_or_blank_attributes
2467
+ *
2468
+ * Default: `false`
2469
+ *
2470
+ * @prop {boolean} [removeEmptyElements]
2471
+ * Remove elements that are empty and safe to remove (for example
2472
+ * `<script />` without `src`).
2473
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_empty_elements
2474
+ *
2475
+ * Default: `false`
2476
+ *
2477
+ * @prop {string[]} [removeEmptyElementsExcept]
2478
+ * Specifies empty elements to preserve when `removeEmptyElements` is enabled.
2479
+ * Has no effect unless `removeEmptyElements: true`.
2480
+ *
2481
+ * Accepts tag names or HTML-like element specifications:
2482
+ *
2483
+ * * Tag name only: `["td", "span"]`—preserves all empty elements of these types
2484
+ * * With valued attributes: `["<span aria-hidden='true'>"]`—preserves only when attribute values match
2485
+ * * With boolean attributes: `["<input disabled>"]`—preserves only when boolean attribute is present
2486
+ * * Mixed: `["<button type='button' disabled>"]`—all specified attributes must match
2487
+ *
2488
+ * Attribute matching:
2489
+ *
2490
+ * * All specified attributes must be present and match (valued attributes must have exact values)
2491
+ * * Additional attributes on the element are allowed
2492
+ * * Attribute name matching respects the `caseSensitive` option
2493
+ * * Supports double quotes, single quotes, and unquoted attribute values in specifications
2494
+ *
2495
+ * Limitations:
2496
+ *
2497
+ * * Self-closing syntax (e.g., `["<span/>"]`) is not supported; use `["span"]` instead
2498
+ * * Definitions containing `>` within quoted attribute values (e.g., `["<span title='a>b'>"]`) are not supported
2499
+ *
2500
+ * Default: `[]`
2501
+ *
2502
+ * @prop {boolean} [removeOptionalTags]
2503
+ * Drop optional start/end tags where the HTML specification permits it
2504
+ * (for example `</li>`, optional `<html>` etc.).
2505
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_optional_tags
2506
+ *
2507
+ * Default: `false`
2508
+ *
2509
+ * @prop {boolean} [removeRedundantAttributes]
2510
+ * Remove attributes that are redundant because they match the element’s
2511
+ * default values (for example `<button type="submit">`).
2512
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_redundant_attributes
2513
+ *
2514
+ * Default: `false`
2515
+ *
2516
+ * @prop {boolean} [removeScriptTypeAttributes]
2517
+ * Remove `type` attributes from `<script>` when they are unnecessary
2518
+ * (e.g. `type="text/javascript"`).
2519
+ *
2520
+ * Default: `false`
2521
+ *
2522
+ * @prop {boolean} [removeStyleLinkTypeAttributes]
2523
+ * Remove `type` attributes from `<style>` and `<link>` elements when
2524
+ * they are unnecessary (e.g. `type="text/css"`).
2525
+ *
2526
+ * Default: `false`
2527
+ *
2528
+ * @prop {boolean} [removeTagWhitespace]
2529
+ * **Note that this will result in invalid HTML!**
2530
+ *
2531
+ * When true, extra whitespace between tag name and attributes (or before
2532
+ * the closing bracket) will be removed where possible. Affects output spacing
2533
+ * such as the space used in the short doctype representation.
2534
+ *
2535
+ * Default: `false`
2536
+ *
2537
+ * @prop {boolean | ((tag: string, attrs: HTMLAttribute[]) => void)} [sortAttributes]
2538
+ * When true, enables sorting of attributes. If a function is provided it
2539
+ * will be used as a custom attribute sorter, which should mutate `attrs`
2540
+ * in-place to the desired order. If disabled, the minifier will attempt to
2541
+ * preserve the order from the input.
2542
+ *
2543
+ * Default: `false`
2544
+ *
2545
+ * @prop {boolean | ((value: string) => string)} [sortClassName]
2546
+ * When true, enables sorting of class names inside `class` attributes.
2547
+ * If a function is provided it will be used to transform/sort the class
2548
+ * name string. If disabled, the minifier will attempt to preserve the
2549
+ * class-name order from the input.
2550
+ *
2551
+ * Default: `false`
2552
+ *
2553
+ * @prop {boolean} [trimCustomFragments]
2554
+ * When true, whitespace around ignored custom fragments may be trimmed
2555
+ * more aggressively. This affects how preserved fragments interact with
2556
+ * surrounding whitespace collapse.
2557
+ *
2558
+ * Default: `false`
2559
+ *
2560
+ * @prop {boolean} [useShortDoctype]
2561
+ * Replace the HTML doctype with the short `<!doctype html>` form.
2562
+ * See also: https://perfectionkills.com/experimenting-with-html-minifier/#use_short_doctype
2563
+ *
2564
+ * Default: `false`
2190
2565
  */
2191
- const processOptions = (inputOptions) => {
2192
- const options = {
2193
- name: function (name) {
2194
- return name.toLowerCase();
2195
- },
2196
- canCollapseWhitespace,
2197
- canTrimWhitespace,
2198
- continueOnMinifyError: true,
2199
- html5: true,
2200
- ignoreCustomComments: [
2201
- /^!/,
2202
- /^\s*#/
2203
- ],
2204
- ignoreCustomFragments: [
2205
- /<%[\s\S]*?%>/,
2206
- /<\?[\s\S]*?\?>/
2207
- ],
2208
- includeAutoGeneratedTags: true,
2209
- log: identity,
2210
- minifyCSS: identityAsync,
2211
- minifyJS: identity,
2212
- minifyURLs: identity
2213
- };
2214
-
2215
- Object.keys(inputOptions).forEach(function (key) {
2216
- const option = inputOptions[key];
2217
-
2218
- if (key === 'caseSensitive') {
2219
- if (option) {
2220
- options.name = identity;
2221
- }
2222
- } else if (key === 'log') {
2223
- if (typeof option === 'function') {
2224
- options.log = option;
2225
- }
2226
- } else if (key === 'minifyCSS' && typeof option !== 'function') {
2227
- if (!option) {
2228
- return;
2229
- }
2230
-
2231
- const lightningCssOptions = typeof option === 'object' ? option : {};
2232
-
2233
- options.minifyCSS = async function (text, type) {
2234
- // Fast path: Nothing to minify
2235
- if (!text || !text.trim()) {
2236
- return text;
2237
- }
2238
- text = await replaceAsync(
2239
- text,
2240
- /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
2241
- async function (match, prefix, dq, sq, unq, suffix) {
2242
- const quote = dq != null ? '"' : (sq != null ? "'" : '');
2243
- const url = dq ?? sq ?? unq ?? '';
2244
- try {
2245
- const out = await options.minifyURLs(url);
2246
- return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
2247
- } catch (err) {
2248
- if (!options.continueOnMinifyError) {
2249
- throw err;
2250
- }
2251
- options.log && options.log(err);
2252
- return match;
2253
- }
2254
- }
2255
- );
2256
- // Cache key: wrapped content, type, options signature
2257
- const inputCSS = wrapCSS(text, type);
2258
- const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
2259
- // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
2260
- const cssKey = inputCSS.length > 2048
2261
- ? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
2262
- : (inputCSS + '|' + type + '|' + cssSig);
2263
-
2264
- try {
2265
- const cached = cssMinifyCache.get(cssKey);
2266
- if (cached) {
2267
- return cached;
2268
- }
2269
-
2270
- const transformCSS = await getLightningCSS();
2271
- const result = transformCSS({
2272
- filename: 'input.css',
2273
- code: Buffer.from(inputCSS),
2274
- minify: true,
2275
- errorRecovery: !!options.continueOnMinifyError,
2276
- ...lightningCssOptions
2277
- });
2278
-
2279
- const outputCSS = unwrapCSS(result.code.toString(), type);
2280
-
2281
- // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
2282
- // This preserves:
2283
- // 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
2284
- // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
2285
- // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
2286
- const isCDATA = text.includes('<![CDATA[');
2287
- const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
2288
- const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
2289
- const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
2290
-
2291
- // Preserve if output is empty and input had template syntax or UIDs
2292
- // This catches cases where Lightning CSS removed content that should be preserved
2293
- const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
2294
-
2295
- cssMinifyCache.set(cssKey, finalOutput);
2296
- return finalOutput;
2297
- } catch (err) {
2298
- cssMinifyCache.delete(cssKey);
2299
- if (!options.continueOnMinifyError) {
2300
- throw err;
2301
- }
2302
- options.log && options.log(err);
2303
- return text;
2304
- }
2305
- };
2306
- } else if (key === 'minifyJS' && typeof option !== 'function') {
2307
- if (!option) {
2308
- return;
2309
- }
2310
-
2311
- const terserOptions = typeof option === 'object' ? option : {};
2312
-
2313
- terserOptions.parse = {
2314
- ...terserOptions.parse,
2315
- bare_returns: false
2316
- };
2317
-
2318
- options.minifyJS = async function (text, inline) {
2319
- const start = text.match(/^\s*<!--.*/);
2320
- const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
2321
-
2322
- terserOptions.parse.bare_returns = inline;
2323
-
2324
- let jsKey;
2325
- try {
2326
- // Fast path: Avoid invoking Terser for empty/whitespace-only content
2327
- if (!code || !code.trim()) {
2328
- return '';
2329
- }
2330
- // Cache key: content, inline, options signature (subset)
2331
- const terserSig = stableStringify({
2332
- compress: terserOptions.compress,
2333
- mangle: terserOptions.mangle,
2334
- ecma: terserOptions.ecma,
2335
- toplevel: terserOptions.toplevel,
2336
- module: terserOptions.module,
2337
- keep_fnames: terserOptions.keep_fnames,
2338
- format: terserOptions.format,
2339
- cont: !!options.continueOnMinifyError,
2340
- });
2341
- // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
2342
- jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
2343
- const cached = jsMinifyCache.get(jsKey);
2344
- if (cached) {
2345
- return await cached;
2346
- }
2347
- const inFlight = (async () => {
2348
- const terser = await getTerser();
2349
- const result = await terser(code, terserOptions);
2350
- return result.code.replace(RE_TRAILING_SEMICOLON, '');
2351
- })();
2352
- jsMinifyCache.set(jsKey, inFlight);
2353
- const resolved = await inFlight;
2354
- jsMinifyCache.set(jsKey, resolved);
2355
- return resolved;
2356
- } catch (err) {
2357
- if (jsKey) jsMinifyCache.delete(jsKey);
2358
- if (!options.continueOnMinifyError) {
2359
- throw err;
2360
- }
2361
- options.log && options.log(err);
2362
- return text;
2363
- }
2364
- };
2365
- } else if (key === 'minifyURLs' && typeof option !== 'function') {
2366
- if (!option) {
2367
- return;
2368
- }
2369
-
2370
- let relateUrlOptions = option;
2371
-
2372
- if (typeof option === 'string') {
2373
- relateUrlOptions = { site: option };
2374
- } else if (typeof option !== 'object') {
2375
- relateUrlOptions = {};
2376
- }
2377
-
2378
- // Cache RelateURL instance for reuse (expensive to create)
2379
- const relateUrlInstance = new RelateURL(relateUrlOptions.site || '', relateUrlOptions);
2380
-
2381
- options.minifyURLs = function (text) {
2382
- // Fast-path: Skip if text doesn’t look like a URL that needs processing
2383
- // Only process if contains URL-like characters (`/`, `:`, `#`, `?`) or spaces that need encoding
2384
- if (!/[/:?#\s]/.test(text)) {
2385
- return text;
2386
- }
2387
-
2388
- try {
2389
- return relateUrlInstance.relate(text);
2390
- } catch (err) {
2391
- if (!options.continueOnMinifyError) {
2392
- throw err;
2393
- }
2394
- options.log && options.log(err);
2395
- return text;
2396
- }
2397
- };
2398
- } else {
2399
- options[key] = option;
2400
- }
2401
- });
2402
- return options;
2403
- };
2404
-
2405
- function uniqueId(value) {
2406
- let id;
2407
- do {
2408
- id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
2409
- } while (~value.indexOf(id));
2410
- return id;
2411
- }
2412
-
2413
- const specialContentTags = new Set(['script', 'style']);
2414
2566
 
2415
2567
  async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
2416
2568
  const attrChains = options.sortAttributes && Object.create(null);
@@ -2483,8 +2635,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
2483
2635
  try {
2484
2636
  await parser.parse();
2485
2637
  } catch (err) {
2486
- // If parsing fails during analysis pass, just skip it—we’ll still have
2487
- // partial frequency data from what we could parse
2638
+ // If parsing fails during analysis pass, just skip it—we’ll still have partial frequency data from what we could parse
2488
2639
  if (!options.continueOnParseError) {
2489
2640
  throw err;
2490
2641
  }
@@ -2493,7 +2644,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
2493
2644
 
2494
2645
  // For the first pass, create a copy of options and disable aggressive minification.
2495
2646
  // Keep attribute transformations (like `removeStyleLinkTypeAttributes`) for accurate analysis.
2496
- // This is safe because `createSortFns` is called before custom fragment UID markers (uidAttr) are added.
2647
+ // This is safe because `createSortFns` is called before custom fragment UID markers (`uidAttr`) are added.
2497
2648
  // Note: `htmlmin:ignore` UID markers (uidIgnore) already exist and are expanded for analysis.
2498
2649
  const firstPassOptions = Object.assign({}, options, {
2499
2650
  // Disable sorting for the analysis pass
@@ -2536,8 +2687,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
2536
2687
  uidReplacePattern.lastIndex = 0;
2537
2688
  }
2538
2689
 
2539
- // First pass minification applies attribute transformations
2540
- // like removeStyleLinkTypeAttributes for accurate frequency analysis
2690
+ // First pass minification applies attribute transformations like `removeStyleLinkTypeAttributes` for accurate frequency analysis
2541
2691
  const firstPassOutput = await minifyHTML(expandedValue, firstPassOptions);
2542
2692
 
2543
2693
  // For frequency analysis, we need to remove custom fragments temporarily
@@ -2558,16 +2708,30 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
2558
2708
  for (const tag in attrChains) {
2559
2709
  attrSorters[tag] = attrChains[tag].createSorter();
2560
2710
  }
2711
+ // Memoize sorted attribute orders—attribute sets often repeat in templates
2712
+ const attrOrderCache = new LRU(200);
2713
+
2561
2714
  options.sortAttributes = function (tag, attrs) {
2562
2715
  const sorter = attrSorters[tag];
2563
2716
  if (sorter) {
2564
- const attrMap = Object.create(null);
2565
2717
  const names = attrNames(attrs);
2718
+
2719
+ // Create order-independent cache key from tag and sorted attribute names
2720
+ const cacheKey = tag + ':' + names.slice().sort().join(',');
2721
+ let sortedNames = attrOrderCache.get(cacheKey);
2722
+
2723
+ if (sortedNames === undefined) {
2724
+ // Only sort if not in cache—need to clone names since sort mutates in place
2725
+ sortedNames = sorter.sort(names.slice());
2726
+ attrOrderCache.set(cacheKey, sortedNames);
2727
+ }
2728
+
2729
+ // Apply the sorted order to attrs
2730
+ const attrMap = Object.create(null);
2566
2731
  names.forEach(function (name, index) {
2567
2732
  (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
2568
2733
  });
2569
- const sorted = sorter.sort(names);
2570
- sorted.forEach(function (name, index) {
2734
+ sortedNames.forEach(function (name, index) {
2571
2735
  attrs[index] = attrMap[name].shift();
2572
2736
  });
2573
2737
  }
@@ -2668,10 +2832,8 @@ async function minifyHTML(value, options, partialMarkup) {
2668
2832
  removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
2669
2833
  }
2670
2834
 
2671
- // Temporarily replace ignored chunks with comments,
2672
- // so that we don’t have to worry what’s there.
2673
- // For all we care there might be
2674
- // completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
2835
+ // Temporarily replace ignored chunks with comments, so that we don’t have to worry what’s there.
2836
+ // For all we care there might be completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
2675
2837
  value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
2676
2838
  if (!uidIgnore) {
2677
2839
  uidIgnore = uniqueId(value);
@@ -2879,7 +3041,7 @@ async function minifyHTML(value, options, partialMarkup) {
2879
3041
 
2880
3042
  const parts = [];
2881
3043
  for (let i = attrs.length, isLast = true; --i >= 0;) {
2882
- const normalized = await normalizeAttr(attrs[i], attrs, tag, options);
3044
+ const normalized = await normalizeAttr(attrs[i], attrs, tag, options, minifyHTML);
2883
3045
  if (normalized) {
2884
3046
  parts.push(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
2885
3047
  isLast = false;
@@ -2954,7 +3116,7 @@ async function minifyHTML(value, options, partialMarkup) {
2954
3116
  }
2955
3117
 
2956
3118
  if (!preserve) {
2957
- // Remove last element from buffer
3119
+ // Remove last element from buffer
2958
3120
  removeStartTag();
2959
3121
  optionalStartTag = '';
2960
3122
  optionalEndTag = '';
@@ -3038,7 +3200,7 @@ async function minifyHTML(value, options, partialMarkup) {
3038
3200
  }
3039
3201
  }
3040
3202
  if (specialContentTags.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs))) {
3041
- text = await processScript(text, options, currentAttrs);
3203
+ text = await processScript(text, options, currentAttrs, minifyHTML);
3042
3204
  }
3043
3205
  if (isExecutableScript(currentTag, currentAttrs)) {
3044
3206
  text = await options.minifyJS(text);
@@ -3092,7 +3254,7 @@ async function minifyHTML(value, options, partialMarkup) {
3092
3254
  const prefix = nonStandard ? '<!' : '<!--';
3093
3255
  const suffix = nonStandard ? '>' : '-->';
3094
3256
  if (isConditionalComment(text)) {
3095
- text = prefix + await cleanConditionalComment(text, options) + suffix;
3257
+ text = prefix + await cleanConditionalComment(text, options, minifyHTML) + suffix;
3096
3258
  } else if (options.removeComments) {
3097
3259
  if (isIgnoredComment(text, options)) {
3098
3260
  text = '<!--' + text + '-->';
@@ -3279,7 +3441,12 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
3279
3441
  */
3280
3442
  const minify = async function (value, options) {
3281
3443
  const start = Date.now();
3282
- options = processOptions(options || {});
3444
+ options = processOptions(options || {}, {
3445
+ getLightningCSS,
3446
+ getTerser,
3447
+ cssMinifyCache,
3448
+ jsMinifyCache
3449
+ });
3283
3450
  const result = await minifyHTML(value, options);
3284
3451
  options.log('minified in: ' + (Date.now() - start) + 'ms');
3285
3452
  return result;