Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

106 changes: 106 additions & 0 deletions src/components/ai-elements/math-delimiters.parse.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
90 changes: 89 additions & 1 deletion src/components/ai-elements/message.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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)
})
})
Loading
Loading