From ef488224bc24327dff150ba1552536e1d8405192 Mon Sep 17 00:00:00 2001 From: Amos Date: Sat, 22 Aug 2026 09:38:54 +0300 Subject: [PATCH] =?UTF-8?q?W3:=20presentation=20fixes=20=E2=80=94=20delimi?= =?UTF-8?q?ters,=20line=20breaks,=20mermaid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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
, 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). --- app/globals.css | 30 ++++ components/ai-elements/message.tsx | 85 +++++++++--- components/mermaid-error.tsx | 44 ++++++ src/core/math-delims.ts | 216 +++++++++++++++++++++++++++++ tests/render-cards.test.tsx | 37 ++--- tests/render-markdown.test.tsx | 214 ++++++++++++++++++++++++++++ 6 files changed, 593 insertions(+), 33 deletions(-) create mode 100644 components/mermaid-error.tsx create mode 100644 src/core/math-delims.ts create mode 100644 tests/render-markdown.test.tsx diff --git a/app/globals.css b/app/globals.css index 602696b..a123a02 100644 --- a/app/globals.css +++ b/app/globals.css @@ -127,3 +127,33 @@ body { .dock-variant { animation: dock-variant-in 160ms cubic-bezier(0.2, 0.8, 0.2, 1); } + +/* ============================================================ + * W3 — presentation fixes (appended 2026-08-22) + * ============================================================ */ + +/* Streamdown's internal UI chrome (code blocks, tables, mermaid containers, + copy/download buttons) is styled with Tailwind utilities living in its dist + * bundle. Tailwind v4 never scans node_modules, so without this line those + * classes are dead — code blocks and mermaid containers rendered unstyled. + * (Streamdown README, "Tailwind CSS setup".) */ +@source "../node_modules/streamdown/dist/*.js"; + +/* Token the streamdown utilities reference (code-block/mermaid chrome uses + * bg-sidebar / border-sidebar): map it onto the app's card surface. */ +@theme inline { + --color-sidebar: var(--card); +} + +/* Mermaid diagrams: keep wide charts inside the content column. Mermaid + * inlines a px max-width on the svg, so !important is required to win. */ +[data-streamdown="mermaid"] svg { + max-width: 100% !important; + height: auto !important; +} + +/* Display math: long equations scroll instead of overflowing the column. */ +.katex-display { + overflow-x: auto; + overflow-y: hidden; +} diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx index aa6c625..e37cab3 100644 --- a/components/ai-elements/message.tsx +++ b/components/ai-elements/message.tsx @@ -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 & { from: UIMessage["role"]; @@ -324,26 +332,69 @@ export type MessageResponseProps = ComponentProps; // 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) => ( - *: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 ( + *:first-child]:mt-0 [&>*:last-child]:mb-0", + className + )} + mermaid={mermaidOptions} + plugins={streamdownPlugins} + {...props} + > + {content} + + ); + }, (prevProps, nextProps) => prevProps.children === nextProps.children && nextProps.isAnimating === prevProps.isAnimating diff --git a/components/mermaid-error.tsx b/components/mermaid-error.tsx new file mode 100644 index 0000000..51135c7 --- /dev/null +++ b/components/mermaid-error.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { MermaidErrorComponentProps } from "streamdown"; + +/** + * Fallback for mermaid diagrams that fail to parse — wired via Streamdown's + * `mermaid.errorComponent`. Replaces the default light-themed red box with a + * card matching the app: the error, the source in a details block (so a + * broken diagram doesn't flood the transcript), and a retry. + */ +export const MermaidError = ({ + chart, + error, + retry, + className, +}: MermaidErrorComponentProps & { className?: string }) => ( +
+

+ Diagram failed to render +

+

+ {error} +

+
+ + Show diagram source + +
+				{chart}
+			
+
+ +
+); diff --git a/src/core/math-delims.ts b/src/core/math-delims.ts new file mode 100644 index 0000000..d5bbf30 --- /dev/null +++ b/src/core/math-delims.ts @@ -0,0 +1,216 @@ +/** + * Math delimiter preprocessing — the \(...\)/\[...\] contract. + * + * The teacher writes inline math as `\(...\)` and display math as `\[...\]`; + * a bare `$` is ALWAYS literal currency (system-prompt contract). remark-math + * expects `$...$`/`$$...$$`, so this pure transform rewrites balanced + * delimiters before the markdown hits Streamdown. Combined with + * `singleDollarTextMath: false`, the dollar sign can never start math — the + * currency hazard dies structurally. + * + * Rules: + * - `\(...\)` → `$$...$$` (two-dollar inline: with + * `singleDollarTextMath: false` this is the only inline math form micromark + * still parses), `\[...\]` → `$$\n...\n$$` (block form: flow math). + * - An escaped delimiter (`\\(` = literal backslash + paren) is left alone. + * - Nothing inside fenced code blocks or inline code spans is touched. + * - Unbalanced delimiters are left untouched: Streamdown streams chunks, and + * an incomplete `\[` completes on a later chunk (the whole text is + * re-transformed each render). + * + * Pure string → string. No markdown parsing — a single linear scan with a + * code-state machine, cheap enough to run in the render path of a memo'd + * component. + */ + +const INLINE_OPEN = "\\("; +const INLINE_CLOSE = "\\)"; +const DISPLAY_OPEN = "\\["; +const DISPLAY_CLOSE = "\\]"; + +const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})/; + +/** + * Is the backslash at `i` itself escaped (i.e. preceded by an odd number of + * backslashes)? If so, the `\(`/`\[` starting there is a literal backslash + * followed by a bracket — not a delimiter. + */ +function isEscaped(text: string, i: number): boolean { + let count = 0; + for (let j = i - 1; j >= 0 && text[j] === "\\"; j--) count += 1; + return count % 2 === 1; +} + +/** Length of the backtick run starting at `i` (0 if `text[i]` is not a tick). */ +function backtickRun(text: string, i: number): number { + let count = 0; + while (text[i + count] === "`") count += 1; + return count; +} + +/** + * Try to find the closing delimiter for the opening one at `i` (2 chars, + * already verified unescaped). The close scan stops at inline code spans and + * fence starts (barriers — math never contains them), and returns -1 when the + * delimiter is unbalanced (mid-stream chunk). + */ +function findClose( + text: string, + from: number, + close: string, +): number { + for (let j = from; j < text.length - 1; j++) { + if (text[j] === "`") return -1; // inline code barrier + if ( + text[j] === "\\" && + text[j + 1] === close[1] && + !isEscaped(text, j) + ) { + return j; + } + } + return -1; +} + +/** True when `i` sits at the start of a line (position 0 or right after \n). */ +function atLineStart(text: string, i: number): boolean { + return i === 0 || text[i - 1] === "\n"; +} + +/** + * Copy a fenced code block starting at `i` (line start, fence regex matched) + * verbatim to the output. Returns the index after the closing fence line — + * or the end of the text when the fence is unclosed (mid-stream: everything + * after is literal code until the chunk completes). + */ +function copyFencedBlock(text: string, i: number, out: string[]): number { + const open = FENCE_OPEN_RE.exec(text.slice(i)); + const marker = open?.[1] ?? "```"; + const closeRe = new RegExp(`^ {0,3}\\${marker[0]}{${marker.length},}[ \\t]*$`); + let j = i; + // Copy lines until the matching closing fence. + while (j < text.length) { + const lineEnd = text.indexOf("\n", j); + const next = lineEnd === -1 ? text.length : lineEnd + 1; + const line = text.slice(j, lineEnd === -1 ? text.length : lineEnd); + out.push(text.slice(j, next)); + if (j > i && closeRe.test(line)) return next; // closing fence line + j = next; + } + return text.length; +} + +/** Is the `\[` at `i` block-positioned (own line, only whitespace before)? */ +function displayIsBlockPositioned(text: string, i: number): boolean { + for (let j = i - 1; j >= 0; j--) { + const ch = text[j]; + if (ch === "\n") return true; + if (ch !== " " && ch !== "\t") return false; + } + return true; +} + +/** Is the `\[` at `i` indented (whitespace before it on the line)? */ +function displayIsIndented(text: string, i: number): boolean { + let sawWhitespace = false; + for (let j = i - 1; j >= 0; j--) { + const ch = text[j]; + if (ch === "\n") return sawWhitespace; + if (ch !== " " && ch !== "\t") return false; // mid-paragraph + sawWhitespace = true; + } + return sawWhitespace; +} + +/** Does the closing delimiter end its line (only \n or EOF after it)? */ +function closeEndsLine(text: string, afterClose: number): boolean { + return afterClose >= text.length || text[afterClose] === "\n"; +} + +/** + * Rewrite balanced `\(...\)` → `$$...$$` and `\[...\]` → `$$\n...\n$$`. + * Everything else passes through byte-for-byte. + */ +export function transformMathDelimiters(markdown: string): string { + const out: string[] = []; + let i = 0; + + while (i < markdown.length) { + const ch = markdown[i]!; + + // Fenced code blocks: copied verbatim (only detectable at line start). + if (atLineStart(markdown, i) && FENCE_OPEN_RE.test(markdown.slice(i))) { + i = copyFencedBlock(markdown, i, out); + continue; + } + + // Inline code spans: copied verbatim. + if (ch === "`") { + const run = backtickRun(markdown, i); + let j = i + run; + // Find the matching run (same length, per CommonMark). + while (j < markdown.length) { + const nextRun = backtickRun(markdown, j); + if (nextRun === run) break; + j += Math.max(nextRun, 1); + } + if (j < markdown.length) { + out.push(markdown.slice(i, j + run)); + i = j + run; + } else { + // Unclosed run (mid-stream): literal, keep scanning after it. + out.push(markdown.slice(i, i + run)); + i += run; + } + continue; + } + + // Math delimiters. + if (ch === "\\" && !isEscaped(markdown, i)) { + const next = markdown[i + 1]; + if (next === "(" || next === "[") { + const isDisplay = next === "["; + const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN; + const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE; + const closeAt = findClose(markdown, i + open.length, close); + if (closeAt !== -1) { + const content = markdown.slice(i + open.length, closeAt).trim(); + if (content.length > 0) { + if (isDisplay && displayIsIndented(markdown, i)) { + // Indented display math (inside a list item, quote, …): + // an indented $$ flow block half-parses inside list + // continuations (empty math, eaten items), so degrade to + // the inline form — renders inline, stays inside the item. + // mathText cannot span line endings, so collapse them. + out.push(`$$${content.replace(/\s*\n\s*/g, " ")}$$`); + } else if (isDisplay) { + // Flow math ($$ alone on its lines) renders as display + // KaTeX. Mid-paragraph \[...\] (or trailing text after the + // closer) would half-parse into literal dollars + KaTeX + // errors — pad with blank lines to force a clean block. + // Block-positioned math passes through in place. + const afterClose = closeAt + close.length; + const padBefore = + displayIsBlockPositioned(markdown, i) ? "" : "\n\n"; + const padAfter = + closeEndsLine(markdown, afterClose) ? "" : "\n\n"; + out.push(`${padBefore}$$\n${content}\n$$${padAfter}`); + } else { + // Two-dollar inline: the only inline math form left with + // singleDollarTextMath: false (renders inline, not display). + out.push(`$$${content}$$`); + } + i = closeAt + close.length; + continue; + } + } + // Unbalanced or empty: leave both characters untouched. + } + } + + out.push(ch); + i += 1; + } + + return out.join(""); +} diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx index 99ffc78..ea0b259 100644 --- a/tests/render-cards.test.tsx +++ b/tests/render-cards.test.tsx @@ -112,29 +112,34 @@ describe("render smoke tests", () => { }); describe("math rendering (streamdown + KaTeX)", () => { - it("renders INLINE math ($...$) — the LLM-native convention", () => { + // New delimiter contract (W3): the teacher writes \(...\) / \[...\]; a + // bare $ is ALWAYS literal currency (singleDollarTextMath: false kills it + // structurally). transformMathDelimiters rewrites both to the two-dollar + // forms remark-math still parses. Full coverage: render-markdown.test.tsx. + it("renders $...$ as LITERAL text — a $ is never math (the currency fix)", () => { const html = renderToStaticMarkup({"The map $f(x) = x^2$ is smooth."}); - assert.ok(html.includes("katex"), `inline math should render as KaTeX, got: ${html.slice(0, 300)}`); + assert.ok(!html.includes("katex"), `$-math must not render, got: ${html.slice(0, 300)}`); + assert.match(html, /\$f\(x\) = x\^2\$/, "old-contract math renders verbatim"); }); - it("renders display math ($$ fenced on own lines — the prompt contract)", () => { + it("renders display math ($$ fenced on own lines — the transform's target form)", () => { const html = renderToStaticMarkup({"$$\n\\int_0^1 x^2\\,dx = \\tfrac{1}{3}\n$$"}); assert.ok(html.includes("katex-display"), "fenced display math should render as display KaTeX"); }); - it("one-line $$...$$ degrades to inline (micromark behavior — prompt forbids it)", () => { + it("one-line $$...$$ renders inline (micromark behavior — the transform relies on it for \(...\))", () => { // micromark treats a one-line $$...$$ inside a paragraph as 2-dollar - // inline mathText, not flow math. The system prompt instructs the fenced - // form, so the model never emits this; pin the behavior so a renderer - // swap can't silently change it. + // inline mathText, not flow math. That is exactly how the transformed + // \(...\) renders inline; pin the behavior so a renderer swap can't + // silently change it. const html = renderToStaticMarkup({"$$\\int_0^1 x^2\\,dx$$"}); assert.ok(html.includes("katex") && !html.includes("katex-display"), "one-liner should render inline"); }); - it("does NOT eat escaped currency (\\$5 stays text — the prompt contract)", () => { - // micromark-extension-math@3.1 lacks pandoc's closing-digit rule, so bare - // "$5 and $10" would parse as math. The defense is at the source: the - // system prompt instructs the teacher to escape currency (\$5 / 5 USD). + it("does NOT eat escaped currency (\\$5 stays text — belt and suspenders)", () => { + // With singleDollarTextMath: false even a bare $5 can't parse as math; + // an escaped \$ is doubly safe. The prompt contract still asks the + // teacher to prefer plain "$5" (it renders literally now). const html = renderToStaticMarkup({"It costs \\$5 and \\$10 more."}); assert.ok(!html.includes("katex"), "escaped currency must not parse as math"); assert.match(html, /\$5/); @@ -142,10 +147,10 @@ describe("math rendering (streamdown + KaTeX)", () => { it("quiz cards render math in question, options, and explanation", () => { const mathQuiz = { - question: "What is $\\frac{dy}{dx}$ of $y = x^2$?", - options: [{ label: "$2x$" }, { label: "$x^2$" }, { label: "$x$" }], - correctAnswer: "$2x$", - explanation: "The power rule gives $\\frac{dy}{dx} = 2x$.", + question: "What is \\(\\frac{dy}{dx}\\) of \\(y = x^2\\)?", + options: [{ label: "\\(2x\\)" }, { label: "\\(x^2\\)" }, { label: "\\(x\\)" }], + correctAnswer: "\\(2x\\)", + explanation: "The power rule gives \\(\\frac{dy}{dx} = 2x\\).", }; const pendingHtml = renderDock({ toolName: "quiz", toolCallId: "m1", input: mathQuiz as never }); assert.ok(pendingHtml.includes("katex"), "pending dock: question/options math renders"); @@ -162,7 +167,7 @@ describe("math rendering (streamdown + KaTeX)", () => { createdAt: new Date().toISOString(), }; const answeredHtml = renderToStaticMarkup( - , + , ); assert.ok(answeredHtml.includes("katex"), "answered card: option/explanation math renders"); }); diff --git a/tests/render-markdown.test.tsx b/tests/render-markdown.test.tsx new file mode 100644 index 0000000..444c62d --- /dev/null +++ b/tests/render-markdown.test.tsx @@ -0,0 +1,214 @@ +/** + * Render tests for the presentation layer (W3): the \(...\)/\[...\] math + * delimiter contract, single-newline line breaks, and mermaid rendering. + * + * Same idiom as render-cards.test.tsx: static HTML via react-dom/server, no + * DOM. tsx note: tsx uses the CLASSIC JSX transform, so `React` must be a + * resolvable global — the shim below provides it. + */ + +import React from "react"; +(globalThis as Record).React = React; + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MessageResponse } from "../components/ai-elements/message.tsx"; +import { MermaidError } from "../components/mermaid-error.tsx"; +import { transformMathDelimiters } from "../src/core/math-delims.ts"; + +/** Render through MessageResponse in static mode (no streaming/remend artifacts). */ +function render(md: string): string { + return renderToStaticMarkup({md}); +} + +describe("math delimiter transform (pure unit)", () => { + it("converts inline \\(...\\) to the two-dollar inline form", () => { + assert.equal( + transformMathDelimiters("a \\(x^2\\) b"), + "a $$x^2$$ b", + ); + }); + + it("converts block-positioned \\[...\\] to flow math in place", () => { + assert.equal( + transformMathDelimiters("text\n\n\\[\n\\int x\n\\]\n\nmore"), + "text\n\n$$\n\\int x\n$$\n\nmore", + ); + }); + + it("pads mid-paragraph \\[...\\) with blank lines (forces a clean block)", () => { + assert.equal( + transformMathDelimiters("mid \\[x^2\\] para"), + "mid \n\n$$\nx^2\n$$\n\n para", + ); + }); + + it("degrades indented display math (list items) to the inline form", () => { + // An indented $$ flow block half-parses inside list continuations; + // inline keeps the math inside the item. + assert.equal( + transformMathDelimiters("- item\n \\[\n x^2\n \\]\n- next"), + "- item\n $$x^2$$\n- next", + ); + }); + + it("leaves currency untouched", () => { + const md = "It costs $5 and $10 more. $$big$$ sums too."; + assert.equal(transformMathDelimiters(md), md); + }); + + it("leaves escaped delimiters untouched (\\\\( is a literal backslash)", () => { + assert.equal( + transformMathDelimiters("escaped \\\\( and \\\\[ stay"), + "escaped \\\\( and \\\\[ stay", + ); + }); + + it("does not transform inside fenced code blocks", () => { + const md = "```js\n\\(x\\) and \\[y\\]\n```"; + assert.equal(transformMathDelimiters(md), md); + }); + + it("does not transform inside inline code spans", () => { + const md = "run `\\(x\\)` locally"; + assert.equal(transformMathDelimiters(md), md); + }); + + it("leaves unbalanced delimiters untouched (mid-stream chunks)", () => { + assert.equal( + transformMathDelimiters("partial \\[ mid-stream"), + "partial \\[ mid-stream", + ); + assert.equal( + transformMathDelimiters("open \\( no close"), + "open \\( no close", + ); + }); + + it("still converts a later balanced pair when an earlier one is unbalanced", () => { + assert.equal( + transformMathDelimiters("\\[ partial\n\nbut \\(ok\\)"), + "\\[ partial\n\nbut $$ok$$", + ); + }); +}); + +describe("render — new math contract (streamdown + KaTeX)", () => { + it("renders \\(...\\) as inline KaTeX", () => { + const html = render("The map \\(f(x) = x^2\\) is smooth."); + assert.ok(html.includes("katex"), `inline math should render, got: ${html.slice(0, 300)}`); + assert.ok(!html.includes("katex-display"), "parens math is inline, never display"); + }); + + it("renders \\[x^2\\] as display math", () => { + const html = render("Intro:\n\n\\[\n\\int_0^1 x^2\\,dx = \\tfrac{1}{3}\n\\]\n\nOutro."); + assert.ok(html.includes("katex-display"), "bracket math on its own lines is display math"); + }); + + it("renders mid-paragraph \\[...\\] as display math without leaking dollars", () => { + const html = render("before \\[x^2\\] after"); + assert.ok(html.includes("katex-display"), "mid-paragraph display math is hoisted to a block"); + assert.ok(!html.includes("$$"), "no literal $$ leaks"); + assert.ok(!html.includes("katex-error"), "no KaTeX parse errors"); + }); + + it("renders display math inside list items without eating the list", () => { + const html = render("- item one\n \\[x^2\\]\n- item two"); + assert.ok(html.includes("katex"), "indented display math degrades to inline KaTeX"); + assert.ok(html.includes("item two"), "the following list item survives"); + const items = html.match(/data-streamdown="list-item"/g) ?? []; + assert.equal(items.length, 2, "both list items render"); + }); + + it("renders bare $ as literal currency — the hazard dies structurally", () => { + const html = render("It costs $5 and $10 more."); + assert.ok(!html.includes("katex"), "currency must never parse as math"); + assert.match(html, /\$5 and \$10/); + }); + + it("renders old-contract $...$ math as literal text (singleDollarTextMath: false)", () => { + const html = render("The map $f(x) = x^2$ is smooth."); + assert.ok(!html.includes("katex"), "$ can never start math"); + assert.match(html, /\$f\(x\) = x\^2\$/, "the old form renders verbatim"); + }); + + it("keeps \\(...\\) inside a code fence literal", () => { + const html = render("```\n\\(x^2\\) stays literal\n```"); + assert.ok(!html.includes("katex"), "code fences are never math"); + assert.ok(html.includes("code-block"), "it renders as a code block"); + }); + + it("leaves an unbalanced \\[ mid-stream untouched", () => { + const html = render("partial \\[ mid-stream"); + assert.ok(!html.includes("katex") && !html.includes("$$"), "no math, no dollars"); + }); + + it("renders math inside headings and keeps the heading", () => { + const html = render("## The map \\(f(x)\\)"); + assert.ok(html.includes(" { + it("renders a single newline as a visible line break", () => { + const html = render("line one\nline two"); + assert.match(html, //, "single \\n becomes a hard break"); + }); + + it("keeps single newlines inside code blocks as code lines (no
)", () => { + const html = render("```js\nline one\nline two\n```"); + assert.ok(html.includes("code-block"), "code block renders"); + assert.ok(!html.includes(" { + const html = render("- alpha\n- beta"); + const items = html.match(/data-streamdown="list-item"/g) ?? []; + assert.equal(items.length, 2, "two list items"); + assert.ok(!html.includes(" { + const html = render("| a | b |\n|---|---|\n| 1 | 2 |"); + assert.ok(html.includes(" { + it("renders a mermaid fence as a diagram container, not a raw code block", () => { + // SSR renders the lazy chunk's Suspense skeleton (the diagram itself + // mounts client-side after hydration) — so assert on what must NOT + // happen: the mermaid language must not take the code-block path, and + // the chart source must not leak as visible code. + const html = render("```mermaid\ngraph TD\nA --> B\n```"); + assert.ok( + !html.includes('data-streamdown="code-block"'), + "mermaid fences must not render as code blocks", + ); + assert.ok(!html.includes("graph TD"), "chart source is not leaked as text"); + }); + + it("still renders ordinary code fences as code blocks (contrast)", () => { + const html = render("```js\nconst x = 1;\n```"); + assert.ok(html.includes('data-streamdown="code-block"'), "js fences keep the code-block path"); + }); + + it("renders the error fallback for invalid mermaid: error + source + retry", () => { + // The fallback component mounts client-side when mermaid fails to + // parse; render it directly to pin its contract. + const html = renderToStaticMarkup( + B"} + error="Parse error on line 1: unexpected text" + retry={() => {}} + />, + ); + assert.match(html, /Diagram failed to render/); + assert.match(html, /Parse error on line 1/); + assert.match(html, /Show diagram source/); + assert.match(html, /not a diagram/); + assert.match(html, />Retry