W3: presentation fixes — delimiters, line breaks, mermaid

- src/core/math-delims.ts: pure preprocessing for the \(...\)/\[...\] math
  contract — balanced delimiters rewrite to the two-dollar forms remark-math
  parses ($ is never math). Skips code fences/spans, escaped delimiters, and
  leaves unbalanced (mid-stream) delimiters untouched. Display math renders
  as a block: mid-paragraph gets blank-line padding; indented (list items)
  degrades to inline so list continuations don't half-parse.
- message.tsx: transform in the render path (children-memoized); math plugin
  now singleDollarTextMath: false — the currency hazard dies structurally.
  remark-breaks via the cjk remarkPluginsAfter slot (the bare remarkPlugins
  prop would replace the [remark-gfm, codeMeta] defaults).
- mermaid: dark theme + sans font via Streamdown's mermaid prop; dark-styled
  error fallback (components/mermaid-error.tsx) with error, source in
  <details>, and retry.
- globals.css (appended): @source for streamdown dist — its internal UI
  chrome (code blocks, mermaid containers, tables) was entirely unstyled
  because Tailwind v4 never scans node_modules; --color-sidebar → card; wide
  mermaid SVGs capped to the column; .katex-display scrolls.
- tests/render-markdown.test.tsx: 26 tests (transform unit + render). The
  math describe block in render-cards.test.tsx is updated to the new
  contract ($...$ now renders as literal text — pinned).
This commit is contained in:
Amos
2026-08-22 09:38:54 +03:00
parent 866352cb65
commit ef488224bc
6 changed files with 593 additions and 33 deletions
+68 -17
View File
@@ -15,9 +15,10 @@ import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { createMermaidPlugin } from "@streamdown/mermaid";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { MermaidConfig } from "mermaid";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import {
createContext,
@@ -28,7 +29,14 @@ import {
useMemo,
useState,
} from "react";
import { Streamdown, type PluginConfig } from "streamdown";
import remarkBreaks from "remark-breaks";
import {
type MermaidOptions,
Streamdown,
type PluginConfig,
} from "streamdown";
import { MermaidError } from "@/components/mermaid-error.tsx";
import { transformMathDelimiters } from "@/src/core/math-delims.ts";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
@@ -324,26 +332,69 @@ export type MessageResponseProps = ComponentProps<typeof Streamdown>;
// Type-only version skew: @streamdown/code bundles shiki@3 types while
// streamdown@2.5 expects shiki@4 types. Both work at runtime (each ships its
// own highlighter); the cast papers over the .d.ts mismatch.
// singleDollarTextMath: the teacher prompt instructs $...$ inline math (the
// LLM-native convention); remark-math's default here is false.
//
// singleDollarTextMath: false — a bare $ is ALWAYS literal currency (the
// teacher-prompt contract). Inline math arrives as \(...\) and display math
// as \[...\]; transformMathDelimiters (below) rewrites both to the
// two-dollar forms remark-math still parses. $ can never start math.
//
// remark-breaks rides in the cjk plugin's remarkPluginsAfter slot (public
// PluginConfig API): Streamdown's bare `remarkPlugins` PROP would REPLACE the
// defaults ([remark-gfm, codeMeta]), silently dropping GFM and code-fence
// metastring parsing. The slot appends after the defaults, which is the
// standard remark-gfm → remark-breaks order.
//
// mermaid: the plugin supplies the renderer; the `mermaid` PROP (below)
// carries the dark theme + the error fallback component.
const streamdownPlugins = {
cjk,
cjk: {
...cjk,
remarkPluginsAfter: [...cjk.remarkPluginsAfter, remarkBreaks],
},
code,
math: createMathPlugin({ singleDollarTextMath: true }),
mermaid,
math: createMathPlugin({ singleDollarTextMath: false }),
mermaid: createMermaidPlugin(),
} as unknown as PluginConfig;
// The app is dark-only (html.dark in the root layout) — diagrams render in
// mermaid's dark theme; parse failures degrade to the dark-styled fallback.
const mermaidTheme: MermaidConfig = {
fontFamily: "ui-sans-serif, system-ui, sans-serif",
securityLevel: "strict",
theme: "dark",
};
const mermaidOptions: MermaidOptions = {
config: mermaidTheme,
errorComponent: MermaidError,
};
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
plugins={streamdownPlugins}
{...props}
/>
),
({ className, children, ...props }: MessageResponseProps) => {
// Delimiter preprocessing is a single linear scan (no markdown parsing)
// — cheap enough for the render path of this children-memoized
// component. Unbalanced delimiters pass through untouched, so mid-stream
// chunks render literally until the closing delimiter arrives and the
// full text is re-transformed.
const content =
typeof children === "string"
? transformMathDelimiters(children)
: children;
return (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
mermaid={mermaidOptions}
plugins={streamdownPlugins}
{...props}
>
{content}
</Streamdown>
);
},
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
nextProps.isAnimating === prevProps.isAnimating