/** * Helpers for fast, stable streaming Markdown. * * `splitStreamingMarkdown` cuts a growing Markdown string into completed top-level blocks (rendered * once and memoized) and a live tail (re-rendered on every token). Cuts happen only on blank lines * outside code fences, and never between blocks that Markdown would join across a blank line * (loose lists, block quotes, indented continuations, tables), so the split output renders * identically to a single parse. * * `normalizeMath` rewrites the `\( … \)` / `\[ … \]` delimiters most models emit into the * `$ … $` / `$$ … $$` forms `remark-math` understands, leaving code spans and fences untouched. */ export interface MarkdownSplit { /** Completed blocks — each is a self-contained Markdown fragment. */ blocks: string[]; /** Still-growing tail (may be empty). */ tail: string; } const FENCE_RE = /^(\s{0,3})(`{3,}|~{3,})/; const CONTINUATION_RE = /^(\s{2,}|\t|>|[-*+]\s|\d{1,3}[.)]\s|\|)/; const LIST_OR_QUOTE_RE = /^(\s*)([-*+]\s|\d{1,3}[.)]\s|>)/; /** Whether `text` ends inside an open ``` / ~~~ fence. */ export function endsInsideFence(text: string): boolean { let open: string | null = null; for (const line of text.split("\n")) { const m = FENCE_RE.exec(line); if (!m) continue; const marker = m[2]; if (!open) open = marker; else if (marker[0] === open[0] && marker.length >= open.length) open = null; } return open !== null; } export function splitStreamingMarkdown(text: string, opts: { minTailChars?: number } = {}): MarkdownSplit { if (!text) return { blocks: [], tail: "" }; const minTail = opts.minTailChars ?? 0; const lines = text.split("\n"); const blocks: string[] = []; let current: string[] = []; let fence: string | null = null; const flushIfSafe = (nextLine: string | undefined) => { // Only cut when the block is closed AND the next block does not continue this one. if (!current.length) return; if (fence) return; if (nextLine === undefined) return; const prevLast = [...current].reverse().find((l) => l.trim() !== "") ?? ""; if (CONTINUATION_RE.test(nextLine)) return; // indented / list / quote / table continuation if (LIST_OR_QUOTE_RE.test(prevLast) && LIST_OR_QUOTE_RE.test(nextLine)) return; blocks.push(current.join("\n").replace(/\n+$/, "")); current = []; }; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const m = FENCE_RE.exec(line); if (m) { const marker = m[2]; if (!fence) fence = marker; else if (marker[0] === fence[0] && marker.length >= fence.length) fence = null; } current.push(line); const isLast = i === lines.length - 1; if (isLast) break; // A blank line closes a block candidate (outside fences). if (line.trim() === "" && !fence) { // Skip following blank lines; peek at the first non-blank line. let j = i + 1; while (j < lines.length && lines[j].trim() === "") j++; flushIfSafe(lines[j]); } } let tail = current.join("\n"); // Keep a minimum amount of live text so the caret doesn't jump between blocks too eagerly. while (tail.length < minTail && blocks.length) tail = `${blocks.pop()}\n\n${tail}`; return { blocks, tail }; } /** Rewrite `\( x \)` → `$x$` and `\[ x \]` → `$$x$$` outside code (inline spans and fences). */ export function normalizeMath(text: string): string { if (!text || !/\\[([]/.test(text)) return text; const out: string[] = []; const segments = text.split(/(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)/g); for (const seg of segments) { if (!seg) continue; if (seg.startsWith("```") || seg.startsWith("~~~") || (seg.startsWith("`") && seg.endsWith("`") && seg.length > 1)) { out.push(seg); continue; } out.push(seg.replace(/\\\[\s*([\s\S]*?)\s*\\\]/g, (_m, inner: string) => `$$\n${inner}\n$$`).replace(/\\\((.+?)\\\)/g, (_m, inner: string) => `$${inner.trim()}$`)); } return out.join(""); } /** File extension for the code-block Download action. */ export function extensionForLanguage(lang: string | undefined): string { const l = (lang ?? "").toLowerCase(); const map: Record = { javascript: "js", js: "js", jsx: "jsx", typescript: "ts", ts: "ts", tsx: "tsx", python: "py", py: "py", bash: "sh", sh: "sh", shell: "sh", zsh: "sh", json: "json", yaml: "yml", yml: "yml", markdown: "md", md: "md", html: "html", css: "css", scss: "scss", sql: "sql", go: "go", rust: "rs", rs: "rs", java: "java", kotlin: "kt", kt: "kt", swift: "swift", ruby: "rb", rb: "rb", php: "php", c: "c", cpp: "cpp", "c++": "cpp", h: "h", csharp: "cs", cs: "cs", toml: "toml", xml: "xml", dockerfile: "Dockerfile", diff: "diff", text: "txt", plaintext: "txt", txt: "txt", r: "r", scala: "scala", lua: "lua", perl: "pl", dart: "dart", latex: "tex", tex: "tex", graphql: "graphql", ini: "ini", makefile: "mk", nginx: "conf", powershell: "ps1", ps1: "ps1", }; return map[l] ?? "txt"; }