| author | |
| committer | |
| log | a9dabbf100d1f6c0b2ed7b5e879d469ac14fd186 |
| tree | 7ddf14add43dbe03a0d0f30617681570280677ad |
| parent | 8fc253605e6d203c3654dbb1ce22fa1fd6fa0767 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 258 insertions(+), 1 deletions(-)
apps/web/package.json+2| ... | ... | @@ -59,6 +59,7 @@ |
| 59 | 59 | "@types/babel__core": "^7.20.5", |
| 60 | 60 | "@types/compression": "^1.8.1", |
| 61 | 61 | "@types/culori": "^4.0.1", |
| 62 | "@types/mdast": "^4.0.4", | |
| 62 | 63 | "@types/react": "~19.2.14", |
| 63 | 64 | "@types/react-dom": "~19.2.3", |
| 64 | 65 | "@types/react-test-renderer": "19.1.0", |
| ... | ... | @@ -68,6 +69,7 @@ |
| 68 | 69 | "compression": "^1.8.1", |
| 69 | 70 | "react-test-renderer": "19.2.6", |
| 70 | 71 | "tailwindcss": "^4.0.0", |
| 72 | "unified": "^11.0.5", | |
| 71 | 73 | "vite": "catalog:", |
| 72 | 74 | "vite-plus": "catalog:" |
| 73 | 75 | } |
apps/web/src/components/ChatMarkdown.tsx+7-1| ... | ... | @@ -69,6 +69,7 @@ import React, { |
| 69 | 69 | } from "react"; |
| 70 | 70 | import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; |
| 71 | 71 | import ReactMarkdown from "react-markdown"; |
| 72 | import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; | |
| 72 | 73 | import { defaultUrlTransform } from "react-markdown"; |
| 73 | 74 | import rehypeRaw from "rehype-raw"; |
| 74 | 75 | import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; |
| ... | ... | @@ -3104,12 +3105,17 @@ function ChatMarkdown({ |
| 3104 | 3105 | localMediaPreview, |
| 3105 | 3106 | setLocalMediaPreview, |
| 3106 | 3107 | } = useChatMarkdownState({ text, ...props }); |
| 3108 | const incrementalParsing = | |
| 3109 | props.isStreaming === true && | |
| 3110 | extraRemarkPlugins.length === 0 && | |
| 3111 | /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); | |
| 3107 | 3112 | const remarkPlugins = useMemo( |
| 3108 | 3113 | () => [ |
| 3109 | 3114 | ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), |
| 3110 | 3115 | ...extraRemarkPlugins, |
| 3116 | ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), | |
| 3111 | 3117 | ], |
| 3112 | [extraRemarkPlugins, lineBreaks], | |
| 3118 | [extraRemarkPlugins, incrementalParsing, lineBreaks], | |
| 3113 | 3119 | ); |
| 3114 | 3120 | |
| 3115 | 3121 | // react-markdown converts unparsed HTML nodes to text when skipHtml is false. |
apps/web/src/markdown-incremental.test.tsx created+136| ... | ... | @@ -0,0 +1,136 @@ |
| 1 | import type { Root } from "mdast"; | |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 3 | import ReactMarkdown from "react-markdown"; | |
| 4 | import rehypeRaw from "rehype-raw"; | |
| 5 | import rehypeSanitize from "rehype-sanitize"; | |
| 6 | import remarkGfm from "remark-gfm"; | |
| 7 | import type { Plugin } from "unified"; | |
| 8 | import { describe, expect, it } from "vite-plus/test"; | |
| 9 | ||
| 10 | import { remarkCodexDirectives } from "@t3tools/client-runtime/codex-markdown-directives"; | |
| 11 | import { remarkGithubAlerts } from "./markdown-github-alerts"; | |
| 12 | import { createIncrementalMarkdownPlugin } from "./markdown-incremental"; | |
| 13 | import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation"; | |
| 14 | ||
| 15 | function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) { | |
| 16 | let tree: Root | undefined; | |
| 17 | const observeParsing: Plugin<[], Root> = function () { | |
| 18 | const original = this.parser; | |
| 19 | if (original) { | |
| 20 | this.parser = (text, file) => { | |
| 21 | parsedSources?.push(text); | |
| 22 | return original(text, file); | |
| 23 | }; | |
| 24 | } | |
| 25 | }; | |
| 26 | const capture: Plugin<[], Root> = () => (root) => { | |
| 27 | tree = structuredClone(root); | |
| 28 | }; | |
| 29 | const html = renderToStaticMarkup( | |
| 30 | <ReactMarkdown | |
| 31 | remarkPlugins={[ | |
| 32 | observeParsing, | |
| 33 | capture, | |
| 34 | remarkGfm, | |
| 35 | remarkGithubAlerts, | |
| 36 | remarkNormalizeListItemIndentation, | |
| 37 | remarkCodexDirectives, | |
| 38 | ...(incremental ? [incremental] : []), | |
| 39 | ]} | |
| 40 | rehypePlugins={[rehypeRaw, rehypeSanitize]} | |
| 41 | > | |
| 42 | {source} | |
| 43 | </ReactMarkdown>, | |
| 44 | ); | |
| 45 | return { html, tree }; | |
| 46 | } | |
| 47 | ||
| 48 | const prefix = "# Before\n\n```ts\nconst values = [1, 2];\n```\n\n"; | |
| 49 | ||
| 50 | describe("incremental Markdown parsing", () => { | |
| 51 | it("keeps the document prefix cached when list recovery parses contain fences", () => { | |
| 52 | const source = | |
| 53 | prefix + | |
| 54 | "- first block\n\n ```ts\n const nested = 1;\n ```\n\n tail"; | |
| 55 | const incremental = createIncrementalMarkdownPlugin(); | |
| 56 | const parsedSources: string[] = []; | |
| 57 | expect(render(source, incremental, parsedSources)).toEqual(render(source)); | |
| 58 | parsedSources.length = 0; | |
| 59 | const next = source + " more"; | |
| 60 | expect(render(next, incremental, parsedSources)).toEqual(render(next)); | |
| 61 | expect(parsedSources).not.toContain(next); | |
| 62 | expect(parsedSources.some((text) => text.startsWith("t3-markdown-inline-prefix:"))).toBe(true); | |
| 63 | }); | |
| 64 | ||
| 65 | it.each([ | |
| 66 | "a\n===\n\nb\n---\n", | |
| 67 | "- first\n\n continued\n\n- next\n", | |
| 68 | "> quoted\n>\n> ```js\n> abc\n> ```\n\nend", | |
| 69 | "<div>\nhello\n\n</div>\n\nend", | |
| 70 | "[ref]\n\n[ref]: /later", | |
| 71 | "a[^x]\n\n[^x]: note", | |
| 72 | "a | b\n--|--\na | b\n", | |
| 73 | "```\na\n```\n\nnext\n\n~~~\nb\n~~~\n\nmore", | |
| 74 | "\n\n\tcode\n\nmore", | |
| 75 | "text <https://example.com> *bold*", | |
| 76 | "> [!NOTE]\n> alert\n\n- [ ] task", | |
| 77 | "\uFEFFtext after a byte-order mark", | |
| 78 | ])("preserves the parse tree, positions, and HTML while streaming %j", (tail) => { | |
| 79 | const source = prefix + tail; | |
| 80 | const incremental = createIncrementalMarkdownPlugin(); | |
| 81 | for (let end = 0; end <= source.length; end++) { | |
| 82 | const text = source.slice(0, end); | |
| 83 | expect(render(text, incremental), `prefix ${end}`).toEqual(render(text)); | |
| 84 | } | |
| 85 | }); | |
| 86 | ||
| 87 | it.each(["\r\n", "\r"])("preserves partial %j line endings", (newline) => { | |
| 88 | const source = (prefix + "next\n\n```\nlast\n```\n\nend").replaceAll("\n", newline); | |
| 89 | const incremental = createIncrementalMarkdownPlugin(); | |
| 90 | for (let end = 0; end <= source.length; end++) { | |
| 91 | const text = source.slice(0, end); | |
| 92 | expect(render(text, incremental)).toEqual(render(text)); | |
| 93 | } | |
| 94 | }); | |
| 95 | ||
| 96 | it("updates earlier references when definitions arrive after the cached prefix", () => { | |
| 97 | const before = "[later] and footnote[^note]\n\n" + prefix; | |
| 98 | const incremental = createIncrementalMarkdownPlugin(); | |
| 99 | for (const tail of ["text", "[later]: /target", "[later]: /target\n\n[^note]: a note"]) { | |
| 100 | expect(render(before + tail, incremental)).toEqual(render(before + tail)); | |
| 101 | } | |
| 102 | }); | |
| 103 | ||
| 104 | it("handles edits, replacements, and repeated renders without leaking transformed nodes", () => { | |
| 105 | const incremental = createIncrementalMarkdownPlugin(); | |
| 106 | const documents = [ | |
| 107 | prefix + "- first\n - second", | |
| 108 | prefix + "> [!NOTE]\n> transformed alert", | |
| 109 | prefix + "plain text", | |
| 110 | "replacement without fences", | |
| 111 | prefix.replace("Before", "Edited") + "edited prefix", | |
| 112 | prefix + "plain text", | |
| 113 | prefix + "plain text", | |
| 114 | ]; | |
| 115 | for (const document of documents) { | |
| 116 | expect(render(document, incremental)).toEqual(render(document)); | |
| 117 | } | |
| 118 | }); | |
| 119 | ||
| 120 | it("does not freeze unclosed, nested, indented, or mismatched fences", () => { | |
| 121 | const prefixes = [ | |
| 122 | "```\nopen\n\n", | |
| 123 | "````\n```\n\n", | |
| 124 | "> ```\n> code\n> ```\n\n", | |
| 125 | "- ```\n code\n ```\n\n", | |
| 126 | " ```\n code\n ```\n\n", | |
| 127 | "<script>\n```\ncode\n```\n\n", | |
| 128 | ]; | |
| 129 | for (const start of prefixes) { | |
| 130 | const incremental = createIncrementalMarkdownPlugin(); | |
| 131 | for (const tail of ["", "text", "\n```\n", "\n```\n\nnext"]) { | |
| 132 | expect(render(start + tail, incremental)).toEqual(render(start + tail)); | |
| 133 | } | |
| 134 | } | |
| 135 | }); | |
| 136 | }); |
apps/web/src/markdown-incremental.ts created+107| ... | ... | @@ -0,0 +1,107 @@ |
| 1 | import type { Root, RootContent } from "mdast"; | |
| 2 | import type { Parser as UnifiedParser, Plugin } from "unified"; | |
| 3 | ||
| 4 | type Parser = UnifiedParser<Root>; | |
| 5 | ||
| 6 | interface ParsedPrefix { | |
| 7 | source: string; | |
| 8 | offset: number; | |
| 9 | line: number; | |
| 10 | children: RootContent[]; | |
| 11 | } | |
| 12 | ||
| 13 | function hasDefinitions(node: Root | RootContent): boolean { | |
| 14 | return ( | |
| 15 | node.type === "definition" || | |
| 16 | node.type === "footnoteDefinition" || | |
| 17 | ("children" in node && node.children.some((child) => hasDefinitions(child))) | |
| 18 | ); | |
| 19 | } | |
| 20 | ||
| 21 | function shiftPositions(node: Root | RootContent, offset: number, lines: number): void { | |
| 22 | if (node.position) { | |
| 23 | for (const point of [node.position.start, node.position.end]) { | |
| 24 | if (point.offset !== undefined) point.offset += offset; | |
| 25 | point.line += lines; | |
| 26 | } | |
| 27 | } | |
| 28 | if ("children" in node) { | |
| 29 | for (const child of node.children) shiftPositions(child, offset, lines); | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | /** Keep the full document pipeline while avoiding parsing a completed code-heavy | |
| 34 | * prefix on every token. A closed top-level fence followed by a blank line is a | |
| 35 | * parsing boundary. Definitions are document-wide, so they require a full parse. | |
| 36 | */ | |
| 37 | function createIncrementalMarkdownParser(parse: Parser): Parser { | |
| 38 | let cached: ParsedPrefix | undefined; | |
| 39 | ||
| 40 | return (source, file) => { | |
| 41 | // A streaming CR can become half of a CRLF. A BOM at the suffix boundary | |
| 42 | // would be stripped by a new parser although it is inside the full document. | |
| 43 | if (source.includes("\r") || source.includes("\uFEFF")) return parse(source, file); | |
| 44 | ||
| 45 | const prefix = cached && source.startsWith(cached.source) ? cached : undefined; | |
| 46 | let root: Root; | |
| 47 | if (prefix) { | |
| 48 | root = parse(source.slice(prefix.offset), file); | |
| 49 | if (hasDefinitions(root)) return parse(source, file); | |
| 50 | shiftPositions(root, prefix.offset, prefix.line - 1); | |
| 51 | if (root.position) root.position.start = { line: 1, column: 1, offset: 0 }; | |
| 52 | // Remark transforms mutate their input. The cache owns pristine nodes and | |
| 53 | // each render receives its own copy, including source positions. | |
| 54 | root.children.unshift(...structuredClone(prefix.children)); | |
| 55 | } else { | |
| 56 | root = parse(source, file); | |
| 57 | if (hasDefinitions(root)) return root; | |
| 58 | } | |
| 59 | ||
| 60 | for (let index = root.children.length - 1; index >= 0; index--) { | |
| 61 | const node = root.children[index]; | |
| 62 | if (node?.type !== "code") continue; | |
| 63 | const start = node.position?.start.offset; | |
| 64 | const end = node.position?.end.offset; | |
| 65 | if (start === undefined || end === undefined) continue; | |
| 66 | if (prefix && end < prefix.offset) break; | |
| 67 | const value = source.slice(start, end); | |
| 68 | const opening = /^ {0,3}(`{3,}|~{3,})[^\n]*\n/.exec(value)?.[1]; | |
| 69 | if (!opening) continue; | |
| 70 | const lastLine = value.slice(value.lastIndexOf("\n") + 1); | |
| 71 | const closing = new RegExp(`^ {0,3}${opening[0]}{${opening.length},}[ \\t]*$`); | |
| 72 | const separator = /^\n[ \t]*\n/.exec(source.slice(end))?.[0]; | |
| 73 | if (!closing.test(lastLine) || !separator) continue; | |
| 74 | const offset = end + separator.length; | |
| 75 | cached = { | |
| 76 | source: source.slice(0, offset), | |
| 77 | offset, | |
| 78 | line: node.position!.end.line + 2, | |
| 79 | children: structuredClone(root.children.slice(0, index + 1)), | |
| 80 | }; | |
| 81 | break; | |
| 82 | } | |
| 83 | return root; | |
| 84 | }; | |
| 85 | } | |
| 86 | ||
| 87 | /** One cache per streaming renderer. Extra syntax plugins must use the normal | |
| 88 | * parser because their document-wide dependencies are not known here. | |
| 89 | */ | |
| 90 | export function createIncrementalMarkdownPlugin(): Plugin<[], Root> { | |
| 91 | let parser: Parser | undefined; | |
| 92 | return function () { | |
| 93 | const original = this.parser; | |
| 94 | if (!original) return; | |
| 95 | parser ??= createIncrementalMarkdownParser((source, file) => original(source, file) as Root); | |
| 96 | const parseDocument = parser; | |
| 97 | // ReactMarkdown creates a processor per render. Its first parse is the | |
| 98 | // document; transforms can then parse synthetic recovery text on that same | |
| 99 | // processor. Those parses must not read or replace the document's cache. | |
| 100 | let documentParsed = false; | |
| 101 | this.parser = (source, file) => { | |
| 102 | if (documentParsed) return original(source, file); | |
| 103 | documentParsed = true; | |
| 104 | return parseDocument(source, file); | |
| 105 | }; | |
| 106 | }; | |
| 107 | } |
pnpm-lock.yaml+6| ... | ... | @@ -707,6 +707,9 @@ importers: |
| 707 | 707 | '@types/culori': |
| 708 | 708 | specifier: ^4.0.1 |
| 709 | 709 | version: 4.0.1 |
| 710 | '@types/mdast': | |
| 711 | specifier: ^4.0.4 | |
| 712 | version: 4.0.4 | |
| 710 | 713 | '@types/react': |
| 711 | 714 | specifier: ~19.2.14 |
| 712 | 715 | version: 19.2.16 |
| ... | ... | @@ -734,6 +737,9 @@ importers: |
| 734 | 737 | tailwindcss: |
| 735 | 738 | specifier: 4.3.3 |
| 736 | 739 | version: 4.3.3 |
| 740 | unified: | |
| 741 | specifier: ^11.0.5 | |
| 742 | version: 11.0.5 | |
| 737 | 743 | vite: |
| 738 | 744 | specifier: npm:@voidzero-dev/vite-plus-core@0.3.0 |
| 739 | 745 | version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)' |