From a99eba1ca6e6eaf241d279b302a18a24a8fb2079 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 17 Jun 2026 00:33:00 +0300 Subject: [PATCH] fix(markdown): currency-safe math delimiters Single-dollar $...$ inline math collided with currency text ($50, US$ 680, "$50M to $72M"), parsing money as math and corrupting it. Drop single-dollar inline math; keep $$...$$ display math and add \(...\) inline and \[...\] display via marked tokenizers (caught at lex time so they survive backslash escaping and stay code-safe). Also gate renderMathExpressions on a cheap $-presence check so the split + regex passes are skipped for the non-math majority of blocks. --- .../components/chat/markdown/markdownCore.ts | 80 ++++++++++++++++--- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index 1d324f8f..b1ec558c 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -107,9 +107,64 @@ export const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => { // marked parser (HTML string output) with safe external links // --------------------------------------------------------------------------- +// Math delimiters that use backslashes — `\(...\)` (inline) and `\[...\]` +// (display) — must be caught during lexing: marked treats `\(`/`\[` as +// backslash escapes and strips the slash before any HTML post-process can see +// them. Registering them as tokenizers also makes them code-safe for free +// (marked tokenizes code spans/fences first, so these never fire inside code). +// Single-dollar `$...$` is intentionally NOT supported — it collides with +// currency text ($50, US$ 680); only `$$...$$` survives as display math (see +// renderMathExpressions). This mirrors KaTeX auto-render's default delimiters. +type MathToken = { type: string; raw: string; text: string }; + +const renderKatex = (math: string, raw: string, displayMode: boolean): string => { + try { + return katex.renderToString(math, { displayMode, throwOnError: false }); + } catch { + return raw; + } +}; + +const inlineMathExtension = { + name: 'inlineMath', + level: 'inline' as const, + start(src: string) { + const index = src.indexOf('\\('); + return index < 0 ? undefined : index; + }, + tokenizer(src: string): MathToken | undefined { + const match = /^\\\(([\s\S]+?)\\\)/.exec(src); + if (!match) return undefined; + return { type: 'inlineMath', raw: match[0], text: match[1] ?? '' }; + }, + renderer(token: Tokens.Generic) { + const math = token as MathToken; + return renderKatex(math.text, math.raw, false); + }, +}; + +const blockMathExtension = { + name: 'blockMath', + level: 'block' as const, + start(src: string) { + const index = src.indexOf('\\['); + return index < 0 ? undefined : index; + }, + tokenizer(src: string): MathToken | undefined { + const match = /^\\\[([\s\S]+?)\\\]/.exec(src); + if (!match) return undefined; + return { type: 'blockMath', raw: match[0], text: match[1] ?? '' }; + }, + renderer(token: Tokens.Generic) { + const math = token as MathToken; + return renderKatex(math.text, math.raw, true); + }, +}; + const parser = marked.use({ gfm: true, breaks: false, + extensions: [inlineMathExtension, blockMathExtension], renderer: { link({ href, title, text }) { const target = href ?? ''; @@ -131,8 +186,14 @@ const parser = marked.use({ // Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content // --------------------------------------------------------------------------- -const renderMathInText = (text: string): string => { - let result = text.replace(/\$\$([\s\S]*?)\$\$/g, (_match, math: string) => { +// Only `$$...$$` (display) is handled here. Single-dollar `$...$` inline math is +// deliberately omitted: it parses currency text ($50, US$ 680, "$50M to $72M") +// as math and corrupts it. Inline math is supported via `\(...\)` (see the +// marked extensions above). `$$` survives marked untouched (no backslash), so +// post-processing the parsed HTML — skipping code via renderMathExpressions — +// stays correct and code-safe. +const renderMathInText = (text: string): string => + text.replace(/\$\$([\s\S]*?)\$\$/g, (_match, math: string) => { try { return katex.renderToString(math, { displayMode: true, throwOnError: false }); } catch { @@ -140,18 +201,11 @@ const renderMathInText = (text: string): string => { } }); - result = result.replace(/(? { - try { - return katex.renderToString(math, { displayMode: false, throwOnError: false }); - } catch { - return `$${math}$`; - } - }); - - return result; -}; - const renderMathExpressions = (html: string): string => { + // No `$` anywhere means no math to render — skip the split + regex passes on + // the hot streaming path (the overwhelming majority of blocks have no math). + if (html.indexOf('$') === -1) return html; + const codeBlockPattern = /(<(?:pre|code|kbd)[^>]*>[\s\S]*?<\/(?:pre|code|kbd)>)/gi; return html .split(codeBlockPattern)