From 7c9e8a135eb17e146845de0969f57e0cb5853e64 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Mon, 29 Jun 2026 12:49:12 -0700 Subject: [PATCH] Add LaTeX math rendering to MarkdownPreview (#441) Render LaTeX in the shared MarkdownPreview component, which backs both the timeline message renderer and the markdown file preview. Math is parsed with remark-math and rendered with rehype-katex (KaTeX CSS imported); for the allowHtml file-preview path, rehype-katex runs after rehype-sanitize so the language-math wrappers the default schema preserves are rendered without re-sanitizing KaTeX's own (trust:false) output. GitHub-style delimiters: $x$ inline and $$x$$ block. Single-dollar math is on, so a literal $ in prose is escaped with \$. Also widen isGutterUtilityPath to accept the [EventTarget?] shape that @types/node's web-globals give Event.composedPath(), clearing a pre-existing typecheck failure. Tests: inline/display/escaped-dollar/invalid-TeX and the allowHtml math+sanitize path; added a "math (LaTeX)" story row. Full @bb/app suite and build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/app/package.json | 3 + .../git-diff/PierreLineSelectionActions.tsx | 7 +- .../ui/markdown-preview.stories.tsx | 21 +++ .../components/ui/markdown-preview.test.tsx | 49 +++++++ .../src/components/ui/markdown-preview.tsx | 31 ++++- pnpm-lock.yaml | 131 ++++++++++++++++++ 6 files changed, 238 insertions(+), 4 deletions(-) diff --git a/apps/app/package.json b/apps/app/package.json index dbf260a85..deafba89f 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -59,6 +59,7 @@ "cronstrue": "^3.14.0", "jotai": "^2.19.0", "jotai-family": "^1.0.1", + "katex": "^0.16.22", "mermaid": "^11.15.0", "nanoid": "^5.1.6", "partysocket": "^1.1.16", @@ -67,10 +68,12 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^2.1.7", "react-router-dom": "^7.1.0", + "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "sonner": "^1.7.4", "sugar-high": "^1.2.1", "tailwind-merge": "^3.4.0", diff --git a/apps/app/src/components/git-diff/PierreLineSelectionActions.tsx b/apps/app/src/components/git-diff/PierreLineSelectionActions.tsx index abe6dfa40..60e04c978 100644 --- a/apps/app/src/components/git-diff/PierreLineSelectionActions.tsx +++ b/apps/app/src/components/git-diff/PierreLineSelectionActions.tsx @@ -95,7 +95,12 @@ function isGutterUtilityPointerEvent( return isGutterUtilityPath(event.nativeEvent.composedPath()); } -function isGutterUtilityPath(path: EventTarget[]): boolean { +// `Event.composedPath()` is `EventTarget[]` under lib.dom but `[EventTarget?]` +// once `@types/node`'s web-globals are in scope; accept both (each element is +// re-narrowed with `instanceof Element` below). +function isGutterUtilityPath( + path: readonly (EventTarget | undefined)[], +): boolean { return path.some( (target) => target instanceof Element && diff --git a/apps/app/src/components/ui/markdown-preview.stories.tsx b/apps/app/src/components/ui/markdown-preview.stories.tsx index 5713c87c5..c63d65fa1 100644 --- a/apps/app/src/components/ui/markdown-preview.stories.tsx +++ b/apps/app/src/components/ui/markdown-preview.stories.tsx @@ -106,6 +106,19 @@ Long lines scroll horizontally until you toggle wrap (no language tag): $ pnpm exec turbo run typecheck --filter=@bb/app --filter=@bb/server --filter=@bb/host-daemon --filter=@bb/cli && echo "all packages clean" \`\`\``; +const MATH_MARKDOWN = `Inline math sits in the prose, like the mass–energy +relation $E = mc^2$ or the quadratic root $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$. + +Display math gets its own centered block: + +$$ +\\int_{-\\infty}^{\\infty} e^{-x^2} \\, dx = \\sqrt{\\pi} +$$ + +Escaped dollars stay literal text, so a budget line like \\$5 to \\$10 reads as +written. Invalid TeX such as $\\frac{1}{$ surfaces a contained error instead of +breaking the document.`; + const MERMAID_MARKDOWN = `A Mermaid flowchart renders as a diagram: \`\`\`mermaid @@ -209,6 +222,14 @@ export function Overview() { + + + + + { `${window.location.protocol}//${window.location.hostname}:5173/demo`, ); }); + + it("renders inline LaTeX math with KaTeX", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".katex")).not.toBeNull(); + expect(container.querySelector(".katex-display")).toBeNull(); + }); + + it("renders display LaTeX math blocks with KaTeX", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".katex-display")).not.toBeNull(); + }); + + it("leaves escaped dollar amounts as literal text", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".katex")).toBeNull(); + expect(container.textContent).toContain("$5"); + expect(container.textContent).toContain("$10"); + }); + + it("renders math while still sanitizing untrusted HTML when allowHtml is set", () => { + const { container } = render( + alert(1)"} + />, + ); + + expect(container.querySelector(".katex")).not.toBeNull(); + expect(container.querySelector("script")).toBeNull(); + expect(container.textContent).not.toContain("alert(1)"); + }); + + it("contains invalid TeX instead of throwing", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".katex-error")).not.toBeNull(); + expect(container.textContent).toContain("keeps rendering."); + }); }); diff --git a/apps/app/src/components/ui/markdown-preview.tsx b/apps/app/src/components/ui/markdown-preview.tsx index a18281e1c..059ced74c 100644 --- a/apps/app/src/components/ui/markdown-preview.tsx +++ b/apps/app/src/components/ui/markdown-preview.tsx @@ -16,10 +16,13 @@ import type { Options as ReactMarkdownOptions, UrlTransform, } from "react-markdown"; +import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; import rehypeSanitize from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import "katex/dist/katex.min.css"; import { ImageLightbox } from "./image-lightbox.js"; import { CopyButton } from "./copy-button.js"; import { Icon } from "./icon.js"; @@ -219,13 +222,26 @@ const MARKDOWN_TABLE_BREAKOUT_WIDTH = "max(100%, min(1100px, 100cqw - 2rem))"; const MARKDOWN_CONTENT_WIDTH_VARIABLE = "--md-content-w"; const MARKDOWN_SOURCE_COLOR_SCHEME_MEDIA_PATTERN = /^\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)$/iu; -// Security-critical order: raw HTML must become nodes before sanitization can -// strip unsafe elements, attributes, and URLs. +// `remark-math` emits math as `` (inline) and +// `
` (display) holding the raw TeX, and
+// `rehype-katex` renders any element carrying that class. The default sanitize
+// schema already keeps `language-*` classes on ``, so the wrappers survive
+// sanitization untouched — and `rehype-katex` runs LAST, after sanitize, so KaTeX
+// (which uses `trust: false` and self-escapes its TeX input) emits its rendered
+// output without it being re-sanitized.
+//
+// Security-critical order: raw HTML must become nodes (rehypeRaw) before
+// sanitization can strip unsafe elements, attributes, and URLs.
 const MARKDOWN_HTML_REHYPE_PLUGINS: MarkdownRehypePlugins = [
   rehypeRaw,
   rehypeSanitize,
+  rehypeKatex,
 ];
 
+// No raw HTML means nothing untrusted to sanitize, so KaTeX renders straight
+// from the `remark-math` wrappers.
+const MARKDOWN_MATH_REHYPE_PLUGINS: MarkdownRehypePlugins = [rehypeKatex];
+
 function areMarkdownAbsoluteLocalFileLinkRoutingsEqual({
   next,
   previous,
@@ -1176,6 +1192,11 @@ function MarkdownPreviewComponent({
   const remarkPlugins = useMemo(
     () => [
       remarkGfm,
+      // `remark-math` with single-dollar math left ON (the default), matching
+      // GitHub: `$x$` is inline and `$$x$$` is block. The known trade-off is that
+      // a line with two unescaped `$` (e.g. "$5 to $10", "$HOME and $PATH") parses
+      // the span between them as math; authors escape a literal dollar with `\$`.
+      remarkMath,
       ...(threadMentions !== undefined || promptMentions !== undefined
         ? [remarkBreaks]
         : []),
@@ -1208,7 +1229,11 @@ function MarkdownPreviewComponent({
           
         ) : null}