html-minifier-next 6.2.7 → 6.2.9
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 +67 -45
- package/dist/htmlminifier.cjs +916 -583
- package/dist/types/htmlminifier.d.ts +77 -60
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/htmlparser.d.ts +19 -3
- package/dist/types/htmlparser.d.ts.map +1 -1
- package/dist/types/lib/attributes.d.ts +137 -23
- package/dist/types/lib/attributes.d.ts.map +1 -1
- package/dist/types/lib/content.d.ts +37 -5
- package/dist/types/lib/content.d.ts.map +1 -1
- package/dist/types/lib/elements.d.ts +29 -4
- package/dist/types/lib/elements.d.ts.map +1 -1
- package/dist/types/lib/options.d.ts +17 -14
- package/dist/types/lib/options.d.ts.map +1 -1
- package/dist/types/lib/utils.d.ts +28 -11
- package/dist/types/lib/utils.d.ts.map +1 -1
- package/dist/types/lib/whitespace.d.ts +40 -6
- package/dist/types/lib/whitespace.d.ts.map +1 -1
- package/dist/types/tokenchain.d.ts +17 -3
- package/dist/types/tokenchain.d.ts.map +1 -1
- package/package.json +9 -8
- package/src/htmlminifier.js +439 -372
- package/src/htmlparser.js +101 -48
- package/src/lib/attributes.js +166 -50
- package/src/lib/content.js +28 -12
- package/src/lib/elements.js +30 -7
- package/src/lib/options.js +55 -42
- package/src/lib/utils.js +26 -7
- package/src/lib/whitespace.js +30 -11
- package/src/presets.js +1 -1
- package/src/tokenchain.js +30 -17
package/src/htmlminifier.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// Imports
|
|
2
|
-
|
|
3
1
|
import { HTMLParser, endTag } from './htmlparser.js';
|
|
4
2
|
import TokenChain from './tokenchain.js';
|
|
5
3
|
import { presets, getPreset, getPresetNames } from './presets.js';
|
|
@@ -56,241 +54,12 @@ import {
|
|
|
56
54
|
|
|
57
55
|
import { processOptions } from './lib/options.js';
|
|
58
56
|
|
|
59
|
-
// Lazy-load heavy dependencies only when needed
|
|
60
|
-
|
|
61
|
-
let lightningCSSPromise;
|
|
62
|
-
async function getLightningCSS() {
|
|
63
|
-
if (!lightningCSSPromise) {
|
|
64
|
-
lightningCSSPromise = import('lightningcss').then(m => m.transform);
|
|
65
|
-
}
|
|
66
|
-
return lightningCSSPromise;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
let terserPromise;
|
|
70
|
-
async function getTerser() {
|
|
71
|
-
if (!terserPromise) {
|
|
72
|
-
terserPromise = import('terser').then(m => m.minify);
|
|
73
|
-
}
|
|
74
|
-
return terserPromise;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
let swcPromise;
|
|
78
|
-
async function getSwc() {
|
|
79
|
-
if (!swcPromise) {
|
|
80
|
-
swcPromise = import('@swc/core')
|
|
81
|
-
.then(m => m.default || m)
|
|
82
|
-
.catch(() => {
|
|
83
|
-
throw new Error(
|
|
84
|
-
'The swc minifier requires @swc/core to be installed.\n' +
|
|
85
|
-
'Install it with: npm install @swc/core'
|
|
86
|
-
);
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
return swcPromise;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
let svgoPromise;
|
|
93
|
-
async function getSvgo() {
|
|
94
|
-
if (!svgoPromise) {
|
|
95
|
-
svgoPromise = import('svgo').then(m => m.optimize);
|
|
96
|
-
}
|
|
97
|
-
return svgoPromise;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
let decodeHTMLPromise;
|
|
101
|
-
async function getDecodeHTML() {
|
|
102
|
-
if (!decodeHTMLPromise) {
|
|
103
|
-
decodeHTMLPromise = import('entities').then(m => m.decodeHTML);
|
|
104
|
-
}
|
|
105
|
-
return decodeHTMLPromise;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Minification caches (initialized on first use with configurable sizes)
|
|
109
|
-
let cssMinifyCache = null;
|
|
110
|
-
let jsMinifyCache = null;
|
|
111
|
-
let svgMinifyCache = null;
|
|
112
|
-
|
|
113
|
-
// Pre-compiled patterns for script merging (avoid repeated allocation in hot path)
|
|
114
|
-
const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
115
|
-
const RE_SCRIPT_OPEN = /<script(?=[\s>])/gi; // Finds tag start; use `findTagEnd()` for the actual closing `>`
|
|
116
|
-
const RE_SCRIPT_CLOSE = /<\/script\s*>/gi;
|
|
117
|
-
const SCRIPT_BOOL_ATTRS = new Set(['async', 'defer', 'nomodule']);
|
|
118
|
-
const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript']);
|
|
119
|
-
|
|
120
|
-
// Pre-compiled patterns for buffer scanning
|
|
121
|
-
const RE_START_TAG = /^<[^/!]/;
|
|
122
|
-
const RE_END_TAG = /^<\//;
|
|
123
|
-
|
|
124
|
-
// Pre-compiled patterns for `htmlmin:ignore` block content analysis
|
|
125
|
-
const RE_HTML_COMMENT_START = /^\s*<!--/;
|
|
126
|
-
const RE_CLOSING_TAG_START = /^\s*<\/([a-zA-Z][\w:-]*)/;
|
|
127
|
-
const RE_LAST_HTML_TAG = /[\s\S]*<(\/?[a-zA-Z][\w:-]*)/;
|
|
128
|
-
|
|
129
|
-
// HTML encoding types for annotation-xml (MathML)
|
|
130
|
-
const RE_HTML_ENCODING = /^(text\/html|application\/xhtml\+xml)$/i;
|
|
131
|
-
|
|
132
|
-
// Script merging
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Find the index of the `>` that closes an opening tag, correctly skipping
|
|
136
|
-
* over quoted attribute values (which may contain `>`).
|
|
137
|
-
* @param {string} html
|
|
138
|
-
* @param {number} pos - Start position (just after the tag name)
|
|
139
|
-
* @returns {number} Index of the closing `>`, or -1 if not found
|
|
140
|
-
*/
|
|
141
|
-
function findTagEnd(html, pos) {
|
|
142
|
-
let i = pos;
|
|
143
|
-
while (i < html.length) {
|
|
144
|
-
const ch = html[i];
|
|
145
|
-
if (ch === '>') return i;
|
|
146
|
-
if (ch === '"' || ch === "'") {
|
|
147
|
-
const q = ch;
|
|
148
|
-
i++;
|
|
149
|
-
while (i < html.length && html[i] !== q) i++;
|
|
150
|
-
}
|
|
151
|
-
i++;
|
|
152
|
-
}
|
|
153
|
-
return -1;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Merge consecutive inline script tags into one (`mergeConsecutiveScripts`).
|
|
158
|
-
* Only merges scripts that are compatible:
|
|
159
|
-
* - Both inline (no `src` attribute)
|
|
160
|
-
* - Same `type` (or both default JavaScript)
|
|
161
|
-
* - No conflicting attributes (`async`, `defer`, `nomodule`, different `nonce`)
|
|
162
|
-
*
|
|
163
|
-
* Uses a scanner rather than a regex to locate script boundaries, so literal
|
|
164
|
-
* `</script>` strings inside script content are handled correctly per the HTML
|
|
165
|
-
* spec (raw text ends at the first `</script>`).
|
|
166
|
-
*
|
|
167
|
-
* @param {string} html - The HTML string to process
|
|
168
|
-
* @returns {string} HTML with consecutive scripts merged
|
|
169
|
-
*/
|
|
170
|
-
function mergeConsecutiveScripts(html) {
|
|
171
|
-
// Parse an attribute string into a name→value map
|
|
172
|
-
const parseAttrs = (attrStr) => {
|
|
173
|
-
const attrs = {};
|
|
174
|
-
RE_SCRIPT_ATTRS.lastIndex = 0;
|
|
175
|
-
let m;
|
|
176
|
-
while ((m = RE_SCRIPT_ATTRS.exec(attrStr)) !== null) {
|
|
177
|
-
const name = m[1].toLowerCase();
|
|
178
|
-
const value = m[2] ?? m[3] ?? m[4] ?? '';
|
|
179
|
-
attrs[name] = value;
|
|
180
|
-
}
|
|
181
|
-
return attrs;
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
let changed = true;
|
|
185
|
-
|
|
186
|
-
// Keep merging until no more changes (handles chains of 3+ scripts)
|
|
187
|
-
while (changed) {
|
|
188
|
-
changed = false;
|
|
189
|
-
RE_SCRIPT_OPEN.lastIndex = 0;
|
|
190
|
-
let m1;
|
|
191
|
-
|
|
192
|
-
while ((m1 = RE_SCRIPT_OPEN.exec(html)) !== null) {
|
|
193
|
-
// Use findTagEnd() to get the real closing '>', skipping quoted attribute values
|
|
194
|
-
const tagEnd1 = findTagEnd(html, m1.index + 7);
|
|
195
|
-
if (tagEnd1 === -1) break;
|
|
196
|
-
|
|
197
|
-
const attrs1Str = html.slice(m1.index + 7, tagEnd1);
|
|
198
|
-
const contentStart1 = tagEnd1 + 1;
|
|
199
|
-
|
|
200
|
-
// Find end of this script’s content (first `</script>`—per HTML spec, raw text ends here)
|
|
201
|
-
RE_SCRIPT_CLOSE.lastIndex = contentStart1;
|
|
202
|
-
const close1 = RE_SCRIPT_CLOSE.exec(html);
|
|
203
|
-
if (!close1) break;
|
|
204
|
-
|
|
205
|
-
const content1 = html.slice(contentStart1, close1.index);
|
|
206
|
-
const afterClose1 = close1.index + close1[0].length;
|
|
207
|
-
|
|
208
|
-
// Skip optional whitespace and check for a consecutive <script> tag
|
|
209
|
-
let i = afterClose1;
|
|
210
|
-
while (i < html.length && (html[i] === ' ' || html[i] === '\t' || html[i] === '\n' || html[i] === '\r' || html[i] === '\f')) i++;
|
|
211
|
-
if (html.slice(i, i + 7).toLowerCase() !== '<script' || (html[i + 7] !== '>' && !/\s/.test(html[i + 7]))) {
|
|
212
|
-
RE_SCRIPT_OPEN.lastIndex = afterClose1;
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const tagStart2 = i;
|
|
217
|
-
const tagEnd2 = findTagEnd(html, tagStart2 + 7);
|
|
218
|
-
if (tagEnd2 === -1) break;
|
|
219
|
-
|
|
220
|
-
const attrs2Str = html.slice(tagStart2 + 7, tagEnd2);
|
|
221
|
-
const contentStart2 = tagEnd2 + 1;
|
|
222
|
-
|
|
223
|
-
// Find end of second script’s content
|
|
224
|
-
RE_SCRIPT_CLOSE.lastIndex = contentStart2;
|
|
225
|
-
const close2 = RE_SCRIPT_CLOSE.exec(html);
|
|
226
|
-
if (!close2) break;
|
|
227
|
-
|
|
228
|
-
const content2 = html.slice(contentStart2, close2.index);
|
|
229
|
-
const afterClose2 = close2.index + close2[0].length;
|
|
230
|
-
|
|
231
|
-
const a1 = parseAttrs(attrs1Str);
|
|
232
|
-
const a2 = parseAttrs(attrs2Str);
|
|
233
|
-
|
|
234
|
-
// Check for `src`—cannot merge external scripts
|
|
235
|
-
if ('src' in a1 || 'src' in a2) {
|
|
236
|
-
RE_SCRIPT_OPEN.lastIndex = afterClose1;
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Check `type` compatibility (both must be default JS)
|
|
241
|
-
// Non-JS types (modules, JSON, etc.) must not be merged:
|
|
242
|
-
// Module scripts have per-script lexical scope, and non-JS content (e.g., JSON)
|
|
243
|
-
// is not concatenable; even identical non-JS types are incompatible
|
|
244
|
-
const type1 = (a1.type || '').toLowerCase();
|
|
245
|
-
const type2 = (a2.type || '').toLowerCase();
|
|
246
|
-
if (!DEFAULT_JS_TYPES.has(type1) || !DEFAULT_JS_TYPES.has(type2)) {
|
|
247
|
-
RE_SCRIPT_OPEN.lastIndex = afterClose1;
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Check for conflicting boolean attributes
|
|
252
|
-
let boolConflict = false;
|
|
253
|
-
for (const attr of SCRIPT_BOOL_ATTRS) {
|
|
254
|
-
if ((attr in a1) !== (attr in a2)) { boolConflict = true; break; }
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Check `nonce`—must be same or both absent
|
|
258
|
-
if (boolConflict || a1.nonce !== a2.nonce) {
|
|
259
|
-
RE_SCRIPT_OPEN.lastIndex = afterClose1;
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Scripts are compatible—merge them
|
|
264
|
-
changed = true;
|
|
265
|
-
|
|
266
|
-
// Combine content—use semicolon normally, newline only for trailing `//` comments
|
|
267
|
-
const c1 = content1.trim();
|
|
268
|
-
const c2 = content2.trim();
|
|
269
|
-
let mergedContent;
|
|
270
|
-
if (c1 && c2) {
|
|
271
|
-
// Check if last line of c1 contains `//` (single-line comment)
|
|
272
|
-
// If so, use newline to terminate it; otherwise use semicolon (if not already present)
|
|
273
|
-
const lastLine = c1.slice(c1.lastIndexOf('\n') + 1);
|
|
274
|
-
const separator = lastLine.includes('//') ? '\n' : (c1.endsWith(';') ? '' : ';');
|
|
275
|
-
mergedContent = c1 + separator + c2;
|
|
276
|
-
} else {
|
|
277
|
-
mergedContent = c1 || c2;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Use first script’s attributes (they should be compatible)
|
|
281
|
-
html = html.slice(0, m1.index) + `<script${attrs1Str}>${mergedContent}</script>` + html.slice(afterClose2);
|
|
282
|
-
break; // Restart scanning (outer while loop)
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
return html;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
57
|
// Type definitions
|
|
290
58
|
|
|
291
59
|
/**
|
|
292
60
|
* @typedef {Object} HTMLAttribute
|
|
293
61
|
* Representation of an attribute from the HTML parser.
|
|
62
|
+
* Internal counterpart: `HTMLAttribute` in `lib/attributes.js`—keep in sync.
|
|
294
63
|
*
|
|
295
64
|
* @prop {string} name
|
|
296
65
|
* @prop {string} [value]
|
|
@@ -700,23 +469,275 @@ function mergeConsecutiveScripts(html) {
|
|
|
700
469
|
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#use_short_doctype
|
|
701
470
|
*
|
|
702
471
|
* Default: `false`
|
|
472
|
+
*
|
|
473
|
+
* @prop {boolean} [insideSVG] - Internal: Set when inside SVG/MathML context
|
|
474
|
+
* @prop {boolean} [insideForeignContent] - Internal: Set when inside SVG `foreignObject`
|
|
475
|
+
* @prop {Function} [parentName] - Internal: Preserved name function during namespace transitions
|
|
476
|
+
* @prop {Function} [htmlName] - Internal: HTML name function preserved from outer context
|
|
477
|
+
*/
|
|
478
|
+
|
|
479
|
+
// Lazy-load heavy dependencies only when needed
|
|
480
|
+
|
|
481
|
+
/** @type {Promise<Function> | undefined} */
|
|
482
|
+
let lightningCSSPromise;
|
|
483
|
+
async function getLightningCSS() {
|
|
484
|
+
if (!lightningCSSPromise) {
|
|
485
|
+
lightningCSSPromise = import('lightningcss').then(m => m.transform);
|
|
486
|
+
}
|
|
487
|
+
return lightningCSSPromise;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** @type {Promise<Function> | undefined} */
|
|
491
|
+
let terserPromise;
|
|
492
|
+
async function getTerser() {
|
|
493
|
+
if (!terserPromise) {
|
|
494
|
+
terserPromise = import('terser').then(m => m.minify);
|
|
495
|
+
}
|
|
496
|
+
return terserPromise;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** @type {Promise<any> | undefined} */
|
|
500
|
+
let swcPromise;
|
|
501
|
+
async function getSwc() {
|
|
502
|
+
if (!swcPromise) {
|
|
503
|
+
swcPromise = import('@swc/core')
|
|
504
|
+
.then(m => m.default || m)
|
|
505
|
+
.catch(() => {
|
|
506
|
+
throw new Error(
|
|
507
|
+
'The swc minifier requires @swc/core to be installed.\n' +
|
|
508
|
+
'Install it with: npm install @swc/core'
|
|
509
|
+
);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
return swcPromise;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** @type {Promise<Function> | undefined} */
|
|
516
|
+
let svgoPromise;
|
|
517
|
+
async function getSvgo() {
|
|
518
|
+
if (!svgoPromise) {
|
|
519
|
+
svgoPromise = import('svgo').then(m => m.optimize);
|
|
520
|
+
}
|
|
521
|
+
return svgoPromise;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** @type {Promise<Function> | undefined} */
|
|
525
|
+
let decodeHTMLPromise;
|
|
526
|
+
async function getDecodeHTML() {
|
|
527
|
+
if (!decodeHTMLPromise) {
|
|
528
|
+
decodeHTMLPromise = import('entities').then(m => m.decodeHTML);
|
|
529
|
+
}
|
|
530
|
+
return decodeHTMLPromise;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Minification caches (initialized on first use with configurable sizes)
|
|
534
|
+
/** @type {LRU | null} */
|
|
535
|
+
let cssMinifyCache = null;
|
|
536
|
+
/** @type {LRU | null} */
|
|
537
|
+
let jsMinifyCache = null;
|
|
538
|
+
/** @type {LRU | null} */
|
|
539
|
+
let svgMinifyCache = null;
|
|
540
|
+
|
|
541
|
+
// Pre-compiled patterns for script merging (avoid repeated allocation in hot path)
|
|
542
|
+
const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
543
|
+
const RE_SCRIPT_OPEN = /<script(?=[\s>])/gi; // Finds tag start; use `findTagEnd()` for the actual closing `>`
|
|
544
|
+
const RE_SCRIPT_CLOSE = /<\/script\s*>/gi;
|
|
545
|
+
const SCRIPT_BOOL_ATTRS = new Set(['async', 'defer', 'nomodule']);
|
|
546
|
+
const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript']);
|
|
547
|
+
|
|
548
|
+
// Pre-compiled patterns for buffer scanning
|
|
549
|
+
const RE_START_TAG = /^<[^/!]/;
|
|
550
|
+
const RE_END_TAG = /^<\//;
|
|
551
|
+
|
|
552
|
+
// Pre-compiled patterns for `htmlmin:ignore` block content analysis
|
|
553
|
+
const RE_HTML_COMMENT_START = /^\s*<!--/;
|
|
554
|
+
const RE_CLOSING_TAG_START = /^\s*<\/([a-zA-Z][\w:-]*)/;
|
|
555
|
+
const RE_LAST_HTML_TAG = /[\s\S]*<(\/?[a-zA-Z][\w:-]*)/;
|
|
556
|
+
|
|
557
|
+
// HTML encoding types for annotation-xml (MathML)
|
|
558
|
+
const RE_HTML_ENCODING = /^(text\/html|application\/xhtml\+xml)$/i;
|
|
559
|
+
|
|
560
|
+
// Script merging
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Find the index of the `>` that closes an opening tag, correctly skipping
|
|
564
|
+
* over quoted attribute values (which may contain `>`).
|
|
565
|
+
* @param {string} html
|
|
566
|
+
* @param {number} pos - Start position (just after the tag name)
|
|
567
|
+
* @returns {number} Index of the closing `>`, or -1 if not found
|
|
703
568
|
*/
|
|
569
|
+
function findTagEnd(html, pos) {
|
|
570
|
+
let i = pos;
|
|
571
|
+
while (i < html.length) {
|
|
572
|
+
const ch = html[i];
|
|
573
|
+
if (ch === '>') return i;
|
|
574
|
+
if (ch === '"' || ch === "'") {
|
|
575
|
+
const q = ch;
|
|
576
|
+
i++;
|
|
577
|
+
while (i < html.length && html[i] !== q) i++;
|
|
578
|
+
}
|
|
579
|
+
i++;
|
|
580
|
+
}
|
|
581
|
+
return -1;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Merge consecutive inline script tags into one (`mergeConsecutiveScripts`).
|
|
586
|
+
* Only merges scripts that are compatible:
|
|
587
|
+
* - Both inline (no `src` attribute)
|
|
588
|
+
* - Same `type` (or both default JavaScript)
|
|
589
|
+
* - No conflicting attributes (`async`, `defer`, `nomodule`, different `nonce`)
|
|
590
|
+
*
|
|
591
|
+
* Uses a scanner rather than a regex to locate script boundaries, so literal
|
|
592
|
+
* `</script>` strings inside script content are handled correctly per the HTML
|
|
593
|
+
* spec (raw text ends at the first `</script>`).
|
|
594
|
+
*
|
|
595
|
+
* @param {string} html - The HTML string to process
|
|
596
|
+
* @returns {string} HTML with consecutive scripts merged
|
|
597
|
+
*/
|
|
598
|
+
function mergeConsecutiveScripts(html) {
|
|
599
|
+
// Parse an attribute string into a name→value map
|
|
600
|
+
const parseAttrs = (/** @type {string} */ attrStr) => {
|
|
601
|
+
/** @type {Record<string, string>} */
|
|
602
|
+
const attrs = {};
|
|
603
|
+
RE_SCRIPT_ATTRS.lastIndex = 0;
|
|
604
|
+
let m;
|
|
605
|
+
while ((m = RE_SCRIPT_ATTRS.exec(attrStr)) !== null) {
|
|
606
|
+
const name = (m[1] ?? '').toLowerCase();
|
|
607
|
+
const value = m[2] ?? m[3] ?? m[4] ?? '';
|
|
608
|
+
attrs[name] = value;
|
|
609
|
+
}
|
|
610
|
+
return attrs;
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
let changed = true;
|
|
704
614
|
|
|
615
|
+
// Keep merging until no more changes (handles chains of 3+ scripts)
|
|
616
|
+
while (changed) {
|
|
617
|
+
changed = false;
|
|
618
|
+
RE_SCRIPT_OPEN.lastIndex = 0;
|
|
619
|
+
let m1;
|
|
620
|
+
|
|
621
|
+
while ((m1 = RE_SCRIPT_OPEN.exec(html)) !== null) {
|
|
622
|
+
// Use findTagEnd() to get the real closing '>', skipping quoted attribute values
|
|
623
|
+
const tagEnd1 = findTagEnd(html, m1.index + 7);
|
|
624
|
+
if (tagEnd1 === -1) break;
|
|
625
|
+
|
|
626
|
+
const attrs1Str = html.slice(m1.index + 7, tagEnd1);
|
|
627
|
+
const contentStart1 = tagEnd1 + 1;
|
|
628
|
+
|
|
629
|
+
// Find end of this script’s content (first `</script>`—per HTML spec, raw text ends here)
|
|
630
|
+
RE_SCRIPT_CLOSE.lastIndex = contentStart1;
|
|
631
|
+
const close1 = RE_SCRIPT_CLOSE.exec(html);
|
|
632
|
+
if (!close1) break;
|
|
633
|
+
|
|
634
|
+
const content1 = html.slice(contentStart1, close1.index);
|
|
635
|
+
const afterClose1 = close1.index + close1[0].length;
|
|
636
|
+
|
|
637
|
+
// Skip optional whitespace and check for a consecutive <script> tag
|
|
638
|
+
let i = afterClose1;
|
|
639
|
+
while (i < html.length && (html[i] === ' ' || html[i] === '\t' || html[i] === '\n' || html[i] === '\r' || html[i] === '\f')) i++;
|
|
640
|
+
if (html.slice(i, i + 7).toLowerCase() !== '<script' || (html.charAt(i + 7) !== '>' && !/\s/.test(html.charAt(i + 7)))) {
|
|
641
|
+
RE_SCRIPT_OPEN.lastIndex = afterClose1;
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const tagStart2 = i;
|
|
646
|
+
const tagEnd2 = findTagEnd(html, tagStart2 + 7);
|
|
647
|
+
if (tagEnd2 === -1) break;
|
|
648
|
+
|
|
649
|
+
const attrs2Str = html.slice(tagStart2 + 7, tagEnd2);
|
|
650
|
+
const contentStart2 = tagEnd2 + 1;
|
|
651
|
+
|
|
652
|
+
// Find end of second script’s content
|
|
653
|
+
RE_SCRIPT_CLOSE.lastIndex = contentStart2;
|
|
654
|
+
const close2 = RE_SCRIPT_CLOSE.exec(html);
|
|
655
|
+
if (!close2) break;
|
|
656
|
+
|
|
657
|
+
const content2 = html.slice(contentStart2, close2.index);
|
|
658
|
+
const afterClose2 = close2.index + close2[0].length;
|
|
659
|
+
|
|
660
|
+
const a1 = parseAttrs(attrs1Str);
|
|
661
|
+
const a2 = parseAttrs(attrs2Str);
|
|
662
|
+
|
|
663
|
+
// Check for `src`—cannot merge external scripts
|
|
664
|
+
if ('src' in a1 || 'src' in a2) {
|
|
665
|
+
RE_SCRIPT_OPEN.lastIndex = afterClose1;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Check `type` compatibility (both must be default JS)
|
|
670
|
+
// Non-JS types (modules, JSON, etc.) must not be merged:
|
|
671
|
+
// Module scripts have per-script lexical scope, and non-JS content (e.g., JSON)
|
|
672
|
+
// is not concatenable; even identical non-JS types are incompatible
|
|
673
|
+
const type1 = (a1.type || '').toLowerCase();
|
|
674
|
+
const type2 = (a2.type || '').toLowerCase();
|
|
675
|
+
if (!DEFAULT_JS_TYPES.has(type1) || !DEFAULT_JS_TYPES.has(type2)) {
|
|
676
|
+
RE_SCRIPT_OPEN.lastIndex = afterClose1;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Check for conflicting boolean attributes
|
|
681
|
+
let boolConflict = false;
|
|
682
|
+
for (const attr of SCRIPT_BOOL_ATTRS) {
|
|
683
|
+
if ((attr in a1) !== (attr in a2)) { boolConflict = true; break; }
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Check `nonce`—must be same or both absent
|
|
687
|
+
if (boolConflict || a1.nonce !== a2.nonce) {
|
|
688
|
+
RE_SCRIPT_OPEN.lastIndex = afterClose1;
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Scripts are compatible—merge them
|
|
693
|
+
changed = true;
|
|
694
|
+
|
|
695
|
+
// Combine content—use semicolon normally, newline only for trailing `//` comments
|
|
696
|
+
const c1 = content1.trim();
|
|
697
|
+
const c2 = content2.trim();
|
|
698
|
+
let mergedContent;
|
|
699
|
+
if (c1 && c2) {
|
|
700
|
+
// Check if last line of c1 contains `//` (single-line comment)
|
|
701
|
+
// If so, use newline to terminate it; otherwise use semicolon (if not already present)
|
|
702
|
+
const lastLine = c1.slice(c1.lastIndexOf('\n') + 1);
|
|
703
|
+
const separator = lastLine.includes('//') ? '\n' : (c1.endsWith(';') ? '' : ';');
|
|
704
|
+
mergedContent = c1 + separator + c2;
|
|
705
|
+
} else {
|
|
706
|
+
mergedContent = c1 || c2;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Use first script’s attributes (they should be compatible)
|
|
710
|
+
html = html.slice(0, m1.index) + `<script${attrs1Str}>${mergedContent}</script>` + html.slice(afterClose2);
|
|
711
|
+
break; // Restart scanning (outer while loop)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return html;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* @param {string} value
|
|
720
|
+
* @param {MinifierOptions} options
|
|
721
|
+
* @param {string} uidIgnore
|
|
722
|
+
* @param {string} uidAttr
|
|
723
|
+
* @param {string[]} ignoredMarkupChunks
|
|
724
|
+
*/
|
|
705
725
|
async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
|
|
706
726
|
const attrChains = options.sortAttributes && typeof options.sortAttributes !== 'function' && Object.create(null);
|
|
707
727
|
const classChain = options.sortClassNames && typeof options.sortClassNames !== 'function' && new TokenChain();
|
|
728
|
+
const resolveName = /** @type {(name: string) => string} */ (options.name);
|
|
708
729
|
|
|
709
|
-
function attrNames(attrs) {
|
|
730
|
+
function attrNames(/** @type {HTMLAttribute[]} */ attrs) {
|
|
710
731
|
return attrs.map(function (attr) {
|
|
711
|
-
return
|
|
732
|
+
return resolveName(attr.name);
|
|
712
733
|
});
|
|
713
734
|
}
|
|
714
735
|
|
|
715
|
-
function shouldSkipUID(token, uid) {
|
|
736
|
+
function shouldSkipUID(/** @type {string} */ token, /** @type {string} */ uid) {
|
|
716
737
|
return !uid || token.indexOf(uid) === -1;
|
|
717
738
|
}
|
|
718
739
|
|
|
719
|
-
function shouldKeepToken(token) {
|
|
740
|
+
function shouldKeepToken(/** @type {string} */ token) {
|
|
720
741
|
// Filter out any HTML comment tokens (UID placeholders)
|
|
721
742
|
// These are temporary markers created by `htmlmin:ignore` and `ignoreCustomFragments`
|
|
722
743
|
if (token.startsWith('<!--') && token.endsWith('-->')) {
|
|
@@ -730,10 +751,13 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
730
751
|
const whitespaceSplitPatternScan = /[ \t\n\f\r]+/;
|
|
731
752
|
const whitespaceSplitPatternSort = /[ \n\f\r]+/;
|
|
732
753
|
|
|
733
|
-
async function scan(input) {
|
|
734
|
-
|
|
754
|
+
async function scan(/** @type {string} */ input) {
|
|
755
|
+
/** @type {string} */
|
|
756
|
+
let currentTag;
|
|
757
|
+
/** @type {string | undefined} */
|
|
758
|
+
let currentType;
|
|
735
759
|
const parser = new HTMLParser(input, {
|
|
736
|
-
start: function (tag, attrs) {
|
|
760
|
+
start: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
|
|
737
761
|
if (attrChains) {
|
|
738
762
|
if (!attrChains[tag]) {
|
|
739
763
|
attrChains[tag] = new TokenChain();
|
|
@@ -741,9 +765,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
741
765
|
const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
|
|
742
766
|
attrChains[tag].add(attrNamesList);
|
|
743
767
|
}
|
|
744
|
-
for (
|
|
745
|
-
|
|
746
|
-
if (classChain && attr.value && options.name(attr.name) === 'class') {
|
|
768
|
+
for (const attr of attrs) {
|
|
769
|
+
if (classChain && attr.value && resolveName(attr.name) === 'class') {
|
|
747
770
|
const classes = trimWhitespace(attr.value).split(whitespaceSplitPatternScan).filter(shouldKeepToken);
|
|
748
771
|
classChain.add(classes);
|
|
749
772
|
} else if (options.processScripts && attr.name.toLowerCase() === 'type') {
|
|
@@ -755,11 +778,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
755
778
|
end: function () {
|
|
756
779
|
currentTag = '';
|
|
757
780
|
},
|
|
758
|
-
chars: async function (text) {
|
|
781
|
+
chars: async function (/** @type {string} */ text) {
|
|
759
782
|
// Only recursively scan HTML content, not JSON-LD or other non-HTML script types
|
|
760
783
|
// `scan()` is for analyzing HTML attribute order, not for parsing JSON
|
|
761
784
|
if (options.processScripts && specialContentElements.has(currentTag) &&
|
|
762
|
-
options.processScripts.indexOf(currentType) > -1 &&
|
|
785
|
+
options.processScripts.indexOf(currentType ?? '') > -1 &&
|
|
763
786
|
currentType === 'text/html') {
|
|
764
787
|
await scan(text);
|
|
765
788
|
}
|
|
@@ -818,7 +841,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
818
841
|
// Expand UID tokens back to the original content for frequency analysis
|
|
819
842
|
let expandedValue = value;
|
|
820
843
|
if (uidReplacePattern) {
|
|
821
|
-
expandedValue = value.replace(uidReplacePattern, function (
|
|
844
|
+
expandedValue = value.replace(uidReplacePattern, function (/** @type {string} */ _match, /** @type {string} */ index) {
|
|
822
845
|
return ignoredMarkupChunks[+index] || '';
|
|
823
846
|
});
|
|
824
847
|
// Reset `lastIndex` for pattern reuse
|
|
@@ -839,7 +862,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
839
862
|
await scan(scanValue);
|
|
840
863
|
} finally {
|
|
841
864
|
// Restore original option
|
|
842
|
-
|
|
865
|
+
if (originalContinueOnParseError !== undefined) {
|
|
866
|
+
options.continueOnParseError = originalContinueOnParseError;
|
|
867
|
+
} else {
|
|
868
|
+
delete options.continueOnParseError;
|
|
869
|
+
}
|
|
843
870
|
}
|
|
844
871
|
if (attrChains) {
|
|
845
872
|
const attrSorters = Object.create(null);
|
|
@@ -849,14 +876,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
849
876
|
// Memoize sorted attribute orders—attribute sets often repeat in templates
|
|
850
877
|
const attrOrderCache = new LRU(500);
|
|
851
878
|
|
|
852
|
-
options.sortAttributes = function (tag, attrs) {
|
|
879
|
+
options.sortAttributes = function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
|
|
853
880
|
const sorter = attrSorters[tag];
|
|
854
881
|
if (sorter) {
|
|
855
882
|
const names = attrNames(attrs);
|
|
856
883
|
|
|
857
884
|
// Create order-independent cache key from tag and sorted attribute names
|
|
858
885
|
const cacheKey = tag + ':' + names.slice().sort().join(',');
|
|
859
|
-
let sortedNames = attrOrderCache.get(cacheKey);
|
|
886
|
+
let sortedNames = /** @type {string[] | undefined} */ (attrOrderCache.get(cacheKey));
|
|
860
887
|
|
|
861
888
|
if (sortedNames === undefined) {
|
|
862
889
|
// Only sort if not in cache—need to clone names since sort mutates in place
|
|
@@ -866,10 +893,10 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
866
893
|
|
|
867
894
|
// Apply the sorted order to `attrs`
|
|
868
895
|
const attrMap = Object.create(null);
|
|
869
|
-
names.forEach(function (name, index) {
|
|
896
|
+
names.forEach(function (/** @type {string} */ name, /** @type {number} */ index) {
|
|
870
897
|
(attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
|
|
871
898
|
});
|
|
872
|
-
sortedNames.forEach(function (name, index) {
|
|
899
|
+
/** @type {string[]} */ (sortedNames).forEach(function (/** @type {string} */ name, /** @type {number} */ index) {
|
|
873
900
|
attrs[index] = attrMap[name].shift();
|
|
874
901
|
});
|
|
875
902
|
}
|
|
@@ -880,14 +907,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
880
907
|
// Memoize `sortClassNames` results—class lists often repeat in templates
|
|
881
908
|
const classNameCache = new LRU(500);
|
|
882
909
|
|
|
883
|
-
options.sortClassNames = function (value) {
|
|
910
|
+
options.sortClassNames = function (/** @type {string} */ value) {
|
|
884
911
|
// Fast path: Single class (no spaces) needs no sorting
|
|
885
912
|
if (value.indexOf(' ') === -1) {
|
|
886
913
|
return value;
|
|
887
914
|
}
|
|
888
915
|
|
|
889
916
|
// Check cache first
|
|
890
|
-
const cached = classNameCache.get(value);
|
|
917
|
+
const cached = /** @type {string | undefined} */ (classNameCache.get(value));
|
|
891
918
|
if (cached !== undefined) {
|
|
892
919
|
return cached;
|
|
893
920
|
}
|
|
@@ -896,13 +923,13 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
|
|
|
896
923
|
// Fast path: Skip if no HTML comments (UID markers) present
|
|
897
924
|
let expandedValue = value;
|
|
898
925
|
if (uidReplacePattern && value.indexOf('<!--') !== -1) {
|
|
899
|
-
expandedValue = value.replace(uidReplacePattern, function (
|
|
926
|
+
expandedValue = value.replace(uidReplacePattern, function (/** @type {string} */ _match, /** @type {string} */ index) {
|
|
900
927
|
return ignoredMarkupChunks[+index] || '';
|
|
901
928
|
});
|
|
902
929
|
// Reset `lastIndex` for pattern reuse
|
|
903
930
|
uidReplacePattern.lastIndex = 0;
|
|
904
931
|
}
|
|
905
|
-
const classes = expandedValue.split(whitespaceSplitPatternSort).filter(function(cls) {
|
|
932
|
+
const classes = expandedValue.split(whitespaceSplitPatternSort).filter(function(/** @type {string} */ cls) {
|
|
906
933
|
return cls !== '';
|
|
907
934
|
});
|
|
908
935
|
const sorted = sorter.sort(classes);
|
|
@@ -931,29 +958,46 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
931
958
|
value = collapseWhitespace(value, options, true, true);
|
|
932
959
|
}
|
|
933
960
|
|
|
961
|
+
const resolveName = /** @type {(name: string) => string} */ (options.name);
|
|
962
|
+
/** @type {HTMLAttribute[]} */
|
|
963
|
+
const emptyAttrs = [];
|
|
964
|
+
|
|
965
|
+
/** @type {string[]} */
|
|
934
966
|
const buffer = [];
|
|
935
|
-
|
|
967
|
+
/** @type {string} */
|
|
968
|
+
let charsPrevTag = '';
|
|
936
969
|
let currentChars = '';
|
|
937
|
-
|
|
970
|
+
/** @type {boolean} */
|
|
971
|
+
let hasChars = false;
|
|
938
972
|
let currentTag = '';
|
|
973
|
+
/** @type {Array<{name: string, value?: string, quote?: string, customAssign?: string, customOpen?: string, customClose?: string}>} */
|
|
939
974
|
let currentAttrs = [];
|
|
975
|
+
/** @type {string[]} */
|
|
940
976
|
const stackNoTrimWhitespace = [];
|
|
977
|
+
/** @type {string[]} */
|
|
941
978
|
const stackNoCollapseWhitespace = [];
|
|
942
979
|
let preTextareaDepth = 0; // Count of `pre`/`textarea` entries in `stackNoTrimWhitespace`
|
|
943
980
|
let optionalStartTag = '';
|
|
944
981
|
let optionalEndTag = '';
|
|
945
982
|
let optionalEndTagEmitted = false;
|
|
983
|
+
/** @type {string[]} */
|
|
946
984
|
const ignoredMarkupChunks = [];
|
|
985
|
+
/** @type {Array<RegExpExecArray | null>} */
|
|
947
986
|
const ignoredCustomMarkupChunks = [];
|
|
987
|
+
/** @type {string | undefined} */
|
|
948
988
|
let uidIgnore;
|
|
989
|
+
/** @type {RegExp | undefined} */
|
|
949
990
|
let uidIgnorePlaceholderPattern;
|
|
991
|
+
/** @type {string | undefined} */
|
|
950
992
|
let uidAttr;
|
|
993
|
+
/** @type {RegExp | undefined} */
|
|
951
994
|
let uidPattern;
|
|
995
|
+
/** @type {RegExp | undefined} */
|
|
952
996
|
let uidAttrLeadingPattern;
|
|
953
997
|
// Create inline tags/text sets with custom elements
|
|
954
998
|
const customElementsInput = options.inlineCustomElements ?? [];
|
|
955
999
|
const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
|
|
956
|
-
const normalizedCustomElements = customElementsArr.map(name =>
|
|
1000
|
+
const normalizedCustomElements = customElementsArr.map(name => resolveName(name));
|
|
957
1001
|
// Fast path: Reuse base sets if no custom elements
|
|
958
1002
|
const inlineTextSet = normalizedCustomElements.length
|
|
959
1003
|
? new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements])
|
|
@@ -963,6 +1007,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
963
1007
|
: inlineElementsToKeepWhitespaceAround;
|
|
964
1008
|
|
|
965
1009
|
// Parse `removeEmptyElementsExcept` option
|
|
1010
|
+
/** @type {Array<{tag: string, attrs: {[x: string]: string | undefined} | null}>} */
|
|
966
1011
|
let removeEmptyElementsExcept;
|
|
967
1012
|
if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
|
|
968
1013
|
if (options.log) {
|
|
@@ -970,7 +1015,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
970
1015
|
}
|
|
971
1016
|
removeEmptyElementsExcept = [];
|
|
972
1017
|
} else {
|
|
973
|
-
removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
|
|
1018
|
+
removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept || [], /** @type {any} */ (options)) || [];
|
|
974
1019
|
}
|
|
975
1020
|
|
|
976
1021
|
// Temporarily replace ignored chunks with comments, so that there’s no need to worry what’s there;
|
|
@@ -1012,17 +1057,17 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1012
1057
|
// This allows proper frequency analysis with access to ignored content via UID tokens
|
|
1013
1058
|
if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
|
|
1014
1059
|
(options.sortClassNames && typeof options.sortClassNames !== 'function')) {
|
|
1015
|
-
await createSortFns(value, options, uidIgnore,
|
|
1060
|
+
await createSortFns(value, options, uidIgnore ?? '', uidAttr ?? '', ignoredMarkupChunks);
|
|
1016
1061
|
}
|
|
1017
1062
|
|
|
1018
|
-
const customFragments = options.ignoreCustomFragments.map(function (re) {
|
|
1063
|
+
const customFragments = (options.ignoreCustomFragments || []).map(function (/** @type {RegExp} */ re) {
|
|
1019
1064
|
return re.source;
|
|
1020
1065
|
});
|
|
1021
1066
|
if (customFragments.length) {
|
|
1022
1067
|
// Warn about potential ReDoS if custom fragments use unlimited quantifiers
|
|
1023
|
-
for (
|
|
1024
|
-
if (/[*+]/.test(
|
|
1025
|
-
options.log('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
|
|
1068
|
+
for (const fragment of customFragments) {
|
|
1069
|
+
if (/[*+]/.test(fragment)) {
|
|
1070
|
+
options.log?.('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
|
|
1026
1071
|
break;
|
|
1027
1072
|
}
|
|
1028
1073
|
}
|
|
@@ -1031,60 +1076,68 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1031
1076
|
const maxQuantifier = options.customFragmentQuantifierLimit || 200;
|
|
1032
1077
|
const whitespacePattern = `\\s{0,${maxQuantifier}}`;
|
|
1033
1078
|
|
|
1034
|
-
//
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1079
|
+
// Fast path: The padded replace below is costly on large inputs because the
|
|
1080
|
+
// `\s{0,N}` padding makes the regex engine consume and backtrack at every
|
|
1081
|
+
// whitespace run; since the padding is optional and the fragment group requires
|
|
1082
|
+
// at least one match, a single unpadded probe that finds nothing proves the
|
|
1083
|
+
// replace would be a no-op—so skip it entirely
|
|
1084
|
+
const reFragmentProbe = new RegExp('(?:' + customFragments.join('|') + ')');
|
|
1085
|
+
if (reFragmentProbe.test(value)) {
|
|
1086
|
+
// Use bounded quantifiers to prevent ReDoS—this approach prevents exponential backtracking
|
|
1087
|
+
const reCustomIgnore = new RegExp(
|
|
1088
|
+
whitespacePattern + '(?:' + customFragments.join('|') + '){1,' + maxQuantifier + '}' + whitespacePattern,
|
|
1089
|
+
'g'
|
|
1090
|
+
);
|
|
1091
|
+
// Temporarily replace custom ignored fragments with unique attributes
|
|
1092
|
+
value = value.replace(reCustomIgnore, function (match) {
|
|
1093
|
+
if (!uidAttr) {
|
|
1094
|
+
uidAttr = uniqueId(value);
|
|
1095
|
+
uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
|
|
1096
|
+
uidAttrLeadingPattern = new RegExp('^\\s*' + uidAttr + '(\\d+)' + uidAttr);
|
|
1097
|
+
|
|
1098
|
+
if (options.minifyCSS !== identity) {
|
|
1099
|
+
/** @type {any} */ (options).minifyCSS = (function (/** @type {Function} */ fn) {
|
|
1100
|
+
return function (/** @type {string} */ text, /** @type {string} */ type) {
|
|
1101
|
+
text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
|
|
1102
|
+
const chunks = ignoredCustomMarkupChunks[+index];
|
|
1103
|
+
return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
return fn(text, type);
|
|
1107
|
+
};
|
|
1108
|
+
})(/** @type {Function} */ (options.minifyCSS));
|
|
1109
|
+
}
|
|
1058
1110
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1111
|
+
if (options.minifyJS !== identity) {
|
|
1112
|
+
options.minifyJS = (function (/** @type {Function} */ fn) {
|
|
1113
|
+
return function (/** @type {string} */ text, /** @type {boolean} */ inline, /** @type {boolean} */ isModule) {
|
|
1114
|
+
return fn(text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
|
|
1115
|
+
const chunks = ignoredCustomMarkupChunks[+index];
|
|
1116
|
+
return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
|
|
1117
|
+
}), inline, isModule);
|
|
1118
|
+
};
|
|
1119
|
+
})(/** @type {Function} */ (options.minifyJS));
|
|
1120
|
+
}
|
|
1068
1121
|
}
|
|
1069
|
-
}
|
|
1070
1122
|
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1123
|
+
const token = uidAttr + ignoredCustomMarkupChunks.length + uidAttr;
|
|
1124
|
+
ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
|
|
1125
|
+
return '\t' + token + '\t';
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1075
1128
|
}
|
|
1076
1129
|
|
|
1077
|
-
function canCollapseWhitespace(tag, attrs) {
|
|
1078
|
-
return options.canCollapseWhitespace(tag, attrs, defaultCanCollapseWhitespace);
|
|
1130
|
+
function canCollapseWhitespace(/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
|
|
1131
|
+
return /** @type {Function} */ (options.canCollapseWhitespace)(tag, attrs, defaultCanCollapseWhitespace);
|
|
1079
1132
|
}
|
|
1080
1133
|
|
|
1081
|
-
function canTrimWhitespace(tag, attrs) {
|
|
1082
|
-
return options.canTrimWhitespace(tag, attrs, defaultCanTrimWhitespace);
|
|
1134
|
+
function canTrimWhitespace(/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
|
|
1135
|
+
return /** @type {Function} */ (options.canTrimWhitespace)(tag, attrs, defaultCanTrimWhitespace);
|
|
1083
1136
|
}
|
|
1084
1137
|
|
|
1085
1138
|
function removeStartTag() {
|
|
1086
1139
|
let index = buffer.length - 1;
|
|
1087
|
-
while (index > 0 && !RE_START_TAG.test(buffer[index])) {
|
|
1140
|
+
while (index > 0 && !RE_START_TAG.test(buffer[index] ?? '')) {
|
|
1088
1141
|
index--;
|
|
1089
1142
|
}
|
|
1090
1143
|
buffer.length = Math.max(0, index);
|
|
@@ -1092,20 +1145,20 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1092
1145
|
|
|
1093
1146
|
function removeEndTag() {
|
|
1094
1147
|
let index = buffer.length - 1;
|
|
1095
|
-
while (index > 0 && !RE_END_TAG.test(buffer[index])) {
|
|
1148
|
+
while (index > 0 && !RE_END_TAG.test(buffer[index] ?? '')) {
|
|
1096
1149
|
index--;
|
|
1097
1150
|
}
|
|
1098
1151
|
buffer.length = Math.max(0, index);
|
|
1099
1152
|
}
|
|
1100
1153
|
|
|
1101
1154
|
// Look for trailing whitespaces, bypass any inline tags
|
|
1102
|
-
function trimTrailingWhitespace(index, nextTag) {
|
|
1103
|
-
for (let endTag =
|
|
1104
|
-
const str = buffer[index];
|
|
1155
|
+
function trimTrailingWhitespace(/** @type {number} */ index, /** @type {string} */ nextTag) {
|
|
1156
|
+
for (let endTag = ''; index >= 0 && canTrimWhitespace(endTag, emptyAttrs); index--) {
|
|
1157
|
+
const str = buffer[index] ?? '';
|
|
1105
1158
|
const match = str.match(/^<\/([\w:-]+)>$/);
|
|
1106
1159
|
if (match) {
|
|
1107
|
-
endTag = match[1];
|
|
1108
|
-
} else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str,
|
|
1160
|
+
endTag = match[1] ?? '';
|
|
1161
|
+
} else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, '', nextTag, emptyAttrs, emptyAttrs, options, inlineElements, inlineTextSet))) {
|
|
1109
1162
|
break;
|
|
1110
1163
|
}
|
|
1111
1164
|
}
|
|
@@ -1114,10 +1167,10 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1114
1167
|
// Look for trailing whitespaces from previously processed text
|
|
1115
1168
|
// which may not be trimmed due to a following comment or an empty
|
|
1116
1169
|
// element which has now been removed
|
|
1117
|
-
function squashTrailingWhitespace(nextTag) {
|
|
1170
|
+
function squashTrailingWhitespace(/** @type {string} */ nextTag) {
|
|
1118
1171
|
let charsIndex = buffer.length - 1;
|
|
1119
1172
|
if (buffer.length > 1) {
|
|
1120
|
-
const item = buffer[buffer.length - 1];
|
|
1173
|
+
const item = buffer[buffer.length - 1] ?? '';
|
|
1121
1174
|
if (/^(?:<!|$)/.test(item) && (!uidIgnore || item.indexOf(uidIgnore) === -1)) {
|
|
1122
1175
|
charsIndex--;
|
|
1123
1176
|
}
|
|
@@ -1126,6 +1179,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1126
1179
|
}
|
|
1127
1180
|
|
|
1128
1181
|
// SVG subtree capture: When SVGO is active, record buffer positions for post-processing
|
|
1182
|
+
/** @type {Array<{start: number, end: number}>} */
|
|
1129
1183
|
const svgBlocks = []; // Array of { start, end } buffer indices
|
|
1130
1184
|
let svgBufferStartIndex = -1;
|
|
1131
1185
|
let svgDepth = 0;
|
|
@@ -1138,7 +1192,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1138
1192
|
// Compute `nextTag` only when whitespace collapse features require it
|
|
1139
1193
|
wantsNextTag: !!(options.collapseWhitespace || options.collapseInlineTagWhitespace || options.conservativeCollapse),
|
|
1140
1194
|
|
|
1141
|
-
start: async function (tag, attrs, unary, unarySlash, autoGenerated) {
|
|
1195
|
+
start: async function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ unary, /** @type {string} */ unarySlash, /** @type {boolean} */ autoGenerated) {
|
|
1142
1196
|
const lowerTag = tag.toLowerCase();
|
|
1143
1197
|
if (lowerTag === 'svg' || lowerTag === 'math') {
|
|
1144
1198
|
options = Object.create(options);
|
|
@@ -1160,21 +1214,21 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1160
1214
|
// Note: The element itself is in SVG/MathML namespace, only its children are HTML
|
|
1161
1215
|
let useParentNameForTag = false;
|
|
1162
1216
|
if (options.insideForeignContent && (lowerTag === 'foreignobject' ||
|
|
1163
|
-
(lowerTag === 'annotation-xml' && attrs.some(a => a.name.toLowerCase() === 'encoding' &&
|
|
1164
|
-
RE_HTML_ENCODING.test(a.value))))) {
|
|
1165
|
-
const parentName = options.name;
|
|
1217
|
+
(lowerTag === 'annotation-xml' && attrs.some((/** @type {HTMLAttribute} */ a) => a.name.toLowerCase() === 'encoding' &&
|
|
1218
|
+
RE_HTML_ENCODING.test(a.value ?? ''))))) {
|
|
1219
|
+
const parentName = /** @type {(name: string) => string} */ (options.name);
|
|
1166
1220
|
options = Object.create(options);
|
|
1167
1221
|
options.caseSensitive = false;
|
|
1168
1222
|
options.keepClosingSlash = false;
|
|
1169
1223
|
options.parentName = parentName; // Preserve for the element tag itself
|
|
1170
|
-
options.name = options.htmlName
|
|
1224
|
+
options.name = /** @type {(name: string) => string} */ (options.htmlName ?? lowercase);
|
|
1171
1225
|
options.insideForeignContent = false;
|
|
1172
1226
|
// Note: `removeAttributeQuotes`, `removeTagWhitespace`, and `decodeEntities`
|
|
1173
1227
|
// stay disabled (inherited from SVG context) because the entire SVG block
|
|
1174
1228
|
// must be valid XML for SVGO processing
|
|
1175
1229
|
useParentNameForTag = true;
|
|
1176
1230
|
}
|
|
1177
|
-
tag = (useParentNameForTag ? options.parentName : options.name)(tag);
|
|
1231
|
+
tag = /** @type {(name: string) => string} */ (useParentNameForTag ? options.parentName : options.name)(tag);
|
|
1178
1232
|
currentTag = tag;
|
|
1179
1233
|
charsPrevTag = tag;
|
|
1180
1234
|
if (!inlineTextSet.has(tag)) {
|
|
@@ -1240,10 +1294,10 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1240
1294
|
// Remove duplicate attributes (per HTML spec, first occurrence wins)
|
|
1241
1295
|
// Duplicate attributes result in invalid HTML
|
|
1242
1296
|
// https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state
|
|
1243
|
-
deduplicateAttributes(attrs, options.caseSensitive);
|
|
1297
|
+
deduplicateAttributes(attrs, Boolean(options.caseSensitive));
|
|
1244
1298
|
|
|
1245
1299
|
if (options.sortAttributes) {
|
|
1246
|
-
options.sortAttributes(tag, attrs);
|
|
1300
|
+
/** @type {Function} */ (options.sortAttributes)(tag, attrs);
|
|
1247
1301
|
}
|
|
1248
1302
|
|
|
1249
1303
|
const attrResults = attrs.map(attr => normalizeAttr(attr, attrs, tag, options, minifyHTML));
|
|
@@ -1273,7 +1327,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1273
1327
|
currentTag = '';
|
|
1274
1328
|
}
|
|
1275
1329
|
},
|
|
1276
|
-
end: function (tag, attrs, autoGenerated) {
|
|
1330
|
+
end: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ autoGenerated) {
|
|
1277
1331
|
const lowerTag = tag.toLowerCase();
|
|
1278
1332
|
// Restore parent context when exiting SVG/MathML or HTML-in-foreign-content elements
|
|
1279
1333
|
if (lowerTag === 'svg' || lowerTag === 'math') {
|
|
@@ -1282,7 +1336,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1282
1336
|
!options.insideForeignContent && Object.getPrototypeOf(options).insideForeignContent) {
|
|
1283
1337
|
options = Object.getPrototypeOf(options);
|
|
1284
1338
|
}
|
|
1285
|
-
tag = options.name(tag);
|
|
1339
|
+
tag = /** @type {Function} */ (options.name)(tag);
|
|
1286
1340
|
|
|
1287
1341
|
// Check if current tag is in a whitespace stack
|
|
1288
1342
|
if (options.collapseWhitespace) {
|
|
@@ -1328,7 +1382,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1328
1382
|
let preserve = false;
|
|
1329
1383
|
if (removeEmptyElementsExcept.length) {
|
|
1330
1384
|
// Normalize attribute names for comparison with specs
|
|
1331
|
-
const normalizedAttrs = attrs.map(attr => ({ ...attr, name:
|
|
1385
|
+
const normalizedAttrs = attrs.map((/** @type {HTMLAttribute} */ attr) => ({ ...attr, name: resolveName(attr.name) }));
|
|
1332
1386
|
preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
|
|
1333
1387
|
}
|
|
1334
1388
|
|
|
@@ -1375,7 +1429,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1375
1429
|
}
|
|
1376
1430
|
}
|
|
1377
1431
|
},
|
|
1378
|
-
chars: function (text, prevTag, nextTag, prevAttrs, nextAttrs) {
|
|
1432
|
+
chars: function (/** @type {string} */ text, /** @type {string} */ prevTag, /** @type {string} */ nextTag, /** @type {HTMLAttribute[]} */ prevAttrs, /** @type {HTMLAttribute[]} */ nextAttrs) {
|
|
1379
1433
|
prevTag = prevTag === '' ? 'comment' : prevTag;
|
|
1380
1434
|
nextTag = nextTag === '' ? 'comment' : nextTag;
|
|
1381
1435
|
prevAttrs = prevAttrs || [];
|
|
@@ -1391,7 +1445,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1391
1445
|
const needsMinifyCSS = options.minifyCSS !== identity && isStyleElement(currentTag, currentAttrs);
|
|
1392
1446
|
|
|
1393
1447
|
// Whitespace collapsing phase (sync); captures `prevTag`/`nextTag`/`prevAttrs`/`nextAttrs` from outer scope
|
|
1394
|
-
function charsCollapse(text) {
|
|
1448
|
+
function charsCollapse(/** @type {string} */ text) {
|
|
1395
1449
|
// Trim outermost newline-based whitespace inside `pre`/`textarea` elements
|
|
1396
1450
|
// This removes trailing newlines often added by template engines before closing tags
|
|
1397
1451
|
// Only trims single trailing newlines (multiple newlines are likely intentional formatting)
|
|
@@ -1413,7 +1467,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1413
1467
|
// to avoid side effects on the `inlineTextSet` branch below
|
|
1414
1468
|
let effectivePrevTag = prevTag;
|
|
1415
1469
|
if (prevTag === 'comment') {
|
|
1416
|
-
const prevComment = buffer[buffer.length - 1];
|
|
1470
|
+
const prevComment = buffer[buffer.length - 1] ?? '';
|
|
1417
1471
|
if (!uidIgnore || prevComment.indexOf(uidIgnore) === -1) {
|
|
1418
1472
|
if (!prevComment) {
|
|
1419
1473
|
prevTag = charsPrevTag;
|
|
@@ -1421,7 +1475,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1421
1475
|
}
|
|
1422
1476
|
if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
|
|
1423
1477
|
const charsIndex = buffer.length - 2;
|
|
1424
|
-
buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
|
|
1478
|
+
buffer[charsIndex] = (buffer[charsIndex] ?? '').replace(/\s+$/, function (trailingSpaces) {
|
|
1425
1479
|
text = trailingSpaces + text;
|
|
1426
1480
|
return '';
|
|
1427
1481
|
});
|
|
@@ -1432,13 +1486,14 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1432
1486
|
// when `nextTag` is `comment` (another UID placeholder), `commentFinalize` handles it
|
|
1433
1487
|
const match = prevComment.match(uidIgnorePlaceholderPattern);
|
|
1434
1488
|
if (match) {
|
|
1435
|
-
const idx = +match[1];
|
|
1489
|
+
const idx = +(match[1] ?? '');
|
|
1436
1490
|
if (idx < ignoredMarkupChunks.length) {
|
|
1437
1491
|
const content = ignoredMarkupChunks[idx];
|
|
1438
1492
|
const lastTagMatch = content && RE_LAST_HTML_TAG.exec(content);
|
|
1439
1493
|
if (lastTagMatch) {
|
|
1440
|
-
const
|
|
1441
|
-
const
|
|
1494
|
+
const group = lastTagMatch[1] ?? '';
|
|
1495
|
+
const isClose = group.charAt(0) === '/';
|
|
1496
|
+
const tagName = resolveName(isClose ? group.slice(1) : group);
|
|
1442
1497
|
effectivePrevTag = isClose ? '/' + tagName : tagName;
|
|
1443
1498
|
}
|
|
1444
1499
|
}
|
|
@@ -1449,13 +1504,13 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1449
1504
|
if (prevTag === '/nobr' || prevTag === 'wbr') {
|
|
1450
1505
|
if (/^\s/.test(text)) {
|
|
1451
1506
|
let tagIndex = buffer.length - 1;
|
|
1452
|
-
while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
|
|
1507
|
+
while (tagIndex > 0 && (buffer[tagIndex] ?? '').lastIndexOf('<' + prevTag) !== 0) {
|
|
1453
1508
|
tagIndex--;
|
|
1454
1509
|
}
|
|
1455
1510
|
trimTrailingWhitespace(tagIndex - 1, 'br');
|
|
1456
1511
|
}
|
|
1457
1512
|
} else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
|
|
1458
|
-
text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
|
|
1513
|
+
text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars), false);
|
|
1459
1514
|
}
|
|
1460
1515
|
}
|
|
1461
1516
|
if (prevTag || nextTag) {
|
|
@@ -1475,18 +1530,18 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1475
1530
|
}
|
|
1476
1531
|
|
|
1477
1532
|
// Finalization phase (sync): Optional tag handling, entity re-encoding, buffer push
|
|
1478
|
-
function charsFinalize(text) {
|
|
1533
|
+
function charsFinalize(/** @type {string} */ text) {
|
|
1479
1534
|
if (options.removeOptionalTags && text) {
|
|
1480
1535
|
// UID-attr tokens are padded with `\t`, which would falsely look like leading whitespace;
|
|
1481
1536
|
// resolve single-token text to its actual content for the space/comment checks below
|
|
1482
1537
|
let effectiveText = text;
|
|
1483
|
-
if (uidAttrLeadingPattern && text.includes(uidAttr)) {
|
|
1538
|
+
if (uidAttrLeadingPattern && uidAttr && text.includes(uidAttr)) {
|
|
1484
1539
|
const uidMatch = uidAttrLeadingPattern.exec(text);
|
|
1485
1540
|
if (uidMatch) {
|
|
1486
|
-
const idx = +uidMatch[1];
|
|
1541
|
+
const idx = +(uidMatch[1] ?? 0);
|
|
1487
1542
|
const chunks = idx < ignoredCustomMarkupChunks.length ? ignoredCustomMarkupChunks[idx] : null;
|
|
1488
1543
|
if (chunks != null) {
|
|
1489
|
-
effectiveText = chunks[0];
|
|
1544
|
+
effectiveText = chunks[0] ?? '';
|
|
1490
1545
|
}
|
|
1491
1546
|
}
|
|
1492
1547
|
}
|
|
@@ -1522,8 +1577,8 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1522
1577
|
}
|
|
1523
1578
|
}
|
|
1524
1579
|
if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
|
|
1525
|
-
text = text.replace(uidPattern, function (match,
|
|
1526
|
-
return ignoredCustomMarkupChunks[+index][0];
|
|
1580
|
+
text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ match, /** @type {string} */ _prefix, /** @type {string} */ index) {
|
|
1581
|
+
return ignoredCustomMarkupChunks[+index]?.[0] ?? match;
|
|
1527
1582
|
});
|
|
1528
1583
|
}
|
|
1529
1584
|
currentChars += text;
|
|
@@ -1549,20 +1604,20 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1549
1604
|
text = await processScript(text, options, currentAttrs, minifyHTML);
|
|
1550
1605
|
}
|
|
1551
1606
|
if (needsMinifyJS) {
|
|
1552
|
-
text = await options.minifyJS(text, false, isModuleScript);
|
|
1607
|
+
text = await /** @type {Function} */ (options.minifyJS)(text, false, isModuleScript);
|
|
1553
1608
|
}
|
|
1554
1609
|
if (needsMinifyCSS) {
|
|
1555
|
-
text = await options.minifyCSS(text);
|
|
1610
|
+
text = await /** @type {Function} */ (options.minifyCSS)(text);
|
|
1556
1611
|
}
|
|
1557
1612
|
charsFinalize(text);
|
|
1558
1613
|
})();
|
|
1559
1614
|
},
|
|
1560
|
-
comment: function (text, nonStandard) {
|
|
1615
|
+
comment: function (/** @type {string} */ text, /** @type {boolean} */ nonStandard) {
|
|
1561
1616
|
const prefix = nonStandard ? '<!' : '<!--';
|
|
1562
1617
|
const suffix = nonStandard ? '>' : '-->';
|
|
1563
1618
|
|
|
1564
1619
|
// Finalization phase (sync): Optional tag handling, `htmlmin:ignore` whitespace collapsing, buffer push
|
|
1565
|
-
function commentFinalize(comment) {
|
|
1620
|
+
function commentFinalize(/** @type {string} */ comment) {
|
|
1566
1621
|
if (options.removeOptionalTags && comment) {
|
|
1567
1622
|
if (uidIgnorePlaceholderPattern) {
|
|
1568
1623
|
const match = uidIgnorePlaceholderPattern.exec(comment);
|
|
@@ -1571,11 +1626,12 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1571
1626
|
// if there’s a pending optional end tag and the ignored content isn’t itself
|
|
1572
1627
|
// a comment (which per the HTML spec prevents omission), resolve it now,
|
|
1573
1628
|
// before the UID is pushed to the buffer
|
|
1574
|
-
const idx = +match[1];
|
|
1629
|
+
const idx = +(match[1] ?? 0);
|
|
1575
1630
|
const content = idx < ignoredMarkupChunks.length ? ignoredMarkupChunks[idx] : null;
|
|
1576
1631
|
if (optionalEndTag && optionalEndTagEmitted && content != null && !/^\s*<!--/.test(content)) {
|
|
1577
1632
|
const firstTagMatch = content.match(/^\s*<([a-zA-Z][^\s/>]*)/);
|
|
1578
|
-
const
|
|
1633
|
+
const firstTagGroup = firstTagMatch?.[1] ?? '';
|
|
1634
|
+
const firstTag = firstTagGroup ? resolveName(firstTagGroup) : '';
|
|
1579
1635
|
if (canRemovePrecedingTag(optionalEndTag, firstTag)) {
|
|
1580
1636
|
removeEndTag();
|
|
1581
1637
|
}
|
|
@@ -1603,8 +1659,8 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1603
1659
|
const prevMatch = prevComment.match(uidIgnorePlaceholderPattern);
|
|
1604
1660
|
|
|
1605
1661
|
if (currentMatch && prevMatch) {
|
|
1606
|
-
const currentIndex = +currentMatch[1];
|
|
1607
|
-
const prevIndex = +prevMatch[1];
|
|
1662
|
+
const currentIndex = +(currentMatch[1] ?? 0);
|
|
1663
|
+
const prevIndex = +(prevMatch[1] ?? 0);
|
|
1608
1664
|
|
|
1609
1665
|
// Defensive bounds check to ensure indices are valid
|
|
1610
1666
|
if (currentIndex < ignoredMarkupChunks.length && prevIndex < ignoredMarkupChunks.length) {
|
|
@@ -1627,13 +1683,13 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1627
1683
|
// Collapse if both sides are element/closing tags or HTML comments, and neither is inline
|
|
1628
1684
|
if ((currentTagMatch || currentIsHtmlComment || currentClosingTagMatch) &&
|
|
1629
1685
|
(prevTagMatch || prevIsHtmlComment || prevClosingTagMatch)) {
|
|
1630
|
-
const currentTag = currentTagMatch ?
|
|
1631
|
-
: currentClosingTagMatch ?
|
|
1632
|
-
const prevTag = prevTagMatch ?
|
|
1633
|
-
: prevClosingTagMatch ?
|
|
1686
|
+
const currentTag = currentTagMatch ? resolveName(currentTagMatch[1] ?? '')
|
|
1687
|
+
: currentClosingTagMatch ? resolveName(currentClosingTagMatch[1] ?? '') : null;
|
|
1688
|
+
const prevTag = prevTagMatch ? resolveName(prevTagMatch[1] ?? '')
|
|
1689
|
+
: prevClosingTagMatch ? resolveName(prevClosingTagMatch[1] ?? '') : null;
|
|
1634
1690
|
|
|
1635
1691
|
// Don’t collapse between inline elements (HTML comments count as non-inline)
|
|
1636
|
-
if (!inlineElements.has(currentTag) && !inlineElements.has(prevTag)) {
|
|
1692
|
+
if (!inlineElements.has(currentTag ?? '') && !inlineElements.has(prevTag ?? '')) {
|
|
1637
1693
|
// Collapse whitespace respecting context rules
|
|
1638
1694
|
let collapsedText = prevText;
|
|
1639
1695
|
|
|
@@ -1668,7 +1724,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1668
1724
|
}
|
|
1669
1725
|
|
|
1670
1726
|
if (options.removeComments) {
|
|
1671
|
-
if (isIgnoredComment(text, options)) {
|
|
1727
|
+
if (isIgnoredComment(text, /** @type {any} */ (options))) {
|
|
1672
1728
|
text = prefix + text + suffix;
|
|
1673
1729
|
} else {
|
|
1674
1730
|
text = '';
|
|
@@ -1678,7 +1734,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1678
1734
|
}
|
|
1679
1735
|
commentFinalize(text);
|
|
1680
1736
|
},
|
|
1681
|
-
doctype: function (doctype) {
|
|
1737
|
+
doctype: function (/** @type {string} */ doctype) {
|
|
1682
1738
|
buffer.push(options.useShortDoctype
|
|
1683
1739
|
? '<!doctype' +
|
|
1684
1740
|
(options.removeTagWhitespace ? '' : ' ') + 'html>'
|
|
@@ -1693,11 +1749,12 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1693
1749
|
if (options.minifySVG && svgBlocks.length) {
|
|
1694
1750
|
const optimized = await Promise.all(
|
|
1695
1751
|
svgBlocks.map(({ start, end }) =>
|
|
1696
|
-
options.minifySVG(buffer.slice(start, end).join(''))
|
|
1752
|
+
/** @type {Function} */ (options.minifySVG)(buffer.slice(start, end).join(''))
|
|
1697
1753
|
)
|
|
1698
1754
|
);
|
|
1699
1755
|
for (let i = svgBlocks.length - 1; i >= 0; i--) {
|
|
1700
|
-
|
|
1756
|
+
const block = svgBlocks[i];
|
|
1757
|
+
if (block) buffer.splice(block.start, block.end - block.start, optimized[i] ?? '');
|
|
1701
1758
|
}
|
|
1702
1759
|
}
|
|
1703
1760
|
|
|
@@ -1717,9 +1774,9 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1717
1774
|
}
|
|
1718
1775
|
|
|
1719
1776
|
return joinResultSegments(buffer, options, uidPattern
|
|
1720
|
-
? function (str) {
|
|
1721
|
-
return str.replace(uidPattern, function (match, prefix, index, suffix) {
|
|
1722
|
-
let chunk = ignoredCustomMarkupChunks[+index][0];
|
|
1777
|
+
? function (/** @type {string} */ str) {
|
|
1778
|
+
return str.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ match, /** @type {string} */ prefix, /** @type {string} */ index, /** @type {string} */ suffix) {
|
|
1779
|
+
let chunk = ignoredCustomMarkupChunks[+index]?.[0] ?? match;
|
|
1723
1780
|
if (options.collapseWhitespace) {
|
|
1724
1781
|
if (prefix !== '\t') {
|
|
1725
1782
|
chunk = prefix + chunk;
|
|
@@ -1736,14 +1793,20 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
1736
1793
|
});
|
|
1737
1794
|
}
|
|
1738
1795
|
: identity, uidIgnore
|
|
1739
|
-
? function (str) {
|
|
1740
|
-
return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (
|
|
1741
|
-
return ignoredMarkupChunks[+index];
|
|
1796
|
+
? function (/** @type {string} */ str) {
|
|
1797
|
+
return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (/** @type {string} */ _match, /** @type {string} */ index) {
|
|
1798
|
+
return ignoredMarkupChunks[+index] ?? '';
|
|
1742
1799
|
});
|
|
1743
1800
|
}
|
|
1744
1801
|
: identity);
|
|
1745
1802
|
}
|
|
1746
1803
|
|
|
1804
|
+
/**
|
|
1805
|
+
* @param {string[]} results
|
|
1806
|
+
* @param {MinifierOptions} options
|
|
1807
|
+
* @param {Function} restoreCustom
|
|
1808
|
+
* @param {Function} restoreIgnore
|
|
1809
|
+
*/
|
|
1747
1810
|
function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
|
|
1748
1811
|
let str;
|
|
1749
1812
|
const maxLineLength = options.maxLineLength;
|
|
@@ -1753,15 +1816,16 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
|
|
|
1753
1816
|
let line = ''; const lines = [];
|
|
1754
1817
|
while (results.length) {
|
|
1755
1818
|
const len = line.length;
|
|
1756
|
-
const
|
|
1757
|
-
const
|
|
1819
|
+
const cur = results[0] ?? '';
|
|
1820
|
+
const end = cur.indexOf('\n');
|
|
1821
|
+
const isClosingTag = Boolean(cur.match(endTag));
|
|
1758
1822
|
const shouldKeepSameLine = noNewlinesBeforeTagClose && isClosingTag;
|
|
1759
1823
|
|
|
1760
1824
|
if (end < 0) {
|
|
1761
|
-
line += restoreIgnore(restoreCustom(results.shift()));
|
|
1825
|
+
line += restoreIgnore(restoreCustom(results.shift() ?? ''));
|
|
1762
1826
|
} else {
|
|
1763
|
-
line += restoreIgnore(restoreCustom(
|
|
1764
|
-
results[0] =
|
|
1827
|
+
line += restoreIgnore(restoreCustom(cur.slice(0, end)));
|
|
1828
|
+
results[0] = cur.slice(end + 1);
|
|
1765
1829
|
}
|
|
1766
1830
|
if (len > 0 && line.length > maxLineLength && !shouldKeepSameLine) {
|
|
1767
1831
|
lines.push(line.slice(0, len));
|
|
@@ -1791,13 +1855,14 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
|
|
|
1791
1855
|
* - The first call’s options determine the cache sizes for subsequent calls
|
|
1792
1856
|
* - Invalid values (NaN, Infinity) fall back to the default size (500); values below `1` are clamped to `1`
|
|
1793
1857
|
*/
|
|
1858
|
+
/** @param {MinifierOptions} options */
|
|
1794
1859
|
function initCaches(options) {
|
|
1795
1860
|
// Only create caches once (on first call)—sizes are locked after this
|
|
1796
1861
|
if (!cssMinifyCache) {
|
|
1797
1862
|
const defaultSize = 500;
|
|
1798
1863
|
|
|
1799
1864
|
// Helper to parse env var—returns parsed number (including 0) or undefined if absent, invalid, or negative
|
|
1800
|
-
const parseEnvCacheSize = (envVar) => {
|
|
1865
|
+
const parseEnvCacheSize = (/** @type {string | undefined} */ envVar) => {
|
|
1801
1866
|
if (envVar === undefined) return undefined;
|
|
1802
1867
|
const parsed = Number(envVar);
|
|
1803
1868
|
if (Number.isNaN(parsed) || !Number.isFinite(parsed) || parsed < 0) {
|
|
@@ -1807,7 +1872,7 @@ function initCaches(options) {
|
|
|
1807
1872
|
};
|
|
1808
1873
|
|
|
1809
1874
|
// Sanitize a cache size: Non-finite/NaN falls back to `defaultSize`; otherwise clamped to min 1 and floored
|
|
1810
|
-
const sanitizeSize = (size) => Number.isFinite(size) ? Math.max(1, Math.floor(size)) : defaultSize;
|
|
1875
|
+
const sanitizeSize = (/** @type {number} */ size) => Number.isFinite(size) ? Math.max(1, Math.floor(size)) : defaultSize;
|
|
1811
1876
|
|
|
1812
1877
|
// Get cache sizes with precedence: Options > env > default
|
|
1813
1878
|
const cssSize = options.cacheCSS !== undefined ? options.cacheCSS
|
|
@@ -1845,7 +1910,9 @@ export const minify = async function (value, options) {
|
|
|
1845
1910
|
getTerser,
|
|
1846
1911
|
getSwc,
|
|
1847
1912
|
getSvgo,
|
|
1848
|
-
|
|
1913
|
+
cssMinifyCache: caches.cssMinifyCache ?? undefined,
|
|
1914
|
+
jsMinifyCache: caches.jsMinifyCache ?? undefined,
|
|
1915
|
+
svgMinifyCache: caches.svgMinifyCache ?? undefined
|
|
1849
1916
|
});
|
|
1850
1917
|
let result = await minifyHTML(value, options);
|
|
1851
1918
|
|
|
@@ -1854,7 +1921,7 @@ export const minify = async function (value, options) {
|
|
|
1854
1921
|
result = mergeConsecutiveScripts(result);
|
|
1855
1922
|
}
|
|
1856
1923
|
|
|
1857
|
-
options.log('minified in: ' + (Date.now() - start) + 'ms');
|
|
1924
|
+
/** @type {Function} */ (options.log)('minified in: ' + (Date.now() - start) + 'ms');
|
|
1858
1925
|
return result;
|
|
1859
1926
|
};
|
|
1860
1927
|
|