From 0da7234e053a8f457d5d1647885cb9380aee77af Mon Sep 17 00:00:00 2001 From: hsqbyte <22915141+hsqbyte@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:18:02 +0800 Subject: [PATCH] fix(markdown): don't rewrite \(...\) inside nested or unclosed code fences The code-masking regex in normalizeMathDelimiters paired any run of 3+ backticks with the *next* run of 3+ backticks. That ignores CommonMark's rule that a fence only closes on a same-char run at least as long as its opener, so: - a ````md block wrapping an inner ```js block was mis-split at the inner ``` fence, leaving the code between the two inner fences unmasked. Any \(x\) / \[x\] in that code got rewritten to $$x$$. - an unclosed fence (which every code block is, mid-stream) matched nothing and was left entirely unmasked, so its contents were rewritten and KaTeX flickered inside the code block until the closing fence arrived. Capture the opening fence and require the closer to be a run of the same char at least as long (\1`* / \2~*), or end of input. Prose math between two real fences still normalizes as before. --- .../ai-elements/math-delimiters.parse.test.ts | 26 +++++++++++++++++++ src/components/ai-elements/message.tsx | 10 ++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/components/ai-elements/math-delimiters.parse.test.ts b/src/components/ai-elements/math-delimiters.parse.test.ts index 69a91213e..6fedcb63a 100644 --- a/src/components/ai-elements/math-delimiters.parse.test.ts +++ b/src/components/ai-elements/math-delimiters.parse.test.ts @@ -161,6 +161,32 @@ describe("remark-math with singleDollarTextMath: false", () => { expect(parseMath(normalizeMathDelimiters(text))).toEqual([]) } }) + + it("does not rewrite \\(...\\) inside a fence nested in a longer fence", () => { + // ````md wraps a ```js block; the inner ``` must not close the outer + // ```` fence, so the code stays verbatim. + const text = "````md\n```js\nconst x = 1 // \\(x\\)\n```\n````" + expect(normalizeMathDelimiters(text)).toBe(text) + expect(parseMath(normalizeMathDelimiters(text))).toEqual([]) + }) + + it("does not rewrite \\(...\\) inside a still-open fence (streaming mid-state)", () => { + // A code block whose closing fence has not streamed in yet runs to EOF; + // its contents must not be touched while it is transiently unclosed. + const text = "Here is the file:\n```tex\n\\(a+b\\)\n" + expect(normalizeMathDelimiters(text)).toBe(text) + }) + + it("still rewrites \\(...\\) in prose between two fenced blocks", () => { + const text = + "```\ncode1 \\(keep\\)\n```\nprose \\(x\\) here\n```\ncode2\n```" + const out = normalizeMathDelimiters(text) + expect(out).toContain("$$x$$") + expect(out).toContain("\\(keep\\)") + expect(parseMath(out)).toEqual([ + { type: "inlineMath", value: "x", meta: null }, + ]) + }) }) describe("block structure around a normalized formula", () => { diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx index e4decc215..5d197033c 100644 --- a/src/components/ai-elements/message.tsx +++ b/src/components/ai-elements/message.tsx @@ -381,8 +381,16 @@ export function normalizeMathDelimiters(text: string): string { saved.push(m) return `\0CBLK${saved.length - 1}\0` } + // A fenced block closes only on a fence of the SAME char run at least as + // long as its opener (CommonMark 4.5), and an unclosed fence runs to EOF. + // Pairing any 3+ run with the next one (the old `` `{3,}[\s\S]*?`{3,} ``) + // mis-split a ```` ````md ```` block at its inner ``` ``` ```` fence and left + // the code inside unmasked, so `\(...\)` there got rewritten — and every + // streaming code block hit the same rewrite while its closing fence was + // still pending. Capture the opener and require the closer to be `\1`+ (or + // end of string). const masked = text.replace( - /`{3,}[\s\S]*?`{3,}|~{3,}[\s\S]*?~{3,}|`[^`\n]+`/g, + /(`{3,})[\s\S]*?(?:\1`*|$)|(~{3,})[\s\S]*?(?:\2~*|$)|`[^`\n]+`/g, placeholder ) // Fold line endings only after masking, so the offsets and line scans below