| 1 | // Run: tsx src/__tests__/markdown-pipeline.test.tsx |
| 2 | // |
| 3 | // Parse-parity goldens for the isomorphic markdown pipeline (Phase E): the |
| 4 | // worker/fallback pipeline must render byte-identical static markup to the |
| 5 | // production react-markdown path for the same document, both unsliced and |
| 6 | // sliced into blocks (footnotes/reference definitions resolve across blocks |
| 7 | // because parsing is whole-document). |
| 8 | |
| 9 | import { createElement, Fragment, type ReactNode } from "react"; |
| 10 | import { renderToStaticMarkup } from "react-dom/server"; |
| 11 | import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; |
| 12 | import { JSDOM } from "jsdom"; |
| 13 | import { normalizeMath } from "../components/mathNormalize"; |
| 14 | import { createComponents } from "../components/markdownComponents"; |
| 15 | import { reasonixRehypePlugins, reasonixRemarkPlugins } from "../components/markdownRemarkPlugins"; |
| 16 | import { hastBlockToJsx } from "../lib/hastJsx"; |
| 17 | import { visibleMarkdownBlockCount } from "../lib/markdownDomBudget"; |
| 18 | import { |
| 19 | defaultMarkdownUrlTransform, |
| 20 | estimateHastBytes, |
| 21 | markdownContentRevision, |
| 22 | markdownUrlTransform, |
| 23 | parseMarkdown, |
| 24 | parseMarkdownToBlocks, |
| 25 | parseMarkdownToHast, |
| 26 | sliceHastBlocks, |
| 27 | type MarkdownBlock, |
| 28 | } from "../lib/markdownPipeline"; |
| 29 | |
| 30 | let passed = 0; |
| 31 | let failed = 0; |
| 32 | |
| 33 | function ok(value: unknown, label: string) { |
| 34 | if (value) { |
| 35 | process.stdout.write(` PASS ${label}\n`); |
| 36 | passed += 1; |
| 37 | } else { |
| 38 | process.stdout.write(` FAIL ${label}\n`); |
| 39 | failed += 1; |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | function eq(actual: unknown, expected: unknown, label: string) { |
| 44 | if (actual === expected) ok(true, label); |
| 45 | else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); |
| 46 | } |
| 47 | |
| 48 | function renderCurrent(text: string): string { |
| 49 | return renderToStaticMarkup( |
| 50 | createElement(ReactMarkdown, { |
| 51 | remarkPlugins: reasonixRemarkPlugins, |
| 52 | rehypePlugins: reasonixRehypePlugins, |
| 53 | components: createComponents(false), |
| 54 | urlTransform: markdownUrlTransform, |
| 55 | children: normalizeMath(text), |
| 56 | }), |
| 57 | ); |
| 58 | } |
| 59 | |
| 60 | function renderBlocks(blocks: MarkdownBlock[]): string { |
| 61 | const components = createComponents(false); |
| 62 | return renderToStaticMarkup( |
| 63 | createElement(Fragment, { |
| 64 | children: blocks.map((block) => |
| 65 | createElement(Fragment, { key: block.key, children: hastBlockToJsx(block, components) as ReactNode })), |
| 66 | }), |
| 67 | ); |
| 68 | } |
| 69 | |
| 70 | function projectRenderedBlocks(blocks: MarkdownBlock[]): string { |
| 71 | const dom = new JSDOM(`<!doctype html><body><div id="root" data-transcript-selectable>${renderBlocks(blocks)}</div></body>`); |
| 72 | globalThis.Node = dom.window.Node; |
| 73 | globalThis.Element = dom.window.Element; |
| 74 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 75 | const root = dom.window.document.getElementById("root") as HTMLElement; |
| 76 | const selection = dom.window.getSelection()!; |
| 77 | selection.selectAllChildren(root); |
| 78 | const projected = selection.toString(); |
| 79 | dom.window.close(); |
| 80 | return projected; |
| 81 | } |
| 82 | |
| 83 | console.log("\nmarkdown pipeline parity"); |
| 84 | |
| 85 | const bigCode = "```js\n" + "const value = compute(index); // keep this line long enough\n".repeat(4000) + "```"; |
| 86 | ok(bigCode.length > 100_000, "code-fence fixture exceeds 100KB"); |
| 87 | |
| 88 | const fixtures: Record<string, string> = { |
| 89 | headingsAndProse: "# Title\n\nHello **world**, this is [a link](https://example.com).\n\nSecond paragraph with `code`.", |
| 90 | gfmTable: "| name | value |\n| --- | ---: |\n| a | 1 |\n| b | 2 |", |
| 91 | taskList: "- [x] done\n- [ ] todo\n- [ ] item with **bold** and `code`", |
| 92 | strikethroughAutolink: "~~gone~~ and https://example.com/auto plus www.example.com", |
| 93 | mathInline: "Price is $5 and math $x^2$ works. Also \\(y=1\\) and $E=mc^2$.", |
| 94 | mathBlockRepair: "Before\n\n\\[\n\\int_0^1 x\\,dx\n\\]\n\nAfter\n\n$$\n\\frac{a}{b}\n$$", |
| 95 | mathInlinePipe: "| formula | note |\n| --- | --- |\n| $|x|$ cell | pipe inside math |", |
| 96 | footnotes: "Text with a note[^1] and another[^long].\n\n[^1]: first note\n\n[^long]: second note with [ref link][r]\n\n[r]: https://example.com/ref", |
| 97 | crossBlockRefs: "Use [shared] here.\n\n## Section\n\nMore text.\n\n## Later\n\nAgain [shared] and [other].\n\n[shared]: https://example.com/shared\n[other]: https://example.com/other", |
| 98 | mermaidFence: "```mermaid\ngraph TD\nA-->B\n```", |
| 99 | bigCodeFence: bigCode, |
| 100 | cjk: "中文段落,包含「引号」和路径 D:\\work\\项目\\文件.md。\n\n- 列表项一\n- 列表项二", |
| 101 | rawHtml: "Before <div class=\"x\">raw</div> after\n\n<script>alert(1)</script>", |
| 102 | unsafeAndFileLinks: "[bad](javascript:alert(1)) and [file](file:///tmp/a%20b.txt) and D:\\src\\app.ts", |
| 103 | manyBlocks: Array.from({ length: 40 }, (_, i) => `## Part ${i}\n\nParagraph ${i} with *emphasis*.\n`).join("\n"), |
| 104 | }; |
| 105 | |
| 106 | for (const [name, text] of Object.entries(fixtures)) { |
| 107 | const expected = renderCurrent(text); |
| 108 | const root = parseMarkdownToHast(text); |
| 109 | const whole = renderBlocks([{ key: "whole", children: root.children }]); |
| 110 | eq(whole, expected, `${name}: pipeline render matches react-markdown`); |
| 111 | const blocks = sliceHastBlocks(root); |
| 112 | const sliced = renderBlocks(blocks); |
| 113 | eq(sliced, expected, `${name}: sliced blocks render identically (${blocks.length} blocks)`); |
| 114 | } |
| 115 | |
| 116 | // Empty fenced blocks are formatting placeholders. They must not create a |
| 117 | // bordered CodeViewer, while comment-only and whitespace-adjacent blocks stay |
| 118 | // visible as real code content. |
| 119 | { |
| 120 | const emptyFences = "Before\n\n```\n\n```\n\nAfter\n\n```ts\n \n```"; |
| 121 | const html = renderCurrent(emptyFences); |
| 122 | ok(!html.includes("code-block"), "empty fenced blocks do not render phantom code cards"); |
| 123 | ok(html.includes("Before") && html.includes("After"), "text around empty fenced blocks remains visible"); |
| 124 | |
| 125 | const comments = "```ts\n// keep this comment\nconst stable = true;\n```"; |
| 126 | const commentHtml = renderCurrent(comments); |
| 127 | ok(commentHtml.includes("keep this comment"), "non-empty comment code blocks remain visible"); |
| 128 | ok(commentHtml.includes("code-block"), "non-empty comment code keeps its code-block surface"); |
| 129 | } |
| 130 | |
| 131 | // Block keys are stable top-level indexes. |
| 132 | { |
| 133 | const blocks = parseMarkdownToBlocks("one\n\ntwo\n\nthree"); |
| 134 | eq(blocks.map((b) => b.key).join(","), "b0,b1,b2", "block keys are stable indexes"); |
| 135 | ok(blocks.every((block) => (block.elementCount ?? 0) > 0), "parse stamps DOM element counts with block fingerprints"); |
| 136 | } |
| 137 | |
| 138 | // Progressive DOM publication keeps semantic blocks whole and advances by an |
| 139 | // explicit element budget instead of mounting an unbounded parsed document. |
| 140 | { |
| 141 | const blocks = parseMarkdownToBlocks(Array.from({ length: 12 }, (_, index) => |
| 142 | `## Part ${index}\n\nParagraph with **bold** and [link](https://example.com/${index}).`).join("\n\n")); |
| 143 | const first = visibleMarkdownBlockCount(blocks, 8); |
| 144 | ok(first > 0 && first < blocks.length, "small DOM budget publishes a strict leading block page"); |
| 145 | ok(visibleMarkdownBlockCount(blocks, 10_000) === blocks.length, "larger DOM budget makes every block reachable"); |
| 146 | } |
| 147 | |
| 148 | // Footnote definitions survive slicing as a trailing block with working refs. |
| 149 | { |
| 150 | const text = "first[^a]\n\n## middle\n\nsecond[^b]\n\n[^a]: note a\n[^b]: note b"; |
| 151 | const blocks = parseMarkdownToBlocks(text); |
| 152 | const last = blocks[blocks.length - 1]; |
| 153 | const lastHtml = renderBlocks([last]); |
| 154 | ok(lastHtml.includes("data-footnotes"), "footnote section is the trailing block"); |
| 155 | const sliced = renderBlocks(blocks); |
| 156 | ok(sliced.includes('href="#user-content-fn-a"'), "footnote reference links survive slicing"); |
| 157 | ok(sliced.includes('id="user-content-fn-a"'), "footnote definition anchors survive slicing"); |
| 158 | } |
| 159 | |
| 160 | // The copied defaultUrlTransform must match react-markdown's across protocols. |
| 161 | { |
| 162 | const corpus = [ |
| 163 | "https://example.com/a?b=c#d", |
| 164 | "http://example.com", |
| 165 | "mailto:user@example.com", |
| 166 | "javascript:alert(1)", |
| 167 | "vbscript:x", |
| 168 | "data:text/html,boom", |
| 169 | "./relative/path", |
| 170 | "../up", |
| 171 | "/absolute", |
| 172 | "#fragment", |
| 173 | "query?only", |
| 174 | "ftp://example.com/file", |
| 175 | "file:///etc/passwd", |
| 176 | "HTTPS://EXAMPLE.COM/upper", |
| 177 | "ircs://irc.example.com/chan", |
| 178 | "xmpp:user@example.com", |
| 179 | "a:b:c", |
| 180 | "", |
| 181 | "C:\\src\\app.ts", |
| 182 | ]; |
| 183 | for (const url of corpus) { |
| 184 | eq(defaultMarkdownUrlTransform(url), defaultUrlTransform(url), `urlTransform parity for ${JSON.stringify(url)}`); |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | // Authority-form UNC links use the same strict local-file allowlist in the |
| 189 | // worker pipeline as canonical file:/// links do. |
| 190 | { |
| 191 | const unc = "file://nas/share/report.md"; |
| 192 | eq(markdownUrlTransform(unc), unc, "authority-form UNC survives pipeline URL sanitization"); |
| 193 | const root = parseMarkdownToHast(`[report](${unc})`); |
| 194 | const html = renderBlocks([{ key: "unc", children: root.children }]); |
| 195 | ok(html.includes(`href="${unc}"`), "authority-form UNC href survives HAST rendering"); |
| 196 | } |
| 197 | |
| 198 | // Windows device namespaces and alternate data streams must not be restored |
| 199 | // after react-markdown's default URL sanitizer rejects their file: scheme. |
| 200 | for (const unsafe of [ |
| 201 | "file://./PhysicalDrive0", |
| 202 | "file:////?/C:/Windows", |
| 203 | "file:///C:/safe.txt:payload", |
| 204 | "file:///tmp/report.md?download=1", |
| 205 | "file:///tmp/report.md#section", |
| 206 | ]) { |
| 207 | eq(markdownUrlTransform(unsafe), "", `unsafe local URL is blanked: ${unsafe}`); |
| 208 | } |
| 209 | |
| 210 | // Content revision + byte weight. |
| 211 | { |
| 212 | eq(markdownContentRevision("alpha") === markdownContentRevision("alpha"), true, "content revision is deterministic"); |
| 213 | eq(markdownContentRevision("alpha") !== markdownContentRevision("beta"), true, "content revision distinguishes texts"); |
| 214 | const blocks = parseMarkdownToBlocks("hello **world**"); |
| 215 | const bytes = estimateHastBytes(blocks); |
| 216 | ok(bytes > 0 && bytes < 100_000, "hast byte estimate is positive and bounded"); |
| 217 | const bigBlocks = parseMarkdownToBlocks(bigCode); |
| 218 | ok(estimateHastBytes(bigBlocks) > bytes, "hast byte estimate grows with content"); |
| 219 | } |
| 220 | |
| 221 | console.log("\nmarkdown selection projection"); |
| 222 | |
| 223 | { |
| 224 | const result = parseMarkdown([ |
| 225 | "# 标题 😀", |
| 226 | "", |
| 227 | "段落 [链接文字](https://example.com) 与 $x^2$。", |
| 228 | "", |
| 229 | "内联 **粗体** *斜体*。", |
| 230 | "", |
| 231 | "- 第一项", |
| 232 | "- 第二项", |
| 233 | "", |
| 234 | "```ts", |
| 235 | "const value = 1;", |
| 236 | "```", |
| 237 | "", |
| 238 | "| 名称 | 值 |", |
| 239 | "| --- | --- |", |
| 240 | "| 一 | 1 |", |
| 241 | ].join("\n")); |
| 242 | eq( |
| 243 | result.selectionText, |
| 244 | "标题 😀\n段落 链接文字 与 $x^2$。\n内联 粗体 斜体。\n第一项\n第二项\nconst value = 1;\n名称\t值\n一\t1", |
| 245 | "selection projection preserves readable structure, code, tables, CJK, emoji and LaTeX", |
| 246 | ); |
| 247 | const selected = projectRenderedBlocks(result.blocks); |
| 248 | ok(selected.includes("标题 😀") && selected.includes("链接文字") && selected.includes("const value = 1;"), |
| 249 | "native DOM selection includes Unicode, links and complete code text"); |
| 250 | eq(result.selectionRevision, markdownContentRevision(result.selectionText), "selection revision fingerprints projected UTF-16 text"); |
| 251 | } |
| 252 | |
| 253 | { |
| 254 | const rows = Array.from({ length: 52 }, (_, index) => `| row-${index} | ${index} |`).join("\n"); |
| 255 | const result = parseMarkdown(`| name | value |\n| --- | --- |\n${rows}`); |
| 256 | ok(result.blocks.some((block) => block.virtualTable), "large plain table uses the virtual table representation"); |
| 257 | ok(result.selectionText.includes("row-51\t51"), "virtual table projection includes rows that never mount in the DOM"); |
| 258 | } |
| 259 | |
| 260 | // ── block fingerprints ────────────────────────────────────────────────────── |
| 261 | // The render path keeps a previous AST object when key and fingerprint match, |
| 262 | // so the fingerprint must be equal exactly when the block's rendered content is |
| 263 | // equal. A false "unchanged" leaves a stale block on screen. |
| 264 | { |
| 265 | const same = parseMarkdown("paragraph one\n\n- a\n- b"); |
| 266 | const again = parseMarkdown("paragraph one\n\n- a\n- b"); |
| 267 | eq( |
| 268 | again.blocks.map(block => block.fingerprint).join(","), |
| 269 | same.blocks.map(block => block.fingerprint).join(","), |
| 270 | "identical sources fingerprint identically", |
| 271 | ); |
| 272 | ok(same.blocks.every(block => Number.isInteger(block.fingerprint)), "every block is stamped with a fingerprint"); |
| 273 | |
| 274 | // A block that changed must not be mistaken for its previous self. |
| 275 | const appended = parseMarkdown("paragraph one\n\n- a\n- b\n- c"); |
| 276 | ok(appended.blocks[0].fingerprint === same.blocks[0].fingerprint, "an unchanged leading block keeps its fingerprint"); |
| 277 | ok(appended.blocks[1].fingerprint !== same.blocks[1].fingerprint, "a grown list block changes its fingerprint"); |
| 278 | |
| 279 | // Appending a reference definition rewrites an EARLIER paragraph's link. A |
| 280 | // positional-only identity would keep the stale block here. |
| 281 | const unresolved = parseMarkdown("see [docs][ref]\n\nand more"); |
| 282 | const resolved = parseMarkdown("see [docs][ref]\n\nand more\n\n[ref]: https://example.com/doc"); |
| 283 | ok(unresolved.blocks[0].fingerprint !== resolved.blocks[0].fingerprint, |
| 284 | "a reference definition that resolves an earlier link changes that block's fingerprint"); |
| 285 | |
| 286 | // Structure the markdown source does not spell out still separates blocks. |
| 287 | const inline = parseMarkdown("a **bold** word"); |
| 288 | const plain = parseMarkdown("a bold word"); |
| 289 | ok(inline.blocks[0].fingerprint !== plain.blocks[0].fingerprint, "inline emphasis changes the fingerprint"); |
| 290 | |
| 291 | // Distinct content must not collide, including across block boundaries. |
| 292 | const boundary = parseMarkdown("ab\n\nc"); |
| 293 | const shifted = parseMarkdown("a\n\nbc"); |
| 294 | ok(boundary.blocks[0].fingerprint !== shifted.blocks[0].fingerprint, "a shorter first block fingerprints differently"); |
| 295 | } |
| 296 | |
| 297 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 298 | if (failed > 0) process.exit(1); |
| 299 |