diff --git a/package.json b/package.json index a4ab5a427..fb2dd0b65 100644 --- a/package.json +++ b/package.json @@ -106,8 +106,12 @@ "eslint-plugin-prettier": "^5.5.5", "jsdom": "^25.0.1", "prettier": "^3.8.1", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", "tw-animate-css": "^1.4.0", "typescript": "~5.8.3", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", "vitest": "^2.1.8" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee8d8f06c..82731e62d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,12 +264,24 @@ importers: prettier: specifier: ^3.8.1 version: 3.8.1 + remark-math: + specifier: ^6.0.0 + version: 6.0.0 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 typescript: specifier: ~5.8.3 version: 5.8.3 + unified: + specifier: ^11.0.5 + version: 11.0.5 + unist-util-visit: + specifier: ^5.0.0 + version: 5.1.0 vitest: specifier: ^2.1.8 version: 2.1.9(@types/node@25.2.2)(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.8.3)) @@ -2602,6 +2614,7 @@ packages: '@testing-library/jest-dom@6.10.0': resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. peerDependencies: '@testing-library/dom': '>=10 <11' @@ -3029,6 +3042,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -4408,6 +4422,7 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true globals@14.0.0: diff --git a/src/components/ai-elements/math-delimiters.parse.test.ts b/src/components/ai-elements/math-delimiters.parse.test.ts new file mode 100644 index 000000000..a7205e256 --- /dev/null +++ b/src/components/ai-elements/math-delimiters.parse.test.ts @@ -0,0 +1,106 @@ +import { unified } from "unified" +import remarkParse from "remark-parse" +import remarkMath from "remark-math" +import { visit } from "unist-util-visit" +import { describe, expect, it } from "vitest" +import { normalizeMathDelimiters } from "./message" + +interface MathNode { + type: "inlineMath" | "math" + value: string + meta: string | null +} + +function parseMath(text: string, singleDollarTextMath = false): MathNode[] { + const tree = unified() + .use(remarkParse) + .use(remarkMath, { singleDollarTextMath }) + .parse(text) + const nodes: MathNode[] = [] + visit(tree, (node) => { + if (node.type === "inlineMath" || node.type === "math") { + const math = node as { + type: "inlineMath" | "math" + value: string + meta?: string | null + } + nodes.push({ + type: math.type, + value: math.value, + meta: math.meta ?? null, + }) + } + }) + return nodes +} + +describe("remark-math with singleDollarTextMath: false", () => { + it("does not parse currency pairs as inlineMath", () => { + const text = + "The Pro plan costs $9.99 but the Team plan costs $19.99 per month." + expect(parseMath(normalizeMathDelimiters(text))).toEqual([]) + }) + + it("treats $x$ as literal text (recorded: reverts b23f6a5a)", () => { + expect(parseMath("$x$")).toEqual([]) + expect(parseMath(normalizeMathDelimiters("$x$"))).toEqual([]) + }) + + it("does not parse shell variables as inlineMath", () => { + expect(parseMath("Set $HOME and $PATH before running.")).toEqual([]) + expect(parseMath("Use $1 and $2 as positional args.")).toEqual([]) + }) + + it("keeps single-line \\(...\\) as inline math after normalize", () => { + const nodes = parseMath(normalizeMathDelimiters("Also \\(x\\).")) + expect(nodes).toEqual([{ type: "inlineMath", value: "x", meta: null }]) + }) + + it("keeps multi-line \\(...\\) at the start of a block (does not drop the first line)", () => { + const one = parseMath(normalizeMathDelimiters("\\(a\nb\\)")) + expect(one).toHaveLength(1) + expect(one[0]?.type).toBe("inlineMath") + expect(one[0]?.value.replace(/\s+/g, "")).toBe("ab") + + const two = parseMath(normalizeMathDelimiters("\\(a\nb\n\\)")) + expect(two).toHaveLength(1) + expect(two[0]?.type).toBe("inlineMath") + expect(two[0]?.value).toContain("a") + expect(two[0]?.value).toContain("b") + }) + + it("keeps formula text when the closer sits on a continuation prefix", () => { + const quote = parseMath(normalizeMathDelimiters("> \\(a\n> b\n> \\)")) + expect(quote).toHaveLength(1) + expect(quote[0]?.type).toBe("inlineMath") + expect(quote[0]?.value.replace(/\s+/g, "")).toBe("ab") + + const list = parseMath( + normalizeMathDelimiters("- Note:\n \\(a\n b\n \\) holds.") + ) + expect(list).toHaveLength(1) + expect(list[0]?.type).toBe("inlineMath") + expect(list[0]?.value.replace(/\s+/g, "")).toBe("ab") + }) + + it("keeps wrapped list-continuation math as inline, not a flow fence", () => { + const nodes = parseMath( + normalizeMathDelimiters("- Note that\n \\(a + b\n = c\\) holds.") + ) + expect(nodes).toHaveLength(1) + expect(nodes[0]?.type).toBe("inlineMath") + expect(nodes[0]?.value.replace(/\s+/g, "")).toBe("a+b=c") + }) + + it("parses CR / CRLF multiline \\(...\\) as inline math", () => { + const crlf = parseMath(normalizeMathDelimiters("\\(a\r\n\\)")) + expect(crlf).toHaveLength(1) + expect(crlf[0]?.type).toBe("inlineMath") + expect(crlf[0]?.value.replace(/\s+/g, "")).toBe("a") + + const cr = parseMath(normalizeMathDelimiters("\\(a\rb\\)")) + expect(cr).toHaveLength(1) + expect(cr[0]?.type).toBe("inlineMath") + expect(cr[0]?.value.replace(/\s+/g, "")).toBe("ab") + }) +}) diff --git a/src/components/ai-elements/message.test.tsx b/src/components/ai-elements/message.test.tsx index 92d51f46c..296d9202b 100644 --- a/src/components/ai-elements/message.test.tsx +++ b/src/components/ai-elements/message.test.tsx @@ -34,7 +34,7 @@ vi.mock("@/components/ai-elements/link-safety", () => ({ useStreamdownLinkSafety: () => ({ enabled: false }), })) -import { MessageResponse } from "./message" +import { MessageResponse, normalizeMathDelimiters } from "./message" describe("MessageResponse", () => { it("applies marker styles so ordered Markdown lists render as lists", () => { @@ -46,3 +46,91 @@ describe("MessageResponse", () => { ) }) }) + +describe("normalizeMathDelimiters", () => { + it("normalizes \\[...\\] to $$...$$", () => { + expect(normalizeMathDelimiters("\\[ x^2 \\]")).toBe("$$ x^2 $$") + }) + + it("normalizes \\(...\\) to $$...$$", () => { + expect(normalizeMathDelimiters("\\( y \\)")).toBe("$$ y $$") + }) + + it("does not rewrite currency or shell $ tokens", () => { + // This helper only rewrites `\(`/`\[`. The real `$` fix is + // `singleDollarTextMath: false` — covered in math-delimiters.parse.test.ts. + const text = "Costs $25. Set $HOME and $1." + expect(normalizeMathDelimiters(text)).toBe(text) + }) + + it("pads multi-line \\(...\\) at the start of a block so $$ is not a flow fence", () => { + expect(normalizeMathDelimiters("\\(a\nb\\)")).toBe("\u200b$$a\nb$$") + expect(normalizeMathDelimiters("\\(a\nb\n\\)")).toBe("\u200b$$a\nb$$\n") + }) + + it("moves a prefix-only closer line after $$ so it cannot fence", () => { + expect(normalizeMathDelimiters("> \\(a\n> b\n> \\)")).toBe( + "> \u200b$$a\n> b$$\n> " + ) + expect(normalizeMathDelimiters("- Note:\n \\(a\n b\n \\) holds.")).toBe( + "- Note:\n \u200b$$a\n b$$\n holds." + ) + }) + + it("treats +, indent, extra marker spaces, and list continuation as fence prefixes", () => { + expect(normalizeMathDelimiters("+ \\(a\n b\\)")).toBe("+ \u200b$$a\n b$$") + expect(normalizeMathDelimiters(" \\(a\nb\\)")).toBe(" \u200b$$a\nb$$") + expect(normalizeMathDelimiters(" \\(a\nb\\)")).toBe(" \u200b$$a\nb$$") + expect(normalizeMathDelimiters("- \\(a\n b\\)")).toBe( + "- \u200b$$a\n b$$" + ) + expect(normalizeMathDelimiters("> \\(a\n> b\\)")).toBe( + "> \u200b$$a\n> b$$" + ) + expect( + normalizeMathDelimiters("- Note that\n \\(a + b\n = c\\) holds.") + ).toBe("- Note that\n \u200b$$a + b\n = c$$ holds.") + }) + + it("canonicalizes CR / CRLF before offset logic", () => { + // After LF fold, trailing newlines peel so the closer is not alone. + expect(normalizeMathDelimiters("\\(a\r\n\\)")).toBe("$$a$$\n") + expect(normalizeMathDelimiters("\\(a\rb\\)")).toBe("\u200b$$a\nb$$") + }) + + it("prefix scan stays linear on a deep failed prefix", () => { + const text = `${"> ".repeat(40)}x \\(a\nb\\)` + const start = performance.now() + const out = normalizeMathDelimiters(text) + expect(performance.now() - start).toBeLessThan(50) + expect(out).toContain("$$a\nb$$") + expect(out.startsWith("\u200b")).toBe(false) + }) + + it("does not pad mid-paragraph multi-line \\(...\\)", () => { + expect(normalizeMathDelimiters("text \\(a\nb\\) tail")).toBe( + "text $$a\nb$$ tail" + ) + }) + + it("does not collapse newlines inside \\(...\\) (TeX % comments)", () => { + expect(normalizeMathDelimiters("\\(a % comment\nb + c\\)")).toBe( + "\u200b$$a % comment\nb + c$$" + ) + }) + + it("preserves inline and fenced code blocks", () => { + expect(normalizeMathDelimiters("Use `$x` in `\\(y\\)`")).toBe( + "Use `$x` in `\\(y\\)`" + ) + expect(normalizeMathDelimiters("```\n\\(a\\)\n```")).toBe( + "```\n\\(a\\)\n```" + ) + }) + + it("normalizes mixed LaTeX and currency correctly", () => { + const input = "Costs $25 and the equation \\(x^2 + y^2\\)." + const expected = "Costs $25 and the equation $$x^2 + y^2$$." + expect(normalizeMathDelimiters(input)).toBe(expected) + }) +}) diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx index 17971d9e2..a47d6a92f 100644 --- a/src/components/ai-elements/message.tsx +++ b/src/components/ai-elements/message.tsx @@ -344,28 +344,171 @@ export const MessageBranchPage = ({ // / `/slash`-badging hooks were removed. export type MessageResponseProps = ComponentProps -// remark-math only supports `$` delimiters. Convert LaTeX-style -// `\[...\]` / `\(...\)` to `$$...$$` / `$...$` so they are recognized. -// Code blocks and inline code are preserved to avoid false positives. +// remark-math uses dollar delimiters. `\[...\]` / `\(...\)` are rewritten +// to `$$...$$`. Single-dollar `$...$` is disabled (`singleDollarTextMath: +// false`) so currency (`$9.99`) and shell vars (`$HOME`, `$1`) stay prose. +// A single-line `$$x$$` inside a paragraph stays *inline* math (mdast tags +// it `math-inline`); `$$` at column 0 of a line is a math FLOW fence. +// Multi-line `\(...\)` that would land `$$` at a fence position is padded +// on the opener. A prefix-only closer line is moved after `$$` (ZWSP on +// the closer either fences or lands inside the formula). Code / inline +// code is masked so delimiters inside them stay literal. CR / CRLF is +// folded to LF first so offset math matches what remark-parse sees on +// Windows files. export function normalizeMathDelimiters(text: string): string { + const canonical = text.replace(/\r\n|\r/g, "\n") const saved: string[] = [] const placeholder = (m: string) => { saved.push(m) return `\0CBLK${saved.length - 1}\0` } - const masked = text.replace( + const masked = canonical.replace( /`{3,}[\s\S]*?`{3,}|~{3,}[\s\S]*?~{3,}|`[^`\n]+`/g, placeholder ) const normalized = masked .replace(/\\\[([\s\S]*?)\\\]/g, (_m, inner: string) => `$$${inner}$$`) - .replace(/\\\(([\s\S]*?)\\\)/g, (_m, inner: string) => `$${inner}$`) + .replace(/\\\(([\s\S]*?)\\\)/g, (_m, inner: string, offset: number) => { + // Keep inner newlines (TeX `%` comments, `> \(a\n> b\)`). Only peel + // trailing newlines off the formula so the closer is not alone on a + // line (`\(a\nb\n\)` would otherwise become a display fence). + const trimmed = inner.replace(/\n+$/, "") + const after = inner.slice(trimmed.length) + if (!trimmed.includes("\n")) { + return `$$${trimmed}$$${after}` + } + // A closer `$$` on a container continuation (`\n> `, `\n `) is + // itself a flow fence. ZWSP before that closer lands *inside* the + // formula; ZWSP after it still fences. Move a prefix-only last + // line to after the closer instead. + const { body, prefixTail } = peelPrefixOnlyLastLine(trimmed) + // A leading space is not enough — math flow fences allow the same + // 0-3 spaces as ATX headings. A ZWSP keeps `$$` off column 0 + // without becoming a visible character or indented code. + // + // ZWSP is a real character every later matcher sees. Emphasis in + // the padded shapes is fine. A rare link-reference pair can stop + // matching if only one of the label / definition is padded. + const open = wouldStartMathFlowFence(masked, offset) ? MATH_FENCE_PAD : "" + return `${open}$$${body}$$${prefixTail}${after}` + }) return normalized.replace( /\0CBLK(\d+)\0/g, (_m, i: string) => saved[Number(i)] ) } +const MATH_FENCE_PAD = "\u200b" + +/** True when a `$$` emitted at `offset` would open a math flow fence. */ +function wouldStartMathFlowFence(source: string, offset: number): boolean { + const lineStart = source.lastIndexOf("\n", offset - 1) + 1 + return scanContainerPrefix(source, lineStart, offset) +} + +/** Split a prefix-only last line (`> `, list indent) off so `$$` is not there. */ +function peelPrefixOnlyLastLine(inner: string): { + body: string + prefixTail: string +} { + const nl = inner.lastIndexOf("\n") + if (nl < 0) return { body: inner, prefixTail: "" } + const lastLine = inner.slice(nl + 1) + if ( + lastLine.length > 0 && + scanContainerPrefix(lastLine, 0, lastLine.length) + ) { + return { body: inner.slice(0, nl), prefixTail: inner.slice(nl) } + } + return { body: inner, prefixTail: "" } +} + +/** + * Linear CommonMark-ish prefix walk. Consumes blockquote markers, list + * markers (`*`, `-`, `+`, ordered), their following spaces, and 0-3 + * spaces of indent / list-continuation. No backtracking. + */ +function scanContainerPrefix( + source: string, + start: number, + end: number +): boolean { + let i = start + while (true) { + let indent = 0 + while ( + i < end && + indent < 3 && + (source.charCodeAt(i) === 32 || source.charCodeAt(i) === 9) + ) { + indent += 1 + i += 1 + } + if (i >= end) return true + + const ch = source.charCodeAt(i) + if (ch === 62 /* > */) { + i += 1 + while ( + i < end && + (source.charCodeAt(i) === 32 || source.charCodeAt(i) === 9) + ) { + i += 1 + } + continue + } + + if (ch === 42 /* * */ || ch === 45 /* - */ || ch === 43 /* + */) { + i += 1 + if ( + i < end && + (source.charCodeAt(i) === 32 || source.charCodeAt(i) === 9) + ) { + while ( + i < end && + (source.charCodeAt(i) === 32 || source.charCodeAt(i) === 9) + ) { + i += 1 + } + continue + } + return false + } + + if (ch >= 48 && ch <= 57) { + let digits = 0 + while ( + i < end && + digits < 9 && + source.charCodeAt(i) >= 48 && + source.charCodeAt(i) <= 57 + ) { + digits += 1 + i += 1 + } + const marker = i < end ? source.charCodeAt(i) : 0 + if (digits > 0 && (marker === 46 /* . */ || marker === 41) /* ) */) { + i += 1 + if ( + i < end && + (source.charCodeAt(i) === 32 || source.charCodeAt(i) === 9) + ) { + while ( + i < end && + (source.charCodeAt(i) === 32 || source.charCodeAt(i) === 9) + ) { + i += 1 + } + continue + } + } + return false + } + + return false + } +} + const remarkPlugins = [ ...Object.values(defaultRemarkPlugins), remarkRewriteFileUriLinks, diff --git a/src/components/ai-elements/streamdown-plugins.test.ts b/src/components/ai-elements/streamdown-plugins.test.ts index 972251179..cf1f7df11 100644 --- a/src/components/ai-elements/streamdown-plugins.test.ts +++ b/src/components/ai-elements/streamdown-plugins.test.ts @@ -83,10 +83,14 @@ describe("detectHeavyPlugins", () => { expect(detectHeavyPlugins("prose with no indent or tab").code).toBe(false) }) - it("flags math for `$` and the pre-normalized `\\[` / `\\(` escapes", () => { - expect(detectHeavyPlugins("price $5").math).toBe(true) + it("flags math for $$ / \\[ / \\( / math fences, not a lone $", () => { + expect(detectHeavyPlugins("price $5").math).toBe(false) + expect(detectHeavyPlugins("Set $HOME and $1").math).toBe(false) + expect(detectHeavyPlugins("energy $$E=mc^2$$").math).toBe(true) expect(detectHeavyPlugins("\\[ x^2 \\]").math).toBe(true) expect(detectHeavyPlugins("\\( y \\)").math).toBe(true) + expect(detectHeavyPlugins("```math\nx\n```").math).toBe(true) + expect(detectHeavyPlugins("~~~math\nx\n~~~").math).toBe(true) expect(detectHeavyPlugins("no dollar, no math").math).toBe(false) }) @@ -122,7 +126,7 @@ describe("prefetchHeavyPlugins", () => { // math was never requested ⇒ its engine factory is never invoked. expect(mocks.createMathPlugin).not.toHaveBeenCalled() - const math = renderHook(() => useStreamdownPlugins("energy $E=mc^2$")) + const math = renderHook(() => useStreamdownPlugins("energy $$E=mc^2$$")) await waitFor(() => expect(math.result.current.math).toBeDefined()) const code = renderHook(() => useStreamdownPlugins("```\nx\n```")) // code was prefetched ⇒ available immediately, no waitFor needed. @@ -170,14 +174,16 @@ describe("useStreamdownPlugins", () => { expect(result.current.code).toMatchObject({ type: "code-highlighter" }) }) - it("loads math only, not code/mermaid, for a `$`-only document", async () => { - const { result } = renderHook(() => useStreamdownPlugins("energy $E=mc^2$")) + it("loads math only, not code/mermaid, for a `$$`-only document", async () => { + const { result } = renderHook(() => + useStreamdownPlugins("energy $$E=mc^2$$") + ) await waitFor(() => expect(result.current.math).toBeDefined()) expect(result.current.code).toBeUndefined() expect(result.current.mermaid).toBeUndefined() expect(mocks.createMathPlugin).toHaveBeenCalledWith({ - singleDollarTextMath: true, + singleDollarTextMath: false, }) }) diff --git a/src/components/ai-elements/streamdown-plugins.ts b/src/components/ai-elements/streamdown-plugins.ts index 913eede82..cf2ec91f9 100644 --- a/src/components/ai-elements/streamdown-plugins.ts +++ b/src/components/ai-elements/streamdown-plugins.ts @@ -31,15 +31,16 @@ export type HeavyKind = "code" | "math" | "mermaid" // when an engine resolves, upgrading the already-rendered fallback in place. // // Correctness: each engine only *affects* output when its trigger syntax is -// present (shiki only highlights fenced code; remark-math only transforms `$…$`; -// mermaid only replaces ```mermaid blocks). Loading exactly when the trigger -// appears therefore reproduces the eager-plugin output byte-for-byte, save for a -// one-time pre-load fallback render (plain code / literal `$…$` / mermaid source) -// that upgrades once the engine arrives. Detection errs LOOSE on purpose — a -// false positive merely pre-loads an engine that then no-ops, exactly matching -// the previous always-loaded behavior; a false *negative* would silently drop -// real rendering, so the math trigger is a superset (`$` OR the pre-normalized -// `\[` / `\(` delimiters that `normalizeMathDelimiters` maps to `$`). +// present (shiki only highlights fenced code; remark-math transforms `$$…$$` +// and ` ```math ` fences — not lone `$…$`; mermaid only replaces ```mermaid +// blocks). Loading exactly when the trigger appears therefore reproduces the +// eager-plugin output byte-for-byte, save for a one-time pre-load fallback +// render (plain code / literal `$…$` / mermaid source) that upgrades once the +// engine arrives. Detection errs LOOSE on purpose — a false positive merely +// pre-loads an engine that then no-ops, exactly matching the previous +// always-loaded behavior; a false *negative* would silently drop real +// rendering. The math trigger is `$$` / `\(`/`\[` / a `math` fence, never a +// lone `$` (currency and `$HOME` / `$1` are prose). const loaded: { code?: CodePlugin @@ -110,7 +111,12 @@ function ensure(kind: HeavyKind): void { } else if (kind === "math") { import("@streamdown/math") .then((mod) => { - loaded.math = mod.createMathPlugin({ singleDollarTextMath: true }) + // `$x$` is deliberately literal. That reverts b23f6a5a ("enable + // inline math formula rendering with single dollar signs"): `$VAR`, + // `$1`, and `$9.99` show up in agent prose far more than `$x$`, + // and `false` is @streamdown/math's own default. Inline math still + // works via `$$x$$` (stays inline in a paragraph) and `\(...\)`. + loaded.math = mod.createMathPlugin({ singleDollarTextMath: false }) }) .catch(() => {}) .finally(settle) @@ -165,11 +171,16 @@ export function detectHeavyPlugins(text: string): HeavyPluginNeeds { return { // Any fenced or indented block may want syntax highlighting. code: hasFence || hasIndentedCode, - // `$` is remark-math's only delimiter; `normalizeMathDelimiters` rewrites - // `\[…\]` / `\(…\)` to `$$…$$` / `$…$`, but a caller may detect on the raw - // pre-normalized text, so treat those escapes as math triggers too. No such - // token ⇒ remark-math is a no-op ⇒ katex is not needed. - math: text.includes("$") || text.includes("\\[") || text.includes("\\("), + // After `singleDollarTextMath: false`, a lone `$` can never produce math. + // `normalizeMathDelimiters` rewrites `\[…\]` / `\(...\)` to `$$…$$`. + // rehype-katex also renders ` ```math ` fences with no `$` anywhere, so + // those have to stay a trigger. Callers may detect on raw pre-normalized + // text, so `\(` / `\[` count too. + math: + text.includes("$$") || + text.includes("\\[") || + text.includes("\\(") || + /(?:```|~~~)[^\S\r\n]*math\b/i.test(text), // A ```mermaid (or ~~~mermaid) fence is the only thing the diagram engine // renders. mermaid: /(?:```|~~~)[^\S\r\n]*mermaid\b/i.test(text),