Files
hermes-agent/apps/desktop/src/lib/markdown-preprocess.ts
kshitijk4poor 43f94bc1c4 test(desktop): reasoning tests follow the sibling naming
The lib test sits beside markdown-preprocess.directives.test.ts as
markdown-preprocess.reasoning.test.ts, and the frame-fidelity test joins
its markdown-text.*.test.tsx siblings as markdown-text.reasoning.test.tsx,
so a glance at the directory shows what each file exercises. Also trim the
REASONING_TAGS comment: how the two tag lists converged is git history, not
something a reader of the constant needs.
2026-09-17 21:18:30 +05:30

748 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { normalizeMathDelimiters } from '@assistant-ui/react-streamdown'
import { isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
import { clampHtmlNestingDepth } from '@/lib/markdown-html-depth'
import { mediaKind, mediaMarkdownHref } from '@/lib/media'
import { previewMarkdownHref } from '@/lib/preview-targets'
import { stripPreviewTargets } from '@/lib/preview-targets'
import { linkifySessionRefs } from '@/lib/session-refs'
// Same tag set as agent/think_scrubber.py THINK_TAG_NAMES, plus desktop-only
// `scratchpad`/`analysis`.
const REASONING_TAGS = 'think|thinking|reasoning|thought|reasoning_scratchpad|scratchpad|analysis'
// A run of adjacent closed blocks is one match, so the seam check below sees the
// prose on either side of the whole run rather than the previous block's `>`.
const REASONING_BLOCK_RE = new RegExp(`(?:<(${REASONING_TAGS})>[\\s\\S]*?<\\/\\1>\\s*)+`, 'gi')
// An open tag that starts its own block with no close tag yet. The block-boundary
// requirement is what lets a real reasoning preamble (always its own block) vanish
// while prose that merely mentions `<thinking>` mid-sentence survives — the same
// line agent/think_scrubber.py draws.
const OPEN_REASONING_BLOCK_RE = new RegExp(`(^|\\n)[ \\t]*<(${REASONING_TAGS})>[\\s\\S]*$`, 'i')
// A half-arrived open tag (`<thin`) at a block boundary is not a tag yet, so the
// pass above lets it paint as prose for one frame and then erase it — the same
// paint/un-paint class as #62774, one frame long. Hold it back the way
// agent/think_scrubber.py `_hold_partial`/`_max_partial_suffix` does, but only
// for prefixes of the known tag names so `<div` at a line start still renders.
const REASONING_TAG_PREFIXES = Array.from(
new Set(
REASONING_TAGS.split('|').flatMap((tag) => Array.from({ length: tag.length }, (_, i) => tag.slice(0, i + 1))),
),
).join('|')
const PARTIAL_OPEN_REASONING_TAG_RE = new RegExp(`(^|\\n)[ \\t]*<(?:${REASONING_TAG_PREFIXES})?$`, 'i')
const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi
const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
const EMPTY_FENCE_BLOCK_RE = /(^|\n)[ \t]*(?:`{3,}|~{3,})[^\n]*\n[ \t]*(?:`{3,}|~{3,})[ \t]*(?=\n|$)/g
const CODE_FENCE_SPLIT_RE = /((?:```|~~~)[\s\S]*?(?:```|~~~|$))/g
const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
// Math spans as remark-math will see them: a `$$…$$` block, which may span
// lines, or a same-line `$…$`. A delimiter escaped as `\$` is prose — that is
// exactly how escapeCurrencyDollarsPreservingMath marks a price, so the
// lookbehinds keep `\$5 and \$10` out of the math branch.
//
// The inline body steps over escape pairs (`\\[^\n]`) rather than excluding
// `$` outright, because `\$` is a literal dollar INSIDE math (`$x + \$5$`) and
// must not end the span — the same distinction findClosingSingleDollar draws
// via isEscapedAt. The two alternatives are disjoint on their first character,
// so the body cannot backtrack ambiguously.
const MATH_SPAN_SPLIT_RE = /((?<!\\)\$\$[\s\S]*?(?<!\\)\$\$|(?<!\\)\$(?:[^\n$\\]|\\[^\n])+?(?<!\\)\$)/g
const LATEX_DISPLAY_OPEN_LINE_RE = /^([ \t]*(?:>[ \t]*)*(?:(?:[-+*]|\d+[.)])[ \t]+)?[ \t]*)\\{1,2}\[[ \t]*\r?$/
const LATEX_DISPLAY_CLOSE_LINE_RE = /^([ \t]*(?:>[ \t]*)*(?:(?:[-+*]|\d+[.)])[ \t]+)?[ \t]*)\\{1,2}\][ \t]*\r?$/
const CUSTOM_DISPLAY_MATH_LINE_RE = /^([ \t]*(?:>[ \t]*)*(?:(?:[-+*]|\d+[.)])[ \t]+)?[ \t]*)\[\/math\][ \t]*\r?$/
// A `$$` that opens a block and is immediately followed by the first line of the
// equation, e.g. `$$\begin{aligned}`. Both patterns anchor the `$$` to the start
// of the line (modulo blockquote/list prefix), which is also what keeps them from
// firing inside an inline code span — `` `$$a `` leads with a backtick.
const HUGGING_DISPLAY_MATH_OPEN_RE =
/^([ \t]*(?:>[ \t]*)*(?:(?:[-+*]|\d+[.)])[ \t]+)?[ \t]*)\$\$[ \t]*(\S[^\n]*?)[ \t]*\r?$/
const HUGGING_DISPLAY_MATH_CLOSE_RE = /^([ \t]*(?:>[ \t]*)*[ \t]*)(\S[^\n]*?)\$\$[ \t]*\r?$/
// Bare-URL autolink matcher. The character classes EXCLUDE `*` so a URL that
// abuts markdown emphasis with no separating space (e.g. `**label: https://x**`,
// a very common LLM pattern) doesn't swallow the trailing `**` into the href.
// `*` is never meaningful in a real URL path, and GFM's own autolink extension
// likewise strips trailing emphasis/punctuation — so dropping it here is safe
// and keeps the emphasis run intact. Other trailing punctuation is still peeled
// off by the final `[^\s<>"'`*.,;:!?]` class.
const RAW_URL_RE = /https?:\/\/[^\s<>"'`*]+[^\s<>"'`*.,;:!?]/g
const LOCAL_PREVIEW_URL_RE = /(^|\s)https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?[^\s<>"'`]*/gi
const LOCAL_PREVIEW_ONLY_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?$/i
const URL_ONLY_LINE_RE = /^\s*https?:\/\/\S+\s*$/i
const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*\d+)*)\](?!\()/gu
// Markdown links whose target is a filesystem path on the agent's machine:
// `[report](/home/user/report.md)`, `[notes](file:///srv/notes.txt)`,
// `[todo](~/todo.md)`, `[log](C:\logs\run.txt)`. Negative lookbehind keeps
// image syntax (`![alt](path)`) on its existing inline pipeline. The target
// char class excludes `)`/whitespace, matching how LLMs actually emit these.
const FILE_LINK_RE = /(?<!!)\[(?<label>[^\]\n]+)\]\((?<target><?(?:file:\/\/|\/|~\/|[a-z]:[\\/])[^)\s]*>?)\)/gi
// A transcript directive on its own line: `::name{...}`. Attribute values are
// prose the model wrote (a task brief, a question) and read as markdown to the
// parser — `*by week*` becomes <em>, `a_b c_d` becomes <em>, `~/x` opens
// strikethrough. Any of those splits the paragraph into element children, the
// directive stops being text-only, and the raw line paints as the user's
// message. Same shape as lib/transcript-directives.ts DIRECTIVE_RE.
const DIRECTIVE_LINE_RE = /^([ \t]*)(::[a-z][a-z0-9-]{0,63}\{[^{}\n]{0,1024}\})[ \t]*$/gm
const MARKDOWN_INLINE_META_RE = /[\\`*_~[\]<>]/g
/**
* Returns true when `body` contains a line that's exactly `marker` (modulo
* leading/trailing horizontal whitespace) — i.e. an unambiguous close fence
* for an opening fence with the same marker.
*
* Implemented with string comparisons (not RegExp) so that input-derived
* `marker` values can never bleed into a regex pattern. This matters for
* CodeQL's `js/incomplete-hostname-regexp` dataflow, which would otherwise
* trace test-fixture URLs from the input through `marker` into the regex
* source, even though `marker` is captured by `(`{3,}|~{3,})` and can only
* ever be backticks or tildes.
*/
function hasCloseFenceLine(body: string, marker: string): boolean {
const lines = body.split('\n')
// Original regex required `\n` immediately before the close fence, so the
// first line of `body` (which has no preceding newline within `body`)
// cannot itself be the close fence.
for (let i = 1; i < lines.length; i += 1) {
const line = lines[i]
let lo = 0
let hi = line.length
while (lo < hi && (line[lo] === ' ' || line[lo] === '\t')) {
lo += 1
}
while (hi > lo && (line[hi - 1] === ' ' || line[hi - 1] === '\t')) {
hi -= 1
}
if (line.slice(lo, hi) === marker) {
return true
}
}
return false
}
function scrubBacktickNoise(text: string): string {
const balancedFenceRe = /(^|\n)([ \t]*)(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n[ \t]*\3[ \t]*(?=\n|$)/g
const protectedRanges: { end: number; start: number }[] = []
let match: RegExpExecArray | null
while ((match = balancedFenceRe.exec(text)) !== null) {
const start = match.index + match[1].length
protectedRanges.push({ end: balancedFenceRe.lastIndex, start })
}
const danglingCodeFenceRe = /(^|\n)[ \t]*(`{3,}|~{3,})([a-z0-9][a-z0-9+#-]{0,15})[ \t]*\n([\s\S]*)$/gi
while ((match = danglingCodeFenceRe.exec(text)) !== null) {
const start = match.index + match[1].length
const marker = match[2] || '```'
const info = match[3] || ''
const body = match[4] || ''
if (!hasCloseFenceLine(body, marker) && sanitizeLanguageTag(info) && !isLikelyProseFence(info, body)) {
protectedRanges.push({ end: text.length, start })
break
}
}
protectedRanges.sort((a, b) => a.start - b.start)
const fenceNoiseRe = /`{3,}/g
let out = ''
let cursor = 0
for (const range of protectedRanges) {
out += text.slice(cursor, range.start).replace(fenceNoiseRe, '')
out += text.slice(range.start, range.end)
cursor = range.end
}
out += text.slice(cursor).replace(fenceNoiseRe, '')
for (let pass = 0; pass < 2; pass += 1) {
// Match EXACTLY 2 backticks (not part of a longer run) on each side.
// Without the lookbehind/lookahead, two adjacent triple-backtick
// fences with only whitespace between them get spliced together —
// e.g. ```bash\n...\n```\n\n```latex matches the regex's
// last-2-of-bash-close + \n\n + first-2-of-latex-open and the
// surrounding fence markers collapse into a single longer block,
// which the markdown parser then treats as ONE giant code block.
out = out.replace(/(?<!`)``(?!`)\s*(?<!`)``(?!`)/g, '')
out = out.replace(/(^|[^`])``(?=\s|[.,;:!?)\]'"\u2014\u2013-]|$)/g, '$1')
}
return out
}
// Runs on the ACCUMULATED text every streaming flush, so an unterminated block
// must already be hidden here: otherwise the chain of thought paints as prose
// until the close tag lands and then the whole span vanishes in one frame
// (#62774). Removing a closed block between two words keeps one space so `no` +
// `Hermes` does not fuse into `noHermes`. The seam check reads the two chars at
// the match edges rather than slicing the accumulated text, which would copy
// O(n) per closed block on every flush.
function stripReasoningBlocks(text: string): string {
const closed = text.replace(REASONING_BLOCK_RE, (match: string, _tag: string, offset: number, whole: string) => {
const prev = whole[offset - 1]
const next = whole[offset + match.length]
return prev && next && !/\s/.test(prev) && !/\s/.test(next) ? ' ' : ''
})
return closed.replace(OPEN_REASONING_BLOCK_RE, '$1').replace(PARTIAL_OPEN_REASONING_TAG_RE, '$1')
}
function stripEmptyFenceBlocks(text: string): string {
return text.replace(EMPTY_FENCE_BLOCK_RE, '$1')
}
function isUrlOnlyBlock(lines: string[]): boolean {
const nonEmpty = lines.filter(line => line.trim())
return nonEmpty.length > 0 && nonEmpty.every(line => URL_ONLY_LINE_RE.test(line))
}
function autoLinkRawUrls(text: string): string {
return text.replace(RAW_URL_RE, (url: string, index: number) => {
const previous = text[index - 1] || ''
const beforePrevious = text[index - 2] || ''
if (previous === '<' || (beforePrevious === ']' && previous === '(')) {
return url
}
return `<${url}>`
})
}
// Rewrite filesystem-path links to the renderer's hash-href door (#82140).
// A plain path/file: href names a file on the AGENT's machine: Streamdown's
// URL hardening blocks `file:`/`~/` outright, and an absolute path renders
// as a dead anchor (file:// is blocked in the renderer; on a remote gateway
// the file isn't on this disk at all). `#preview/…` / `#media:…` hrefs pass
// hardening by design and route through PreviewAttachment/MediaAttachment,
// which resolve the path at VIEW time against the session's backend — local
// reads the file directly, remote fetches it over the authenticated /api/fs
// bridge — so the same transcript works from every machine that opens it.
function routeFileLinksToPreview(text: string): string {
return text.replace(FILE_LINK_RE, (match: string, ...args: unknown[]) => {
const groups = args.at(-1) as { label: string; target: string }
const target = groups.target.replace(/^<|>$/g, '')
const href = mediaKind(target) === 'file' ? previewMarkdownHref(target) : mediaMarkdownHref(target)
return `[${groups.label}](${href})`
})
}
function rewriteProseSegment(segment: string): string {
return linkifySessionRefs(
autoLinkRawUrls(
routeFileLinksToPreview(
segment.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '')
)
)
)
}
/**
* Backslash-escape markdown inline syntax inside directive lines so the parser
* yields one text node. The escapes are consumed by the parser, so the
* directive the renderer sees is byte-for-byte what the model wrote.
*/
export function shieldDirectiveLines(text: string): string {
if (!text.includes('::')) {
return text
}
return text.replace(
DIRECTIVE_LINE_RE,
(_match, indent: string, directive: string) => indent + directive.replace(MARKDOWN_INLINE_META_RE, '\\$&')
)
}
/**
* Apply the prose rewrites to visible prose only.
*
* Inline code has always been shielded here. Math has to be shielded for the
* same reason: these rewrites read TeX as prose. `CITATION_MARKER_RE` is the
* one that bites — its lookbehind accepts any letter, so the `t` of `\sqrt`
* qualifies and `$\sqrt[3]{8}$` loses its index to what looks like a citation
* marker, long before KaTeX sees it.
*
* Split on math spans is odd-index-is-a-delimiter (capturing split), not a
* `startsWith('$')` test, so a prose segment that merely opens with a stray
* dollar can't be mistaken for math.
*/
function normalizeVisibleProse(text: string): string {
return text
.split(INLINE_CODE_SPLIT_RE)
.map(part =>
part.startsWith('`')
? part
: part
.split(MATH_SPAN_SPLIT_RE)
.map((segment, index) => (index % 2 === 1 ? segment : rewriteProseSegment(segment)))
.join('')
)
.join('')
}
function isEscapedAt(text: string, index: number): boolean {
let slashCount = 0
for (let cursor = index - 1; cursor >= 0 && text[cursor] === '\\'; cursor -= 1) {
slashCount += 1
}
return slashCount % 2 === 1
}
function findClosingSingleDollar(text: string, openingIndex: number): number {
for (let cursor = openingIndex + 1; cursor < text.length && text[cursor] !== '\n'; cursor += 1) {
if (text[cursor] !== '$' || isEscapedAt(text, cursor)) {
continue
}
// A `$$` run belongs to display math, not to this inline candidate.
if (text[cursor - 1] === '$' || text[cursor + 1] === '$') {
continue
}
return cursor
}
return -1
}
function isLikelyNumericInlineMath(body: string, followingCharacter: string): boolean {
const value = body.trim()
if (!/^\d/u.test(value)) {
return false
}
// Currency ranges and prose fragments can sit between two price openers,
// e.g. `$5-$10` or `$5, then $10`. They are not balanced math spans.
if (/[+\-*/=<>^_,;:(]$/u.test(value)) {
return false
}
if (/https?:\/\//iu.test(value)) {
return false
}
// A dollar immediately followed by a letter/number is more likely the next
// opener in prose such as `$5 and $10` or `$5 and $x$`. Preserve it only
// when the candidate body itself carries an unambiguous math signal.
if (/^\p{N}/u.test(followingCharacter)) {
return false
}
if (/^[\p{L}\\]/u.test(followingCharacter)) {
return /\\[A-Za-z]+|[+*/=<>^_{}]/u.test(value)
}
return true
}
function opensCompleteInlineMath(text: string, openingIndex: number): boolean {
const closingIndex = findClosingSingleDollar(text, openingIndex)
if (closingIndex === -1) {
return false
}
const body = text.slice(openingIndex + 1, closingIndex)
return /^[\p{L}\p{N}\\{([|+\-=_^]/u.test(body)
}
/**
* Escape price openers without corrupting balanced numeric inline math.
*
* The upstream helper deliberately treats every `$` followed by a digit as
* currency. That turns `$4\in A$` into `\$4\in A$`; remark-math then pairs
* the orphan closing dollar with a later formula and renders the intervening
* prose as math. We retain the price behavior for `$5 and $10` and `$5-$10`,
* but preserve balanced, same-line numeric math spans.
*/
function escapeCurrencyDollarsPreservingMath(text: string): string {
let out = ''
let copiedThrough = 0
for (let cursor = 0; cursor < text.length; cursor += 1) {
if (
text[cursor] !== '$' ||
!/\d/u.test(text[cursor + 1] || '') ||
text[cursor - 1] === '$' ||
isEscapedAt(text, cursor)
) {
continue
}
const closingIndex = findClosingSingleDollar(text, cursor)
if (
closingIndex !== -1 &&
!opensCompleteInlineMath(text, closingIndex) &&
isLikelyNumericInlineMath(text.slice(cursor + 1, closingIndex), text[closingIndex + 1] || '')
) {
cursor = closingIndex
continue
}
out += `${text.slice(copiedThrough, cursor)}\\$`
copiedThrough = cursor + 1
}
return out + text.slice(copiedThrough)
}
/**
* Moves the `$$` delimiters of a MULTI-LINE display-math block onto their own
* lines: `$$\begin{aligned}` … `\end{aligned}$$` becomes a `$$`-only line, the
* body, then a `$$`-only line.
*
* remark-math's flow-math construct is fence-shaped: whatever follows the
* opening `$$` on the same line is read as an info string and DISCARDED, and the
* closing `$$` is only recognized alone on its own line. So the hugging form
* loses its first line, never closes, and KaTeX paints the remains as raw source
* text. Models emit this form constantly.
*
* Single-line `$$…$$` is left alone — it routes through the inline math-text
* construct and already renders.
*/
function splitHuggingDisplayMath(text: string): string {
const lines = text.split('\n')
const out: string[] = []
for (let index = 0; index < lines.length; index += 1) {
const openingMatch = lines[index].match(HUGGING_DISPLAY_MATH_OPEN_RE)
// `$$x^2$$` closes on the same line — not our case.
if (!openingMatch || openingMatch[2].endsWith('$$')) {
out.push(lines[index])
continue
}
let closingIndex = -1
for (let candidate = index + 1; candidate < lines.length; candidate += 1) {
if (HUGGING_DISPLAY_MATH_CLOSE_RE.test(lines[candidate])) {
closingIndex = candidate
break
}
}
if (closingIndex === -1) {
out.push(lines[index])
continue
}
const closingMatch = lines[closingIndex].match(HUGGING_DISPLAY_MATH_CLOSE_RE) as RegExpMatchArray
const openingCarriageReturn = lines[index].endsWith('\r') ? '\r' : ''
const closingCarriageReturn = lines[closingIndex].endsWith('\r') ? '\r' : ''
// The container prefix (blockquote marker, list bullet) has to be replayed
// onto the delimiter lines, or the block falls out of its container.
out.push(
`${openingMatch[1]}$$${openingCarriageReturn}`,
`${openingMatch[1]}${openingMatch[2]}${openingCarriageReturn}`
)
out.push(...lines.slice(index + 1, closingIndex))
// The break this split INTRODUCES takes the block's own line ending, not the
// closing line's — a final `\end{aligned}$$` with no trailing CRLF (it's the
// last line of the message) would otherwise emit a bare LF into an
// otherwise-CRLF block.
out.push(
`${closingMatch[1]}${closingMatch[2]}${openingCarriageReturn}`,
`${closingMatch[1]}$$${closingCarriageReturn}`
)
index = closingIndex
}
return out.join('\n')
}
function normalizeDisplayMathForMarkdown(text: string): string {
const lines = text.split('\n')
for (let index = 0; index < lines.length; index += 1) {
const latexMatch = lines[index].match(LATEX_DISPLAY_OPEN_LINE_RE)
const customMatch = lines[index].match(CUSTOM_DISPLAY_MATH_LINE_RE)
const openingMatch = latexMatch || customMatch
if (!openingMatch) {
continue
}
const prefix = openingMatch[1] || ''
const closingPattern = latexMatch ? LATEX_DISPLAY_CLOSE_LINE_RE : CUSTOM_DISPLAY_MATH_LINE_RE
for (let closingIndex = index + 1; closingIndex < lines.length; closingIndex += 1) {
const closingMatch = lines[closingIndex].match(closingPattern)
if (!closingMatch) {
continue
}
const openingCarriageReturn = lines[index].endsWith('\r') ? '\r' : ''
const closingCarriageReturn = lines[closingIndex].endsWith('\r') ? '\r' : ''
const closingPrefix = closingMatch[1] || ''
lines[index] = `${prefix}$$${openingCarriageReturn}`
lines[closingIndex] = `${closingPrefix}$$${closingCarriageReturn}`
index = closingIndex
break
}
}
return lines.join('\n')
}
function normalizeProseMath(text: string): string {
// remark-math requires multiline display delimiters on their own lines.
// Normalize those locally before the dependency handles inline forms;
// its compact `$$body$$` rewrite makes the first equation line metadata
// and leaks the trailing `$$` into KaTeX's error fallback.
//
// splitHuggingDisplayMath runs LAST because normalizeMathDelimiters is itself
// a source of the hugging form: a multi-line `\[…\]` comes out of it as
// `$$\begin{aligned}…\end{aligned}$$`. Running afterwards catches both the
// hugging math the model emitted and the hugging math the rewrite produced.
const normalized = splitHuggingDisplayMath(normalizeMathDelimiters(normalizeDisplayMathForMarkdown(text)))
return escapeCurrencyDollarsPreservingMath(normalized)
}
function extend(out: string[], lines: string[]) {
for (const line of lines) {
out.push(line)
}
}
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
if (info) {
out.push(`${indent}${info}`.trimEnd())
}
extend(out, lines)
}
function findClosingFence(lines: string[], start: number, marker: string): number {
for (let cursor = start + 1; cursor < lines.length; cursor += 1) {
const closeMatch = (lines[cursor] || '').match(FENCE_LINE_RE)
if (!closeMatch) {
continue
}
const closeMarker = closeMatch[2] || ''
const closeInfo = (closeMatch[3] || '').trim()
if (!closeInfo && closeMarker[0] === marker[0] && closeMarker.length >= marker.length) {
return cursor
}
}
return -1
}
// Languages that should be routed to the math (KaTeX) renderer instead of
// being shown as a syntax-highlighted code block.
//
// We deliberately recognize ONLY `math` here, not `latex` or `tex`.
// Reasoning: GitHub-style markdown uses ` ```math ` to mean "render as
// math" and ` ```latex `/` ```tex ` to mean "show LaTeX/TeX source code"
// (syntax highlighted). Conflating the two breaks code blocks where a
// user is *discussing* LaTeX rather than embedding it (e.g.,
// ```latex\n\begin{equation}\n E = mc^2\n\end{equation}``` shown as a
// teaching example). Anyone who wants math rendered should use ```math.
const MATH_FENCE_LANGUAGES = new Set(['math'])
function isMathFence(language: string): boolean {
return MATH_FENCE_LANGUAGES.has(language.toLowerCase())
}
function normalizeFenceBlocks(text: string): string {
const sourceLines = text.split('\n')
const out: string[] = []
let index = 0
while (index < sourceLines.length) {
const line = sourceLines[index] || ''
const match = line.match(FENCE_LINE_RE)
if (!match) {
out.push(line)
index += 1
continue
}
const indent = match[1] || ''
const marker = match[2] || '```'
const infoRaw = (match[3] || '').trim()
const languageToken = infoRaw.split(/\s+/, 1)[0] || ''
const language = sanitizeLanguageTag(languageToken)
const openerValid = !infoRaw || Boolean(language)
if (!openerValid) {
out.push(`${indent}${infoRaw}`.trimEnd())
index += 1
continue
}
const closeIndex = findClosingFence(sourceLines, index, marker)
const bodyLines = sourceLines.slice(index + 1, closeIndex === -1 ? sourceLines.length : closeIndex)
const body = bodyLines.join('\n')
if (closeIndex !== -1 && !body.trim()) {
index = closeIndex + 1
continue
}
if (closeIndex !== -1 && LOCAL_PREVIEW_ONLY_RE.test(body.trim())) {
index = closeIndex + 1
continue
}
if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) {
extend(out, bodyLines)
index = closeIndex + 1
continue
}
if (closeIndex === -1) {
if (!body.trim()) {
index += 1
continue
}
if (isLikelyProseFence(infoRaw, body)) {
pushProseFence(out, indent, infoRaw, bodyLines)
} else if (isMathFence(language)) {
// Streaming math fence — rewrite the language tag to "math".
// remark-math + rehype-katex pick up ```math fenced blocks via
// the language-math class on the resulting <code> element. We
// keep the fence intact (instead of converting to $$..$$) so
// any literal `$$` characters in the body don't collide with
// an outer math wrapper. No close emitted yet — streaming.
out.push(`${indent}${marker}math`)
extend(out, bodyLines)
} else {
out.push(`${indent}${marker}${language}`)
extend(out, bodyLines)
}
break
}
if (isLikelyProseFence(infoRaw, body)) {
pushProseFence(out, indent, infoRaw, bodyLines)
index = closeIndex + 1
continue
}
if (isMathFence(language)) {
// Closed math fence — rewrite the language tag to "math" so
// rehype-katex's language-math class detection picks it up.
// Body stays untouched (no $$..$$ rewrite) so authors can write
// arbitrary LaTeX including `$$display$$` markers without them
// colliding with our wrapper. Without this rewrite the block
// would render as a syntax-highlighted "latex" code listing.
out.push(`${indent}${marker}math`)
extend(out, bodyLines)
out.push(`${indent}${marker}`)
index = closeIndex + 1
continue
}
out.push(`${indent}${marker}${language}`)
extend(out, bodyLines)
out.push(`${indent}${marker}`)
index = closeIndex + 1
}
return out.join('\n')
}
export function preprocessMarkdown(text: string): string {
const cleaned = stripReasoningBlocks(text).replace(PREVIEW_MARKER_RE, '')
const scrubbed = scrubBacktickNoise(cleaned)
const normalizedFences = normalizeFenceBlocks(scrubbed)
const strippedEmptyFences = stripEmptyFenceBlocks(normalizedFences)
return strippedEmptyFences
.split(CODE_FENCE_SPLIT_RE)
.map(part => {
// Fence blocks pass through untouched.
if (/^(?:```|~~~)/.test(part)) {
return part
}
// Run only on prose segments so `$5` literals and `\(` inside code
// blocks stay intact. The HTML-depth clamp belongs here for the same
// reason: a fenced block renders as code and never reaches rehype-raw,
// so escaping tags inside one would corrupt the listing for nothing.
// Directive lines are shielded last, after the prose rewrites have had
// their look, so nothing re-introduces markdown into them.
return shieldDirectiveLines(
clampHtmlNestingDepth(normalizeVisibleProse(stripPreviewTargets(normalizeProseMath(part))))
)
})
.join('')
}
/**
* Math-only normalization for static file previews. Mirrors the math half of
* `preprocessMarkdown` — delimiter normalization (`\(…\)`, `\[…\]`), display
* math on its own lines, and currency-dollar escaping — but deliberately skips
* the chat-only transforms (reasoning-block stripping, `@session:` ref
* linking, preview-target stripping, raw-URL autolinking, citation-marker
* stripping). A file's prose is author content, not model output, so those
* rewrites must not touch it. Code fences and inline code spans pass through
* untouched so `$`, `\(` and `\begin` inside listings are never mangled.
*
* ` ```math ` fences need no preprocessing: remark/rehype emit them as
* `<code class="language-math">` and the memoized rehype-katex wrapper renders
* them regardless of this function.
*/
export function normalizeFilePreviewMath(text: string): string {
return text
.split(CODE_FENCE_SPLIT_RE)
.map(part => {
if (/^(?:```|~~~)/.test(part)) {
return part
}
return part
.split(INLINE_CODE_SPLIT_RE)
.map(segment => (segment.startsWith('`') ? segment : normalizeProseMath(segment)))
.join('')
})
.join('')
}