diff --git a/web/src/components/deer-flow/markdown.tsx b/web/src/components/deer-flow/markdown.tsx index 0a81958..567bae3 100644 --- a/web/src/components/deer-flow/markdown.tsx +++ b/web/src/components/deer-flow/markdown.tsx @@ -13,6 +13,7 @@ import "katex/dist/katex.min.css"; import { Button } from "~/components/ui/button"; import { rehypeSplitWordsIntoSpans } from "~/core/rehype"; +import { katexOptions } from "~/core/markdown/katex"; import { autoFixMarkdown } from "~/core/utils/markdown"; import { cn } from "~/lib/utils"; @@ -50,11 +51,15 @@ export function Markdown({ }; }, [checkLinkCredibility]); - const rehypePlugins = useMemo(() => { + const rehypePlugins = useMemo>(() => { + const plugins: NonNullable = [[ + rehypeKatex, + katexOptions, + ]]; if (animated) { - return [rehypeKatex, rehypeSplitWordsIntoSpans]; + plugins.push(rehypeSplitWordsIntoSpans); } - return [rehypeKatex]; + return plugins; }, [animated]); return (
diff --git a/web/src/core/markdown/katex.ts b/web/src/core/markdown/katex.ts new file mode 100644 index 0000000..767b83b --- /dev/null +++ b/web/src/core/markdown/katex.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT + +import type { Options as RehypeKatexOptions } from "rehype-katex"; + +import "katex/contrib/mhchem"; + +const macros = { + "\\vect": "\\mathbf{#1}", + "\\mat": "\\mathbf{#1}", + "\\grad": "\\nabla #1", + "\\div": "\\nabla \\cdot #1", + "\\curl": "\\nabla \\times #1", + "\\dv": "\\frac{d #1}{d #2}", + "\\pdv": "\\frac{\\partial #1}{\\partial #2}", + "\\pdvN": "\\frac{\\partial^{#3} #1}{\\partial #2^{#3}}", + "\\abs": "\\left|#1\\right|", + "\\norm": "\\left\\lVert#1\\right\\rVert", + "\\set": "\\left\\{#1\\right\\}", + "\\bra": "\\left\\langle#1\\right|", + "\\ket": "\\left|#1\\right\\rangle", + "\\braket": "\\left\\langle#1\\middle|#2\\right\\rangle", + "\\matrix": "\\begin{pmatrix}#1\\end{pmatrix}", +} as const; + +export const katexOptions: RehypeKatexOptions = { + macros, + strict: "ignore", + trust: (context) => context.command === "\\htmlClass" || context.command === "\\href", +}; + +export type KatexMacroKey = keyof typeof macros; diff --git a/web/tests/markdown-katex.test.ts b/web/tests/markdown-katex.test.ts new file mode 100644 index 0000000..b627494 --- /dev/null +++ b/web/tests/markdown-katex.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import katex from "katex"; + +import { katexOptions } from "../src/core/markdown/katex"; + +function render(expression: string) { + return katex.renderToString(expression, { + ...katexOptions, + displayMode: true, + }); +} + +describe("markdown physics katex support", () => { + it("renders vector calculus operators", () => { + assert.doesNotThrow(() => { + render("\\curl{\\vect{B}} = \\mu_0 \\vect{J} + \\mu_0 \\varepsilon_0 \\pdv{\\vect{E}}{t}"); + }); + }); + + it("renders quantum mechanics bra-ket notation", () => { + const html = render("\\braket{\\psi}{\\phi}"); + assert.ok(html.includes("⟨") && html.includes("⟩")); + }); + + it("renders vector magnitude formula with subscripts and square root", () => { + const html = render("(F_1) (F_2), (F=\\sqrt{F_1^2+F_2^2})"); + assert.ok(html.includes("F")); + assert.ok(html.includes("₁") || html.includes("sub")); // subscript check + assert.ok(html.includes("√") || html.includes("sqrt")); // square root check + }); + + it("renders chemical equations via mhchem", () => { + assert.doesNotThrow(() => { + render("\\ce{H2O ->[\\Delta] H+ + OH-}"); + }); + }); +});