Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"@types/babel__core": "^7.20.5",
"@types/compression": "^1.8.1",
"@types/culori": "^4.0.1",
"@types/mdast": "^4.0.4",
"@types/react": "~19.2.14",
"@types/react-dom": "~19.2.3",
"@types/react-test-renderer": "19.1.0",
Expand All @@ -68,6 +69,7 @@
"compression": "^1.8.1",
"react-test-renderer": "19.2.6",
"tailwindcss": "^4.0.0",
"unified": "^11.0.5",
"vite": "catalog:",
"vite-plus": "catalog:"
}
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import React, {
} from "react";
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
import ReactMarkdown from "react-markdown";
import { createIncrementalMarkdownPlugin } from "../markdown-incremental";
import { defaultUrlTransform } from "react-markdown";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
Expand Down Expand Up @@ -3104,12 +3105,17 @@ function ChatMarkdown({
localMediaPreview,
setLocalMediaPreview,
} = useChatMarkdownState({ text, ...props });
const incrementalParsing =
props.isStreaming === true &&
extraRemarkPlugins.length === 0 &&
/(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text);
const remarkPlugins = useMemo(
() => [
...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
...extraRemarkPlugins,
...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []),
],
[extraRemarkPlugins, lineBreaks],
[extraRemarkPlugins, incrementalParsing, lineBreaks],
);

// react-markdown converts unparsed HTML nodes to text when skipHtml is false.
Expand Down
136 changes: 136 additions & 0 deletions apps/web/src/markdown-incremental.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { Root } from "mdast";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import type { Plugin } from "unified";
import { describe, expect, it } from "vite-plus/test";

import { remarkCodexDirectives } from "@t3tools/client-runtime/codex-markdown-directives";
import { remarkGithubAlerts } from "./markdown-github-alerts";
import { createIncrementalMarkdownPlugin } from "./markdown-incremental";
import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation";

function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) {
let tree: Root | undefined;
const observeParsing: Plugin<[], Root> = function () {
const original = this.parser;
if (original) {
this.parser = (text, file) => {
parsedSources?.push(text);
return original(text, file);
};
}
};
const capture: Plugin<[], Root> = () => (root) => {
tree = structuredClone(root);
};
const html = renderToStaticMarkup(
<ReactMarkdown
remarkPlugins={[
observeParsing,
capture,
remarkGfm,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
remarkCodexDirectives,
...(incremental ? [incremental] : []),
]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{source}
</ReactMarkdown>,
);
return { html, tree };
}

const prefix = "# Before\n\n```ts\nconst values = [1, 2];\n```\n\n";

describe("incremental Markdown parsing", () => {
it("keeps the document prefix cached when list recovery parses contain fences", () => {
const source =
prefix +
"- first block\n\n ```ts\n const nested = 1;\n ```\n\n tail";
const incremental = createIncrementalMarkdownPlugin();
const parsedSources: string[] = [];
expect(render(source, incremental, parsedSources)).toEqual(render(source));
parsedSources.length = 0;
const next = source + " more";
expect(render(next, incremental, parsedSources)).toEqual(render(next));
expect(parsedSources).not.toContain(next);
expect(parsedSources.some((text) => text.startsWith("t3-markdown-inline-prefix:"))).toBe(true);
});

it.each([
"a\n===\n\nb\n---\n",
"- first\n\n continued\n\n- next\n",
"> quoted\n>\n> ```js\n> abc\n> ```\n\nend",
"<div>\nhello\n\n</div>\n\nend",
"[ref]\n\n[ref]: /later",
"a[^x]\n\n[^x]: note",
"a | b\n--|--\na | b\n",
"```\na\n```\n\nnext\n\n~~~\nb\n~~~\n\nmore",
"\n\n\tcode\n\nmore",
"text <https://example.com> *bold*",
"> [!NOTE]\n> alert\n\n- [ ] task",
"\uFEFFtext after a byte-order mark",
])("preserves the parse tree, positions, and HTML while streaming %j", (tail) => {
const source = prefix + tail;
const incremental = createIncrementalMarkdownPlugin();
for (let end = 0; end <= source.length; end++) {
const text = source.slice(0, end);
expect(render(text, incremental), `prefix ${end}`).toEqual(render(text));
}
});

it.each(["\r\n", "\r"])("preserves partial %j line endings", (newline) => {
const source = (prefix + "next\n\n```\nlast\n```\n\nend").replaceAll("\n", newline);
const incremental = createIncrementalMarkdownPlugin();
for (let end = 0; end <= source.length; end++) {
const text = source.slice(0, end);
expect(render(text, incremental)).toEqual(render(text));
}
});

it("updates earlier references when definitions arrive after the cached prefix", () => {
const before = "[later] and footnote[^note]\n\n" + prefix;
const incremental = createIncrementalMarkdownPlugin();
for (const tail of ["text", "[later]: /target", "[later]: /target\n\n[^note]: a note"]) {
expect(render(before + tail, incremental)).toEqual(render(before + tail));
}
});

it("handles edits, replacements, and repeated renders without leaking transformed nodes", () => {
const incremental = createIncrementalMarkdownPlugin();
const documents = [
prefix + "- first\n - second",
prefix + "> [!NOTE]\n> transformed alert",
prefix + "plain text",
"replacement without fences",
prefix.replace("Before", "Edited") + "edited prefix",
prefix + "plain text",
prefix + "plain text",
];
for (const document of documents) {
expect(render(document, incremental)).toEqual(render(document));
}
});

it("does not freeze unclosed, nested, indented, or mismatched fences", () => {
const prefixes = [
"```\nopen\n\n",
"````\n```\n\n",
"> ```\n> code\n> ```\n\n",
"- ```\n code\n ```\n\n",
" ```\n code\n ```\n\n",
"<script>\n```\ncode\n```\n\n",
];
for (const start of prefixes) {
const incremental = createIncrementalMarkdownPlugin();
for (const tail of ["", "text", "\n```\n", "\n```\n\nnext"]) {
expect(render(start + tail, incremental)).toEqual(render(start + tail));
}
}
});
});
107 changes: 107 additions & 0 deletions apps/web/src/markdown-incremental.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { Root, RootContent } from "mdast";
import type { Parser as UnifiedParser, Plugin } from "unified";

type Parser = UnifiedParser<Root>;

interface ParsedPrefix {
source: string;
offset: number;
line: number;
children: RootContent[];
}

function hasDefinitions(node: Root | RootContent): boolean {
return (
node.type === "definition" ||
node.type === "footnoteDefinition" ||
("children" in node && node.children.some((child) => hasDefinitions(child)))
);
}

function shiftPositions(node: Root | RootContent, offset: number, lines: number): void {
if (node.position) {
for (const point of [node.position.start, node.position.end]) {
if (point.offset !== undefined) point.offset += offset;
point.line += lines;
}
}
if ("children" in node) {
for (const child of node.children) shiftPositions(child, offset, lines);
}
}

/** Keep the full document pipeline while avoiding parsing a completed code-heavy
* prefix on every token. A closed top-level fence followed by a blank line is a
* parsing boundary. Definitions are document-wide, so they require a full parse.
*/
function createIncrementalMarkdownParser(parse: Parser): Parser {
let cached: ParsedPrefix | undefined;

return (source, file) => {
// A streaming CR can become half of a CRLF. A BOM at the suffix boundary
// would be stripped by a new parser although it is inside the full document.
if (source.includes("\r") || source.includes("\uFEFF")) return parse(source, file);

const prefix = cached && source.startsWith(cached.source) ? cached : undefined;
let root: Root;
if (prefix) {
root = parse(source.slice(prefix.offset), file);
if (hasDefinitions(root)) return parse(source, file);
shiftPositions(root, prefix.offset, prefix.line - 1);
if (root.position) root.position.start = { line: 1, column: 1, offset: 0 };
// Remark transforms mutate their input. The cache owns pristine nodes and
// each render receives its own copy, including source positions.
root.children.unshift(...structuredClone(prefix.children));
} else {
root = parse(source, file);
if (hasDefinitions(root)) return root;
}

for (let index = root.children.length - 1; index >= 0; index--) {
const node = root.children[index];
if (node?.type !== "code") continue;
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start === undefined || end === undefined) continue;
if (prefix && end < prefix.offset) break;
const value = source.slice(start, end);
const opening = /^ {0,3}(`{3,}|~{3,})[^\n]*\n/.exec(value)?.[1];
if (!opening) continue;
const lastLine = value.slice(value.lastIndexOf("\n") + 1);
const closing = new RegExp(`^ {0,3}${opening[0]}{${opening.length},}[ \\t]*$`);
const separator = /^\n[ \t]*\n/.exec(source.slice(end))?.[0];
if (!closing.test(lastLine) || !separator) continue;
const offset = end + separator.length;
cached = {
source: source.slice(0, offset),
offset,
line: node.position!.end.line + 2,
children: structuredClone(root.children.slice(0, index + 1)),
};
break;
}
return root;
};
}

/** One cache per streaming renderer. Extra syntax plugins must use the normal
* parser because their document-wide dependencies are not known here.
*/
export function createIncrementalMarkdownPlugin(): Plugin<[], Root> {
let parser: Parser | undefined;
return function () {
const original = this.parser;
if (!original) return;
parser ??= createIncrementalMarkdownParser((source, file) => original(source, file) as Root);
const parseDocument = parser;
// ReactMarkdown creates a processor per render. Its first parse is the
// document; transforms can then parse synthetic recovery text on that same
// processor. Those parses must not read or replace the document's cache.
let documentParsed = false;
this.parser = (source, file) => {
if (documentParsed) return original(source, file);
documentParsed = true;
return parseDocument(source, file);
};
};
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading