html-minifier-next 7.3.0 → 7.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/htmlparser.js CHANGED
@@ -37,13 +37,6 @@ import { isThenable } from './lib/utils.js';
37
37
  * });
38
38
  */
39
39
 
40
- class CaseInsensitiveSet extends Set {
41
- /** @override */
42
- has(/** @type {string} */ str) {
43
- return super.has(str.toLowerCase());
44
- }
45
- }
46
-
47
40
  // Regular expressions for parsing tags and attributes
48
41
  const singleAttrIdentifier = /([^\s"'<>/=]+)/;
49
42
  const singleAttrAssigns = [/=/];
@@ -80,20 +73,20 @@ let IS_REGEX_CAPTURING_BROKEN = false;
80
73
  });
81
74
 
82
75
  // Empty elements
83
- const empty = new CaseInsensitiveSet(['area', 'base', 'basefont', 'br', 'col', 'embed', 'frame', 'hr', 'img', 'input', 'isindex', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
76
+ const empty = new Set(['area', 'base', 'basefont', 'br', 'col', 'embed', 'frame', 'hr', 'img', 'input', 'isindex', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
84
77
 
85
78
  // Elements that you can, intentionally, leave open (and which close themselves)
86
- const closeSelf = new CaseInsensitiveSet(['colgroup', 'dd', 'dt', 'li', 'option', 'p', 'td', 'tfoot', 'th', 'thead', 'tr', 'source']);
79
+ const closeSelf = new Set(['colgroup', 'dd', 'dt', 'li', 'option', 'p', 'td', 'tfoot', 'th', 'thead', 'tr', 'source']);
87
80
 
88
81
  // Attributes that have their values filled in `disabled='disabled'`
89
- const fillAttrs = new CaseInsensitiveSet(['checked', 'compact', 'declare', 'defer', 'disabled', 'ismap', 'multiple', 'nohref', 'noresize', 'noshade', 'nowrap', 'readonly', 'selected']);
82
+ const fillAttrs = new Set(['checked', 'compact', 'declare', 'defer', 'disabled', 'ismap', 'multiple', 'nohref', 'noresize', 'noshade', 'nowrap', 'readonly', 'selected']);
90
83
 
91
84
  // Special elements (can contain anything)
92
- const special = new CaseInsensitiveSet(['script', 'style']);
85
+ const special = new Set(['script', 'style']);
93
86
 
94
87
  // HTML elements, https://html.spec.whatwg.org/multipage/indices.html#elements-3
95
88
  // Phrasing content, https://html.spec.whatwg.org/multipage/dom.html#phrasing-content
96
- const nonPhrasing = new CaseInsensitiveSet(['address', 'article', 'aside', 'base', 'blockquote', 'body', 'caption', 'col', 'colgroup', 'dd', 'details', 'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'legend', 'li', 'menuitem', 'meta', 'ol', 'optgroup', 'option', 'param', 'rp', 'rt', 'source', 'style', 'summary', 'tbody', 'td', 'tfoot', 'th', 'thead', 'title', 'tr', 'track', 'ul']);
89
+ const nonPhrasing = new Set(['address', 'article', 'aside', 'base', 'blockquote', 'body', 'caption', 'col', 'colgroup', 'dd', 'details', 'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'legend', 'li', 'menuitem', 'meta', 'ol', 'optgroup', 'option', 'param', 'rp', 'rt', 'source', 'style', 'summary', 'tbody', 'td', 'tfoot', 'th', 'thead', 'title', 'tr', 'track', 'ul']);
97
90
 
98
91
  const reCache = {};
99
92
 
@@ -213,6 +206,9 @@ export class HTMLParser {
213
206
  const stack = [];
214
207
  /** @type {string} */
215
208
  let lastTag = '';
209
+ // Lowercase counterpart of `lastTag`, kept in sync to avoid per-iteration lowercasing
210
+ /** @type {string} */
211
+ let lastTagLower = '';
216
212
  // Use cached attribute regex for this handler configuration
217
213
  const attribute = getAttrRegexForHandler(handler);
218
214
  const attributeY = getAttrRegexStickyForHandler(handler);
@@ -242,6 +238,19 @@ export class HTMLParser {
242
238
  let pos = 0;
243
239
  let lastPos;
244
240
 
241
+ // An end tag always ends at a `>`; without one, the `endTag` regex’s name run
242
+ // and its `[^>]*` tail overlap and backtrack O(n²) on a long unclosed name
243
+ // (e.g., `</aaaa…` with no `>`). Track the next `>` monotonically—`pos` only
244
+ // advances, so the aggregate scan work stays O(n)—and skip the regex when none
245
+ // lies ahead, where it could not match anyway.
246
+ let nextGtPos = fullHtml.indexOf('>');
247
+ const hasCloseAtOrAfter = (/** @type {number} */ p) => {
248
+ if (nextGtPos !== -1 && nextGtPos < p) {
249
+ nextGtPos = fullHtml.indexOf('>', p);
250
+ }
251
+ return nextGtPos !== -1;
252
+ };
253
+
245
254
  // Helper to advance position
246
255
  const advance = (/** @type {number} */ n) => { pos += n; };
247
256
 
@@ -269,7 +278,7 @@ export class HTMLParser {
269
278
  lastPos = pos;
270
279
 
271
280
  // Make sure not to be in a `script` or `style` element
272
- if (!lastTag || !special.has(lastTag)) {
281
+ if (!lastTagLower || !special.has(lastTagLower)) {
273
282
  const textEnd = fullHtml.indexOf('<', pos);
274
283
 
275
284
  if (textEnd === pos) {
@@ -345,15 +354,18 @@ export class HTMLParser {
345
354
  continue;
346
355
  }
347
356
 
348
- // End tag
349
- endTagY.lastIndex = pos;
350
- const endTagMatch = endTagY.exec(fullHtml);
351
- if (endTagMatch) {
352
- advance((endTagMatch[0] ?? '').length);
353
- await parseEndTag(endTagMatch[0] ?? '', endTagMatch[1] ?? '');
354
- prevTag = '/' + (endTagMatch[1] ?? '').toLowerCase();
355
- prevAttrs = [];
356
- continue;
357
+ // End tag—skip when no `>` lies ahead: The regex cannot match, and would
358
+ // otherwise backtrack quadratically on a long unclosed name
359
+ if (hasCloseAtOrAfter(pos)) {
360
+ endTagY.lastIndex = pos;
361
+ const endTagMatch = endTagY.exec(fullHtml);
362
+ if (endTagMatch) {
363
+ advance((endTagMatch[0] ?? '').length);
364
+ await parseEndTag(endTagMatch[0] ?? '', endTagMatch[1] ?? '');
365
+ prevTag = '/' + (endTagMatch[1] ?? '').toLowerCase();
366
+ prevAttrs = [];
367
+ continue;
368
+ }
357
369
  }
358
370
 
359
371
  // Start tag
@@ -388,7 +400,7 @@ export class HTMLParser {
388
400
  // Extract minimal attribute info for whitespace logic (just name/value pairs)
389
401
  nextAttrs = extractAttrInfo(nextStartTagMatch.attrs);
390
402
  cachedNextStartTag = { match: nextStartTagMatch, pos };
391
- } else {
403
+ } else if (hasCloseAtOrAfter(pos)) {
392
404
  endTagY.lastIndex = pos;
393
405
  const nextEndTagMatch = endTagY.exec(fullHtml);
394
406
  if (nextEndTagMatch) {
@@ -399,6 +411,9 @@ export class HTMLParser {
399
411
  nextTag = '';
400
412
  nextAttrs = [];
401
413
  }
414
+ } else {
415
+ nextTag = '';
416
+ nextAttrs = [];
402
417
  }
403
418
  }
404
419
 
@@ -409,7 +424,7 @@ export class HTMLParser {
409
424
  prevTag = '';
410
425
  prevAttrs = [];
411
426
  } else {
412
- const stackedTag = lastTag.toLowerCase();
427
+ const stackedTag = lastTagLower;
413
428
  // Use pre-compiled regex for common tags (`script`, `style`, `noscript`) to avoid regex creation overhead
414
429
  const reStackedTag = /** @type {Record<string, RegExp>} */ (preCompiledStackedTags)[stackedTag] || /** @type {Record<string, RegExp>} */ (reCache)[stackedTag] || (/** @type {Record<string, RegExp>} */ (reCache)[stackedTag] = new RegExp('([\\s\\S]*?)\\x3c/' + stackedTag + '[^>]*>', 'i'));
415
430
 
@@ -640,9 +655,9 @@ export class HTMLParser {
640
655
  return undefined;
641
656
  }
642
657
 
643
- function findTagInCurrentTable(/** @type {string} */ tagName) {
658
+ // `needle` must already be lowercase
659
+ function findTagInCurrentTable(/** @type {string} */ needle) {
644
660
  let pos;
645
- const needle = tagName.toLowerCase();
646
661
  for (pos = stack.length - 1; pos >= 0; pos--) {
647
662
  const entry = stack[pos];
648
663
  const currentTag = entry?.lowerTag;
@@ -657,23 +672,25 @@ export class HTMLParser {
657
672
  return -1;
658
673
  }
659
674
 
660
- async function parseEndTagAt(/** @type {number} */ pos) {
661
- // Close all open elements up to `pos` (mirrors `parseEndTag`’s core branch)
675
+ function parseEndTagAt(/** @type {number} */ pos) {
676
+ // Close all open elements up to `pos` (mirrors `parseEndTag`’s core branch);
677
+ // `end` handlers are synchronous—invoked without awaiting, as in `parseEndTag`
662
678
  for (let i = stack.length - 1; i >= pos; i--) {
663
679
  const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
664
680
  if (handler.end) {
665
- await handler.end(entry.tag, entry.attrs, true);
681
+ handler.end(entry.tag, entry.attrs, true);
666
682
  }
667
683
  }
668
684
  stack.length = pos;
669
685
  lastTag = pos ? (stack[pos - 1]?.tag ?? '') : '';
686
+ lastTagLower = pos ? (stack[pos - 1]?.lowerTag ?? '') : '';
670
687
  }
671
688
 
672
- async function closeIfFoundInCurrentTable(/** @type {string} */ tagName) {
689
+ function closeIfFoundInCurrentTable(/** @type {string} */ tagName) {
673
690
  const pos = findTagInCurrentTable(tagName);
674
691
  if (pos >= 0) {
675
692
  // Close at the specific index to avoid re-searching
676
- await parseEndTagAt(pos);
693
+ parseEndTagAt(pos);
677
694
  return true;
678
695
  }
679
696
  return false;
@@ -681,45 +698,47 @@ export class HTMLParser {
681
698
 
682
699
  async function handleStartTag(/** @type {{tagName: string, attrs: Array<Array<string | undefined>>, advance: number, unarySlash?: string}} */ match) {
683
700
  const tagName = match.tagName;
701
+ const lowerTagName = tagName.toLowerCase();
684
702
  let unarySlash = match.unarySlash;
685
703
 
686
- if (lastTag === 'p' && nonPhrasing.has(tagName)) {
704
+ if (lastTagLower === 'p' && nonPhrasing.has(lowerTagName)) {
687
705
  await parseEndTag('', lastTag);
688
- } else if (tagName === 'tbody') {
689
- if (!await closeIfFoundInCurrentTable('tfoot')) {
690
- await closeIfFoundInCurrentTable('thead');
706
+ } else if (lowerTagName === 'tbody') {
707
+ if (!closeIfFoundInCurrentTable('tfoot')) {
708
+ closeIfFoundInCurrentTable('thead');
691
709
  }
692
- } else if (tagName === 'tfoot') {
693
- if (!await closeIfFoundInCurrentTable('tbody')) {
694
- await closeIfFoundInCurrentTable('thead');
710
+ } else if (lowerTagName === 'tfoot') {
711
+ if (!closeIfFoundInCurrentTable('tbody')) {
712
+ closeIfFoundInCurrentTable('thead');
695
713
  }
696
- } else if (tagName === 'thead') {
714
+ } else if (lowerTagName === 'thead') {
697
715
  // If a `tbody` or `tfoot` is open in the current table, close it
698
- if (!await closeIfFoundInCurrentTable('tbody')) {
699
- await closeIfFoundInCurrentTable('tfoot');
716
+ if (!closeIfFoundInCurrentTable('tbody')) {
717
+ closeIfFoundInCurrentTable('tfoot');
700
718
  }
701
719
  }
702
- if (tagName === 'col' && findTagInCurrentTable('colgroup') < 0) {
720
+ if (lowerTagName === 'col' && findTagInCurrentTable('colgroup') < 0) {
703
721
  lastTag = 'colgroup';
722
+ lastTagLower = 'colgroup';
704
723
  stack.push({ tag: lastTag, lowerTag: 'colgroup', attrs: [] });
705
724
  if (handler.start) {
706
725
  await handler.start(lastTag, [], false, '', true);
707
726
  }
708
- } else if (tagName !== 'col' && lastTag === 'colgroup') {
727
+ } else if (lowerTagName !== 'col' && lastTagLower === 'colgroup') {
709
728
  // Auto-close synthetic `<colgroup>` when a non-`col` element starts
710
729
  await parseEndTag('', 'colgroup');
711
730
  }
712
731
 
713
- if (closeSelf.has(tagName) && lastTag === tagName) {
732
+ if (closeSelf.has(lowerTagName) && lastTagLower === lowerTagName) {
714
733
  await parseEndTag('', tagName);
715
734
  }
716
735
 
717
736
  // Handle `dt`/`dd` cross-closing: `dt` followed by `dd`, or `dd` followed by `dt`
718
- if ((tagName === 'dt' || tagName === 'dd') && (lastTag === 'dt' || lastTag === 'dd')) {
737
+ if ((lowerTagName === 'dt' || lowerTagName === 'dd') && (lastTagLower === 'dt' || lastTagLower === 'dd')) {
719
738
  await parseEndTag('', lastTag);
720
739
  }
721
740
 
722
- const unary = empty.has(tagName) || (tagName === 'html' && lastTag === 'head') || !!unarySlash;
741
+ const unary = empty.has(lowerTagName) || (lowerTagName === 'html' && lastTagLower === 'head') || !!unarySlash;
723
742
 
724
743
  const attrs = /** @type {HTMLAttribute[]} */ (match.attrs.map(function (/** @type {Array<string | undefined>} */ args) {
725
744
  /** @type {string | undefined} */
@@ -743,7 +762,7 @@ export class HTMLParser {
743
762
  return '\'';
744
763
  }
745
764
  value = args[index + 3];
746
- if (typeof value === 'undefined' && name && fillAttrs.has(name)) {
765
+ if (typeof value === 'undefined' && name && fillAttrs.has(name.toLowerCase())) {
747
766
  value = name;
748
767
  }
749
768
  return '';
@@ -777,8 +796,9 @@ export class HTMLParser {
777
796
  }));
778
797
 
779
798
  if (!unary) {
780
- stack.push({ tag: tagName, lowerTag: tagName.toLowerCase(), attrs });
799
+ stack.push({ tag: tagName, lowerTag: lowerTagName, attrs });
781
800
  lastTag = tagName;
801
+ lastTagLower = lowerTagName;
782
802
  unarySlash = '';
783
803
  }
784
804
 
@@ -790,9 +810,9 @@ export class HTMLParser {
790
810
  }
791
811
  }
792
812
 
793
- function findTag(/** @type {string} */ tagName) {
813
+ // `needle` must already be lowercase
814
+ function findTag(/** @type {string} */ needle) {
794
815
  let pos;
795
- const needle = tagName.toLowerCase();
796
816
  for (pos = stack.length - 1; pos >= 0; pos--) {
797
817
  if (stack[pos]?.lowerTag === needle) {
798
818
  break;
@@ -803,10 +823,11 @@ export class HTMLParser {
803
823
 
804
824
  async function parseEndTag(/** @type {string} */ tag, /** @type {string} */ tagName) {
805
825
  let pos;
826
+ const lowerTagName = tagName ? tagName.toLowerCase() : '';
806
827
 
807
828
  // Find the closest opened tag of the same type
808
829
  if (tagName) {
809
- pos = findTag(tagName);
830
+ pos = findTag(lowerTagName);
810
831
  } else { // If no tag name is provided, clean shop
811
832
  pos = 0;
812
833
  }
@@ -822,16 +843,17 @@ export class HTMLParser {
822
843
  // Remove the open elements from the stack
823
844
  stack.length = pos;
824
845
  lastTag = pos ? (stack[pos - 1]?.tag ?? '') : '';
846
+ lastTagLower = pos ? (stack[pos - 1]?.lowerTag ?? '') : '';
825
847
  } else if (handler.partialMarkup && tagName) {
826
848
  // In partial markup mode, preserve stray end tags
827
849
  if (handler.end) {
828
850
  handler.end(tagName, [], false);
829
851
  }
830
- } else if (tagName && tagName.toLowerCase() === 'br') {
852
+ } else if (lowerTagName === 'br') {
831
853
  if (handler.start) {
832
854
  await handler.start(tagName, [], true, '');
833
855
  }
834
- } else if (tagName && tagName.toLowerCase() === 'p') {
856
+ } else if (lowerTagName === 'p') {
835
857
  if (handler.start) {
836
858
  await handler.start(tagName, [], false, '', true);
837
859
  }
@@ -552,7 +552,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
552
552
  return processed.join(', ');
553
553
  }
554
554
 
555
- if (isMetaViewport(tag, attrs) && attrName === 'content') {
555
+ if (attrName === 'content' && isMetaViewport(tag, attrs)) {
556
556
  return attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
557
557
  // 0.90000 → 0.9
558
558
  // 1.0 → 1
@@ -561,7 +561,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
561
561
  });
562
562
  }
563
563
 
564
- if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
564
+ if (attrName.toLowerCase() === 'content' && isContentSecurityPolicy(tag, attrs)) {
565
565
  return collapseWhitespaceAll(attrValue);
566
566
  }
567
567
 
@@ -49,6 +49,29 @@ function shouldMinifyInnerHTML(options) {
49
49
  );
50
50
  }
51
51
 
52
+ // Persistent per-site URL minification caches—results depend only on the `site`
53
+ // configuration, so entries can be shared across `minify()` calls; bounded so
54
+ // batch runs with many distinct sites can’t grow memory without limit
55
+ const MAX_URL_CACHE_SITES = 20;
56
+ /** @type {Map<string, LRU>} */
57
+ const urlMinifyCaches = new Map();
58
+
59
+ /** @param {string} site */
60
+ function getUrlMinifyCache(site) {
61
+ let cache = urlMinifyCaches.get(site);
62
+ if (!cache) {
63
+ if (urlMinifyCaches.size >= MAX_URL_CACHE_SITES) {
64
+ const oldestSite = urlMinifyCaches.keys().next().value;
65
+ if (oldestSite !== undefined) {
66
+ urlMinifyCaches.delete(oldestSite);
67
+ }
68
+ }
69
+ cache = new LRU(500);
70
+ urlMinifyCaches.set(site, cache);
71
+ }
72
+ return cache;
73
+ }
74
+
52
75
  // User-facing option keys that are valid but not listed in `optionDefinitions`
53
76
  const optionKeysExtra = new Set(['preset', 'log', 'canCollapseWhitespace', 'canTrimWhitespace']);
54
77
 
@@ -56,6 +79,9 @@ const optionKeysExtra = new Set(['preset', 'log', 'canCollapseWhitespace', 'canT
56
79
  // key per process, so repeated `minify` calls (e.g., batch runs) don’t flood STDERR
57
80
  const optionKeysWarned = new Set();
58
81
  const presetNamesWarned = new Set();
82
+ // The custom-fragment ReDoS warning is security-relevant, so it reaches the
83
+ // console even without a `log` hook—once per process, like the warnings above
84
+ let customFragmentQuantifierWarned = false;
59
85
 
60
86
  // Main options processor
61
87
 
@@ -96,7 +122,14 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
96
122
  };
97
123
 
98
124
  // Route warnings through the user-provided `log` hook so API consumers can
99
- // capture or suppress them consistently; fall back to `console.warn`
125
+ // capture or suppress them consistently; fall back to `console.warn`.
126
+ //
127
+ // Message convention: Strings that may reach the console unsolicited (via
128
+ // this fallback) carry the “HTML Minifier Next: ” prefix for attribution and
129
+ // no “Warning:” label—the channel already conveys severity. Strings sent
130
+ // only to an explicitly provided `log` hook carry “Warning: ” instead, since
131
+ // the hook mixes severities (info strings, warnings, `Error` objects) and
132
+ // its consumer already knows the source.
100
133
  const warn = typeof inputOptions.log === 'function' ? inputOptions.log : console.warn;
101
134
 
102
135
  // Warn about unrecognized options—catches typos as well as options removed in earlier versions
@@ -373,8 +406,8 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
373
406
 
374
407
  const relate = createUrlMinifier(urlOptions.site || '');
375
408
 
376
- // Create instance-specific cache (results depend on site configuration)
377
- const instanceCache = new LRU(500);
409
+ // Reuse the persistent cache for this site configuration
410
+ const instanceCache = getUrlMinifyCache(urlOptions.site || '');
378
411
 
379
412
  options.minifyURLs = function (/** @type {string} */ text) {
380
413
  // Fast-path: Skip if text doesn’t look like a URL that needs processing
@@ -467,6 +500,17 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
467
500
  } else if (['customAttrAssign', 'customEventAttributes', 'ignoreCustomComments', 'ignoreCustomFragments'].includes(key)) {
468
501
  // Array of regex patterns
469
502
  optionsDynamic[key] = parseRegExpArray(option);
503
+ // Warn about potential ReDoS when user-provided fragments use unlimited
504
+ // quantifiers; only explicitly passed fragments are checked
505
+ if (key === 'ignoreCustomFragments' && !customFragmentQuantifierWarned) {
506
+ for (const re of /** @type {RegExp[]} */ (optionsDynamic[key])) {
507
+ if (/[*+]/.test(re.source)) {
508
+ customFragmentQuantifierWarned = true;
509
+ warn('HTML Minifier Next: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
510
+ break;
511
+ }
512
+ }
513
+ }
470
514
  } else {
471
515
  optionsDynamic[key] = option;
472
516
  }
@@ -142,6 +142,22 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll = fal
142
142
 
143
143
  // Collapse whitespace smartly based on surrounding tags
144
144
 
145
+ // Check if an input element has `type="hidden"`; module-scope so the hot path
146
+ // doesn’t allocate a closure per text node
147
+ /**
148
+ * @param {string} tagName
149
+ * @param {Array<{name: string, value?: string}>} attrs
150
+ */
151
+ function isHiddenInput(tagName, attrs) {
152
+ if (tagName !== 'input' || !attrs || !attrs.length) return false;
153
+ for (const attr of attrs) {
154
+ if (attr.name === 'type') {
155
+ return attr.value === 'hidden';
156
+ }
157
+ }
158
+ return false;
159
+ }
160
+
145
161
  /**
146
162
  * @param {string} str
147
163
  * @param {string} prevTag
@@ -156,13 +172,6 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, prevAttrs, nextAttrs, op
156
172
  const prevTagName = prevTag && (prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag);
157
173
  const nextTagName = nextTag && (nextTag.charAt(0) === '/' ? nextTag.slice(1) : nextTag);
158
174
 
159
- // Helper: Check if an input element has `type="hidden"`
160
- const isHiddenInput = (/** @type {string} */ tagName, /** @type {Array<{name: string, value?: string}>} */ attrs) => {
161
- if (tagName !== 'input' || !attrs || !attrs.length) return false;
162
- const typeAttr = attrs.find((/** @type {{name: string, value?: string}} */ attr) => attr.name === 'type');
163
- return typeAttr && typeAttr.value === 'hidden';
164
- };
165
-
166
175
  // Check if prev/next are non-rendering (hidden) elements
167
176
  const prevIsHidden = isHiddenInput(prevTagName, prevAttrs);
168
177
  const nextIsHidden = isHiddenInput(nextTagName, nextAttrs);