html-minifier-next 4.12.1 → 4.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ // Imports
2
+
3
+ import RelateURL from 'relateurl';
4
+ import { stableStringify, identity, identityAsync, replaceAsync } from './utils.js';
5
+ import { RE_TRAILING_SEMICOLON } from './constants.js';
6
+ import { canCollapseWhitespace, canTrimWhitespace } from './whitespace.js';
7
+ import { wrapCSS, unwrapCSS } from './content.js';
8
+
9
+ // Helper functions
10
+
11
+ function shouldMinifyInnerHTML(options) {
12
+ return Boolean(
13
+ options.collapseWhitespace ||
14
+ options.removeComments ||
15
+ options.removeOptionalTags ||
16
+ options.minifyJS !== identity ||
17
+ options.minifyCSS !== identityAsync ||
18
+ options.minifyURLs !== identity
19
+ );
20
+ }
21
+
22
+ // Main options processor
23
+
24
+ /**
25
+ * @param {Partial<MinifierOptions>} inputOptions - User-provided options
26
+ * @param {Object} deps - Dependencies from htmlminifier.js
27
+ * @param {Function} deps.getLightningCSS - Function to lazily load lightningcss
28
+ * @param {Function} deps.getTerser - Function to lazily load terser
29
+ * @param {LRU} deps.cssMinifyCache - CSS minification cache
30
+ * @param {LRU} deps.jsMinifyCache - JS minification cache
31
+ * @returns {MinifierOptions} Normalized options with defaults applied
32
+ */
33
+ const processOptions = (inputOptions, { getLightningCSS, getTerser, cssMinifyCache, jsMinifyCache } = {}) => {
34
+ const options = {
35
+ name: function (name) {
36
+ return name.toLowerCase();
37
+ },
38
+ canCollapseWhitespace,
39
+ canTrimWhitespace,
40
+ continueOnMinifyError: true,
41
+ html5: true,
42
+ ignoreCustomComments: [
43
+ /^!/,
44
+ /^\s*#/
45
+ ],
46
+ ignoreCustomFragments: [
47
+ /<%[\s\S]*?%>/,
48
+ /<\?[\s\S]*?\?>/
49
+ ],
50
+ includeAutoGeneratedTags: true,
51
+ log: identity,
52
+ minifyCSS: identityAsync,
53
+ minifyJS: identity,
54
+ minifyURLs: identity
55
+ };
56
+
57
+ Object.keys(inputOptions).forEach(function (key) {
58
+ const option = inputOptions[key];
59
+
60
+ if (key === 'caseSensitive') {
61
+ if (option) {
62
+ options.name = identity;
63
+ }
64
+ } else if (key === 'log') {
65
+ if (typeof option === 'function') {
66
+ options.log = option;
67
+ }
68
+ } else if (key === 'minifyCSS' && typeof option !== 'function') {
69
+ if (!option) {
70
+ return;
71
+ }
72
+
73
+ const lightningCssOptions = typeof option === 'object' ? option : {};
74
+
75
+ options.minifyCSS = async function (text, type) {
76
+ // Fast path: Nothing to minify
77
+ if (!text || !text.trim()) {
78
+ return text;
79
+ }
80
+ text = await replaceAsync(
81
+ text,
82
+ /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
83
+ async function (match, prefix, dq, sq, unq, suffix) {
84
+ const quote = dq != null ? '"' : (sq != null ? "'" : '');
85
+ const url = dq ?? sq ?? unq ?? '';
86
+ try {
87
+ const out = await options.minifyURLs(url);
88
+ return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
89
+ } catch (err) {
90
+ if (!options.continueOnMinifyError) {
91
+ throw err;
92
+ }
93
+ options.log && options.log(err);
94
+ return match;
95
+ }
96
+ }
97
+ );
98
+ // Cache key: Wrapped content, type, options signature
99
+ const inputCSS = wrapCSS(text, type);
100
+ const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
101
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
102
+ const cssKey = inputCSS.length > 2048
103
+ ? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
104
+ : (inputCSS + '|' + type + '|' + cssSig);
105
+
106
+ try {
107
+ const cached = cssMinifyCache.get(cssKey);
108
+ if (cached) {
109
+ return cached;
110
+ }
111
+
112
+ const transformCSS = await getLightningCSS();
113
+ const result = transformCSS({
114
+ filename: 'input.css',
115
+ code: Buffer.from(inputCSS),
116
+ minify: true,
117
+ errorRecovery: !!options.continueOnMinifyError,
118
+ ...lightningCssOptions
119
+ });
120
+
121
+ const outputCSS = unwrapCSS(result.code.toString(), type);
122
+
123
+ // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
124
+ // This preserves:
125
+ // 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
126
+ // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
127
+ // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
128
+ const isCDATA = text.includes('<![CDATA[');
129
+ const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
130
+ const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
131
+ const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
132
+
133
+ // Preserve if output is empty and input had template syntax or UIDs
134
+ // This catches cases where Lightning CSS removed content that should be preserved
135
+ const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
136
+
137
+ cssMinifyCache.set(cssKey, finalOutput);
138
+ return finalOutput;
139
+ } catch (err) {
140
+ cssMinifyCache.delete(cssKey);
141
+ if (!options.continueOnMinifyError) {
142
+ throw err;
143
+ }
144
+ options.log && options.log(err);
145
+ return text;
146
+ }
147
+ };
148
+ } else if (key === 'minifyJS' && typeof option !== 'function') {
149
+ if (!option) {
150
+ return;
151
+ }
152
+
153
+ const terserOptions = typeof option === 'object' ? option : {};
154
+
155
+ terserOptions.parse = {
156
+ ...terserOptions.parse,
157
+ bare_returns: false
158
+ };
159
+
160
+ options.minifyJS = async function (text, inline) {
161
+ const start = text.match(/^\s*<!--.*/);
162
+ const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
163
+
164
+ terserOptions.parse.bare_returns = inline;
165
+
166
+ let jsKey;
167
+ try {
168
+ // Fast path: Avoid invoking Terser for empty/whitespace-only content
169
+ if (!code || !code.trim()) {
170
+ return '';
171
+ }
172
+ // Cache key: content, inline, options signature (subset)
173
+ const terserSig = stableStringify({
174
+ compress: terserOptions.compress,
175
+ mangle: terserOptions.mangle,
176
+ ecma: terserOptions.ecma,
177
+ toplevel: terserOptions.toplevel,
178
+ module: terserOptions.module,
179
+ keep_fnames: terserOptions.keep_fnames,
180
+ format: terserOptions.format,
181
+ cont: !!options.continueOnMinifyError,
182
+ });
183
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
184
+ jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
185
+ const cached = jsMinifyCache.get(jsKey);
186
+ if (cached) {
187
+ return await cached;
188
+ }
189
+ const inFlight = (async () => {
190
+ const terser = await getTerser();
191
+ const result = await terser(code, terserOptions);
192
+ return result.code.replace(RE_TRAILING_SEMICOLON, '');
193
+ })();
194
+ jsMinifyCache.set(jsKey, inFlight);
195
+ const resolved = await inFlight;
196
+ jsMinifyCache.set(jsKey, resolved);
197
+ return resolved;
198
+ } catch (err) {
199
+ if (jsKey) jsMinifyCache.delete(jsKey);
200
+ if (!options.continueOnMinifyError) {
201
+ throw err;
202
+ }
203
+ options.log && options.log(err);
204
+ return text;
205
+ }
206
+ };
207
+ } else if (key === 'minifyURLs' && typeof option !== 'function') {
208
+ if (!option) {
209
+ return;
210
+ }
211
+
212
+ let relateUrlOptions = option;
213
+
214
+ if (typeof option === 'string') {
215
+ relateUrlOptions = { site: option };
216
+ } else if (typeof option !== 'object') {
217
+ relateUrlOptions = {};
218
+ }
219
+
220
+ // Cache RelateURL instance for reuse (expensive to create)
221
+ const relateUrlInstance = new RelateURL(relateUrlOptions.site || '', relateUrlOptions);
222
+
223
+ options.minifyURLs = function (text) {
224
+ // Fast-path: Skip if text doesn’t look like a URL that needs processing
225
+ // Only process if contains URL-like characters (`/`, `:`, `#`, `?`) or spaces that need encoding
226
+ if (!/[/:?#\s]/.test(text)) {
227
+ return text;
228
+ }
229
+
230
+ try {
231
+ return relateUrlInstance.relate(text);
232
+ } catch (err) {
233
+ if (!options.continueOnMinifyError) {
234
+ throw err;
235
+ }
236
+ options.log && options.log(err);
237
+ return text;
238
+ }
239
+ };
240
+ } else {
241
+ options[key] = option;
242
+ }
243
+ });
244
+ return options;
245
+ };
246
+
247
+ // Exports
248
+
249
+ export {
250
+ shouldMinifyInnerHTML,
251
+ processOptions
252
+ };
@@ -0,0 +1,90 @@
1
+ // Stringify for options signatures (sorted keys, shallow, nested objects)
2
+
3
+ function stableStringify(obj) {
4
+ if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
5
+ if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
6
+ const keys = Object.keys(obj).sort();
7
+ let out = '{';
8
+ for (let i = 0; i < keys.length; i++) {
9
+ const k = keys[i];
10
+ out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
11
+ }
12
+ return out + '}';
13
+ }
14
+
15
+ // LRU cache for strings and promises
16
+
17
+ class LRU {
18
+ constructor(limit = 200) {
19
+ this.limit = limit;
20
+ this.map = new Map();
21
+ }
22
+ get(key) {
23
+ if (this.map.has(key)) {
24
+ const v = this.map.get(key);
25
+ this.map.delete(key);
26
+ this.map.set(key, v);
27
+ return v;
28
+ }
29
+ return undefined;
30
+ }
31
+ set(key, value) {
32
+ if (this.map.has(key)) this.map.delete(key);
33
+ this.map.set(key, value);
34
+ if (this.map.size > this.limit) {
35
+ const first = this.map.keys().next().value;
36
+ this.map.delete(first);
37
+ }
38
+ }
39
+ delete(key) { this.map.delete(key); }
40
+ }
41
+
42
+ // Unique ID generator
43
+
44
+ function uniqueId(value) {
45
+ let id;
46
+ do {
47
+ id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
48
+ } while (~value.indexOf(id));
49
+ return id;
50
+ }
51
+
52
+ // Identity functions
53
+
54
+ function identity(value) {
55
+ return value;
56
+ }
57
+
58
+ function identityAsync(value) {
59
+ return Promise.resolve(value);
60
+ }
61
+
62
+ // Replace async helper
63
+
64
+ /**
65
+ * Asynchronously replace matches in a string
66
+ * @param {string} str - Input string
67
+ * @param {RegExp} regex - Regular expression with global flag
68
+ * @param {Function} asyncFn - Async function to process each match
69
+ * @returns {Promise<string>} Processed string
70
+ */
71
+ async function replaceAsync(str, regex, asyncFn) {
72
+ const promises = [];
73
+
74
+ str.replace(regex, (match, ...args) => {
75
+ const promise = asyncFn(match, ...args);
76
+ promises.push(promise);
77
+ });
78
+
79
+ const data = await Promise.all(promises);
80
+ return str.replace(regex, () => data.shift());
81
+ }
82
+
83
+ // Exports
84
+
85
+ export { stableStringify };
86
+ export { LRU };
87
+ export { uniqueId };
88
+ export { identity };
89
+ export { identityAsync };
90
+ export { replaceAsync };
@@ -0,0 +1,139 @@
1
+ // Imports
2
+
3
+ import {
4
+ RE_WS_START,
5
+ RE_WS_END,
6
+ RE_ALL_WS_NBSP,
7
+ RE_NBSP_LEADING_GROUP,
8
+ RE_NBSP_LEAD_GROUP,
9
+ RE_NBSP_TRAILING_GROUP,
10
+ RE_NBSP_TRAILING_STRIP,
11
+ inlineElementsToKeepWhitespace
12
+ } from './constants.js';
13
+
14
+ // Trim whitespace
15
+
16
+ const trimWhitespace = str => {
17
+ if (!str) return str;
18
+ // Fast path: If no whitespace at start or end, return early
19
+ if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
20
+ return str;
21
+ }
22
+ return str.replace(RE_WS_START, '').replace(RE_WS_END, '');
23
+ };
24
+
25
+ // Collapse all whitespace
26
+
27
+ function collapseWhitespaceAll(str) {
28
+ if (!str) return str;
29
+ // Fast path: If there are no common whitespace characters, return early
30
+ if (!/[ \n\r\t\f\xA0]/.test(str)) {
31
+ return str;
32
+ }
33
+ // No-break space is specifically handled inside the replacer function here:
34
+ return str.replace(RE_ALL_WS_NBSP, function (spaces) {
35
+ // Preserve standalone tabs
36
+ if (spaces === '\t') return '\t';
37
+ // Fast path: No no-break space, common case—just collapse to single space
38
+ // This avoids the nested regex for the majority of cases
39
+ if (spaces.indexOf('\xA0') === -1) return ' ';
40
+ // For no-break space handling, use the original regex approach
41
+ return spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
42
+ });
43
+ }
44
+
45
+ // Collapse whitespace with options
46
+
47
+ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
48
+ let lineBreakBefore = ''; let lineBreakAfter = '';
49
+
50
+ if (!str) return str;
51
+
52
+ // Fast path: Nothing to do
53
+ if (!trimLeft && !trimRight && !collapseAll && !options.preserveLineBreaks) {
54
+ return str;
55
+ }
56
+
57
+ // Fast path: No whitespace at all
58
+ if (!/[ \n\r\t\f\xA0]/.test(str)) {
59
+ return str;
60
+ }
61
+
62
+ if (options.preserveLineBreaks) {
63
+ str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function () {
64
+ lineBreakBefore = '\n';
65
+ return '';
66
+ }).replace(/[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*$/, function () {
67
+ lineBreakAfter = '\n';
68
+ return '';
69
+ });
70
+ }
71
+
72
+ if (trimLeft) {
73
+ // Non-breaking space is specifically handled inside the replacer function
74
+ str = str.replace(/^[ \n\r\t\f\xA0]+/, function (spaces) {
75
+ const conservative = !lineBreakBefore && options.conservativeCollapse;
76
+ if (conservative && spaces === '\t') {
77
+ return '\t';
78
+ }
79
+ return spaces.replace(/^[^\xA0]+/, '').replace(RE_NBSP_LEAD_GROUP, '$1 ') || (conservative ? ' ' : '');
80
+ });
81
+ }
82
+
83
+ if (trimRight) {
84
+ // Non-breaking space is specifically handled inside the replacer function
85
+ str = str.replace(/[ \n\r\t\f\xA0]+$/, function (spaces) {
86
+ const conservative = !lineBreakAfter && options.conservativeCollapse;
87
+ if (conservative && spaces === '\t') {
88
+ return '\t';
89
+ }
90
+ return spaces.replace(RE_NBSP_TRAILING_GROUP, ' $1').replace(RE_NBSP_TRAILING_STRIP, '') || (conservative ? ' ' : '');
91
+ });
92
+ }
93
+
94
+ if (collapseAll) {
95
+ // Strip non-space whitespace then compress spaces to one
96
+ str = collapseWhitespaceAll(str);
97
+ }
98
+
99
+ // Avoid string concatenation when no line breaks (common case)
100
+ if (!lineBreakBefore && !lineBreakAfter) return str;
101
+ if (!lineBreakBefore) return str + lineBreakAfter;
102
+ if (!lineBreakAfter) return lineBreakBefore + str;
103
+ return lineBreakBefore + str + lineBreakAfter;
104
+ }
105
+
106
+ // Collapse whitespace smartly based on surrounding tags
107
+
108
+ function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements, inlineTextSet) {
109
+ let trimLeft = prevTag && !inlineElementsToKeepWhitespace.has(prevTag);
110
+ if (trimLeft && !options.collapseInlineTagWhitespace) {
111
+ trimLeft = prevTag.charAt(0) === '/' ? !inlineElements.has(prevTag.slice(1)) : !inlineTextSet.has(prevTag);
112
+ }
113
+ let trimRight = nextTag && !inlineElementsToKeepWhitespace.has(nextTag);
114
+ if (trimRight && !options.collapseInlineTagWhitespace) {
115
+ trimRight = nextTag.charAt(0) === '/' ? !inlineTextSet.has(nextTag.slice(1)) : !inlineElements.has(nextTag);
116
+ }
117
+ return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag);
118
+ }
119
+
120
+ // Collapse/trim whitespace for given tag
121
+
122
+ function canCollapseWhitespace(tag) {
123
+ return !/^(?:script|style|pre|textarea)$/.test(tag);
124
+ }
125
+
126
+ function canTrimWhitespace(tag) {
127
+ return !/^(?:pre|textarea)$/.test(tag);
128
+ }
129
+
130
+ // Exports
131
+
132
+ export {
133
+ trimWhitespace,
134
+ collapseWhitespaceAll,
135
+ collapseWhitespace,
136
+ collapseWhitespaceSmart,
137
+ canCollapseWhitespace,
138
+ canTrimWhitespace
139
+ };
package/src/presets.js CHANGED
@@ -22,7 +22,6 @@ export const presets = {
22
22
  useShortDoctype: true
23
23
  },
24
24
  comprehensive: {
25
- // @@ Add `collapseAttributeWhitespace: true` (also add to preset in demo)
26
25
  caseSensitive: true,
27
26
  collapseBooleanAttributes: true,
28
27
  collapseInlineTagWhitespace: true,
package/src/tokenchain.js CHANGED
@@ -60,7 +60,7 @@ class TokenChain {
60
60
  const sorter = new Sorter();
61
61
  sorter.sorterMap = new Map();
62
62
 
63
- // Convert Map entries to array and sort
63
+ // Convert Map entries to array and sort by frequency (descending) then alphabetically
64
64
  const entries = Array.from(this.map.entries()).sort((a, b) => {
65
65
  const m = a[1].arrays.length;
66
66
  const n = b[1].arrays.length;
@@ -1,2 +0,0 @@
1
- export function replaceAsync(str: any, regex: any, asyncFn: any): Promise<any>;
2
- //# sourceMappingURL=utils.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/utils.js"],"names":[],"mappings":"AAAA,+EAUC"}