From 360b755210d6e364c3d63a9bbad666f441401a6a Mon Sep 17 00:00:00 2001 From: Simone Checchia <62959575+simonechecchia@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:06:56 +0200 Subject: [PATCH] fix(app): render \[...\] LaTeX display math delimiters Fixes #24426 --- packages/ui/src/context/marked.test.ts | 84 ++++++++++++++++++++++++++ packages/ui/src/context/marked.tsx | 16 ++--- 2 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/context/marked.test.ts diff --git a/packages/ui/src/context/marked.test.ts b/packages/ui/src/context/marked.test.ts new file mode 100644 index 000000000000..1e43de1a752f --- /dev/null +++ b/packages/ui/src/context/marked.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test" +import { marked } from "marked" +import { katexExtension, renderMathInText } from "./marked" + +describe("renderMathInText (native markdown parser path)", () => { + test("renders \\(...\\) inline math", () => { + const html = renderMathInText("A quantum state \\( \\rho \\) is normalized.") + expect(html).toContain("katex") + expect(html).not.toContain("\\(") + expect(html).not.toContain("\\)") + }) + + test("renders \\[...\\] display math", () => { + const html = renderMathInText("\\[\n\\langle O \\rangle = \\mathrm{Tr}(O\\rho)\n\\]") + expect(html).toContain("katex") + expect(html).toContain('class="katex-display"') + expect(html).not.toContain("\\[") + expect(html).not.toContain("\\]") + }) + + test("still renders $$...$$ display math", () => { + const html = renderMathInText("$$\nx^2\n$$") + expect(html).toContain("katex") + expect(html).toContain('class="katex-display"') + }) +}) + +describe("katexExtension (marked tokenizer path)", () => { + const parser = marked.use(katexExtension) + + test("renders \\(...\\) inline math", async () => { + const html = await parser.parse("A quantum state \\( \\rho \\) is normalized.") + expect(html).toContain("katex") + expect(html).not.toContain("\\(") + }) + + test("renders \\[...\\] display math on its own lines", async () => { + const html = await parser.parse("\\[\n\\langle O \\rangle = \\mathrm{Tr}(O\\rho)\n\\]") + expect(html).toContain("katex") + expect(html).toContain('class="katex-display"') + }) + + test("still renders $$...$$ display math", async () => { + const html = await parser.parse("$$\nx^2\n$$") + expect(html).toContain("katex") + expect(html).toContain('class="katex-display"') + }) + + test("does not mangle fenced code blocks containing backslash-bracket text", async () => { + const html = await parser.parse("```\n\\[ not math \\]\n```") + expect(html).toContain("\\[ not math \\]") + expect(html).not.toContain("katex") + }) + + test("renders \\(...\\) inside a markdown table cell without CommonMark eating the backslash", async () => { + const md = [ + "| Fratto semplice | Antitrasformata |", + "|---|---|", + "| \\(\\frac{1}{s+a}\\) | \\(e^{-at}\\text{sca}(t)\\) |", + ].join("\n") + const html = await parser.parse(md) + expect(html).toContain("katex") + expect(html).not.toContain("\\(") + expect(html).not.toContain("\\)") + }) +}) + +describe("issue #24426 repro", () => { + test("full snippet renders both delimiter styles without leaking raw LaTeX", () => { + const input = [ + "A quantum state \\( \\rho \\) gives an observable expectation by", + "", + "\\[", + "\\langle O \\rangle = \\mathrm{Tr}(O\\rho)", + "\\]", + ].join("\n") + + const html = renderMathInText(input) + expect(html).not.toContain("\\(") + expect(html).not.toContain("\\)") + expect(html).not.toContain("\\[") + expect(html).not.toContain("\\]") + }) +}) diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index a4f264ac6720..abbde1006830 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -378,19 +378,20 @@ export const OpenCodeTheme = { registerCustomTheme("OpenCode", () => Promise.resolve(OpenCodeTheme)) -function renderMathInText(text: string): string { +export function renderMathInText(text: string): string { let result = text - // Display math: $$...$$ - const displayMathRegex = /\$\$([\s\S]*?)\$\$/g - result = result.replace(displayMathRegex, (_, math) => { + // Display math: $$...$$ or \[...\] + const displayMathRegex = /\$\$([\s\S]*?)\$\$|\\\[([\s\S]*?)\\\]/g + result = result.replace(displayMathRegex, (match, dollarMath, bracketMath) => { + const math = dollarMath ?? bracketMath try { return katex.renderToString(math, { displayMode: true, throwOnError: false, }) } catch { - return `$$${math}$$` + return match } }) @@ -412,8 +413,9 @@ function renderMathInText(text: string): string { const inlineMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/ const blockMathRegex = /^\$\$\n([\s\S]+?)\n\$\$(?:\n|$)/ +const blockMathBracketRegex = /^\\\[\n([\s\S]+?)\n\\\](?:\n|$)/ -const katexExtension: MarkedExtension = { +export const katexExtension: MarkedExtension = { extensions: [ { name: "inlineKatex", @@ -439,7 +441,7 @@ const katexExtension: MarkedExtension = { name: "blockKatex", level: "block", tokenizer(src) { - const match = src.match(blockMathRegex) + const match = src.match(blockMathRegex) ?? src.match(blockMathBracketRegex) if (!match) return return { type: "blockKatex",