1import { CHAT_MARKDOWN_REHYPE_PLUGINS } from "@t3tools/shared/markdownPipeline";
2import type { Root } from "mdast";
3import { renderToStaticMarkup } from "react-dom/server";
4import ReactMarkdown from "react-markdown";
5import remarkGfm from "remark-gfm";
6import type { Plugin } from "unified";
7import { describe, expect, it } from "vite-plus/test";
8
9import { remarkCodexDirectives } from "@t3tools/shared/codexMarkdownDirectives";
10import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts";
11import { createIncrementalMarkdownPlugin } from "./markdown-incremental";
12import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation";
13
14function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) {
15 let tree: Root | undefined;
16 const observeParsing: Plugin<[], Root> = function () {
17 const original = this.parser;
18 if (original) {
19 this.parser = (text, file) => {
20 parsedSources?.push(text);
21 return original(text, file);
22 };
23 }
24 };
25 const capture: Plugin<[], Root> = () => (root) => {
26 tree = structuredClone(root);
27 };
28 const html = renderToStaticMarkup(
29 <ReactMarkdown
30 remarkPlugins={[
31 observeParsing,
32 capture,
33 remarkGfm,
34 remarkGithubAlerts,
35 remarkNormalizeListItemIndentation,
36 remarkCodexDirectives,
37 ...(incremental ? [incremental] : []),
38 ]}
39 rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS}
40 >
41 {source}
42 </ReactMarkdown>,
43 );
44 return { html, tree };
45}
46
47const prefix = "# Before\n\n```ts\nconst values = [1, 2];\n```\n\n";
48
49describe("incremental Markdown parsing", () => {
50 it("keeps the document prefix cached when list recovery parses contain fences", () => {
51 const source =
52 prefix +
53 "- first block\n\n ```ts\n const nested = 1;\n ```\n\n tail";
54 const incremental = createIncrementalMarkdownPlugin();
55 const parsedSources: string[] = [];
56 expect(render(source, incremental, parsedSources)).toEqual(render(source));
57 parsedSources.length = 0;
58 const next = source + " more";
59 expect(render(next, incremental, parsedSources)).toEqual(render(next));
60 expect(parsedSources).not.toContain(next);
61 expect(parsedSources.some((text) => text.startsWith("t3-markdown-inline-prefix:"))).toBe(true);
62 });
63
64 it.each([
65 "a\n===\n\nb\n---\n",
66 "- first\n\n continued\n\n- next\n",
67 "> quoted\n>\n> ```js\n> abc\n> ```\n\nend",
68 "<div>\nhello\n\n</div>\n\nend",
69 "[ref]\n\n[ref]: /later",
70 "a[^x]\n\n[^x]: note",
71 "a | b\n--|--\na | b\n",
72 "```\na\n```\n\nnext\n\n~~~\nb\n~~~\n\nmore",
73 "\n\n\tcode\n\nmore",
74 "text <https://example.com> *bold*",
75 "> [!NOTE]\n> alert\n\n- [ ] task",
76 "\uFEFFtext after a byte-order mark",
77 ])("preserves the parse tree, positions, and HTML while streaming %j", (tail) => {
78 const source = prefix + tail;
79 const incremental = createIncrementalMarkdownPlugin();
80 for (let end = 0; end <= source.length; end++) {
81 const text = source.slice(0, end);
82 expect(render(text, incremental), `prefix ${end}`).toEqual(render(text));
83 }
84 });
85
86 it.each(["\r\n", "\r"])("preserves partial %j line endings", (newline) => {
87 const source = (prefix + "next\n\n```\nlast\n```\n\nend").replaceAll("\n", newline);
88 const incremental = createIncrementalMarkdownPlugin();
89 for (let end = 0; end <= source.length; end++) {
90 const text = source.slice(0, end);
91 expect(render(text, incremental)).toEqual(render(text));
92 }
93 });
94
95 it("updates earlier references when definitions arrive after the cached prefix", () => {
96 const before = "[later] and footnote[^note]\n\n" + prefix;
97 const incremental = createIncrementalMarkdownPlugin();
98 for (const tail of ["text", "[later]: /target", "[later]: /target\n\n[^note]: a note"]) {
99 expect(render(before + tail, incremental)).toEqual(render(before + tail));
100 }
101 });
102
103 it("handles edits, replacements, and repeated renders without leaking transformed nodes", () => {
104 const incremental = createIncrementalMarkdownPlugin();
105 const documents = [
106 prefix + "- first\n - second",
107 prefix + "> [!NOTE]\n> transformed alert",
108 prefix + "plain text",
109 "replacement without fences",
110 prefix.replace("Before", "Edited") + "edited prefix",
111 prefix + "plain text",
112 prefix + "plain text",
113 ];
114 for (const document of documents) {
115 expect(render(document, incremental)).toEqual(render(document));
116 }
117 });
118
119 it("does not freeze unclosed, nested, indented, or mismatched fences", () => {
120 const prefixes = [
121 "```\nopen\n\n",
122 "````\n```\n\n",
123 "> ```\n> code\n> ```\n\n",
124 "- ```\n code\n ```\n\n",
125 " ```\n code\n ```\n\n",
126 "<script>\n```\ncode\n```\n\n",
127 ];
128 for (const start of prefixes) {
129 const incremental = createIncrementalMarkdownPlugin();
130 for (const tail of ["", "text", "\n```\n", "\n```\n\nnext"]) {
131 expect(render(start + tail, incremental)).toEqual(render(start + tail));
132 }
133 }
134 });
135});