/**
* Bound the nesting depth of raw inline HTML before it reaches `rehype-raw`.
*
* Streamdown parses assistant markdown with `allowDangerousHtml` + `rehype-raw`,
* so any `` run in the text is handed to parse5. `hast-util-from-parse5`
* then walks the resulting tree with mutual recursion (`element → one → all`),
* one JS frame per level of nesting — so a deeply nested run of UNCLOSED tags
* overflows the call stack and throws `RangeError: Maximum call stack size
* exceeded` out of the middle of a React render.
*
* That is not a hypothetical: a degenerating model emits thousands of ``
* tokens as its reasoning, every one of which parse5 opens as another element
* that never closes. Measured against our pinned deps, the throw starts at
* ~1,750 consecutive unclosed tags and the payload persists to the session, so
* every reload re-renders it — the crash repeats until the message is deleted.
*
* The bound is on DEPTH, not size: 20,000 balanced `x` pairs (160KB) and
* 20,000 void `
` tags parse fine, because neither drives the tree deeper.
* Only unclosed elements accumulate depth, so only they are counted.
*
* Past the cap the remaining opening tags are escaped (`<` → `<`) rather than
* dropped: the text stays visible and readable as literal ``, which is
* exactly what it is. Content under the cap is returned untouched — the common
* case pays one scan and no rewrite.
*/
// parse5 nests only known HTML elements meaningfully, but an unknown tag
// (``) still becomes an element node, so the depth cost is identical.
// Well under the ~1,750 measured failure point, and far above any nesting real
// markup uses — a deeply structured HTML document is a few dozen levels.
const MAX_HTML_DEPTH = 300
// Elements that never take children, so an opening tag adds no depth.
const VOID_ELEMENTS = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr'
])
// `"'])*)>/g
/**
* Escape raw HTML tags past `MAX_HTML_DEPTH` levels of unclosed nesting.
*
* Returns `text` unchanged when the depth never exceeds the cap, so the
* identity is preserved for the overwhelming majority of messages (which
* matters: the markdown pipeline caches on string identity).
*/
export function clampHtmlNestingDepth(text: string): string {
// Fast path: no tags at all, nothing to bound.
if (!text.includes('<')) {
return text
}
const open: string[] = []
let depth = 0
let firstOverflow = -1
let match: RegExpExecArray | null
TAG_RE.lastIndex = 0
while ((match = TAG_RE.exec(text)) !== null) {
const closing = match[1] === '/'
const name = match[2].toLowerCase()
const selfClosing = match[3].endsWith('/')
if (closing) {
// Close the nearest matching open element, mirroring parse5's recovery:
// an unmatched close tag is ignored rather than unwinding the stack.
const index = open.lastIndexOf(name)
if (index !== -1) {
depth = index
open.length = index
}
continue
}
if (selfClosing || VOID_ELEMENTS.has(name)) {
continue
}
open.push(name)
depth += 1
if (depth > MAX_HTML_DEPTH && firstOverflow === -1) {
firstOverflow = match.index
}
}
if (firstOverflow === -1) {
return text
}
// Everything before the overflow parses at a safe depth and is left exactly
// as-is; from there on, opening tags become visible literal text.
return text.slice(0, firstOverflow) + text.slice(firstOverflow).replaceAll('<', '<')
}