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