| 1 | import { CHAT_MARKDOWN_REHYPE_PLUGINS } from "@t3tools/shared/markdownPipeline"; |
| 2 | import type { Root } from "mdast"; |
| 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 4 | import ReactMarkdown from "react-markdown"; |
| 5 | import remarkGfm from "remark-gfm"; |
| 6 | import type { Plugin } from "unified"; |
| 7 | import { describe, expect, it } from "vite-plus/test"; |
| 8 | |
| 9 | import { remarkCodexDirectives } from "@t3tools/shared/codexMarkdownDirectives"; |
| 10 | import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts"; |
| 11 | import { createIncrementalMarkdownPlugin } from "./markdown-incremental"; |
| 12 | import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation"; |
| 13 | |
| 14 | function 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 | |
| 47 | const prefix = "# Before\n\n```ts\nconst values = [1, 2];\n```\n\n"; |
| 48 | |
| 49 | describe("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 | }); |