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