html-minifier-next 4.17.2 → 4.19.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.
@@ -43,16 +43,16 @@ const singleAttrValues = [
43
43
  // https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName
44
44
  const qnameCapture = (function () {
45
45
  // https://www.npmjs.com/package/ncname
46
- const combiningChar = '\\u0300-\\u0345\\u0360\\u0361\\u0483-\\u0486\\u0591-\\u05A1\\u05A3-\\u05B9\\u05BB-\\u05BD\\u05BF\\u05C1\\u05C2\\u05C4\\u064B-\\u0652\\u0670\\u06D6-\\u06E4\\u06E7\\u06E8\\u06EA-\\u06ED\\u0901-\\u0903\\u093C\\u093E-\\u094D\\u0951-\\u0954\\u0962\\u0963\\u0981-\\u0983\\u09BC\\u09BE-\\u09C4\\u09C7\\u09C8\\u09CB-\\u09CD\\u09D7\\u09E2\\u09E3\\u0A02\\u0A3C\\u0A3E-\\u0A42\\u0A47\\u0A48\\u0A4B-\\u0A4D\\u0A70\\u0A71\\u0A81-\\u0A83\\u0ABC\\u0ABE-\\u0AC5\\u0AC7-\\u0AC9\\u0ACB-\\u0ACD\\u0B01-\\u0B03\\u0B3C\\u0B3E-\\u0B43\\u0B47\\u0B48\\u0B4B-\\u0B4D\\u0B56\\u0B57\\u0B82\\u0B83\\u0BBE-\\u0BC2\\u0BC6-\\u0BC8\\u0BCA-\\u0BCD\\u0BD7\\u0C01-\\u0C03\\u0C3E-\\u0C44\\u0C46-\\u0C48\\u0C4A-\\u0C4D\\u0C55\\u0C56\\u0C82\\u0C83\\u0CBE-\\u0CC4\\u0CC6-\\u0CC8\\u0CCA-\\u0CCD\\u0CD5\\u0CD6\\u0D02\\u0D03\\u0D3E-\\u0D43\\u0D46-\\u0D48\\u0D4A-\\u0D4D\\u0D57\\u0E31\\u0E34-\\u0E3A\\u0E47-\\u0E4E\\u0EB1\\u0EB4-\\u0EB9\\u0EBB\\u0EBC\\u0EC8-\\u0ECD\\u0F18\\u0F19\\u0F35\\u0F37\\u0F39\\u0F3E\\u0F3F\\u0F71-\\u0F84\\u0F86-\\u0F8B\\u0F90-\\u0F95\\u0F97\\u0F99-\\u0FAD\\u0FB1-\\u0FB7\\u0FB9\\u20D0-\\u20DC\\u20E1\\u302A-\\u302F\\u3099\\u309A';
47
- const digit = '0-9\\u0660-\\u0669\\u06F0-\\u06F9\\u0966-\\u096F\\u09E6-\\u09EF\\u0A66-\\u0A6F\\u0AE6-\\u0AEF\\u0B66-\\u0B6F\\u0BE7-\\u0BEF\\u0C66-\\u0C6F\\u0CE6-\\u0CEF\\u0D66-\\u0D6F\\u0E50-\\u0E59\\u0ED0-\\u0ED9\\u0F20-\\u0F29';
48
- const extender = '\\xB7\\u02D0\\u02D1\\u0387\\u0640\\u0E46\\u0EC6\\u3005\\u3031-\\u3035\\u309D\\u309E\\u30FC-\\u30FE';
49
- const letter = 'A-Za-z\\xC0-\\xD6\\xD8-\\xF6\\xF8-\\u0131\\u0134-\\u013E\\u0141-\\u0148\\u014A-\\u017E\\u0180-\\u01C3\\u01CD-\\u01F0\\u01F4\\u01F5\\u01FA-\\u0217\\u0250-\\u02A8\\u02BB-\\u02C1\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03CE\\u03D0-\\u03D6\\u03DA\\u03DC\\u03DE\\u03E0\\u03E2-\\u03F3\\u0401-\\u040C\\u040E-\\u044F\\u0451-\\u045C\\u045E-\\u0481\\u0490-\\u04C4\\u04C7\\u04C8\\u04CB\\u04CC\\u04D0-\\u04EB\\u04EE-\\u04F5\\u04F8\\u04F9\\u0531-\\u0556\\u0559\\u0561-\\u0586\\u05D0-\\u05EA\\u05F0-\\u05F2\\u0621-\\u063A\\u0641-\\u064A\\u0671-\\u06B7\\u06BA-\\u06BE\\u06C0-\\u06CE\\u06D0-\\u06D3\\u06D5\\u06E5\\u06E6\\u0905-\\u0939\\u093D\\u0958-\\u0961\\u0985-\\u098C\\u098F\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09DC\\u09DD\\u09DF-\\u09E1\\u09F0\\u09F1\\u0A05-\\u0A0A\\u0A0F\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32\\u0A33\\u0A35\\u0A36\\u0A38\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A72-\\u0A74\\u0A85-\\u0A8B\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AE0\\u0B05-\\u0B0C\\u0B0F\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32\\u0B33\\u0B36-\\u0B39\\u0B3D\\u0B5C\\u0B5D\\u0B5F-\\u0B61\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99\\u0B9A\\u0B9C\\u0B9E\\u0B9F\\u0BA3\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB5\\u0BB7-\\u0BB9\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C33\\u0C35-\\u0C39\\u0C60\\u0C61\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CDE\\u0CE0\\u0CE1\\u0D05-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D28\\u0D2A-\\u0D39\\u0D60\\u0D61\\u0E01-\\u0E2E\\u0E30\\u0E32\\u0E33\\u0E40-\\u0E45\\u0E81\\u0E82\\u0E84\\u0E87\\u0E88\\u0E8A\\u0E8D\\u0E94-\\u0E97\\u0E99-\\u0E9F\\u0EA1-\\u0EA3\\u0EA5\\u0EA7\\u0EAA\\u0EAB\\u0EAD\\u0EAE\\u0EB0\\u0EB2\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0F40-\\u0F47\\u0F49-\\u0F69\\u10A0-\\u10C5\\u10D0-\\u10F6\\u1100\\u1102\\u1103\\u1105-\\u1107\\u1109\\u110B\\u110C\\u110E-\\u1112\\u113C\\u113E\\u1140\\u114C\\u114E\\u1150\\u1154\\u1155\\u1159\\u115F-\\u1161\\u1163\\u1165\\u1167\\u1169\\u116D\\u116E\\u1172\\u1173\\u1175\\u119E\\u11A8\\u11AB\\u11AE\\u11AF\\u11B7\\u11B8\\u11BA\\u11BC-\\u11C2\\u11EB\\u11F0\\u11F9\\u1E00-\\u1E9B\\u1EA0-\\u1EF9\\u1F00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2126\\u212A\\u212B\\u212E\\u2180-\\u2182\\u3007\\u3021-\\u3029\\u3041-\\u3094\\u30A1-\\u30FA\\u3105-\\u312C\\u4E00-\\u9FA5\\uAC00-\\uD7A3';
46
+ const combiningChar = '\u0300-\u0345\u0360\u0361\u0483-\u0486\u0591-\u05A1\u05A3-\u05B9\u05BB-\u05BD\u05BF\u05C1\u05C2\u05C4\u064B-\u0652\u0670\u06D6-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0901-\u0903\u093C\u093E-\u094D\u0951-\u0954\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u0A02\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A70\u0A71\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0B01-\u0B03\u0B3C\u0B3E-\u0B43\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B82\u0B83\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C01-\u0C03\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C82\u0C83\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0D02\u0D03\u0D3E-\u0D43\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86-\u0F8B\u0F90-\u0F95\u0F97\u0F99-\u0FAD\u0FB1-\u0FB7\u0FB9\u20D0-\u20DC\u20E1\u302A-\u302F\u3099\u309A';
47
+ const digit = '0-9\u0660-\u0669\u06F0-\u06F9\u0966-\u096F\u09E6-\u09EF\u0A66-\u0A6F\u0AE6-\u0AEF\u0B66-\u0B6F\u0BE7-\u0BEF\u0C66-\u0C6F\u0CE6-\u0CEF\u0D66-\u0D6F\u0E50-\u0E59\u0ED0-\u0ED9\u0F20-\u0F29';
48
+ const extender = '\xB7\u02D0\u02D1\u0387\u0640\u0E46\u0EC6\u3005\u3031-\u3035\u309D\u309E\u30FC-\u30FE';
49
+ const letter = 'A-Za-z\xC0-\xD6\xD8-\xF6\xF8-\u0131\u0134-\u013E\u0141-\u0148\u014A-\u017E\u0180-\u01C3\u01CD-\u01F0\u01F4\u01F5\u01FA-\u0217\u0250-\u02A8\u02BB-\u02C1\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03CE\u03D0-\u03D6\u03DA\u03DC\u03DE\u03E0\u03E2-\u03F3\u0401-\u040C\u040E-\u044F\u0451-\u045C\u045E-\u0481\u0490-\u04C4\u04C7\u04C8\u04CB\u04CC\u04D0-\u04EB\u04EE-\u04F5\u04F8\u04F9\u0531-\u0556\u0559\u0561-\u0586\u05D0-\u05EA\u05F0-\u05F2\u0621-\u063A\u0641-\u064A\u0671-\u06B7\u06BA-\u06BE\u06C0-\u06CE\u06D0-\u06D3\u06D5\u06E5\u06E6\u0905-\u0939\u093D\u0958-\u0961\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8B\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AE0\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B36-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB5\u0BB7-\u0BB9\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CDE\u0CE0\u0CE1\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39\u0D60\u0D61\u0E01-\u0E2E\u0E30\u0E32\u0E33\u0E40-\u0E45\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD\u0EAE\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0F40-\u0F47\u0F49-\u0F69\u10A0-\u10C5\u10D0-\u10F6\u1100\u1102\u1103\u1105-\u1107\u1109\u110B\u110C\u110E-\u1112\u113C\u113E\u1140\u114C\u114E\u1150\u1154\u1155\u1159\u115F-\u1161\u1163\u1165\u1167\u1169\u116D\u116E\u1172\u1173\u1175\u119E\u11A8\u11AB\u11AE\u11AF\u11B7\u11B8\u11BA\u11BC-\u11C2\u11EB\u11F0\u11F9\u1E00-\u1E9B\u1EA0-\u1EF9\u1F00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2126\u212A\u212B\u212E\u2180-\u2182\u3007\u3021-\u3029\u3041-\u3094\u30A1-\u30FA\u3105-\u312C\u4E00-\u9FA5\uAC00-\uD7A3';
50
50
  const ncname = '[' + letter + '_][' + letter + digit + '\\.\\-_' + combiningChar + extender + ']*';
51
51
  return '((?:' + ncname + '\\:)?' + ncname + ')';
52
52
  })();
53
53
  const startTagOpen = new RegExp('^<' + qnameCapture);
54
54
  const startTagClose = /^\s*(\/?)>/;
55
- const endTag = new RegExp('^<\\/' + qnameCapture + '[^>]*>');
55
+ const endTag = new RegExp('^</' + qnameCapture + '[^>]*>');
56
56
  const doctype = /^<!DOCTYPE\s?[^>]+>/i;
57
57
 
58
58
  let IS_REGEX_CAPTURING_BROKEN = false;
@@ -151,9 +151,6 @@ class HTMLParser {
151
151
  let pos = 0;
152
152
  let lastPos;
153
153
 
154
- // Helper to get remaining HTML from current position
155
- const remaining = () => fullHtml.slice(pos);
156
-
157
154
  // Helper to advance position
158
155
  const advance = (n) => { pos += n; };
159
156
 
@@ -172,22 +169,32 @@ class HTMLParser {
172
169
  return { line, column };
173
170
  };
174
171
 
172
+ // Helper to safely extract substring when needed for regex operations
173
+ const sliceFromPos = (startPos, len) => {
174
+ const endPos = fullLength;
175
+ return fullHtml.slice(startPos, endPos);
176
+ };
177
+
175
178
  while (pos < fullLength) {
176
179
  lastPos = pos;
177
- const html = remaining();
180
+
178
181
  // Make sure we’re not in a `script` or `style` element
179
182
  if (!lastTag || !special.has(lastTag)) {
180
- let textEnd = html.indexOf('<');
181
- if (textEnd === 0) {
183
+ const textEnd = fullHtml.indexOf('<', pos);
184
+
185
+ if (textEnd === pos) {
186
+ // We found a tag at current position
187
+ const remaining = sliceFromPos(pos);
188
+
182
189
  // Comment
183
- if (/^<!--/.test(html)) {
184
- const commentEnd = html.indexOf('-->');
190
+ if (/^<!--/.test(remaining)) {
191
+ const commentEnd = fullHtml.indexOf('-->', pos + 4);
185
192
 
186
193
  if (commentEnd >= 0) {
187
194
  if (handler.comment) {
188
- await handler.comment(html.substring(4, commentEnd));
195
+ await handler.comment(fullHtml.substring(pos + 4, commentEnd));
189
196
  }
190
- advance(commentEnd + 3);
197
+ advance(commentEnd + 3 - pos);
191
198
  prevTag = '';
192
199
  prevAttrs = [];
193
200
  continue;
@@ -195,14 +202,14 @@ class HTMLParser {
195
202
  }
196
203
 
197
204
  // https://web.archive.org/web/20241201212701/https://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
198
- if (/^<!\[/.test(html)) {
199
- const conditionalEnd = html.indexOf(']>');
205
+ if (/^<!\[/.test(remaining)) {
206
+ const conditionalEnd = fullHtml.indexOf(']>', pos + 3);
200
207
 
201
208
  if (conditionalEnd >= 0) {
202
209
  if (handler.comment) {
203
- await handler.comment(html.substring(2, conditionalEnd + 1), true /* Non-standard */);
210
+ await handler.comment(fullHtml.substring(pos + 2, conditionalEnd + 1), true /* Non-standard */);
204
211
  }
205
- advance(conditionalEnd + 2);
212
+ advance(conditionalEnd + 2 - pos);
206
213
  prevTag = '';
207
214
  prevAttrs = [];
208
215
  continue;
@@ -210,8 +217,8 @@ class HTMLParser {
210
217
  }
211
218
 
212
219
  // Doctype
213
- const doctypeMatch = html.match(doctype);
214
- if (doctypeMatch) {
220
+ if (doctype.test(remaining)) {
221
+ const doctypeMatch = remaining.match(doctype);
215
222
  if (handler.doctype) {
216
223
  handler.doctype(doctypeMatch[0]);
217
224
  }
@@ -222,8 +229,8 @@ class HTMLParser {
222
229
  }
223
230
 
224
231
  // End tag
225
- const endTagMatch = html.match(endTag);
226
- if (endTagMatch) {
232
+ if (endTag.test(remaining)) {
233
+ const endTagMatch = remaining.match(endTag);
227
234
  advance(endTagMatch[0].length);
228
235
  await parseEndTag(endTagMatch[0], endTagMatch[1]);
229
236
  prevTag = '/' + endTagMatch[1].toLowerCase();
@@ -232,7 +239,7 @@ class HTMLParser {
232
239
  }
233
240
 
234
241
  // Start tag
235
- const startTagMatch = parseStartTag(html);
242
+ const startTagMatch = parseStartTag(remaining, pos);
236
243
  if (startTagMatch) {
237
244
  advance(startTagMatch.advance);
238
245
  await handleStartTag(startTagMatch);
@@ -241,31 +248,29 @@ class HTMLParser {
241
248
  }
242
249
 
243
250
  // Treat `<` as text
244
- if (handler.continueOnParseError) {
245
- textEnd = html.indexOf('<', 1);
246
- }
251
+ if (handler.continueOnParseError) ;
247
252
  }
248
253
 
249
254
  let text;
250
255
  if (textEnd >= 0) {
251
- text = html.substring(0, textEnd);
252
- advance(textEnd);
256
+ text = fullHtml.substring(pos, textEnd);
257
+ advance(textEnd - pos);
253
258
  } else {
254
- text = html;
255
- advance(html.length);
259
+ text = fullHtml.substring(pos);
260
+ advance(fullLength - pos);
256
261
  }
257
262
 
258
- // Next tag
259
- const nextHtml = remaining();
260
- let nextTagMatch = parseStartTag(nextHtml);
263
+ // Next tag for whitespace processing context
264
+ const remainingAfterText = sliceFromPos(pos);
265
+ let nextTagMatch = parseStartTag(remainingAfterText, pos);
261
266
  if (nextTagMatch) {
262
267
  nextTag = nextTagMatch.tagName;
263
268
  // Extract minimal attribute info for whitespace logic (just name/value pairs)
264
269
  nextAttrs = extractAttrInfo(nextTagMatch.attrs);
265
270
  } else {
266
- nextTagMatch = nextHtml.match(endTag);
267
- if (nextTagMatch) {
268
- nextTag = '/' + nextTagMatch[1];
271
+ const endTagMatch = remainingAfterText.match(endTag);
272
+ if (endTagMatch) {
273
+ nextTag = '/' + endTagMatch[1];
269
274
  nextAttrs = [];
270
275
  } else {
271
276
  nextTag = '';
@@ -281,10 +286,11 @@ class HTMLParser {
281
286
  } else {
282
287
  const stackedTag = lastTag.toLowerCase();
283
288
  // Use pre-compiled regex for common tags (`script`, `style`, `noscript`) to avoid regex creation overhead
284
- const reStackedTag = preCompiledStackedTags[stackedTag] || reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)</' + stackedTag + '[^>]*>', 'i'));
289
+ const reStackedTag = preCompiledStackedTags[stackedTag] || reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)\\x3c/' + stackedTag + '[^>]*>', 'i'));
285
290
 
286
- const m = reStackedTag.exec(html);
287
- if (m) {
291
+ const remaining = sliceFromPos(pos);
292
+ const m = reStackedTag.exec(remaining);
293
+ if (m && m.index === 0) {
288
294
  let text = m[1];
289
295
  if (stackedTag !== 'script' && stackedTag !== 'style' && stackedTag !== 'noscript') {
290
296
  text = text
@@ -295,12 +301,12 @@ class HTMLParser {
295
301
  await handler.chars(text);
296
302
  }
297
303
  // Advance HTML past the matched special tag content and its closing tag
298
- advance(m.index + m[0].length);
304
+ advance(m[0].length);
299
305
  await parseEndTag('</' + stackedTag + '>', stackedTag);
300
306
  } else {
301
307
  // No closing tag found; to avoid infinite loop, break similarly to previous behavior
302
- if (handler.continueOnParseError && handler.chars && html) {
303
- await handler.chars(html[0], prevTag, '', prevAttrs, []);
308
+ if (handler.continueOnParseError && handler.chars && pos < fullLength) {
309
+ await handler.chars(fullHtml[pos], prevTag, '', prevAttrs, []);
304
310
  advance(1);
305
311
  } else {
306
312
  break;
@@ -320,7 +326,7 @@ class HTMLParser {
320
326
  continue;
321
327
  }
322
328
  const loc = getLineColumn(pos);
323
- // Include some context before the error position so the snippet contains the offending markup plus preceding characters (e.g., invalid<tag)
329
+ // Include some context before the error position so the snippet contains the offending markup plus preceding characters (e.g., `invalid<tag`)
324
330
  const CONTEXT_BEFORE = 50;
325
331
  const startPos = Math.max(0, pos - CONTEXT_BEFORE);
326
332
  const snippet = fullHtml.slice(startPos, startPos + 200).replace(/\n/g, ' ');
@@ -352,8 +358,8 @@ class HTMLParser {
352
358
  }).filter(attr => attr.name); // Filter out invalid entries
353
359
  }
354
360
 
355
- function parseStartTag(input) {
356
- const start = input.match(startTagOpen);
361
+ function parseStartTag(remaining, startPos) {
362
+ const start = remaining.match(startTagOpen);
357
363
  if (start) {
358
364
  const match = {
359
365
  tagName: start[1],
@@ -361,7 +367,7 @@ class HTMLParser {
361
367
  advance: 0
362
368
  };
363
369
  let consumed = start[0].length;
364
- input = input.slice(consumed);
370
+ let currentPos = startPos + consumed;
365
371
  let end, attr;
366
372
 
367
373
  // Safety limit: Max length of input to check for attributes
@@ -370,16 +376,20 @@ class HTMLParser {
370
376
 
371
377
  while (true) {
372
378
  // Check for closing tag first
373
- end = input.match(startTagClose);
379
+ const remainingForEnd = sliceFromPos(currentPos);
380
+ end = remainingForEnd.match(startTagClose);
374
381
  if (end) {
375
382
  break;
376
383
  }
377
384
 
378
385
  // Limit the input length we pass to the regex to prevent catastrophic backtracking
379
- const isLimited = input.length > MAX_ATTR_PARSE_LENGTH;
380
- const searchInput = isLimited ? input.slice(0, MAX_ATTR_PARSE_LENGTH) : input;
386
+ const remainingLen = fullLength - currentPos;
387
+ const isLimited = remainingLen > MAX_ATTR_PARSE_LENGTH;
388
+ const extractEndPos = isLimited ? currentPos + MAX_ATTR_PARSE_LENGTH : fullLength;
381
389
 
382
- attr = searchInput.match(attribute);
390
+ // Create a temporary substring only for attribute parsing (this is limited and necessary for regex)
391
+ const searchStr = fullHtml.substring(currentPos, extractEndPos);
392
+ attr = searchStr.match(attribute);
383
393
 
384
394
  // If we limited the input and got a match, check if the value might be truncated
385
395
  if (attr && isLimited) {
@@ -388,32 +398,31 @@ class HTMLParser {
388
398
  // If the match ends near the limit, the value might be truncated
389
399
  if (attrEnd > MAX_ATTR_PARSE_LENGTH - 100) {
390
400
  // Manually extract this attribute to handle potentially huge value
391
- const manualMatch = input.match(/^\s*([^\s"'<>/=]+)\s*=\s*/);
401
+ const manualMatch = searchStr.match(/^\s*([^\s"'<>/=]+)\s*=\s*/);
392
402
  if (manualMatch) {
393
- const quoteChar = input[manualMatch[0].length];
403
+ const quoteChar = searchStr[manualMatch[0].length];
394
404
  if (quoteChar === '"' || quoteChar === "'") {
395
- const closeQuote = input.indexOf(quoteChar, manualMatch[0].length + 1);
405
+ const closeQuote = searchStr.indexOf(quoteChar, manualMatch[0].length + 1);
396
406
  if (closeQuote !== -1) {
397
- const fullAttr = input.slice(0, closeQuote + 1);
407
+ const fullAttrLen = closeQuote + 1;
398
408
  const numCustomParts = handler.customAttrSurround
399
409
  ? handler.customAttrSurround.length * NCP
400
410
  : 0;
401
411
  const baseIndex = 1 + numCustomParts;
402
412
 
403
413
  attr = [];
404
- attr[0] = fullAttr;
414
+ attr[0] = searchStr.substring(0, fullAttrLen);
405
415
  attr[baseIndex] = manualMatch[1]; // Attribute name
406
- attr[baseIndex + 1] = '='; // `customAssign` (falls back to “=” for huge attributes)
407
- const value = input.slice(manualMatch[0].length + 1, closeQuote);
416
+ attr[baseIndex + 1] = '='; // `customAssign` (falls back to "=" for huge attributes)
417
+ const value = searchStr.substring(manualMatch[0].length + 1, closeQuote);
408
418
  // Place value at correct index based on quote type
409
419
  if (quoteChar === '"') {
410
420
  attr[baseIndex + 2] = value; // Double-quoted value
411
421
  } else {
412
422
  attr[baseIndex + 3] = value; // Single-quoted value
413
423
  }
414
- const attrLen = fullAttr.length;
415
- input = input.slice(attrLen);
416
- consumed += attrLen;
424
+ currentPos += fullAttrLen;
425
+ consumed += fullAttrLen;
417
426
  match.attrs.push(attr);
418
427
  continue;
419
428
  }
@@ -426,18 +435,55 @@ class HTMLParser {
426
435
  }
427
436
  }
428
437
 
438
+ if (!attr && isLimited) {
439
+ // If we limited the input and got no match, try manual extraction
440
+ // This handles cases where quoted attributes exceed `MAX_ATTR_PARSE_LENGTH`
441
+ const manualMatch = searchStr.match(/^\s*([^\s"'<>/=]+)\s*=\s*/);
442
+ if (manualMatch) {
443
+ const quoteChar = searchStr[manualMatch[0].length];
444
+ if (quoteChar === '"' || quoteChar === "'") {
445
+ // Search in the full HTML (not limited substring) for closing quote
446
+ const closeQuote = fullHtml.indexOf(quoteChar, currentPos + manualMatch[0].length + 1);
447
+ if (closeQuote !== -1) {
448
+ const fullAttrLen = closeQuote - currentPos + 1;
449
+ const numCustomParts = handler.customAttrSurround
450
+ ? handler.customAttrSurround.length * NCP
451
+ : 0;
452
+ const baseIndex = 1 + numCustomParts;
453
+
454
+ attr = [];
455
+ attr[0] = fullHtml.substring(currentPos, closeQuote + 1);
456
+ attr[baseIndex] = manualMatch[1]; // Attribute name
457
+ attr[baseIndex + 1] = '='; // customAssign
458
+ const value = fullHtml.substring(currentPos + manualMatch[0].length + 1, closeQuote);
459
+ // Place value at correct index based on quote type
460
+ if (quoteChar === '"') {
461
+ attr[baseIndex + 2] = value; // Double-quoted value
462
+ } else {
463
+ attr[baseIndex + 3] = value; // Single-quoted value
464
+ }
465
+ currentPos += fullAttrLen;
466
+ consumed += fullAttrLen;
467
+ match.attrs.push(attr);
468
+ continue;
469
+ }
470
+ }
471
+ }
472
+ }
473
+
429
474
  if (!attr) {
430
475
  break;
431
476
  }
432
477
 
433
478
  const attrLen = attr[0].length;
434
- input = input.slice(attrLen);
479
+ currentPos += attrLen;
435
480
  consumed += attrLen;
436
481
  match.attrs.push(attr);
437
482
  }
438
483
 
439
484
  // Check for closing tag
440
- end = input.match(startTagClose);
485
+ const remainingForClose = sliceFromPos(currentPos);
486
+ end = remainingForClose.match(startTagClose);
441
487
  if (end) {
442
488
  match.unarySlash = end[1];
443
489
  consumed += end[0].length;
@@ -634,11 +680,11 @@ class HTMLParser {
634
680
  if (handler.end) {
635
681
  handler.end(tagName, [], false);
636
682
  }
637
- } else if (tagName.toLowerCase() === 'br') {
683
+ } else if (tagName && tagName.toLowerCase() === 'br') {
638
684
  if (handler.start) {
639
685
  await handler.start(tagName, [], true, '');
640
686
  }
641
- } else if (tagName.toLowerCase() === 'p') {
687
+ } else if (tagName && tagName.toLowerCase() === 'p') {
642
688
  if (handler.start) {
643
689
  await handler.start(tagName, [], false, '', true);
644
690
  }
@@ -781,6 +827,7 @@ const presets = {
781
827
  collapseWhitespace: true,
782
828
  continueOnParseError: true,
783
829
  decodeEntities: true,
830
+ mergeScripts: true,
784
831
  minifyCSS: true,
785
832
  minifyJS: true,
786
833
  minifySVG: true,
@@ -999,6 +1046,11 @@ const isSimpleBoolean = new Set(['allowfullscreen', 'async', 'autofocus', 'autop
999
1046
 
1000
1047
  const isBooleanValue = new Set(['true', 'false']);
1001
1048
 
1049
+ // Attributes where empty value can be collapsed to just the attribute name
1050
+ // `crossorigin=""` → `crossorigin` (empty string equals anonymous mode)
1051
+ // `contenteditable=""` → `contenteditable` (empty string equals `true`)
1052
+ const emptyCollapsible = new Set(['crossorigin', 'contenteditable']);
1053
+
1002
1054
  // `srcset` elements
1003
1055
 
1004
1056
  const srcsetElements = new Set(['img', 'source']);
@@ -1448,7 +1500,8 @@ function minifyNumber(num, precision = 3) {
1448
1500
  const fixed = parsed.toFixed(precision);
1449
1501
  const trimmed = fixed.replace(/\.?0+$/, '');
1450
1502
 
1451
- const result = trimmed || '0';
1503
+ // Remove leading zero before decimal point (e.g., `0.5` → `.5`, `-0.3` → `-.3`)
1504
+ const result = (trimmed || '0').replace(/^(-?)0\./, '$1.');
1452
1505
  numberCache.set(cacheKey, result);
1453
1506
  return result;
1454
1507
  }
@@ -1468,17 +1521,23 @@ function minifyPathData(pathData, precision = 3) {
1468
1521
  });
1469
1522
 
1470
1523
  // Remove unnecessary spaces around path commands
1471
- // Safe to remove space after a command letter when it’s followed by a number (which may be negative)
1472
- // `M 10 20` `M10 20`, `L -5 -3` → `L-5-3`
1473
- result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\d)/g, '$1');
1524
+ // Safe to remove space after a command letter when it’s followed by a number
1525
+ // (which may be negative or start with a decimal point)
1526
+ // `M 10 20` → `M10 20`, `L -5 -3` → `L-5-3`, `M .5 .3` → `M.5.3`
1527
+ result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\.?\d)/g, '$1');
1474
1528
 
1475
1529
  // Safe to remove space before command letter when preceded by a number
1476
- // `0 L` → `0L`, `20 M` → `20M`
1477
- result = result.replace(/(\d)\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
1530
+ // `0 L` → `0L`, `20 M` → `20M`, `.5 L` → `.5L`
1531
+ result = result.replace(/([\d.])\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
1478
1532
 
1479
1533
  // Safe to remove space before negative number when preceded by a number
1480
- // `10 -20` → `10-20` (numbers are separated by the minus sign)
1481
- result = result.replace(/(\d)\s+(-\d)/g, '$1$2');
1534
+ // `10 -20` → `10-20`, `.5 -.3` → `.5-.3` (minus sign is always a separator)
1535
+ result = result.replace(/([\d.])\s+(-)/g, '$1$2');
1536
+
1537
+ // Safe to remove space between two decimal numbers (decimal point acts as separator)
1538
+ // `.5 .3` → `.5.3` (only when previous char is `.`, indicating a complete decimal)
1539
+ // Note: `0 .3` must not become `0.3` (that would change two numbers into one)
1540
+ result = result.replace(/(\.\d*)\s+(\.)/g, '$1$2');
1482
1541
 
1483
1542
  return result;
1484
1543
  }
@@ -1776,10 +1835,9 @@ function shouldMinifyInnerHTML(options) {
1776
1835
  * @param {Function} deps.getSwc - Function to lazily load @swc/core
1777
1836
  * @param {LRU} deps.cssMinifyCache - CSS minification cache
1778
1837
  * @param {LRU} deps.jsMinifyCache - JS minification cache
1779
- * @param {LRU} deps.urlMinifyCache - URL minification cache
1780
1838
  * @returns {MinifierOptions} Normalized options with defaults applied
1781
1839
  */
1782
- const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssMinifyCache, jsMinifyCache, urlMinifyCache } = {}) => {
1840
+ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssMinifyCache, jsMinifyCache } = {}) => {
1783
1841
  const options = {
1784
1842
  name: function (name) {
1785
1843
  return name.toLowerCase();
@@ -2073,7 +2131,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
2073
2131
  const relateUrlInstance = new RelateURL(relateUrlOptions.site || '', relateUrlOptions);
2074
2132
 
2075
2133
  // Create instance-specific cache (results depend on site configuration)
2076
- const instanceCache = urlMinifyCache ? new (urlMinifyCache.constructor)(500) : null;
2134
+ const instanceCache = new LRU(500);
2077
2135
 
2078
2136
  options.minifyURLs = function (text) {
2079
2137
  // Fast-path: Skip if text doesn’t look like a URL that needs processing
@@ -2082,20 +2140,15 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
2082
2140
  return text;
2083
2141
  }
2084
2142
 
2085
- // Check instance-specific cache
2086
- if (instanceCache) {
2087
- const cached = instanceCache.get(text);
2088
- if (cached !== undefined) {
2089
- return cached;
2090
- }
2143
+ // Check cache
2144
+ const cached = instanceCache.get(text);
2145
+ if (cached !== undefined) {
2146
+ return cached;
2091
2147
  }
2092
2148
 
2093
2149
  try {
2094
2150
  const result = relateUrlInstance.relate(text);
2095
- // Cache successful results
2096
- if (instanceCache) {
2097
- instanceCache.set(text, result);
2098
- }
2151
+ instanceCache.set(text, result);
2099
2152
  return result;
2100
2153
  } catch (err) {
2101
2154
  // Don’t cache errors
@@ -2255,7 +2308,9 @@ function isStyleElement(tag, attrs) {
2255
2308
  }
2256
2309
 
2257
2310
  function isBooleanAttribute(attrName, attrValue) {
2258
- return isSimpleBoolean.has(attrName) || (attrName === 'draggable' && !isBooleanValue.has(attrValue));
2311
+ return isSimpleBoolean.has(attrName) ||
2312
+ (attrName === 'draggable' && !isBooleanValue.has(attrValue)) ||
2313
+ (attrValue === '' && emptyCollapsible.has(attrName));
2259
2314
  }
2260
2315
 
2261
2316
  function isUriTypeAttribute(attrName, tag) {
@@ -2932,11 +2987,125 @@ async function getSwc() {
2932
2987
  return swcPromise;
2933
2988
  }
2934
2989
 
2935
- // Minification caches
2990
+ // Minification caches (initialized on first use with configurable sizes)
2991
+ let cssMinifyCache = null;
2992
+ let jsMinifyCache = null;
2993
+
2994
+ // Pre-compiled patterns for script merging (avoid repeated allocation in hot path)
2995
+ const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
2996
+ const SCRIPT_BOOL_ATTRS = new Set(['async', 'defer', 'nomodule']);
2997
+ const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript']);
2998
+
2999
+ // Pre-compiled patterns for buffer scanning
3000
+ const RE_START_TAG = /^<[^/!]/;
3001
+ const RE_END_TAG = /^<\//;
3002
+
3003
+ // Script merging
3004
+
3005
+ /**
3006
+ * Merge consecutive inline script tags into one (`mergeConsecutiveScripts`).
3007
+ * Only merges scripts that are compatible:
3008
+ * - Both inline (no `src` attribute)
3009
+ * - Same `type` (or both default JavaScript)
3010
+ * - No conflicting attributes (`async`, `defer`, `nomodule`, different `nonce`)
3011
+ *
3012
+ * Limitation: This function uses regex-based matching (`pattern` variable below),
3013
+ * which can produce incorrect results if a script’s content contains a literal
3014
+ * `</script>` string (e.g., `document.write('<script>…</script>')`). In valid
3015
+ * HTML, such strings should be escaped as `<\/script>` or split like
3016
+ * `'</scr' + 'ipt>'`, so this limitation rarely affects real-world code. The
3017
+ * earlier `minifyJS` step (if enabled) typically handles this escaping already.
3018
+ *
3019
+ * @param {string} html - The HTML string to process
3020
+ * @returns {string} HTML with consecutive scripts merged
3021
+ */
3022
+ function mergeConsecutiveScripts(html) {
3023
+ // `pattern`: Regex to match consecutive `</script>` followed by `<script…>`.
3024
+ // See function JSDoc above for known limitations with literal `</script>` in content.
3025
+ // Captures:
3026
+ // 1. first script attrs
3027
+ // 2. first script content
3028
+ // 3. whitespace between
3029
+ // 4. second script attrs
3030
+ // 5. second script content
3031
+ const pattern = /<script([^>]*)>([\s\S]*?)<\/script>([\s]*)<script([^>]*)>([\s\S]*?)<\/script>/gi;
3032
+
3033
+ let result = html;
3034
+ let changed = true;
3035
+
3036
+ // Keep merging until no more changes (handles chains of 3+ scripts)
3037
+ while (changed) {
3038
+ changed = false;
3039
+ result = result.replace(pattern, (match, attrs1, content1, whitespace, attrs2, content2) => {
3040
+ // Parse attributes from both script tags (uses pre-compiled RE_SCRIPT_ATTRS)
3041
+ const parseAttrs = (attrStr) => {
3042
+ const attrs = {};
3043
+ RE_SCRIPT_ATTRS.lastIndex = 0; // Reset for reuse
3044
+ let m;
3045
+ while ((m = RE_SCRIPT_ATTRS.exec(attrStr)) !== null) {
3046
+ const name = m[1].toLowerCase();
3047
+ const value = m[2] ?? m[3] ?? m[4] ?? '';
3048
+ attrs[name] = value;
3049
+ }
3050
+ return attrs;
3051
+ };
3052
+
3053
+ const a1 = parseAttrs(attrs1);
3054
+ const a2 = parseAttrs(attrs2);
3055
+
3056
+ // Check for `src`—cannot merge external scripts
3057
+ if ('src' in a1 || 'src' in a2) {
3058
+ return match;
3059
+ }
3060
+
3061
+ // Check `type` compatibility (both must be same, or both default JS)
3062
+ const type1 = a1.type || '';
3063
+ const type2 = a2.type || '';
3064
+
3065
+ if (DEFAULT_JS_TYPES.has(type1) && DEFAULT_JS_TYPES.has(type2)) ; else if (type1 === type2) ; else {
3066
+ // Incompatible types
3067
+ return match;
3068
+ }
3069
+
3070
+ // Check for conflicting boolean attributes (uses pre-compiled SCRIPT_BOOL_ATTRS)
3071
+ for (const attr of SCRIPT_BOOL_ATTRS) {
3072
+ const has1 = attr in a1;
3073
+ const has2 = attr in a2;
3074
+ if (has1 !== has2) {
3075
+ // One has it, one doesn't - incompatible
3076
+ return match;
3077
+ }
3078
+ }
3079
+
3080
+ // Check `nonce`—must be same or both absent
3081
+ if (a1.nonce !== a2.nonce) {
3082
+ return match;
3083
+ }
2936
3084
 
2937
- const cssMinifyCache = new LRU(500);
2938
- const jsMinifyCache = new LRU(500);
2939
- const urlMinifyCache = new LRU(500);
3085
+ // Scripts are compatible—merge them
3086
+ changed = true;
3087
+
3088
+ // Combine content—use semicolon normally, newline only for trailing `//` comments
3089
+ const c1 = content1.trim();
3090
+ const c2 = content2.trim();
3091
+ let mergedContent;
3092
+ if (c1 && c2) {
3093
+ // Check if last line of c1 contains `//` (single-line comment)
3094
+ // If so, use newline to terminate it; otherwise use semicolon (if not already present)
3095
+ const lastLine = c1.slice(c1.lastIndexOf('\n') + 1);
3096
+ const separator = lastLine.includes('//') ? '\n' : (c1.endsWith(';') ? '' : ';');
3097
+ mergedContent = c1 + separator + c2;
3098
+ } else {
3099
+ mergedContent = c1 || c2;
3100
+ }
3101
+
3102
+ // Use first script’s attributes (they should be compatible)
3103
+ return `<script${attrs1}>${mergedContent}</script>`;
3104
+ });
3105
+ }
3106
+
3107
+ return result;
3108
+ }
2940
3109
 
2941
3110
  // Type definitions
2942
3111
 
@@ -2969,6 +3138,24 @@ const urlMinifyCache = new LRU(500);
2969
3138
  *
2970
3139
  * Default: Built-in `canTrimWhitespace` function
2971
3140
  *
3141
+ * @prop {number} [cacheCSS]
3142
+ * The maximum number of entries for the CSS minification cache. Higher values
3143
+ * improve performance for inputs with repeated CSS (e.g., batch processing).
3144
+ * - Cache is created on first `minify()` call and persists for the process lifetime
3145
+ * - Cache size is locked after first call—subsequent calls reuse the same cache
3146
+ * - Explicit `0` values are coerced to `1` (minimum functional cache size)
3147
+ *
3148
+ * Default: `500` (or `1000` when `CI=true` environment variable is set)
3149
+ *
3150
+ * @prop {number} [cacheJS]
3151
+ * The maximum number of entries for the JavaScript minification cache. Higher
3152
+ * values improve performance for inputs with repeated JavaScript.
3153
+ * - Cache is created on first `minify()` call and persists for the process lifetime
3154
+ * - Cache size is locked after first call—subsequent calls reuse the same cache
3155
+ * - Explicit `0` values are coerced to `1` (minimum functional cache size)
3156
+ *
3157
+ * Default: `500` (or `1000` when `CI=true` environment variable is set)
3158
+ *
2972
3159
  * @prop {boolean} [caseSensitive]
2973
3160
  * When true, tag and attribute names are treated as case-sensitive.
2974
3161
  * Useful for custom HTML tags.
@@ -3118,6 +3305,13 @@ const urlMinifyCache = new LRU(500);
3118
3305
  *
3119
3306
  * Default: No limit
3120
3307
  *
3308
+ * @prop {boolean} [mergeScripts]
3309
+ * When true, consecutive inline `<script>` elements are merged into one.
3310
+ * Only merges compatible scripts (same `type`, matching `async`/`defer`/
3311
+ * `nomodule`/`nonce` attributes). Does not merge external scripts (with `src`).
3312
+ *
3313
+ * Default: `false`
3314
+ *
3121
3315
  * @prop {boolean | Partial<import("lightningcss").TransformOptions<import("lightningcss").CustomAtRules>> | ((text: string, type?: string) => Promise<string> | string)} [minifyCSS]
3122
3316
  * When true, enables CSS minification for inline `<style>` tags or
3123
3317
  * `style` attributes. If an object is provided, it is passed to
@@ -3696,7 +3890,7 @@ async function minifyHTML(value, options, partialMarkup) {
3696
3890
 
3697
3891
  function removeStartTag() {
3698
3892
  let index = buffer.length - 1;
3699
- while (index > 0 && !/^<[^/!]/.test(buffer[index])) {
3893
+ while (index > 0 && !RE_START_TAG.test(buffer[index])) {
3700
3894
  index--;
3701
3895
  }
3702
3896
  buffer.length = Math.max(0, index);
@@ -3704,7 +3898,7 @@ async function minifyHTML(value, options, partialMarkup) {
3704
3898
 
3705
3899
  function removeEndTag() {
3706
3900
  let index = buffer.length - 1;
3707
- while (index > 0 && !/^<\//.test(buffer[index])) {
3901
+ while (index > 0 && !RE_END_TAG.test(buffer[index])) {
3708
3902
  index--;
3709
3903
  }
3710
3904
  buffer.length = Math.max(0, index);
@@ -4015,8 +4209,8 @@ async function minifyHTML(value, options, partialMarkup) {
4015
4209
  charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
4016
4210
  if (options.decodeEntities && text && !specialContentElements.has(currentTag)) {
4017
4211
  // Escape any `&` symbols that start either:
4018
- // 1) a legacy-named character reference (i.e., one that doesn’t end with `;`)
4019
- // 2) or any other character reference (i.e., one that does end with `;`)
4212
+ // 1. a legacy-named character reference (i.e., one that doesn’t end with `;`)
4213
+ // 2. or any other character reference (i.e., one that does end with `;`)
4020
4214
  // Note that `&` can be escaped as `&amp`, without the semicolon.
4021
4215
  // https://mathiasbynens.be/notes/ambiguous-ampersands
4022
4216
  if (text.indexOf('&') !== -1) {
@@ -4220,6 +4414,49 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
4220
4414
  return options.collapseWhitespace ? collapseWhitespace(str, options, true, true) : str;
4221
4415
  }
4222
4416
 
4417
+ /**
4418
+ * Initialize minification caches with configurable sizes.
4419
+ *
4420
+ * Important behavior notes:
4421
+ * - Caches are created on the first `minify()` call and persist for the lifetime of the process
4422
+ * - Cache sizes are locked after first initialization—subsequent calls use the same caches
4423
+ * even if different `cacheCSS`/`cacheJS` options are provided
4424
+ * - The first call’s options determine the cache sizes for subsequent calls
4425
+ * - Explicit `0` values are coerced to `1` (minimum functional cache size)
4426
+ */
4427
+ function initCaches(options) {
4428
+ // Only create caches once (on first call)—sizes are locked after this
4429
+ if (!cssMinifyCache) {
4430
+ // Determine default size based on environment
4431
+ const defaultSize = process.env.CI === 'true' ? 1000 : 500;
4432
+
4433
+ // Helper to parse env var—returns parsed number (including 0) or undefined if absent, invalid, or negative
4434
+ const parseEnvCacheSize = (envVar) => {
4435
+ if (envVar === undefined) return undefined;
4436
+ const parsed = Number(envVar);
4437
+ if (Number.isNaN(parsed) || !Number.isFinite(parsed) || parsed < 0) {
4438
+ return undefined;
4439
+ }
4440
+ return parsed;
4441
+ };
4442
+
4443
+ // Get cache sizes with precedence: Options > env > default
4444
+ const cssSize = options.cacheCSS !== undefined ? options.cacheCSS
4445
+ : (parseEnvCacheSize(process.env.HMN_CACHE_CSS) ?? defaultSize);
4446
+ const jsSize = options.cacheJS !== undefined ? options.cacheJS
4447
+ : (parseEnvCacheSize(process.env.HMN_CACHE_JS) ?? defaultSize);
4448
+
4449
+ // Coerce `0` to `1` (minimum functional cache size) to avoid immediate eviction
4450
+ const cssFinalSize = cssSize === 0 ? 1 : cssSize;
4451
+ const jsFinalSize = jsSize === 0 ? 1 : jsSize;
4452
+
4453
+ cssMinifyCache = new LRU(cssFinalSize);
4454
+ jsMinifyCache = new LRU(jsFinalSize);
4455
+ }
4456
+
4457
+ return { cssMinifyCache, jsMinifyCache };
4458
+ }
4459
+
4223
4460
  /**
4224
4461
  * @param {string} value
4225
4462
  * @param {MinifierOptions} [options]
@@ -4227,15 +4464,23 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
4227
4464
  */
4228
4465
  const minify = async function (value, options) {
4229
4466
  const start = Date.now();
4467
+
4468
+ // Initialize caches on first use with configurable sizes
4469
+ const caches = initCaches(options || {});
4470
+
4230
4471
  options = processOptions(options || {}, {
4231
4472
  getLightningCSS,
4232
4473
  getTerser,
4233
4474
  getSwc,
4234
- cssMinifyCache,
4235
- jsMinifyCache,
4236
- urlMinifyCache
4475
+ ...caches
4237
4476
  });
4238
- const result = await minifyHTML(value, options);
4477
+ let result = await minifyHTML(value, options);
4478
+
4479
+ // Post-processing: Merge consecutive inline scripts if enabled
4480
+ if (options.mergeScripts) {
4481
+ result = mergeConsecutiveScripts(result);
4482
+ }
4483
+
4239
4484
  options.log('minified in: ' + (Date.now() - start) + 'ms');
4240
4485
  return result;
4241
4486
  };