diff --git a/apps/web/package.json b/apps/web/package.json
index 4d10f89743b6..36b49cc28176 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -60,6 +60,7 @@
"jose": "catalog:",
"jsonc-parser": "3.3.1",
"jszip": "3.10.1",
+ "katex": "^0.16.22",
"lucide": "^0.564.0",
"lucide-react": "^0.564.0",
"mermaid": "^11.17.2",
@@ -67,6 +68,7 @@
"react": "19.2.6",
"react-dom": "19.2.6",
"react-markdown": "^10.1.0",
+ "rehype-katex": "^7.0.1",
"remark-gfm": "catalog:",
"tailwind-merge": "^3.4.0",
"unbash": "^5.0.0",
diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx
index d55986a9589e..860659313480 100644
--- a/apps/web/src/components/ChatMarkdown.test.tsx
+++ b/apps/web/src/components/ChatMarkdown.test.tsx
@@ -10,12 +10,14 @@ import { MarkdownFindContext } from "./chat/markdownFindContext";
import { act, type ComponentProps, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { create, type ReactTestRenderer } from "react-test-renderer";
-import { describe, expect, it, vi } from "vite-plus/test";
+import { beforeAll, describe, expect, it, vi } from "vite-plus/test";
import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
import { GitHubIcon } from "./Icons";
import { Button } from "./ui/button";
import { setMarkdownTaskChecked } from "./files/filePreviewMode";
+import { serializeRenderedMarkdownFragment } from "../markdown-clipboard";
+import katex from "katex";
vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null }));
vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
@@ -24,13 +26,18 @@ vi.mock("./chat/MermaidDiagram", () => ({
MermaidDiagram: () => ,
}));
vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
+const mathPreference = vi.hoisted(() => ({
+ latexRenderingMode: "on" as "off" | "readable" | "on",
+}));
vi.mock("../hooks/useSettings", async (importOriginal) => {
const actual = await importOriginal();
const settings = actual.getClientSettings();
return {
...actual,
- useClientSettings: (select?: (value: typeof settings) => unknown) =>
- select ? select(settings) : settings,
+ useClientSettings: (select?: (value: typeof settings) => unknown) => {
+ const current = { ...settings, ...mathPreference };
+ return select ? select(current) : current;
+ },
};
});
vi.mock("./ui/tooltip", async () => {
@@ -81,6 +88,7 @@ vi.mock("~/lib/openPullRequestLink", () => ({
useOpenChangeRequestLink: () => vi.fn(),
}));
+import { loadChatMathPlugins } from "./chat/useChatMath";
import ChatMarkdown, {
canUseMarkdownFileShellActions,
hasMarkdownFilePrimaryAction,
@@ -95,6 +103,327 @@ function codeButton(renderer: ReactTestRenderer, label: string) {
return button.props as ComponentProps;
}
+describe("ChatMarkdown math", () => {
+ beforeAll(() => Promise.all([loadChatMathPlugins(), loadChatMathPlugins("readable")]));
+ it.each([
+ { mode: "on", parseRawHtml: true },
+ { mode: "on", parseRawHtml: false },
+ { mode: "readable", parseRawHtml: true },
+ { mode: "readable", parseRawHtml: false },
+ ] as const)(
+ "keeps heading links and HTML handling with $mode math (parseRawHtml=$parseRawHtml)",
+ async ({ mode, parseRawHtml }) => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+ const authoredHtml = 'Formatted';
+ const text = `[Equation](#equation)\n\n## Equation\n\n\\(\\frac{17}{29}\\)\n\n${authoredHtml}`;
+ const url = window.location.href;
+ try {
+ mathPreference.latexRenderingMode = mode;
+ await act(async () =>
+ root.render(),
+ );
+ if (mode === "on") {
+ expect(container.querySelector("mfrac")?.textContent).toBe("1729");
+ } else {
+ expect(container.querySelector(".math-readable-inline")?.textContent).toBe("17 / 29");
+ }
+ expect(container.querySelector("script, [onclick]")).toBeNull();
+ if (parseRawHtml) {
+ expect(container.querySelector("b")?.textContent).toBe("Formatted");
+ } else {
+ expect(container.querySelector("b")).toBeNull();
+ expect(container.textContent).toContain(authoredHtml);
+ }
+
+ const heading = container.querySelector("h2")!;
+ expect(heading.id).toBe("user-content-equation");
+ const scrollIntoView = vi.fn();
+ heading.scrollIntoView = scrollIntoView;
+ const click = new MouseEvent("click", { bubbles: true, cancelable: true });
+ container.querySelector("a")!.dispatchEvent(click);
+ expect(click.defaultPrevented).toBe(true);
+ expect(scrollIntoView.mock.contexts).toEqual([heading]);
+ expect(window.location.href).toBe(url);
+ } finally {
+ mathPreference.latexRenderingMode = "on";
+ await act(async () => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+ }
+ },
+ );
+ it.each(["on", "readable"] as const)(
+ "keeps math fences as code in %s mode when an unrelated equation is added",
+ async (mode) => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ const fence = "```math\nx^2\n```";
+ try {
+ mathPreference.latexRenderingMode = mode;
+ await act(async () => root.render());
+ expect(container.querySelector("pre code")?.textContent).toContain("x^2");
+ await act(async () =>
+ root.render(),
+ );
+ expect(container.querySelector("pre code")?.textContent).toContain("x^2");
+ const selector = mode === "on" ? ".katex" : ".math-readable-inline";
+ expect(container.querySelectorAll(selector)).toHaveLength(1);
+ expect(container.querySelector(".katex-display, .math-readable-display")).toBeNull();
+ } finally {
+ mathPreference.latexRenderingMode = "on";
+ await act(async () => root.unmount());
+ vi.unstubAllGlobals();
+ }
+ },
+ );
+ it("skips visual HTML in readable mode without reusing it for typeset mode", async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const renderMath = vi.spyOn(katex, "renderToString");
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ const text = String.raw`\(\frac{139}{251}\)`;
+ try {
+ mathPreference.latexRenderingMode = "readable";
+ await act(async () => root.render());
+ expect(container.querySelector(".math-readable-inline")?.textContent).toBe("139 / 251");
+ expect(renderMath).toHaveBeenCalledTimes(1);
+ expect(renderMath.mock.calls[0]?.[1]).toMatchObject({ output: "mathml", trust: false });
+
+ mathPreference.latexRenderingMode = "on";
+ await act(async () =>
+ root.render(),
+ );
+ expect(container.querySelector(".katex-html")).not.toBeNull();
+ expect(renderMath).toHaveBeenCalledTimes(2);
+ expect(renderMath.mock.calls[1]?.[1]).toMatchObject({
+ output: "htmlAndMathml",
+ trust: false,
+ });
+ } finally {
+ mathPreference.latexRenderingMode = "on";
+ renderMath.mockRestore();
+ await act(async () => root.unmount());
+ vi.unstubAllGlobals();
+ }
+ });
+ it.each([
+ [String.raw`\frac{1}{2}`, "1 / 2"],
+ [String.raw`x^2+b_{12}`, "x^2 + b[12]"],
+ [String.raw`x_a^b`, "x[a]^b"],
+ [String.raw`\int_0^\pi \sin x\,dx=2`, "integral[0 to π]"],
+ [String.raw`\sum_{n=1}^{\infty}\frac{1}{n^2}`, "sum[n = 1 to ∞]"],
+ [String.raw`\lim_{x\to0}\frac{\sin x}{x}=1`, "lim(x → 0)"],
+ [String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, "sqrt(b^2 − 4 a c)"],
+ [String.raw`\begin{pmatrix}3&8\\-2&6\end{pmatrix}`, "3"],
+ [String.raw`f(x)=\begin{cases}x^2&x\ge0\\-x&x<0\end{cases}`, "cases:\nx^2 if x ≥ 0"],
+ ])("shows readable text for %s and preserves its source when copying", async (tex, expected) => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ try {
+ mathPreference.latexRenderingMode = "readable";
+ await act(async () => root.render());
+ const readable = container.querySelector(".math-readable-display");
+ expect(readable?.textContent).toContain(expected);
+ expect(container.querySelector(".katex")).toBeNull();
+ expect(container.querySelector("math")).toBeNull();
+ expect(serializeRenderedMarkdownFragment(container)).toContain(tex);
+ } finally {
+ mathPreference.latexRenderingMode = "on";
+ await act(async () => root.unmount());
+ vi.unstubAllGlobals();
+ }
+ });
+ it("reuses an unchanged equation while surrounding text streams", async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const renderMath = vi.spyOn(katex, "renderToString");
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ const tex = String.raw`\frac{137}{241}`;
+ try {
+ for (let index = 0; index < 10; index++) {
+ await act(async () =>
+ root.render(
+ ,
+ ),
+ );
+ expect(container.querySelector("mfrac")?.textContent).toBe("137241");
+ expect(container.textContent).toContain(`next ${index}`);
+ }
+ expect(renderMath).toHaveBeenCalledTimes(1);
+ } finally {
+ renderMath.mockRestore();
+ await act(async () => root.unmount());
+ vi.unstubAllGlobals();
+ }
+ });
+ it("shows source when disabled and restores equations when enabled", async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ const text = String.raw`Before \(x^2\) after.`;
+ try {
+ mathPreference.latexRenderingMode = "off";
+ await act(async () => root.render());
+ expect(container.querySelector(".katex")).toBeNull();
+ expect(container.textContent).toBe("Before (x^2) after.");
+ mathPreference.latexRenderingMode = "on";
+ await act(async () =>
+ root.render(),
+ );
+ expect(container.querySelector("msup")?.textContent).toBe("x2");
+ } finally {
+ mathPreference.latexRenderingMode = "on";
+ await act(async () => root.unmount());
+ vi.unstubAllGlobals();
+ }
+ });
+ it.each([
+ {
+ name: "definite integrals",
+ tex: String.raw`\int_0^\pi \sin x\,dx=2`,
+ selector: "msubsup",
+ rendered: "∫0π",
+ },
+ {
+ name: "improper integrals with square roots",
+ tex: String.raw`\int_{-\infty}^{\infty}e^{-x^2}\,dx=\sqrt{\pi}`,
+ selector: "msqrt",
+ rendered: "π",
+ },
+ {
+ name: "double integrals",
+ tex: String.raw`\int_0^1\int_0^1(x+y)\,dx\,dy=1`,
+ selector: "msubsup",
+ rendered: "∫01",
+ },
+ {
+ name: "infinite series",
+ tex: String.raw`\sum_{n=1}^{\infty}\frac{1}{n^2}=\frac{\pi^2}{6}`,
+ selector: "munderover",
+ rendered: "∑n=1∞",
+ },
+ {
+ name: "limits with fractions",
+ tex: String.raw`\lim_{x\to0}\frac{\sin x}{x}=1`,
+ selector: "munder",
+ rendered: "lim\u2061x→0",
+ },
+ {
+ name: "the quadratic formula",
+ tex: String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`,
+ selector: "msqrt",
+ rendered: "b2−4ac",
+ },
+ {
+ name: "aligned integration steps",
+ tex: String.raw`\begin{aligned}\int xe^x\,dx&=xe^x-\int e^x\,dx\\&=e^x(x-1)+C\end{aligned}`,
+ selector: "mtable mtr:last-child",
+ rendered: "=ex(x−1)+C",
+ },
+ {
+ name: "piecewise functions",
+ tex: String.raw`f(x)=\begin{cases}x^2&x\ge0\\-x&x<0\end{cases}`,
+ selector: "mtable mtr:last-child",
+ rendered: "−xx<0",
+ },
+ ])("typesets $name into readable MathML", async ({ tex, selector, rendered }) => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+ try {
+ await act(async () => {
+ root.render();
+ });
+ expect(container.querySelector(".katex-error")).toBeNull();
+ expect(container.querySelectorAll(".katex-display")).toHaveLength(1);
+ expect(container.querySelector("annotation")?.textContent?.trim()).toBe(tex);
+ expect(container.querySelector(selector)?.textContent).toBe(rendered);
+ } finally {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it.each([true, false])(
+ "renders the diagnostic matrix with parseRawHtml=%s",
+ async (parseRawHtml) => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+ try {
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ expect(container.querySelectorAll(".katex-display")).toHaveLength(1);
+ expect(container.querySelectorAll(".katex")).toHaveLength(3);
+ const rows = container.querySelectorAll("mtable mtr");
+ expect(
+ [...rows].map((row) => [...row.querySelectorAll("mtd")].map((cell) => cell.textContent)),
+ ).toEqual([
+ ["3", "8", "1"],
+ ["−2", "6", "4"],
+ ]);
+ expect(container.querySelector("msub")?.textContent).toBe("b12");
+ expect(container.querySelectorAll("pre")).toHaveLength(0);
+ const copied = serializeRenderedMarkdownFragment(container);
+ expect(copied).toContain(String.raw`B=\begin{pmatrix}3&8&1\\-2&6&4\end{pmatrix}`);
+ expect(copied).toContain(String.raw`\(b_{12}\)`);
+ } finally {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+ }
+ },
+ );
+
+ it("renders dollar math and keeps malformed LaTeX from breaking the message", async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ try {
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ expect(container.querySelectorAll(".katex")).toHaveLength(2);
+ expect(container.querySelector("mfrac")?.textContent).toBe("12");
+ expect(container.querySelector(".katex-error")?.textContent).toBe("\\frac{");
+ expect(container.textContent).toContain("and still readable.");
+ } finally {
+ await act(async () => root.unmount());
+ vi.unstubAllGlobals();
+ }
+ });
+});
+
describe("ChatMarkdown bare anchor placeholders", () => {
it.each(["", "", "", "", "", ""])(
"preserves unmatched %s without linking later blocks",
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index a7a2b0c4eba2..38558e2bfe87 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -98,6 +98,7 @@ import type {
Options as ReactMarkdownOptions,
} from "react-markdown";
import ReactMarkdown from "react-markdown";
+import { useChatMathPlugins } from "./chat/useChatMath";
import { toHtml } from "hast-util-to-html";
import { createIncrementalMarkdownPlugin } from "../markdown-incremental";
import { defaultUrlTransform } from "react-markdown";
@@ -3404,6 +3405,8 @@ function ChatMarkdown({
extraRemarkPlugins = EMPTY_REMARK_PLUGINS,
...props
}: ChatMarkdownProps) {
+ const mathMode = useClientSettings((settings) => settings.latexRenderingMode);
+ const mathPlugins = useChatMathPlugins(mathMode, text);
const {
componentState,
handleCopy,
@@ -3419,11 +3422,20 @@ function ChatMarkdown({
const remarkPlugins = useMemo(
() => [
...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
+ ...(mathPlugins?.remark ?? EMPTY_REMARK_PLUGINS),
...extraRemarkPlugins,
...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []),
],
- [extraRemarkPlugins, incrementalParsing, lineBreaks],
+ [extraRemarkPlugins, incrementalParsing, lineBreaks, mathPlugins],
);
+ const rehypePlugins = useMemo(() => {
+ if (!mathPlugins) {
+ return parseRawHtml
+ ? CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS
+ : CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS;
+ }
+ return [...(parseRawHtml ? mathPlugins.rehype : mathPlugins.literalRehype), rehypeHeadingIds];
+ }, [mathPlugins, parseRawHtml]);
// react-markdown converts unparsed HTML nodes to text when skipHtml is false.
// Keep that behavior explicit because literal mode depends on escaping the
@@ -3442,11 +3454,7 @@ function ChatMarkdown({
>[0];
+type RootContent = Root["children"][number];
+type Element = Extract;
+
+const MAX_CACHED_EXPRESSIONS = 64;
+const expressionCache = new Map();
+type MathOutput = "htmlAndMathml" | "mathml";
+
+function rehypeCachedKatex({ output }: { output: MathOutput }): ReturnType {
+ const render = rehypeKatex({ trust: false, output });
+ return (tree, file) => {
+ visit(tree);
+
+ function visit(parent: Root | Element) {
+ for (let index = 0; index < parent.children.length; index++) {
+ const child = parent.children[index];
+ if (child?.type !== "element") continue;
+ const code = child.tagName === "pre" ? child.children[0] : child;
+ const classes = code?.type === "element" ? code.properties.className : undefined;
+ if (
+ code?.type !== "element" ||
+ code.tagName !== "code" ||
+ !Array.isArray(classes) ||
+ !classes.some((name) => ["math-inline", "math-display"].includes(String(name)))
+ ) {
+ visit(child);
+ continue;
+ }
+ const value = code.children
+ .map((node) => (node.type === "text" ? node.value : ""))
+ .join("");
+ const display = child.tagName === "pre" || classes.includes("math-display");
+ const key = `${output}\0${display}\0${value}`;
+ let result = expressionCache.get(key);
+ if (!result) {
+ const fragment: Root = { type: "root", children: [child] };
+ render(fragment, file);
+ result = fragment.children;
+ expressionCache.set(key, structuredClone(result));
+ if (expressionCache.size > MAX_CACHED_EXPRESSIONS) {
+ expressionCache.delete(expressionCache.keys().next().value!);
+ }
+ } else {
+ // Each processor owns mutable nodes; never hand it the cached tree.
+ result = structuredClone(result);
+ }
+ parent.children.splice(index, 1, ...(result as Element["children"]));
+ index += result.length - 1;
+ }
+ }
+ };
+}
+
+export function createChatMathPlugins(output: MathOutput = "htmlAndMathml") {
+ const katexPlugins: PluggableList = [[rehypeCachedKatex, { output }]];
+ return {
+ remark: [remarkChatMath] satisfies PluggableList,
+ // Sanitize authored HTML before KaTeX generates its own MathML and styles.
+ rehype: [...CHAT_MARKDOWN_REHYPE_PLUGINS, ...katexPlugins],
+ literalRehype: katexPlugins,
+ };
+}
+
+export const CHAT_MATH_PLUGINS = createChatMathPlugins();
diff --git a/apps/web/src/components/chat/markdownMathReadable.ts b/apps/web/src/components/chat/markdownMathReadable.ts
new file mode 100644
index 000000000000..ece7782d128a
--- /dev/null
+++ b/apps/web/src/components/chat/markdownMathReadable.ts
@@ -0,0 +1,153 @@
+import type rehypeKatex from "rehype-katex";
+import { createChatMathPlugins } from "./markdownMath";
+
+type Root = Parameters>[0];
+type Node = Root["children"][number];
+type Element = Extract;
+
+function childrenOf(node: Node): Node[] {
+ return "children" in node ? node.children : [];
+}
+
+function findElement(node: Node, tagName: string): Element | undefined {
+ if (node.type === "element" && node.tagName === tagName) return node;
+ for (const child of childrenOf(node)) {
+ const found = findElement(child, tagName);
+ if (found) return found;
+ }
+}
+
+function grouped(value: string): string {
+ return /^[\p{L}\p{N}]+$/u.test(value) ? value : `(${value})`;
+}
+
+/** Linearize semantic MathML rather than guessing at TeX command boundaries. */
+function readableMath(node: Node): string {
+ if (node.type === "text") return node.value.replace(/[\u2061-\u2064]/g, "");
+ if (node.type !== "element") return "";
+ const children = node.children;
+ const values = children.map(readableMath);
+ const base = values[0] ?? "";
+ switch (node.tagName) {
+ case "annotation":
+ case "annotation-xml":
+ return "";
+ case "semantics":
+ return base;
+ case "mfrac":
+ return `${grouped(base)} / ${grouped(values[1] ?? "")}`;
+ case "msqrt":
+ return `sqrt(${values.join(" ")})`;
+ case "mroot":
+ return `root(${values[1]}, ${base})`;
+ case "msup":
+ return `${grouped(base)}^${grouped(values[1] ?? "")}`;
+ case "msub":
+ return `${grouped(base)}[${values[1]}]`;
+ case "msubsup":
+ case "munderover": {
+ const operator =
+ base === "∫" ? "integral" : base === "∑" ? "sum" : base === "∏" ? "product" : base;
+ if (base === "∫" || base === "∑" || base === "∏") {
+ return `${operator}[${values[1]} to ${values[2]}]`;
+ }
+ return node.tagName === "msubsup"
+ ? `${grouped(base)}[${values[1]}]^${grouped(values[2] ?? "")}`
+ : `${base} (with ${values[1]} below and ${values[2]} above)`;
+ }
+ case "munder":
+ return `${base}(${values[1]})`;
+ case "mover":
+ return `${base} (with ${values[1]} above)`;
+ case "mrow": {
+ const table = children.find(
+ (child) => child.type === "element" && child.tagName === "mtable",
+ );
+ if (base === "{" && table) {
+ return `cases:\n${childrenOf(table)
+ .map((row) => {
+ const cells = childrenOf(row).map(readableMath);
+ return `${cells[0]} if ${cells.slice(1).join(" ")}`;
+ })
+ .join("\n")}`;
+ }
+ return values
+ .join(" ")
+ .replace(/[ \t]+/g, " ")
+ .trim();
+ }
+ case "mtable": {
+ const rows = children.map((row) => childrenOf(row).map(readableMath));
+ const widths = rows.reduce((result, row) => {
+ row.forEach((cell, index) => {
+ result[index] = Math.max(result[index] ?? 0, cell.length);
+ });
+ return result;
+ }, []);
+ return `\n${rows
+ .map((row) =>
+ row
+ .map((cell, index) => cell.padEnd(widths[index] ?? 0))
+ .join(" ")
+ .trimEnd(),
+ )
+ .join("\n")}\n`;
+ }
+ case "mspace":
+ case "mphantom":
+ return " ";
+ default:
+ return values
+ .join(" ")
+ .replace(/[ \t]+/g, " ")
+ .trim();
+ }
+}
+
+function rehypeReadableMath(): ReturnType {
+ return (tree) => {
+ visit(tree);
+ function visit(parent: Root | Element) {
+ for (let index = 0; index < parent.children.length; index++) {
+ const node = parent.children[index];
+ if (node?.type !== "element") continue;
+ const classes = node.properties.className;
+ const display = Array.isArray(classes) && classes.includes("katex-display");
+ const inline = Array.isArray(classes) && classes.includes("katex");
+ if (Array.isArray(classes) && classes.includes("katex-error")) {
+ node.properties.className = ["math-readable-inline"];
+ delete node.properties.style;
+ continue;
+ }
+ if (!display && !inline) {
+ visit(node);
+ continue;
+ }
+ const math = findElement(node, "math");
+ const annotation = findElement(node, "annotation");
+ if (!math || !annotation) continue;
+ const displayMode = display || math.properties.display === "block";
+ const source = annotation.children
+ .map((child) => (child.type === "text" ? child.value : ""))
+ .join("");
+ parent.children[index] = {
+ type: "element",
+ tagName: "span",
+ properties: {
+ className: [displayMode ? "math-readable-display" : "math-readable-inline"],
+ dataMarkdownCopy: displayMode ? `\\[\n${source}\n\\]` : `\\(${source}\\)`,
+ },
+ children: [{ type: "text", value: readableMath(math).trim() }],
+ };
+ }
+ }
+ };
+}
+
+const mathmlPlugins = createChatMathPlugins("mathml");
+
+export const CHAT_MATH_PLUGINS = {
+ ...mathmlPlugins,
+ rehype: [...mathmlPlugins.rehype, rehypeReadableMath],
+ literalRehype: [...mathmlPlugins.literalRehype, rehypeReadableMath],
+};
diff --git a/apps/web/src/components/chat/markdownMathRendered.ts b/apps/web/src/components/chat/markdownMathRendered.ts
new file mode 100644
index 000000000000..c6542801c3b2
--- /dev/null
+++ b/apps/web/src/components/chat/markdownMathRendered.ts
@@ -0,0 +1,3 @@
+import "katex/dist/katex.min.css";
+
+export { CHAT_MATH_PLUGINS } from "./markdownMath";
diff --git a/apps/web/src/components/chat/useChatMath.test.tsx b/apps/web/src/components/chat/useChatMath.test.tsx
new file mode 100644
index 000000000000..d9f8583663cf
--- /dev/null
+++ b/apps/web/src/components/chat/useChatMath.test.tsx
@@ -0,0 +1,102 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import ReactMarkdown from "react-markdown";
+import {
+ CHAT_MARKDOWN_REMARK_PLUGINS,
+ CHAT_MARKDOWN_REHYPE_PLUGINS,
+} from "@t3tools/shared/markdownPipeline";
+import { expect, it, vi } from "vite-plus/test";
+import { useChatMathPlugins } from "./useChatMath";
+
+const imports = vi.hoisted(() => {
+ function ready() {
+ let resolve!: () => void;
+ const promise = new Promise((complete) => {
+ resolve = complete;
+ });
+ return { promise, resolve };
+ }
+ return { loaded: vi.fn(), on: ready(), readable: ready() };
+});
+
+vi.mock("./markdownMathRendered", () => {
+ imports.loaded("on");
+ imports.on.resolve();
+ return { CHAT_MATH_PLUGINS: { remark: [], rehype: [], literalRehype: [] } };
+});
+vi.mock("./markdownMathReadable", () => {
+ imports.loaded("readable");
+ imports.readable.resolve();
+ return { CHAT_MATH_PLUGINS: { remark: [], rehype: [], literalRehype: [] } };
+});
+
+function Markdown({ mode, text }: { mode: "off" | "on" | "readable"; text: string }) {
+ const plugins = useChatMathPlugins(mode, text);
+ return (
+
+ {text}
+
+ );
+}
+
+it("loads math only for delimiters outside code in both active modes", async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ const codeExamples = [
+ "Run `echo $$` to print the process ID.",
+ "Regex: `\\(x\\)` and `\\[x\\]`.",
+ "``code with ` and \\(x\\)``",
+ "`multiline\n\\(x\\)\ncode`",
+ "```sh\necho $$\n```",
+ "~~~regex\n\\(x\\)\n~~~",
+ "```math\n\\[x^2\\]\n```",
+ "````sh\n```\necho $$\n````",
+ "```sh\necho $$",
+ " echo $$\n \\(x\\)",
+ "> ```sh\n> echo $$\n> ```",
+ "- example:\n\n ```sh\n echo $$\n ```",
+ String.raw`Escaped \\(x\\), \\[x\\], and \$$.`,
+ "[example]: /regex/\\(x\\)",
+ ];
+ try {
+ for (const mode of ["off", "on", "readable"] as const) {
+ for (const text of codeExamples) {
+ await act(async () => root.render());
+ expect(imports.loaded, `${mode}: ${text}`).not.toHaveBeenCalled();
+ }
+ await act(async () =>
+ root.render(
+ Formatted with `echo $$` "}
+ />,
+ ),
+ );
+ expect(container.querySelector("b")?.textContent).toBe("Formatted");
+ expect(container.querySelector("script")).toBeNull();
+ expect(imports.loaded).not.toHaveBeenCalled();
+ }
+ await act(async () =>
+ root.render(),
+ );
+ expect(imports.loaded).not.toHaveBeenCalled();
+ for (const mode of ["on", "readable"] as const) {
+ const equation = mode === "on" ? String.raw`\(x^2\)` : "$$\nx^2\n$$";
+ const text = codeExamples[0] + "\n\n" + equation;
+ await act(async () => root.render());
+ await act(async () => imports[mode].promise);
+ expect(imports.loaded).toHaveBeenCalledWith(mode);
+ expect(container.querySelector("code")?.textContent).toBe("echo $$");
+ }
+ expect(imports.loaded).toHaveBeenCalledTimes(2);
+ } finally {
+ await act(async () => root.unmount());
+ vi.unstubAllGlobals();
+ }
+});
diff --git a/apps/web/src/components/chat/useChatMath.ts b/apps/web/src/components/chat/useChatMath.ts
new file mode 100644
index 000000000000..e16c722ee874
--- /dev/null
+++ b/apps/web/src/components/chat/useChatMath.ts
@@ -0,0 +1,82 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import type { LatexRenderingMode } from "@t3tools/contracts/settings";
+import type { Root, RootContent } from "mdast";
+import type { Plugin } from "unified";
+import { CHAT_MARKDOWN_REHYPE_PLUGINS } from "@t3tools/shared/markdownPipeline";
+
+type MathPlugins = typeof import("./markdownMath").CHAT_MATH_PLUGINS;
+type ActiveMathMode = Exclude;
+
+const loadedPlugins: Partial> = {};
+const loadingPlugins: Partial>> = {};
+
+function hasMathOpening(text: string): boolean {
+ if (!text.includes("\\(") && !text.includes("\\[") && !text.includes("$$")) return false;
+ for (const match of text.matchAll(/\\[([]|\$\$/g)) {
+ let preceding = match.index - 1;
+ while (text[preceding] === "\\") preceding--;
+ if ((match.index - preceding - 1) % 2 === 0) return true;
+ }
+ return false;
+}
+
+function hasMathOutsideCode(node: Root | RootContent, source: string): boolean {
+ if (node.type === "text") {
+ const start = node.position?.start.offset;
+ const end = node.position?.end.offset;
+ return start !== undefined && end !== undefined && hasMathOpening(source.slice(start, end));
+ }
+ return "children" in node && node.children.some((child) => hasMathOutsideCode(child, source));
+}
+
+export function loadChatMathPlugins(mode: ActiveMathMode = "on"): Promise {
+ loadingPlugins[mode] ??= (
+ mode === "on" ? import("./markdownMathRendered") : import("./markdownMathReadable")
+ )
+ .then(({ CHAT_MATH_PLUGINS }) => {
+ loadedPlugins[mode] = CHAT_MATH_PLUGINS;
+ return CHAT_MATH_PLUGINS;
+ })
+ .catch((error: unknown) => {
+ delete loadingPlugins[mode];
+ throw error;
+ });
+ return loadingPlugins[mode];
+}
+
+/** Ordinary messages keep their pipeline. Readable mode does not load typesetting styles or fonts. */
+export function useChatMathPlugins(mode: LatexRenderingMode, text: string) {
+ const needed = mode !== "off" && hasMathOpening(text);
+ const parsedMath = useRef({ source: "", needed: false });
+ const probePlugins = useMemo(() => {
+ // Reuse ReactMarkdown's parse to exclude code without parsing the message a second time.
+ const probe: Plugin<[], Root> = () => (tree, file) => {
+ const source = String(file.value);
+ parsedMath.current = { source, needed: hasMathOutsideCode(tree, source) };
+ };
+ return { remark: [probe], rehype: CHAT_MARKDOWN_REHYPE_PLUGINS, literalRehype: [] };
+ }, []);
+ const [loaded, setLoaded] = useState<{ mode: ActiveMathMode; plugins: MathPlugins }>();
+ const plugins =
+ mode !== "off"
+ ? (loadedPlugins[mode] ?? (loaded?.mode === mode ? loaded.plugins : undefined))
+ : undefined;
+ useEffect(() => {
+ if (!needed || plugins || parsedMath.current.source !== text || !parsedMath.current.needed) {
+ return;
+ }
+ let cancelled = false;
+ void loadChatMathPlugins(mode).then(
+ (result) => {
+ if (!cancelled) setLoaded({ mode, plugins: result });
+ },
+ () => {
+ // A failed chunk download leaves the original Markdown readable.
+ },
+ );
+ return () => {
+ cancelled = true;
+ };
+ }, [mode, needed, plugins, text]);
+ return needed ? (plugins ?? probePlugins) : undefined;
+}
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 131e8539282c..09775c4d3350 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -138,6 +138,7 @@ import {
} from "../ui/number-field";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
import { Switch } from "../ui/switch";
+import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
import { ScopedSwitch } from "./ScopedSwitch";
import { stackedThreadToast, toastManager } from "../ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
@@ -548,6 +549,9 @@ export function useSettingsRestore(onRestored?: () => void) {
? ["Diff colors"]
: []),
...(settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? ["Chat width"] : []),
+ ...(settings.latexRenderingMode !== DEFAULT_UNIFIED_SETTINGS.latexRenderingMode
+ ? ["LaTeX rendering"]
+ : []),
...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs
? ["Panel animations"]
: []),
@@ -675,6 +679,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.appearanceContrast,
settings.diffColorScheme,
settings.chatWidth,
+ settings.latexRenderingMode,
settings.enableAgentBrowserAccess,
settings.confirmQuit,
settings.confirmThreadArchive,
@@ -792,6 +797,7 @@ export function useSettingsRestore(onRestored?: () => void) {
appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth,
+ latexRenderingMode: DEFAULT_UNIFIED_SETTINGS.latexRenderingMode,
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled,
@@ -1457,6 +1463,44 @@ export function AppearanceSettingsPanel() {
}
/>
+
+ updateSettings({
+ latexRenderingMode: DEFAULT_UNIFIED_SETTINGS.latexRenderingMode,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+ {
+ const mode = values[0];
+ if (mode === "off" || mode === "readable" || mode === "on") {
+ updateSettings({ latexRenderingMode: mode });
+ }
+ }}
+ >
+
+ Off
+
+
+ Readable
+
+
+ On
+
+
+ }
+ />
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 0ee55ef3785b..fd9f766c0cee 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -276,6 +276,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/appearance",
searchTerms: ["wide full width column layout messages composer monitor"],
},
+ {
+ id: "latex-rendering",
+ title: "LaTeX rendering",
+ to: "/settings/appearance",
+ searchTerms: ["math equations formulas matrices integrals TeX readable plain text"],
+ },
{
id: "panel-animations",
title: "Panel animations",
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index dfdf09af249d..964fc4e0d906 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -1767,6 +1767,12 @@ code {
/* Chat markdown rendering */
+.chat-markdown .katex-display {
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding: 0.15rem 0;
+}
+
.chat-markdown > :first-child {
margin-top: 0;
}
@@ -2367,3 +2373,15 @@ code {
}
}
}
+
+.chat-markdown .math-readable-inline,
+.chat-markdown .math-readable-display {
+ font-family: var(--font-mono);
+ white-space: pre-wrap;
+}
+
+.chat-markdown .math-readable-display {
+ display: block;
+ margin-block: 1rem;
+ overflow-x: auto;
+}
diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts
index 39b941801638..815aa5c40cba 100644
--- a/apps/web/src/markdown-clipboard.ts
+++ b/apps/web/src/markdown-clipboard.ts
@@ -203,6 +203,12 @@ function serializeNode(node: Node): string {
}
const markdownCopy = element.getAttribute("data-markdown-copy");
if (markdownCopy !== null) return markdownCopy;
+ if (element.classList.contains("katex-display") || element.classList.contains("katex")) {
+ const tex = element.querySelector('annotation[encoding="application/x-tex"]')?.textContent;
+ if (tex !== undefined && tex !== null) {
+ return element.classList.contains("katex-display") ? `\\[\n${tex}\n\\]\n\n` : `\\(${tex}\\)`;
+ }
+ }
if (isSkippedElement(element)) return "";
const headingLevel = /^H([1-6])$/.exec(element.tagName)?.[1];
diff --git a/apps/web/src/markdown-incremental.test.tsx b/apps/web/src/markdown-incremental.test.tsx
index 3682deb281fa..0c3d19876e40 100644
--- a/apps/web/src/markdown-incremental.test.tsx
+++ b/apps/web/src/markdown-incremental.test.tsx
@@ -1,15 +1,15 @@
-import { CHAT_MARKDOWN_REHYPE_PLUGINS } from "@t3tools/shared/markdownPipeline";
+import {
+ CHAT_MARKDOWN_REHYPE_PLUGINS,
+ CHAT_MARKDOWN_REMARK_PLUGINS,
+} from "@t3tools/shared/markdownPipeline";
+import { remarkChatMath } from "@t3tools/shared/markdownMath";
import type { Root } from "mdast";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown from "react-markdown";
-import remarkGfm from "remark-gfm";
import type { Plugin } from "unified";
import { describe, expect, it } from "vite-plus/test";
-import { remarkCodexDirectives } from "@t3tools/shared/codexMarkdownDirectives";
-import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts";
import { createIncrementalMarkdownPlugin } from "./markdown-incremental";
-import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation";
function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) {
let tree: Root | undefined;
@@ -30,10 +30,8 @@ function render(source: string, incremental?: Plugin<[], Root>, parsedSources?:
remarkPlugins={[
observeParsing,
capture,
- remarkGfm,
- remarkGithubAlerts,
- remarkNormalizeListItemIndentation,
- remarkCodexDirectives,
+ ...CHAT_MARKDOWN_REMARK_PLUGINS,
+ remarkChatMath,
...(incremental ? [incremental] : []),
]}
rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS}
@@ -73,6 +71,8 @@ describe("incremental Markdown parsing", () => {
"\n\n\tcode\n\nmore",
"text *bold*",
"> [!NOTE]\n> alert\n\n- [ ] task",
+ String.raw`Inline \(b_{12}\), then \[\begin{pmatrix}3&8\\-2&6\end{pmatrix}\]`,
+ "\\[\nB=\\begin{pmatrix}3&8\\\\-2&6\\end{pmatrix}\n\\]",
"\uFEFFtext after a byte-order mark",
])("preserves the parse tree, positions, and HTML while streaming %j", (tail) => {
const source = prefix + tail;
diff --git a/docs/user/appearance.md b/docs/user/appearance.md
index ec02a28ed787..012549ded22e 100644
--- a/docs/user/appearance.md
+++ b/docs/user/appearance.md
@@ -21,6 +21,13 @@ Android uses **Material You Layout** by default unless you have turned it off in
It changes shapes, spacing, and controls independently
of the selected theme.
+## Math display
+
+On web and desktop, use **Settings → Appearance → LaTeX rendering** to choose how equations
+appear in chat. It is **Off** by default, which keeps the original source. Choose **On** to typeset
+equations, or **Readable** for plain text with readable fractions, powers, and integral limits. This
+preference is saved separately on each device or browser. Copying an equation preserves its LaTeX source.
+
## Composer context
Git-backed projects show branch and worktree controls below the composer while you create a thread.
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index deafcf507165..b3df4bacb3ab 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -23,6 +23,25 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
const encodeServerSettings = Schema.encodeSync(ServerSettings);
const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
+describe("ClientSettings LaTeX rendering", () => {
+ it("leaves math rendering off until it is turned on, in new and existing settings", () => {
+ expect(decodeClientSettings({}).latexRenderingMode).toBe("off");
+ expect(decodeClientSettings({ chatWidth: "wide" }).latexRenderingMode).toBe("off");
+ });
+
+ it.each(["off", "readable", "on"])(
+ "preserves the %s mode when saving and loading preferences",
+ (mode) => {
+ const preference = { latexRenderingMode: mode };
+ expect(decodeClientSettingsPatch(preference)).toEqual(preference);
+ expect(encodeClientSettings(decodeClientSettings(preference))).toMatchObject(preference);
+ },
+ );
+ it("rejects unsupported rendering modes", () => {
+ expect(() => decodeClientSettingsPatch({ latexRenderingMode: "other" })).toThrow();
+ });
+});
+
describe("ServerSettings response streaming", () => {
it("defaults to paragraph buffering", () => {
expect(decodeServerSettings({}).responseStreamingMode).toBe("paragraph");
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 2d3fd53e31f1..90e7e8290b8a 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -299,6 +299,9 @@ export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]);
export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]);
export type ChatWidth = typeof ChatWidth.Type;
+export const LatexRenderingMode = Schema.Literals(["off", "readable", "on"]);
+export type LatexRenderingMode = typeof LatexRenderingMode.Type;
+
/** Preferences shared by clients connected to the same environment. */
export const ModelFavorite = Schema.Struct({
// The historical field name is retained; default instance ids also decode old provider-kind favorites.
@@ -327,6 +330,10 @@ export const ClientSettingsSchema = Schema.Struct({
Schema.withDecodingDefault(Effect.succeed("red-green" as const)),
),
chatWidth: ChatWidth.pipe(Schema.withDecodingDefault(Effect.succeed("comfortable" as const))),
+ // Web and desktop render explicit LaTeX; native mobile keeps its existing renderer.
+ latexRenderingMode: LatexRenderingMode.pipe(
+ Schema.withDecodingDefault(Effect.succeed("off" as const)),
+ ),
loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
appearanceContrast: AppearanceContrast.pipe(
@@ -1668,6 +1675,7 @@ export const ClientSettingsPatch = Schema.Struct({
inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean),
diffColorScheme: Schema.optionalKey(DiffColorScheme),
chatWidth: Schema.optionalKey(ChatWidth),
+ latexRenderingMode: Schema.optionalKey(LatexRenderingMode),
loadBalancingEnabled: Schema.optionalKey(Schema.Boolean),
loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),
appearanceContrast: Schema.optionalKey(AppearanceContrast),
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 5e290d502e6f..3dd4d5705a10 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -443,6 +443,10 @@
"types": "./src/proposedPlanText.ts",
"import": "./src/proposedPlanText.ts"
},
+ "./markdownMath": {
+ "types": "./src/markdownMath.ts",
+ "import": "./src/markdownMath.ts"
+ },
"./markdownPipeline": {
"types": "./src/markdownPipeline.ts",
"import": "./src/markdownPipeline.ts"
@@ -495,7 +499,9 @@
"effect": "catalog:",
"jose": "catalog:",
"mdast-util-directive": "catalog:",
+ "mdast-util-math": "^3.0.0",
"micromark-extension-directive": "catalog:",
+ "micromark-extension-math": "^3.1.0",
"micromark-util-character": "catalog:",
"rehype-raw": "catalog:",
"rehype-sanitize": "catalog:",
@@ -509,6 +515,7 @@
"devDependencies": {
"@effect/platform-node": "catalog:",
"@effect/vitest": "catalog:",
+ "@types/mdast": "^4.0.4",
"@types/node": "catalog:",
"micromark-util-types": "catalog:",
"vite-plus": "catalog:"
diff --git a/packages/shared/src/markdownMath.test.ts b/packages/shared/src/markdownMath.test.ts
new file mode 100644
index 000000000000..e32b3679c8b8
--- /dev/null
+++ b/packages/shared/src/markdownMath.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from "vite-plus/test";
+import { unified } from "unified";
+import type { Root } from "mdast";
+import remarkParse from "remark-parse";
+import {
+ CHAT_MARKDOWN_REMARK_PLUGINS,
+ CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS,
+} from "./markdownPipeline.ts";
+
+import { remarkChatMath } from "./markdownMath.ts";
+
+const mathPlugins = [...CHAT_MARKDOWN_REMARK_PLUGINS, remarkChatMath];
+const mathPluginsWithBreaks = [...CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, remarkChatMath];
+
+describe("chat math parsing", () => {
+ it.each(["$$\nx^2", "> $$\n> x^2\n\noutside", "- $$\n x^2\n\noutside"])(
+ "keeps an unfinished dollar block literal: %s",
+ (source) => {
+ const processor = unified().use(remarkParse).use(mathPlugins);
+ const tree = processor.runSync(processor.parse(source)) as Root;
+ expect(JSON.stringify(tree)).not.toContain('"type":"math"');
+ expect(JSON.stringify(tree)).not.toContain('"type":"inlineMath"');
+ },
+ );
+
+ it("keeps single-dollar text and links intact", () => {
+ const processor = unified().use(remarkParse).use(mathPlugins);
+ const tree = processor.runSync(
+ processor.parse("Use $TOKEN, then [docs](/users?$select=id)."),
+ ) as Root;
+ expect(tree.children[0]).toMatchObject({
+ type: "paragraph",
+ children: [
+ { type: "text", value: "Use $TOKEN, then " },
+ { type: "link", url: "/users?$select=id" },
+ { type: "text", value: "." },
+ ],
+ });
+ });
+ it.each([[mathPlugins], [mathPluginsWithBreaks]])(
+ "preserves matrix row separators and source positions",
+ (plugins) => {
+ const processor = unified().use(remarkParse).use(plugins);
+ const source = String.raw`Before \(
+B=\begin{pmatrix}3&8&1\\-2&6&4\end{pmatrix}
+\) after`;
+ const tree = processor.runSync(processor.parse(source)) as Root;
+ const paragraph = tree.children[0];
+ expect(paragraph?.type).toBe("paragraph");
+ if (paragraph?.type !== "paragraph") throw new Error("Missing paragraph");
+ const math = paragraph.children[1];
+ expect(math).toMatchObject({
+ type: "inlineMath",
+ value: "\nB=\\begin{pmatrix}3&8&1\\\\-2&6&4\\end{pmatrix}\n",
+ });
+ expect(source.slice(math?.position?.start.offset, math?.position?.end.offset)).toBe(
+ source.slice(7, -6),
+ );
+ },
+ );
+
+ it("marks backslash display math without changing ordinary Markdown escapes", () => {
+ const processor = unified().use(remarkParse).use(mathPlugins);
+ const source = String.raw`\[x_{12}\] and \[literal bracket and \(x\)`;
+ const tree = processor.runSync(processor.parse(source)) as Root;
+ expect(tree.children[0]).toMatchObject({
+ type: "paragraph",
+ children: [
+ {
+ type: "inlineMath",
+ value: "x_{12}",
+ data: { hProperties: { className: ["language-math", "math-display"] } },
+ },
+ { type: "text", value: " and [literal bracket and " },
+ { type: "inlineMath", value: "x" },
+ ],
+ });
+ });
+
+ it("keeps code examples, escaped delimiters, and unfinished math literal", () => {
+ const processor = unified().use(remarkParse).use(mathPlugins);
+ const source =
+ "Inline: `\\(x\\) $x$`" +
+ String.raw`
+
+~~~tex
+\[x\]
+~~~
+
+Escaped \\(x\\), price \$5, unfinished \(x`;
+ const tree = processor.runSync(processor.parse(source)) as Root;
+ expect(JSON.stringify(tree)).not.toContain('"type":"inlineMath"');
+ expect(JSON.stringify(tree)).not.toContain('"type":"math"');
+ expect(tree.children[1]).toMatchObject({ type: "code", value: "\\[x\\]" });
+ });
+
+ it("preserves skill mentions and prices that use single dollars", () => {
+ const processor = unified().use(remarkParse).use(mathPlugins);
+ const tree = processor.runSync(
+ processor.parse("Use $2spec with a $20k budget; $5 or $10."),
+ ) as Root;
+ expect(tree.children[0]).toMatchObject({
+ type: "paragraph",
+ children: [{ type: "text", value: "Use $2spec with a $20k budget; $5 or $10." }],
+ });
+ });
+});
diff --git a/packages/shared/src/markdownMath.ts b/packages/shared/src/markdownMath.ts
new file mode 100644
index 000000000000..f7f94d9a8be1
--- /dev/null
+++ b/packages/shared/src/markdownMath.ts
@@ -0,0 +1,143 @@
+import { mathFromMarkdown } from "mdast-util-math";
+import { math } from "micromark-extension-math";
+import { markdownLineEnding } from "micromark-util-character";
+import type { Construct, Extension, Tokenizer } from "micromark-util-types";
+import type { Processor } from "unified";
+
+// Use the math extension's tokens so backslash delimiters keep the same AST and source
+// positions as dollar math. Recognize them before CommonMark consumes escapes.
+const tokenizeLatexMath: Tokenizer = (effects, ok, nok) => {
+ let closing = 0;
+ let dataOpen = false;
+ const end: Construct = {
+ partial: true,
+ tokenize(effects, ok, nok) {
+ return (code) => {
+ effects.enter("mathTextSequence");
+ effects.consume(code!);
+ return (code) => {
+ if (code !== closing) return nok(code);
+ effects.consume(code);
+ effects.exit("mathTextSequence");
+ return ok;
+ };
+ };
+ },
+ };
+
+ return (code) => {
+ effects.enter("mathText");
+ effects.enter("mathTextSequence");
+ effects.consume(code!);
+ return (code) => {
+ if (code !== 40 && code !== 91) return nok(code);
+ closing = code === 40 ? 41 : 93;
+ effects.consume(code);
+ effects.exit("mathTextSequence");
+ return body;
+ };
+ };
+
+ function body(code: number | null) {
+ if (code === null) return nok(code);
+ if (code === 92) return effects.check(end, close, escape)(code);
+ if (markdownLineEnding(code)) {
+ if (dataOpen) effects.exit("mathTextData");
+ dataOpen = false;
+ effects.enter("lineEnding");
+ effects.consume(code);
+ effects.exit("lineEnding");
+ return body;
+ }
+ openData();
+ effects.consume(code);
+ return body;
+ }
+
+ function escape(code: number | null) {
+ openData();
+ effects.consume(code!);
+ return (code: number | null) => {
+ if (code === null || markdownLineEnding(code)) return body(code);
+ effects.consume(code);
+ return body;
+ };
+ }
+
+ function close(code: number | null) {
+ if (dataOpen) effects.exit("mathTextData");
+ effects.enter("mathTextSequence");
+ effects.consume(code!);
+ return (code: number | null) => {
+ effects.consume(code!);
+ effects.exit("mathTextSequence");
+ effects.exit("mathText");
+ return ok;
+ };
+ }
+
+ function openData() {
+ if (!dataOpen) effects.enter("mathTextData");
+ dataOpen = true;
+ }
+};
+
+const LATEX_MATH_SYNTAX: Extension = { text: { 92: { tokenize: tokenizeLatexMath } } };
+
+/** Parse dollar math and the LaTeX delimiters commonly emitted by providers. */
+function remarkChatMath(this: Processor) {
+ // oxlint-disable-next-line oxc/no-this-in-exported-function -- Unified binds plugins to their processor.
+ const data = this.data();
+ // Single dollars are skill mentions and prices in chat. Use \(...\) for
+ // inline math and \[...\] or $$ fences for display math.
+ const syntax = math({ singleDollarTextMath: false });
+ const flow = syntax.flow![36] as Construct;
+ syntax.flow = {
+ 36: {
+ ...flow,
+ tokenize(effects, ok, nok) {
+ const start = this.events.length;
+ return flow.tokenize.call(
+ this,
+ effects,
+ (code) => {
+ if (this.interrupt) return ok(code);
+ let fences = 0;
+ for (let index = start; index < this.events.length; index++) {
+ const event = this.events[index]!;
+ if (event[0] === "exit" && event[1].type === "mathFlowFence") fences++;
+ }
+ // Unlike code fences, unfinished math stays literal while streaming.
+ return fences > 1 ? ok(code) : nok(code);
+ },
+ nok,
+ );
+ },
+ },
+ };
+ (data.micromarkExtensions ??= []).push(syntax, LATEX_MATH_SYNTAX);
+ const fromMarkdown = mathFromMarkdown();
+ fromMarkdown.enter!.mathText = function (token) {
+ this.enter(
+ {
+ type: "inlineMath",
+ value: "",
+ data: {
+ hName: "code",
+ hProperties: {
+ className: [
+ "language-math",
+ this.sliceSerialize(token).startsWith("\\[") ? "math-display" : "math-inline",
+ ],
+ },
+ hChildren: [],
+ },
+ },
+ token,
+ );
+ this.buffer();
+ };
+ (data.fromMarkdownExtensions ??= []).push(fromMarkdown);
+}
+
+export { remarkChatMath };
diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts
index 7317dcc5b9ff..069511911acd 100644
--- a/packages/shared/src/markdownPipeline.ts
+++ b/packages/shared/src/markdownPipeline.ts
@@ -145,7 +145,14 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
attributes: {
...defaultSchema.attributes,
"*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),
- code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],
+ code: [
+ ...(defaultSchema.attributes?.code ?? []).filter(
+ (attribute) => !(Array.isArray(attribute) && attribute[0] === "className"),
+ ),
+ ["className", /^language-./, "math-inline", "math-display"],
+ "dataCodeMeta",
+ "dataInlineCode",
+ ],
blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],
div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES],
a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"],
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e3b0094e6e3b..4447a6af6eb2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -890,6 +890,9 @@ importers:
jszip:
specifier: 3.10.1
version: 3.10.1
+ katex:
+ specifier: ^0.16.22
+ version: 0.16.47
lucide:
specifier: ^0.564.0
version: 0.564.0
@@ -911,6 +914,9 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.3.0)(react@19.2.6)
+ rehype-katex:
+ specifier: ^7.0.1
+ version: 7.0.1
remark-gfm:
specifier: 'catalog:'
version: 4.0.1
@@ -1509,9 +1515,15 @@ importers:
mdast-util-directive:
specifier: 'catalog:'
version: 3.1.0
+ mdast-util-math:
+ specifier: ^3.0.0
+ version: 3.0.0
micromark-extension-directive:
specifier: 'catalog:'
version: 4.0.0
+ micromark-extension-math:
+ specifier: ^3.1.0
+ version: 3.1.0
micromark-util-character:
specifier: 'catalog:'
version: 2.1.1
@@ -1546,6 +1558,9 @@ importers:
'@effect/vitest':
specifier: 4.0.2
version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ '@types/mdast':
+ specifier: ^4.0.4
+ version: 4.0.4
'@types/node':
specifier: 24.12.4
version: 24.12.4
@@ -6370,6 +6385,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+ '@types/katex@0.16.8':
+ resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
+
'@types/keyv@3.1.4':
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
@@ -9126,9 +9144,21 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
+ hast-util-from-dom@5.0.1:
+ resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
+
+ hast-util-from-html-isomorphic@2.0.0:
+ resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==}
+
+ hast-util-from-html@2.0.3:
+ resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
+
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
+ hast-util-is-element@3.0.0:
+ resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
+
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
@@ -9147,6 +9177,9 @@ packages:
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
+ hast-util-to-text@4.0.2:
+ resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
+
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
@@ -9752,6 +9785,9 @@ packages:
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
+ mdast-util-math@3.0.0:
+ resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==}
+
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
@@ -9893,6 +9929,9 @@ packages:
micromark-extension-gfm@3.0.0:
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
+ micromark-extension-math@3.1.0:
+ resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==}
+
micromark-factory-destination@2.0.1:
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
@@ -10988,6 +11027,9 @@ packages:
resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==}
hasBin: true
+ rehype-katex@7.0.1:
+ resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
+
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
@@ -11743,12 +11785,18 @@ packages:
unifont@0.7.5:
resolution: {integrity: sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==}
+ unist-util-find-after@5.0.0:
+ resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
+
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
unist-util-position@5.0.0:
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+ unist-util-remove-position@5.0.0:
+ resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==}
+
unist-util-stringify-position@4.0.0:
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
@@ -16991,6 +17039,8 @@ snapshots:
'@types/json-schema@7.0.15': {}
+ '@types/katex@0.16.8': {}
+
'@types/keyv@3.1.4':
dependencies:
'@types/node': 24.12.4
@@ -19830,6 +19880,28 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ hast-util-from-dom@5.0.1:
+ dependencies:
+ '@types/hast': 3.0.5
+ hastscript: 9.0.1
+ web-namespaces: 2.0.1
+
+ hast-util-from-html-isomorphic@2.0.0:
+ dependencies:
+ '@types/hast': 3.0.5
+ hast-util-from-dom: 5.0.1
+ hast-util-from-html: 2.0.3
+ unist-util-remove-position: 5.0.0
+
+ hast-util-from-html@2.0.3:
+ dependencies:
+ '@types/hast': 3.0.5
+ devlop: 1.1.0
+ hast-util-from-parse5: 8.0.3
+ parse5: 7.3.0
+ vfile: 6.0.3
+ vfile-message: 4.0.3
+
hast-util-from-parse5@8.0.3:
dependencies:
'@types/hast': 3.0.5
@@ -19841,6 +19913,10 @@ snapshots:
vfile-location: 5.0.3
web-namespaces: 2.0.1
+ hast-util-is-element@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.5
+
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.5
@@ -19911,6 +19987,13 @@ snapshots:
web-namespaces: 2.0.1
zwitch: 2.0.4
+ hast-util-to-text@4.0.2:
+ dependencies:
+ '@types/hast': 3.0.5
+ '@types/unist': 3.0.3
+ hast-util-is-element: 3.0.0
+ unist-util-find-after: 5.0.0
+
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -20534,6 +20617,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ mdast-util-math@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.5
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ longest-streak: 3.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ unist-util-remove-position: 5.0.0
+ transitivePeerDependencies:
+ - supports-color
+
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -20912,6 +21007,16 @@ snapshots:
micromark-util-combine-extensions: 2.0.1
micromark-util-types: 2.0.2
+ micromark-extension-math@3.1.0:
+ dependencies:
+ '@types/katex': 0.16.8
+ devlop: 1.1.0
+ katex: 0.16.47
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
micromark-factory-destination@2.0.1:
dependencies:
micromark-util-character: 2.1.1
@@ -22295,6 +22400,16 @@ snapshots:
dependencies:
jsesc: 3.1.0
+ rehype-katex@7.0.1:
+ dependencies:
+ '@types/hast': 3.0.5
+ '@types/katex': 0.16.8
+ hast-util-from-html-isomorphic: 2.0.0
+ hast-util-to-text: 4.0.2
+ katex: 0.16.47
+ unist-util-visit-parents: 6.0.2
+ vfile: 6.0.3
+
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.5
@@ -23168,6 +23283,11 @@ snapshots:
ohash: 2.0.12
undici: 8.11.2
+ unist-util-find-after@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3
@@ -23176,6 +23296,11 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
+ unist-util-remove-position@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-visit: 5.1.0
+
unist-util-stringify-position@4.0.0:
dependencies:
'@types/unist': 3.0.3
diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json
index a2eb54574ffe..da215badcdc4 100644
--- a/third-party-licenses.config.json
+++ b/third-party-licenses.config.json
@@ -189,6 +189,16 @@
"copyrights": ["Copyright (c) 2025 Aiden Bai"]
}
},
+ {
+ "license": "MIT",
+ "name": "rehype-katex",
+ "version": "7.0.1",
+ "sourceUrl": "https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex",
+ "generatedNotice": {
+ "licenseId": "MIT",
+ "copyrights": ["Copyright (c) Junyoung Choi "]
+ }
+ },
{
"name": "glob-to-regexp",
"generatedNotice": {