From ad24d626f178581b1258cadeebaeacc3370343e1 Mon Sep 17 00:00:00 2001 From: zhifei Date: Tue, 29 Sep 2026 10:11:27 +0800 Subject: [PATCH] fix(ui): render $..$ and same-line $$..$$ math The katex extension only recognized \(...\) inline math and block math requiring newlines right after the opening $$ and before the closing $$. Most LLM chat output uses $..$ for inline math and single-line $$..$$ for display math, so those formulas currently render as raw source. Extend the existing extension rather than adding a new one: - inline: try $..$ after \(...\) fails, with display mode for $$..$$ - block: accept single-line and same-line $$..$$ alongside the existing newline-delimited form - start hints: report the earliest $ for inline math and \n$$ for block math so marked can split paragraphs around glued display math Currency guards keep money amounts as plain text: the opening $ must not be followed by whitespace, the closing $ must not be preceded by whitespace nor followed by a digit, and the content cannot contain $ or newlines ("costs $5 and $10", "$1,000 to $2,000" stay literal). Unclosed delimiters are left as text; invalid latex renders as an inline error via the existing throwOnError:false path. Co-Authored-By: Claude Code --- packages/ui/src/context/marked-parser.test.ts | 73 +++++++++++++++++++ packages/ui/src/context/marked-parser.tsx | 32 ++++++-- 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/context/marked-parser.test.ts b/packages/ui/src/context/marked-parser.test.ts index a5aa8003fd09..b78e7edccdfc 100644 --- a/packages/ui/src/context/marked-parser.test.ts +++ b/packages/ui/src/context/marked-parser.test.ts @@ -15,6 +15,79 @@ test("renders inline and block math", async () => { expect(await parser.parse("$$\nx^2\n$$\n")).toContain('') }) +test("renders single-dollar inline math", async () => { + const html = await parser.parse("Given $f(0)=f(0)+f(0)$ it follows that $f(0)=0$, so $f(-2)=-f(2)=4$") + expect(html).toContain('') + expect(html).not.toContain("$") +}) + +test("renders symbol-heavy inline math", async () => { + const html = await parser.parse("The solution set is $(-\\infty,-3)\\cup(1,+\\infty)$ where $x_1') + expect(html).not.toContain("$") +}) + +test("renders CJK text inside \\text{}", async () => { + const html = await parser.parse("Given $\\text{速度} \\times \\text{时间} = \\text{路程}$, solve for speed") + expect(html).toContain('') + expect(html).not.toContain("$") +}) + +test("renders single-line dollar-block math", async () => { + const html = await parser.parse("Derivation:\n\n$$f(-x^2)>f(2x)+f(-3)=f(2x-3)$$\n") + expect(html).toContain('') + expect(html).not.toContain("$$") +}) + +test("renders block math glued to preceding text", async () => { + const html = await parser.parse("so the inequality becomes\n$$\nx^2+2x-3>0\\;\\Rightarrow\\;(x+3)(x-1)>0\n$$\n") + expect(html).toContain('') + expect(html).not.toContain("$$") +}) + +test("renders double-dollar display math inside a paragraph", async () => { + const html = await parser.parse("Conclusion $$x<-3$$ or $$x>1$$ holds") + expect(html).toContain('') + expect(html).not.toContain("$$") +}) + +test("leaves money amounts as text", async () => { + const html = await parser.parse("It costs $5, originally $10, ranging from $1,000 to $2,000.") + expect(html).not.toContain("katex") + expect(html).toContain("$5") + expect(html).toContain("$10") +}) + +test("leaves unclosed math delimiters as text without throwing", async () => { + const html = await parser.parse("Unclosed $f(0)=f(0) keeps streaming\n\nand $$\nx=1") + expect(html).not.toContain("katex") + expect(html).toContain("$f(0)") +}) + +test("renders invalid latex as inline error instead of throwing", async () => { + const html = await parser.parse("$\\notacommand{x}$") + expect(html).toContain('') + expect(html).toContain("#cc0000") +}) + +test("keeps dollar delimiters literal inside code", async () => { + const inline = await parser.parse("Use `$f(0)$` as a placeholder") + expect(inline).toContain("$f(0)$") + expect(inline).not.toContain("katex") + expect(await parser.parse("```text\n$$ and $b$\n```\n")).toBe( + '
$$ and $b$
\n', + ) +}) + +test("plain markdown is unaffected by the math extension", async () => { + const html = await parser.parse("# Heading\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n- list **bold** *italic*\n") + expect(html).toContain("

Heading

") + expect(html).toContain("") + expect(html).toContain("bold") + expect(html).toContain("italic") + expect(html).not.toContain("katex") +}) + test("uses the configured code highlighter", async () => { expect(await parser.parse("```ts\nconst value = 1\n```\n")).toBe('
const value = 1
\n') }) diff --git a/packages/ui/src/context/marked-parser.tsx b/packages/ui/src/context/marked-parser.tsx index e058559ea6ba..65b4b86863c6 100644 --- a/packages/ui/src/context/marked-parser.tsx +++ b/packages/ui/src/context/marked-parser.tsx @@ -8,7 +8,8 @@ export function createMarkdownParser(highlight: (code: string, language: string) } const inlineMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/ -const blockMathRegex = /^\$\$\n([\s\S]+?)\n\$\$(?:\n|$)/ +const dollarMathRegex = /^(\$+)(?!\s)((?:\\.|[^\n$])+?)\1(?!\d)/ +const blockMathRegex = /^\$\$([\s\S]+?)\$\$(?:\n|$)/ const katexExtension: MarkedExtension = { extensions: [ @@ -16,18 +17,28 @@ const katexExtension: MarkedExtension = { name: "inlineKatex", level: "inline", start(src) { - const index = src.indexOf("\\(") - if (index === -1) return - return index + const paren = src.indexOf("\\(") + const dollar = src.indexOf("$") + if (paren === -1) return dollar === -1 ? undefined : dollar + if (dollar === -1) return paren + return Math.min(paren, dollar) }, tokenizer(src) { const match = src.match(inlineMathRegex) - if (!match) return + if (match) + return { + type: "inlineKatex", + raw: match[0], + text: match[1].trim(), + displayMode: false, + } + const dollar = src.match(dollarMathRegex) + if (!dollar || /\s$/.test(dollar[2]!)) return return { type: "inlineKatex", - raw: match[0], - text: match[1].trim(), - displayMode: false, + raw: dollar[0], + text: dollar[2]!.trim(), + displayMode: dollar[1]!.length === 2, } }, renderer: renderKatexToken, @@ -35,6 +46,11 @@ const katexExtension: MarkedExtension = { { name: "blockKatex", level: "block", + start(src) { + const index = src.indexOf("\n$$") + if (index === -1) return + return index + }, tokenizer(src) { const match = src.match(blockMathRegex) if (!match) return