- 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).
217 lines
7.4 KiB
TypeScript
217 lines
7.4 KiB
TypeScript
/**
|
|
* 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("");
|
|
}
|