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