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:
@@ -127,3 +127,33 @@ body {
|
|||||||
.dock-variant {
|
.dock-variant {
|
||||||
animation: dock-variant-in 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import { cn } from "@/lib/utils";
|
|||||||
import { cjk } from "@streamdown/cjk";
|
import { cjk } from "@streamdown/cjk";
|
||||||
import { code } from "@streamdown/code";
|
import { code } from "@streamdown/code";
|
||||||
import { createMathPlugin } from "@streamdown/math";
|
import { createMathPlugin } from "@streamdown/math";
|
||||||
import { mermaid } from "@streamdown/mermaid";
|
import { createMermaidPlugin } from "@streamdown/mermaid";
|
||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
|
import type { MermaidConfig } from "mermaid";
|
||||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -28,7 +29,14 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} 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> & {
|
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
from: UIMessage["role"];
|
from: UIMessage["role"];
|
||||||
@@ -324,26 +332,69 @@ export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
|||||||
// Type-only version skew: @streamdown/code bundles shiki@3 types while
|
// 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
|
// streamdown@2.5 expects shiki@4 types. Both work at runtime (each ships its
|
||||||
// own highlighter); the cast papers over the .d.ts mismatch.
|
// 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 = {
|
const streamdownPlugins = {
|
||||||
cjk,
|
cjk: {
|
||||||
|
...cjk,
|
||||||
|
remarkPluginsAfter: [...cjk.remarkPluginsAfter, remarkBreaks],
|
||||||
|
},
|
||||||
code,
|
code,
|
||||||
math: createMathPlugin({ singleDollarTextMath: true }),
|
math: createMathPlugin({ singleDollarTextMath: false }),
|
||||||
mermaid,
|
mermaid: createMermaidPlugin(),
|
||||||
} as unknown as PluginConfig;
|
} 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(
|
export const MessageResponse = memo(
|
||||||
({ className, ...props }: MessageResponseProps) => (
|
({ className, children, ...props }: MessageResponseProps) => {
|
||||||
<Streamdown
|
// Delimiter preprocessing is a single linear scan (no markdown parsing)
|
||||||
className={cn(
|
// — cheap enough for the render path of this children-memoized
|
||||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
// component. Unbalanced delimiters pass through untouched, so mid-stream
|
||||||
className
|
// chunks render literally until the closing delimiter arrives and the
|
||||||
)}
|
// full text is re-transformed.
|
||||||
plugins={streamdownPlugins}
|
const content =
|
||||||
{...props}
|
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, nextProps) =>
|
||||||
prevProps.children === nextProps.children &&
|
prevProps.children === nextProps.children &&
|
||||||
nextProps.isAnimating === prevProps.isAnimating
|
nextProps.isAnimating === prevProps.isAnimating
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"my-4 rounded-xl border border-destructive/40 bg-destructive/5 p-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-streamdown="mermaid-error"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
Diagram failed to render
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-xs break-words text-muted-foreground">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<details className="mt-3">
|
||||||
|
<summary className="cursor-pointer text-xs text-muted-foreground">
|
||||||
|
Show diagram source
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 overflow-x-auto rounded-lg bg-muted p-3 font-mono text-xs text-foreground">
|
||||||
|
{chart}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
<Button className="mt-3" onClick={retry} size="sm" variant="outline">
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -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("");
|
||||||
|
}
|
||||||
+21
-16
@@ -112,29 +112,34 @@ describe("render smoke tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("math rendering (streamdown + KaTeX)", () => {
|
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(<MessageResponse>{"The map $f(x) = x^2$ is smooth."}</MessageResponse>);
|
const html = renderToStaticMarkup(<MessageResponse>{"The map $f(x) = x^2$ is smooth."}</MessageResponse>);
|
||||||
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(<MessageResponse>{"$$\n\\int_0^1 x^2\\,dx = \\tfrac{1}{3}\n$$"}</MessageResponse>);
|
const html = renderToStaticMarkup(<MessageResponse>{"$$\n\\int_0^1 x^2\\,dx = \\tfrac{1}{3}\n$$"}</MessageResponse>);
|
||||||
assert.ok(html.includes("katex-display"), "fenced display math should render as display KaTeX");
|
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
|
// micromark treats a one-line $$...$$ inside a paragraph as 2-dollar
|
||||||
// inline mathText, not flow math. The system prompt instructs the fenced
|
// inline mathText, not flow math. That is exactly how the transformed
|
||||||
// form, so the model never emits this; pin the behavior so a renderer
|
// \(...\) renders inline; pin the behavior so a renderer swap can't
|
||||||
// swap can't silently change it.
|
// silently change it.
|
||||||
const html = renderToStaticMarkup(<MessageResponse>{"$$\\int_0^1 x^2\\,dx$$"}</MessageResponse>);
|
const html = renderToStaticMarkup(<MessageResponse>{"$$\\int_0^1 x^2\\,dx$$"}</MessageResponse>);
|
||||||
assert.ok(html.includes("katex") && !html.includes("katex-display"), "one-liner should render inline");
|
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)", () => {
|
it("does NOT eat escaped currency (\\$5 stays text — belt and suspenders)", () => {
|
||||||
// micromark-extension-math@3.1 lacks pandoc's closing-digit rule, so bare
|
// With singleDollarTextMath: false even a bare $5 can't parse as math;
|
||||||
// "$5 and $10" would parse as math. The defense is at the source: the
|
// an escaped \$ is doubly safe. The prompt contract still asks the
|
||||||
// system prompt instructs the teacher to escape currency (\$5 / 5 USD).
|
// teacher to prefer plain "$5" (it renders literally now).
|
||||||
const html = renderToStaticMarkup(<MessageResponse>{"It costs \\$5 and \\$10 more."}</MessageResponse>);
|
const html = renderToStaticMarkup(<MessageResponse>{"It costs \\$5 and \\$10 more."}</MessageResponse>);
|
||||||
assert.ok(!html.includes("katex"), "escaped currency must not parse as math");
|
assert.ok(!html.includes("katex"), "escaped currency must not parse as math");
|
||||||
assert.match(html, /\$5/);
|
assert.match(html, /\$5/);
|
||||||
@@ -142,10 +147,10 @@ describe("math rendering (streamdown + KaTeX)", () => {
|
|||||||
|
|
||||||
it("quiz cards render math in question, options, and explanation", () => {
|
it("quiz cards render math in question, options, and explanation", () => {
|
||||||
const mathQuiz = {
|
const mathQuiz = {
|
||||||
question: "What is $\\frac{dy}{dx}$ of $y = x^2$?",
|
question: "What is \\(\\frac{dy}{dx}\\) of \\(y = x^2\\)?",
|
||||||
options: [{ label: "$2x$" }, { label: "$x^2$" }, { label: "$x$" }],
|
options: [{ label: "\\(2x\\)" }, { label: "\\(x^2\\)" }, { label: "\\(x\\)" }],
|
||||||
correctAnswer: "$2x$",
|
correctAnswer: "\\(2x\\)",
|
||||||
explanation: "The power rule gives $\\frac{dy}{dx} = 2x$.",
|
explanation: "The power rule gives \\(\\frac{dy}{dx} = 2x\\).",
|
||||||
};
|
};
|
||||||
const pendingHtml = renderDock({ toolName: "quiz", toolCallId: "m1", input: mathQuiz as never });
|
const pendingHtml = renderDock({ toolName: "quiz", toolCallId: "m1", input: mathQuiz as never });
|
||||||
assert.ok(pendingHtml.includes("katex"), "pending dock: question/options math renders");
|
assert.ok(pendingHtml.includes("katex"), "pending dock: question/options math renders");
|
||||||
@@ -162,7 +167,7 @@ describe("math rendering (streamdown + KaTeX)", () => {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const answeredHtml = renderToStaticMarkup(
|
const answeredHtml = renderToStaticMarkup(
|
||||||
<AnsweredCard answer={buildAnswer(qd, { values: ["$2x$"] })} />,
|
<AnsweredCard answer={buildAnswer(qd, { values: ["\\(2x\\)"] })} />,
|
||||||
);
|
);
|
||||||
assert.ok(answeredHtml.includes("katex"), "answered card: option/explanation math renders");
|
assert.ok(answeredHtml.includes("katex"), "answered card: option/explanation math renders");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<string, unknown>).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(<MessageResponse mode="static">{md}</MessageResponse>);
|
||||||
|
}
|
||||||
|
|
||||||
|
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("<h2"), "heading survives");
|
||||||
|
assert.ok(html.includes("katex"), "heading math renders");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("render — line breaks (remark-breaks)", () => {
|
||||||
|
it("renders a single newline as a visible line break", () => {
|
||||||
|
const html = render("line one\nline two");
|
||||||
|
assert.match(html, /<br\s*\/?>/, "single \\n becomes a hard break");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps single newlines inside code blocks as code lines (no <br>)", () => {
|
||||||
|
const html = render("```js\nline one\nline two\n```");
|
||||||
|
assert.ok(html.includes("code-block"), "code block renders");
|
||||||
|
assert.ok(!html.includes("<br"), "code blocks have no injected breaks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps lists as lists (no break-collapse)", () => {
|
||||||
|
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("<br"), "list lines are not breaks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps GFM tables intact (remark-breaks did not replace the defaults)", () => {
|
||||||
|
const html = render("| a | b |\n|---|---|\n| 1 | 2 |");
|
||||||
|
assert.ok(html.includes("<table"), "GFM table renders");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("render — mermaid", () => {
|
||||||
|
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(
|
||||||
|
<MermaidError
|
||||||
|
chart={"not a diagram @@@\nA --> 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</);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user