import { fromMarkdown } from "mdast-util-from-markdown"; import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser"; import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown"; import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks"; import { matchCommandLink } from "@/agent-commands"; import { createForgeShorthand } from "@/git/forge-shorthand"; import { classifyAssistantFileLink } from "@/assistant-file-links"; import { markInlineImages } from "react-native-enriched-markdown/src/web/utils"; import type { ASTNode } from "react-native-enriched-markdown/src/web/types"; import { QueryClient } from "@tanstack/react-query"; import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding"; import { describe, expect, it } from "vitest"; import { darkTheme, lightTheme } from "@/styles/theme"; import { createMarkdownStyles } from "./styles"; import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source"; import { normalizeMarkdownHtml } from "./html"; it("recognizes complete known commands in inline code", () => { const names = new Set(["code-review", "review:fast"]); const prepared = prepareMarkdownSource("`/code-review` `/unknown`", undefined, { commandNames: names, }); expect(prepared.linkRecognition.inlineCode.test("/code-review")).toBe(true); expect(prepared.linkRecognition.inlineCode.test("/unknown")).toBe( prepareMarkdownSource("`/unknown`").linkRecognition.inlineCode.test("/unknown"), ); const nonPath = prepareMarkdownSource("`/review:fast`", undefined, { commandNames: names }); expect(nonPath.linkRecognition.inlineCode.test("/review:fast")).toBe(true); expect(matchCommandLink("/code-review", "inline-code", names)).toBe("/code-review"); }); it("resolves authored root-relative links only when the target is a known command", () => { const names = new Set(["code-review"]); expect(matchCommandLink("/code-review", "markdown", names)).toBe("/code-review"); expect(matchCommandLink("/code-review/file", "markdown", names)).toBeNull(); expect(matchCommandLink("/code-review", "text", names)).toBeNull(); expect(matchCommandLink("https://example.com/code-review", "markdown", names)).toBeNull(); }); describe("shared Markdown source adaptation", () => { it("keeps paragraphs, fences and quotes together for the library", () => { const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote"; expect(prepareMarkdownSource(source).render()).toBe(source); }); it("resolves workspace images without rewriting code or remote image URLs", () => { const source = "![Local](assets/a.png)\n\n![Remote](https://example.com/a.png)\n\n```md\n![Code](assets/a.png)\n```"; const prepared = prepareMarkdownSource(source); expect(prepared.images.map((image) => image.source)).toEqual([ "assets/a.png", "https://example.com/a.png", ]); expect(prepared.render(new Map([["assets/a.png", "blob:cached"]]))).toBe( "![Local]()\n\n![Remote](https://example.com/a.png)\n\n```md\n![Code](assets/a.png)\n```", ); }); it("resolves reference images and images nested in lists and quotes", () => { const prepared = prepareMarkdownSource("> - ![Image][picture]\n\n[picture]: ../a.png"); expect(prepared.render(new Map([["../a.png", "file:///cache/a.png"]]))).toBe( "> - ![Image]()\n\n[picture]: ../a.png", ); }); it("gives an image alone on its line its own paragraph", () => { expect( prepareMarkdownSource("Before the shot:\n![Shot](https://x/a.png)\nAfter it.").render(), ).toBe("Before the shot:\n\n![Shot](https://x/a.png)\n\nAfter it."); expect(prepareMarkdownSource("![A](https://x/a.png)\n![B](https://x/b.png)").render()).toBe( "![A](https://x/a.png)\n\n![B](https://x/b.png)", ); expect( prepareMarkdownSource( "Line one\\\n[![Linked](https://x/a.png)](https://x)\nLine three", ).render(), ).toBe("Line one\n\n[![Linked](https://x/a.png)](https://x)\n\nLine three"); }); it("keeps images that share a line with text inline", () => { const source = "Icon ![i](https://x/i.png) in text\n![a](https://x/a.png) caption\nNext line"; expect(prepareMarkdownSource(source).render()).toBe(source); }); it("splits own-line images inside quotes and list items without ending the container", () => { expect(prepareMarkdownSource("> Quote text\n> ![Shot](https://x/a.png)\n> More").render()).toBe( "> Quote text\n>\n> ![Shot](https://x/a.png)\n>\n> More", ); expect( prepareMarkdownSource("- Item text\n ![Shot](https://x/a.png)\n More\n- Next").render(), ).toBe("- Item text\n\n ![Shot](https://x/a.png)\n\n More\n- Next"); expect(prepareMarkdownSource("- ![Shot](https://x/a.png)\n Caption line").render()).toBe( "- ![Shot](https://x/a.png)\n\n Caption line", ); }); it("renders own-line images as blocks through the library parser", async () => { const source = "Before:\n![Shot](https://x/a.png)\nIcon ![i](https://x/i.png) in text\n> Quoted\n> ![Q](https://x/q.png)"; const tree = await parseMarkdown(prepareMarkdownSource(source).render(), { hardSoftBreaks: true, latexMath: false, }); markInlineImages(tree); const images: ASTNode[] = []; const collect = (node: ASTNode) => { if (node.type === "Image") images.push(node); node.children?.forEach(collect); }; collect(tree); expect( images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]), ).toEqual([ ["https://x/a.png", false], ["https://x/i.png", true], ["https://x/q.png", false], ]); }); it("renders linked images that share a line with text inline", async () => { // A review bot's summary header: linked badges inside a heading with the score text. const reviewHeader = '

RetriggerConfidence Score: 5/5

'; const source = [ reviewHeader, "[![Alone](https://x/alone.png)](https://x)", "[![a](https://x/a.png)](https://x) [![b](https://x/b.png)](https://x)", "See [![gh](https://x/gh.png) GitHub](https://x) for more.", ].join("\n\n"); const tree = await parseMarkdown(prepareMarkdownSource(source).render(), { hardSoftBreaks: true, latexMath: false, }); markInlineImages(tree); const images: ASTNode[] = []; const collect = (node: ASTNode) => { if (node.type === "Image") images.push(node); node.children?.forEach(collect); }; collect(tree); expect( images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]), ).toEqual([ ["https://x/r.svg", true], ["https://x/alone.png", false], ["https://x/a.png", true], ["https://x/b.png", true], ["https://x/gh.png", true], ]); }); it("escapes spoiler delimiters only in prose", () => { expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe( "Show \\|\\|words\\|\\| and `||code||`.", ); }); }); it("preserves named and numeric entities as literal rendered text", () => { expect(prepareMarkdownSource("AT&T 😀 # heading and `&`").render()).toBe( "AT\\&T 😀 \\# heading and `&`", ); }); it("preserves empty table cells while escaping spoilers in cell text", () => { const source = "| A || B |\n| --- | --- | --- |\n| x || z |"; expect(prepareMarkdownSource(source).render()).toBe( "| A | | B |\n| --- | --- | --- |\n| x | | z |", ); }); it("renders a table directly after a paragraph through the library parser", async () => { const source = "**Notes**\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |"; const separated = "**Notes**\n\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |"; const flags = { hardSoftBreaks: true, latexMath: false }; const expected = await parseMarkdown(separated, flags); expect(expected.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]); expect(await parseMarkdown(prepareMarkdownSource(source).render(), flags)).toEqual(expected); }); it.each([ [ "a quote", "> Notes\n> | Item |\n> | --- |\n> | A |", "> Notes\n>\n> | Item |\n> | --- |\n> | A |", ["Document", ["Blockquote", "Paragraph", "Table"]], ], [ "a list item", "- Notes\n | Item |\n | --- |\n | A |\n- Next", "- Notes\n\n | Item |\n | --- |\n | A |\n- Next", ["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]], ], [ "a quoted list item", "> - Notes\n> | Item |\n> | --- |\n> | A |", "> - Notes\n>\n> | Item |\n> | --- |\n> | A |", ["Document", ["Blockquote", ["UnorderedList", ["ListItem", "Paragraph", "Table"]]]], ], [ "a quote inside a list item", "- > Notes\n > | Item |\n > | --- |\n > | A |", "- > Notes\n >\n > | Item |\n > | --- |\n > | A |", ["Document", ["UnorderedList", ["ListItem", ["Blockquote", "Paragraph", "Table"]]]], ], [ "aligned columns without outer pipes", "Notes\nItem | Detail\n:--- | ---:\nA | one", "Notes\n\nItem | Detail\n:--- | ---:\nA | one", ["Document", "Paragraph", "Table"], ], [ "an entity at the start of a header without outer pipes", "Notes\nAT&T | Detail\n--- | ---\nA | one", "Notes\n\nAT\\&T | Detail\n--- | ---\nA | one", ["Document", "Paragraph", "Table"], ], [ "a local image at the start of a header without outer pipes", "Notes\n![Local](assets/a.png) | Detail\n--- | ---\nA | one", "Notes\n\nLocal | Detail\n--- | ---\nA | one", ["Document", "Paragraph", "Table"], ], [ "CRLF lines", "Notes\r\n| Item |\r\n| --- |\r\n| A |", "Notes\r\n\r\n| Item |\r\n| --- |\r\n| A |", ["Document", "Paragraph", "Table"], ], [ "CR lines", "Notes\r| Item |\r| --- |\r| A |", "Notes\r\r| Item |\r| --- |\r| A |", ["Document", "Paragraph", "Table"], ], [ "a quote with CRLF lines", "> Notes\r\n> | Item |\r\n> | --- |\r\n> | A |", "> Notes\r\n>\r\n> | Item |\r\n> | --- |\r\n> | A |", ["Document", ["Blockquote", "Paragraph", "Table"]], ], [ "a list item with CRLF lines", "- Notes\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next", "- Notes\r\n\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next", ["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]], ], [ "a quote with CR lines", "> Notes\r> | Item |\r> | --- |\r> | A |", "> Notes\r>\r> | Item |\r> | --- |\r> | A |", ["Document", ["Blockquote", "Paragraph", "Table"]], ], [ "a list item with CR lines", "- Notes\r | Item |\r | --- |\r | A |\r- Next", "- Notes\r\r | Item |\r | --- |\r | A |\r- Next", ["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]], ], [ "a non-interrupting ordered marker with tildes", "Intro\n2. ~~~\n Notes\n | A |\n | --- |\n | x |", "Intro\n2. ~~~\n Notes\n\n | A |\n | --- |\n | x |", ["Document", "Paragraph", "Table"], ], [ "a non-interrupting ordered marker with backticks", "Notes\n2. ```\n | Item |\n | --- |\n | A |", "Notes\n2. ```\n\n | Item |\n | --- |\n | A |", ["Document", "Paragraph", "Table"], ], ])("preserves tables after paragraphs in %s", async (_name, source, separated, expected) => { const rendered = prepareMarkdownSource(source).render(); expect(rendered).toEqual(separated); const flags = { hardSoftBreaks: true, latexMath: false }; function blocks(node: ASTNode): unknown { if (node.type === "Paragraph" || node.type === "Table") return node.type; return [node.type, ...(node.children ?? []).map(blocks)]; } expect(blocks(await parseMarkdown(rendered, flags))).toEqual(expected); }); it.each([ [ "a literal backslash", "Notes\\\n| Item |\n| --- |\n| A |", "Notes\\\n\n| Item |\n| --- |\n| A |", "Notes\\", ], [ "an entity and literal backslash with LF lines", "AT&T\\\n| Item |\n| --- |\n| A |", "AT\\&T\\\n\n| Item |\n| --- |\n| A |", "AT&T\\", ], [ "an entity and literal backslash with CRLF lines", "AT&T\\\r\n| Item |\r\n| --- |\r\n| A |", "AT\\&T\\\r\n\r\n| Item |\r\n| --- |\r\n| A |", "AT&T\\", ], [ "an entity and literal backslash with CR lines", "AT&T\\\r| Item |\r| --- |\r| A |", "AT\\&T\\\r\r| Item |\r| --- |\r| A |", "AT&T\\", ], ])( "preserves paragraph text before an interrupting table in %s", async (_name, source, separated, text) => { const rendered = prepareMarkdownSource(source).render(); expect(rendered).toEqual(separated); const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false }); expect(tree.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]); expect(tree.children?.[0]?.children?.map((node) => node.content).join("")).toEqual(text); }, ); it.each([ "```yaml\n---\nkey: value\n---\n```\n", "~~~md\n| Item |\n| --- |\n| A |\n~~~\n", "````md\n```\n| Item |\n| --- |\n```\n`````\n", "> ```md\n> | Item |\n> | --- |\n> ```\n", "> ```md\n> Code\n", "- ```md\n Code\n", " ```\n\n", "```not`a-fence\n", ])("renders a table after a fence without changing its code: %s", async (prefix) => { const table = "| Item |\n| --- |\n| A |"; const rendered = prepareMarkdownSource(`${prefix}Notes\n${table}`).render(); expect(rendered).toEqual(`${prefix}Notes\n\n${table}`); const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false }); expect(tree.children?.slice(-2).map((node) => node.type)).toEqual(["Paragraph", "Table"]); }); it.each([ "| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100), "Notes\n\n| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100), "Notes\n\n| Item |\n| --- |\n| A |", "| Item |\n| --- |\n| A |", "```md\nNotes\n| Item |\n| --- |\n| A |\n```", "```yaml\n---\nkey: value\n---\n```", "```yaml\n---\nkey: value", "~~~md\n| Item |\n| --- |\n| A |\n~~~", "````md\n```\n| Item |\n| --- |\n```\n````", " Notes\n | Item |\n | --- |\n | A |", "Notes\n| Item | Detail |\n| --- |\n| A |", ])("leaves separated tables and table-like text unchanged: %s", (source) => { expect(prepareMarkdownSource(source).render()).toEqual(source); }); it.each([ "src/components/message.tsx", "C:\\repo\\file.ts", "docs/说明.md", "my%20file.ts", "src/file.ts:10-20", "src/file.ts(10,2)", ])("recognizes inline paths through the file resolver: %s", (path) => { const prepared = prepareMarkdownSource(`Open \`${path}\`.`, "/repo"); expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true); expect(prepared.linkSource(path, "inline-code")).toEqual({ href: path, text: path, sourceType: "inline-code", }); }); it("preserves stable inline-code file classification through the library parser", async () => { const paths = [ "src/app/[id].tsx", "src/app/(tabs)/index.tsx", "src/a$b.ts", ".gitignore", "src/index.ts#L10-L20", "src/index.ts:10-20", "src/index.ts:20-10", "src/index.ts#L0", "src/index.ts?raw", "before/after", "word.word", "README.md", "./scripts/run", "src/file.ts(10,2)", "src/file.ts lines 10-20", "/tmp/[route].tsx", "file:///tmp/file.ts#L10-L20", ]; const source = paths.map((path) => `\`${path}\``).join(" and "); const prepared = prepareMarkdownSource(source, "/repo"); const tree = recognizeTextLinks( await parseMarkdown(prepared.render(), { latexMath: false }), prepared.linkRecognition.text, prepared.linkRecognition.inlineCode, ); const links: string[] = []; function collect(node: ASTNode) { if (node.type === "Link") links.push(String(node.attributes?.url)); node.children?.forEach(collect); } collect(tree); expect(links).toEqual([ "src/app/[id].tsx", "src/app/(tabs)/index.tsx", "src/a$b.ts", ".gitignore", "src/index.ts#L10-L20", "src/index.ts:10-20", "README.md", "./scripts/run", "src/file.ts(10,2)", "/tmp/[route].tsx", "file:///tmp/file.ts#L10-L20", ]); expect( classifyAssistantFileLink("src/index.ts#L10-L20", { workspaceRoot: "/repo" }), ).toMatchObject({ target: { path: "/repo/src/index.ts", lineStart: 10, lineEnd: 20 } }); }); it("lets the engine own code boundaries while classifying candidate values", async () => { const source = [ "```ts\n`src/fenced.ts`\n```", "`` src/app/[id].tsx `` and ` src/spaced.ts `", "``src/a`b.ts`` and `src/after.ts`", "\\`src/escaped.ts` and `src/final.ts`", "`\nsrc/newline.ts\n`", "[`src/explicit.ts`](https://example.com)", ].join("\n\n"); const prepared = prepareMarkdownSource(source, "/repo"); const tree = recognizeTextLinks( await parseMarkdown(prepared.render(), { latexMath: false }), prepared.linkRecognition.text, prepared.linkRecognition.inlineCode, ); const recognized: string[] = []; function collect(node: ASTNode) { if (node.type === "Link" && node.attributes?.recognizedLink === "true") { recognized.push(String(node.attributes.url)); } node.children?.forEach(collect); } collect(tree); expect(recognized).toEqual([ "src/app/[id].tsx", " src/spaced.ts ", "src/a`b.ts", "src/after.ts", "src/escaped.ts", "src/final.ts", "src/newline.ts", ]); }); it("recognizes forge references in prose but not in code, links or paths", async () => { const shorthand = createForgeShorthand({ forge: "gitlab", remoteUrl: "git@gitlab.com:g/p.git", })!; const source = [ "Fixed in #12 and !13, see g/sub/q#14 and https://example.com/a#15.", "`#16` and [#17](https://example.com) and src/app/index.ts and src/a/b/c#18", "```\n#19\n```", ].join("\n\n"); const prepared = prepareMarkdownSource(source, "/repo", { forgeReferencePattern: shorthand.pattern, }); const tree = recognizeTextLinks( await parseMarkdown(prepared.render(), { latexMath: false }), prepared.linkRecognition.text, prepared.linkRecognition.inlineCode, ); const recognized: string[] = []; function collect(node: ASTNode) { if (node.type === "Link" && node.attributes?.recognizedLink === "true") { recognized.push(String(node.attributes.url)); } node.children?.forEach(collect); } collect(tree); expect(recognized).toEqual(["#12", "!13", "g/sub/q#14", "src/app/index.ts", "src/a/b/c#18"]); expect(recognized.map((url) => shorthand.resolve(url))).toEqual([ "https://gitlab.com/g/p/-/issues/12", "https://gitlab.com/g/p/-/merge_requests/13", "https://gitlab.com/g/sub/q/-/issues/14", null, "https://gitlab.com/src/a/b/c/-/issues/18", ]); // A rejected native regex silently falls back to no recognition at all. expect(toNativeLinkRecognition(prepared.linkRecognition).text).toMatchObject({ isDisabled: false, isDefault: false, }); }); describe("bare URL sentence punctuation", () => { it.each([".", ",", ";", ":", "!", "?", ")", ").", "...", "?!"])( "leaves %s outside the link and preserves the rendered sentence", async (punctuation) => { const url = "https://github.com/getpaseo/paseo/pull/7357"; const source = `${url}${punctuation} Not merged.`; const prepared = prepareMarkdownSource(source); const tree = recognizeTextLinks( await parseMarkdown(prepared.render(), { latexMath: false }), prepared.linkRecognition.text, prepared.linkRecognition.inlineCode, ); expect(tree.children?.[0]?.children).toEqual([ { type: "Link", attributes: { url }, children: [{ type: "Text", content: url }], }, { type: "Text", content: `${punctuation} Not merged.` }, ]); }, ); it.each([ "https://example.com/a.b", "https://example.com/a?foo=bar&baz=2", "https://example.com/a#L10", "https://en.wikipedia.org/wiki/Function_(mathematics)", "https://example.com/a_(b_(c))", ])("preserves internal URL punctuation: %s", async (url) => { const tree = await parseMarkdown(prepareMarkdownSource(`${url}.`).render()); expect(tree.children?.[0]?.children).toEqual([ { type: "Link", attributes: { url }, children: [{ type: "Text", content: url }], }, { type: "Text", content: "." }, ]); }); it.each([ ["http://127.0.0.1:8059/?token=t-1", ""], ["http://example.com:8080/a", "."], ["http://localhost:8059/?token=t-1", "."], ["http://localhost:8059/?token=t-1", ")"], ["http://user@127.0.0.1:8059/?token=t-1", '"'], ["http://user:pass@127.0.0.1:8059/?token=t-1", ""], ["http://localhost", ":"], ["http://[::1]:8059/?token=t-1", "'"], ["https://github.com/o/r/compare/main...user:repo:fix/branch", ""], ["https://example.com/a:b/c", ":"], ])("links %s whole and leaves %j outside", async (url, punctuation) => { const prepared = prepareMarkdownSource(`See ${url}${punctuation} now`); const tree = recognizeTextLinks( await parseMarkdown(prepared.render(), { latexMath: false }), prepared.linkRecognition.text, prepared.linkRecognition.inlineCode, ); expect(tree.children?.[0]?.children).toEqual([ { type: "Text", content: "See " }, { type: "Link", attributes: expect.objectContaining({ url }), children: [{ type: "Text", content: url }], }, { type: "Text", content: `${punctuation} now` }, ]); }); it.each(["[Authored](https://example.com/a.)", ""])( "preserves punctuation in explicit destinations: %s", async (source) => { const tree = await parseMarkdown(prepareMarkdownSource(source).render()); expect(tree.children?.[0]?.children?.[0]?.attributes?.url).toBe("https://example.com/a."); }, ); }); it("requires workspace context for relative inline paths and escapes literal regex characters", () => { const source = "`src/app/[id].tsx` and `src/a+b.ts` and `README.md` and `/tmp/file.ts`"; const withoutWorkspace = prepareMarkdownSource(source).linkRecognition.inlineCode; expect(withoutWorkspace.test("src/app/[id].tsx")).toBe(false); expect(withoutWorkspace.test("README.md")).toBe(false); expect(withoutWorkspace.test("/tmp/file.ts")).toBe(true); const prepared = prepareMarkdownSource(source, "/repo"); const native = toNativeLinkRecognition(prepared.linkRecognition); const pattern = new RegExp(native.inlineCode.pattern); expect(pattern.test("src/app/[id].tsx")).toBe(true); expect(pattern.test("src/a+b.ts")).toBe(true); expect(pattern.test("src/app/i.tsx")).toBe(false); expect(pattern.test("src/ab.ts")).toBe(false); }); it("recognizes bare Unicode filenames and preserves inline-code URLs", () => { const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`."); expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([ "说明.md", ]); expect(prepared.linkRecognition.inlineCode?.test("https://example.com")).toBe(true); }); it("keeps explicit and inline-code links distinct when their URL strings match", () => { const url = "/tmp/my%20file.ts"; const prepared = prepareMarkdownSource(`[open](${url}) and \`${url}\``); expect(prepared.linkSource(url, "markdown")).toEqual({ href: url, text: url }); expect(prepared.linkSource(url, "inline-code")).toEqual({ href: url, text: url, sourceType: "inline-code", }); }); it("extracts only Mermaid fences for the native diagram host", async () => { const { splitMermaidFences } = await import("./fence/mermaid/segments"); const source = "First.\n\n```ts\nconst n = 1;\n```\n\n```mermaid\ngraph TD\nA-->B\n```\n\n> Last."; expect(splitMermaidFences(source)).toEqual([ { kind: "markdown", id: "0", source: "First.\n\n```ts\nconst n = 1;\n```\n\n" }, { kind: "mermaid", id: "1", source: "graph TD\nA-->B" }, { kind: "markdown", id: "2", source: "\n\n> Last." }, ]); expect(splitMermaidFences("```md\n```mermaid\n```")).toEqual([ { kind: "markdown", id: "0", source: "```md\n```mermaid\n```" }, ]); }); it("keeps reference links available across native Mermaid boundaries", async () => { const { splitMermaidFences } = await import("./fence/mermaid/segments"); const parts = splitMermaidFences( "[Before][file]\n\n```mermaid\ngraph TD\nA-->B\n```\n\n[After][file]\n\n[file]: src/file.ts", ); for (const part of parts.filter((candidate) => candidate.kind === "markdown")) expect(part.source).toContain("[file]: "); }); it("recognizes mixed-case Mermaid fence info and incomplete streaming fences", async () => { const { splitMermaidFences } = await import("./fence/mermaid/segments"); expect(splitMermaidFences("```Mermaid title\ngraph TD\nA-->B")).toEqual([ { kind: "mermaid", id: "0", source: "graph TD\nA-->B" }, ]); }); it("carries reference definitions nested in quotes across a diagram", async () => { const { splitMermaidFences } = await import("./fence/mermaid/segments"); const parts = splitMermaidFences( "[open][file]\n\n```mermaid\ngraph TD\nA-->B\n```\n\n> [file]: src/file.ts", ); expect(parts[0].source).toContain("[file]: "); }); it("keeps diagram identity when an earlier workspace image resolves", async () => { const { splitMermaidFences } = await import("./fence/mermaid/segments"); const source = "![image](a.png)\n\n```mermaid\ngraph TD\nA-->B\n```"; const before = splitMermaidFences(source).find((part) => part.kind === "mermaid"); const after = splitMermaidFences( source.replace("a.png", "file:///long/cache/path/image.png"), ).find((part) => part.kind === "mermaid"); expect(before).toHaveProperty("id"); expect(after?.id).toBe(before?.id); }); it("keeps workspace-relative hover titles on explicit absolute file links", () => { expect(prepareMarkdownSource("[Open](/repo/src/file.ts:42-45)", "/repo").render()).toBe( '[Open](/repo/src/file.ts:42-45 "src/file.ts:42-45")', ); }); it.each(['""', "''", "()"])("preserves an authored empty link title: %s", (title) => { const source = `[Open](/repo/file.ts ${title})`; expect(prepareMarkdownSource(source, "/repo").render()).toBe(source); }); describe("shared Markdown theme colours", () => { it.each([ ["dark", darkTheme], ["light", lightTheme], ] as const)("uses %s theme colours for tables and task labels", (_name, theme) => { const { markdownStyle: style } = createMarkdownStyles(theme, { blockGap: 16 }); expect(style.table).toMatchObject({ color: theme.colors.foreground, headerTextColor: theme.colors.foreground, headerBackgroundColor: theme.colors.surface2, rowEvenBackgroundColor: theme.colors.surface0, rowOddBackgroundColor: theme.colors.surface1, borderColor: theme.colors.border, }); expect(style.list).toMatchObject({ color: theme.colors.foreground, bulletColor: theme.colors.foregroundMuted, markerColor: theme.colors.foregroundMuted, }); expect(style.h6?.color).toBe(theme.colors.foregroundMuted); expect(style.taskList).toMatchObject({ checkedTextColor: theme.colors.foregroundMuted, checkedColor: theme.colors.accent, checkmarkColor: theme.colors.accentForeground, borderColor: theme.colors.foregroundMuted, }); }); }); it.each([ ["dark", darkTheme], ["light", lightTheme], ] as const)( "uses %s theme colours for quotes, admonitions, code, links and dividers", (_name, theme) => { const { markdownStyle: style } = createMarkdownStyles(theme, { blockGap: 16 }); expect(style.blockquote).toMatchObject({ color: `${theme.colors.foreground}cc`, backgroundColor: theme.colors.surface1, borderColor: theme.colors.surface2, admonitions: { note: { color: theme.colors.accentBright, backgroundColor: theme.colors.surface2 }, tip: { color: theme.colors.statusSuccess, backgroundColor: theme.colors.surface2 }, important: { color: theme.colors.statusMerged, backgroundColor: theme.colors.surface2 }, warning: { color: theme.colors.statusWarning, backgroundColor: theme.colors.surface2 }, caution: { color: theme.colors.statusDanger, backgroundColor: theme.colors.surface2 }, }, }); expect(style.code).toMatchObject({ color: theme.colors.foreground, backgroundColor: theme.colors.surface2, borderColor: "transparent", }); expect(style.link?.color).toBe(theme.colors.accentBright); expect(style.linkVariants?.["^(?!https?://).+"]).toEqual({ color: theme.colors.accentBright, }); expect(style.thematicBreak?.color).toBe(theme.colors.border); }, ); it.each([darkTheme, lightTheme])("supplies the theme selection colour to the renderer", (theme) => { expect(createMarkdownStyles(theme, { blockGap: 16 })).toHaveProperty( "selectionColor", theme.colors.terminal.selectionBackground, ); }); describe("GitHub comment HTML normalization", () => { it.each([ [ 'P1', "![P1]()", ], ['▶', "[▶]()"], ["Before
After
End", "Before \nAfter \nEnd"], ["bold and strong", "**bold** and **strong**"], ["italic and emphasis", "*italic* and *emphasis*"], ["Use const n = 1.", "Use `const n = 1`."], ["H2O and x2", "H2O and x2"], [ "
Review detailsBody **stays**.
", "**Review details**\n\nBody **stays**.", ], ["BeforeAfter", "BeforeAfter"], [ "

Important Files Changed

Body
", "**Important Files Changed**\n\nBody", ], [ "

First

Second

Body
", "**First Second**\n\nBody", ], ["file.ts", "file.ts"], ['GitHub', "[Git]()Hub"], ["GitHub", "GitHub"], ["Git Hub", "Git Hub"], ["
Keep the content
", "Keep the content"], [ 'Review', "[**Review**]()", ], ['`` and bold', '`` and **bold**'], [ '```html\n\n```\n\nbold', '```html\n\n```\n\n**bold**', ], ['~~~html\nlink\n~~~', '~~~html\nlink\n~~~'], [ '
Code\n\n```html\n\n```\n
', '**Code**\n\n\n\n```html\n\n```\n', ], [ " and ", " and ", ], ])("normalizes %s without changing code", (source, expected) => { expect(prepareMarkdownSource(source).render()).toBe(expected); }); }); it("preserves backslashes and decodes entities inside HTML code", () => { const source = String.raw`\*literal\* <img>`; expect(prepareMarkdownSource(source).render()).toBe("`" + String.raw`\*literal\* ` + "`"); }); it.each([false, true])( "caps inline images to the paragraph line height (compact: %s)", (compact) => { const { markdownStyle } = createMarkdownStyles(darkTheme, { compact, blockGap: 16 }); expect(markdownStyle.inlineImage?.size).toBe(markdownStyle.paragraph?.lineHeight); }, ); it("shows native SVG badges as alt text without reserving an image box", () => { const source = "![P1](https://example.com/p1.svg?v=9) **Finding**"; const prepared = prepareMarkdownSource(source, undefined, { supportsSvg: false }); expect(prepared.render()).toBe("P1 **Finding**"); expect(prepared.images).toEqual([]); }); it("collapses images that fail to load or decode to their alt text", () => { const prepared = prepareMarkdownSource( "![Remote](https://example.com/broken.png) and ![Local](broken.png)", ); expect( prepared.render( new Map([["broken.png", "file:///broken.png"]]), new Set(["https://example.com/broken.png", "broken.png"]), ), ).toBe("Remote and Local"); }); it("preserves angle-bracket image destinations and authored link titles during HTML normalization", () => { const prepared = prepareMarkdownSource( '![shot]() and [open]( "literal title")', ); expect(prepared.images.map((image) => image.source)).toEqual(["my file.png"]); expect(prepared.render(new Map([["my file.png", "blob:shot"]]))).toBe( '![shot]() and [open]( "literal title")', ); }); it("uses the first repeated image reference definition", () => { const source = "![chart][image]\n\n[image]: first.png\n[image]: second.png"; const prepared = prepareMarkdownSource(source); expect(prepared.images.map((image) => image.source)).toEqual(["first.png"]); expect(prepared.render(new Map([["first.png", "blob:first"]]))).toBe( "![chart]()\n\n[image]: first.png\n[image]: second.png", ); }); it("preserves authored adjacency in the Greptile header", () => { const source = '

RetriggerView in GreptileConfidence Score: 2/5

'; const rendered = prepareMarkdownSource(source, undefined, { supportsSvg: false }).render(); expect(rendered).toContain( "[Retrigger]()[View in Greptile]", ); expect(rendered).toContain(")Confidence Score: 2/5"); }); it("keeps paragraph, heading and table-row boundaries when stripping HTML", () => { const source = "
First

Second
line

Heading

AB
CD
"; const rendered = prepareMarkdownSource(source).render(); expect(rendered).toBe("First\n\nSecond \nline\n\nHeading\n\nA B\n\nC D"); }); it("normalizes images following HTML code in the same HTML block", () => { const prepared = prepareMarkdownSource('\nx\n\n'); expect(prepared.images.map((image) => image.source)).toEqual(["shot.png"]); expect(prepared.render(new Map([["shot.png", "blob:shot"]]))).toContain("![]()"); expect(prepared.render()).toContain("`\nx\n`"); }); it("preserves literal Markdown punctuation in unsupported and failed image labels", () => { const label = String.raw`\*literal\* \[label\]`; expect( prepareMarkdownSource(`![${label}](badge.svg)`, undefined, { supportsSvg: false }).render(), ).toBe(label); expect( prepareMarkdownSource(`![${label}](https://example.com/badge.png)`).render( new Map(), new Set(["https://example.com/badge.png"]), ), ).toBe(label); }); it("retries transient image decoding failures instead of caching an unavailable image", async () => { const client = new QueryClient(); let attempts = 0; const decoded = await client.fetchQuery({ queryKey: ["markdownImageDecode", "transient.png"], retry: 1, retryDelay: 0, queryFn: () => decodeMarkdownImage("transient.png", (_uri, success, failure) => { attempts += 1; if (attempts === 1) failure(new Error("temporarily unavailable")); else success(20, 20); }), }); expect(decoded).toBe(true); expect(attempts).toBe(2); client.clear(); }); it("keeps the document's first reference definition across every native Mermaid segment", async () => { const { splitMermaidFences } = await import("./fence/mermaid/segments"); const before = '[first]: /repo/first.ts "Original"\n\n'; const after = '\n\n[open][first]\n\n[first]: /repo/second.ts "Later"'; const source = `${before}\`\`\`mermaid\ngraph TD\nA-->B\n\`\`\`${after}`; const firstDefinition = (markdown: string) => fromMarkdown(markdown).children.find((node) => node.type === "definition"); const canonical = firstDefinition(source); const segments = splitMermaidFences(source); for (const segment of segments.filter((candidate) => candidate.kind === "markdown")) { expect(firstDefinition(segment.source)).toMatchObject({ identifier: canonical?.identifier, url: canonical?.url, title: canonical?.title, }); } expect(segments[0].source.endsWith(before)).toBe(true); expect(segments[2].source.endsWith(after)).toBe(true); expect(segments[1]).toEqual({ kind: "mermaid", id: "1", source: "graph TD\nA-->B" }); }); it("reserves pending image geometry until decode succeeds and collapses only confirmed failures", () => { const source = "![Local](shot.png)\n\n![Remote](https://example.com/image.png)"; const prepared = prepareMarkdownSource(source); const pending = prepared.render( new Map(), new Set(), new Set(prepared.images.map((image) => image.source)), ); expect(pending.match(/!\[/g)).toHaveLength(2); expect(pending).toContain("data:image/png;base64,"); expect(prepared.render(new Map(), new Set(prepared.images.map((image) => image.source)))).toBe( "Local\n\nRemote", ); }); it("normalizes a large HTML table into one block per row", () => { const cell = "cell".repeat(8); const rows = 8000; const source = `${``.repeat(rows)}
${cell}${cell}
`; const rendered = normalizeMarkdownHtml(source); expect(rendered.split("\n\n")).toHaveLength(rows); expect(rendered.startsWith(`${cell} ${cell}`)).toBe(true); expect(rendered.endsWith(`${cell} ${cell}`)).toBe(true); }); it.each([ "3/4", "1.5", "v1.2", "10/10", "2024/10/16", "10.0.0.1", "word.word", "example.com", "file.tsxextra", "v1.2.3-beta", "123/456.789", "×/÷", "2024/10/16", ])("leaves non-path tokens unlinked in prose and inline code: %s", (token) => { const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then \`${token}\`.`, "/repo"); expect([...`Value ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]); expect(linkRecognition.inlineCode.test(token)).toBe(false); }); it.each([ "src/index.ts", "README.md", "./scripts/run", "./x/y", "../x/y", "/x/y", "a/b/c", String.raw`C:\Users\x`, "src/index.ts:12:3", "src/run:12", "src/run:12:3", "src/run:12-20", "~/dev/paseo", String.raw`C:\Users\x\file.txt`, "file.ts:12", "file.ts:12-20", "123.ts", "FILE.TS", "https://example.com/path", ])("recognizes complete paths and URLs in prose: %s", (token) => { const { linkRecognition } = prepareMarkdownSource(`Open ${token} then \`${token}\`.`, "/repo"); expect( [...`Open ${token} then continue.`.matchAll(linkRecognition.text)].map(([text]) => text), ).toEqual([token]); }); it.each([ "before/after", "and/or", "input/output", "read/write", "packages/app", "123/file", "src/123", ])("leaves extensionless relative paths unlinked in prose and inline code: %s", (token) => { const { linkRecognition } = prepareMarkdownSource( `Compare ${token}, then \`${token}\`.`, "/repo", ); expect([...`Compare ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]); expect(linkRecognition.inlineCode.test(token)).toBe(false); }); it("keeps prose and inline-code recognition enabled through the native regex adapter", () => { const { linkRecognition } = prepareMarkdownSource("3/4 src/index.ts"); const native = toNativeLinkRecognition(linkRecognition); expect(native.text).toMatchObject({ pattern: linkRecognition.text.source, isDisabled: false, isDefault: false, }); expect(native.inlineCode).toMatchObject({ pattern: linkRecognition.inlineCode.source, isDisabled: false, isDefault: false, }); }); it.each([undefined, "file:///cache/shot.png"])( "reserves a native workspace image while acquiring or decoding %s", (url) => { const status = getMarkdownImageStatus({ canAcquire: true, url, acquisitionFailed: false, decodeFailed: false, decoded: false, }); expect(status).toBe("pending"); const prepared = prepareMarkdownSource("Before ![image](shot.png) after", undefined, { supportsSvg: false, }); const rendered = prepared.render(new Map(), new Set(), new Set(["shot.png"])); expect(rendered).toBe(`Before ![image](<${PENDING_MARKDOWN_IMAGE}>) after`); expect(fromMarkdown(rendered).children).toMatchObject([ { type: "paragraph", children: [ { type: "text", value: "Before " }, { type: "image", url: PENDING_MARKDOWN_IMAGE }, { type: "text", value: " after" }, ], }, ]); }, ); it.each([ { acquisitionFailed: true, decodeFailed: false }, { acquisitionFailed: false, decodeFailed: true }, ])("collapses confirmed native image failures to alt text: %s", (failure) => { expect( getMarkdownImageStatus({ canAcquire: true, url: "file:///cache/shot.png", decoded: false, ...failure, }), ).toBe("unavailable"); const prepared = prepareMarkdownSource("Before ![image](shot.png) after", undefined, { supportsSvg: false, }); expect(prepared.render(new Map(), new Set(["shot.png"]), new Set(["shot.png"]))).toBe( "Before image after", ); }); it("replaces the native placeholder only after the resolved URL decodes", () => { const url = "file:///cache/shot.png"; expect( getMarkdownImageStatus({ canAcquire: true, url, acquisitionFailed: false, decodeFailed: false, decoded: true, }), ).toBe("ready"); const prepared = prepareMarkdownSource("![image](shot.png)", undefined, { supportsSvg: false }); expect(prepared.render(new Map([["shot.png", url]]))).toBe(`![image](<${url}>)`); }); it("makes the web Markdown container selectable in Safari as well as unprefixed browsers", () => { expect(createMarkdownStyles(lightTheme, { blockGap: 12 }).containerStyle).toEqual({ userSelect: "text", WebkitUserSelect: "text", }); }); it.each([false, true])( "scales inline code from theme tokens with its surrounding block (compact: %s)", (compact) => { const theme = { ...darkTheme, fontSize: { ...darkTheme.fontSize, code: 13, content: 17, base: 16 }, }; const { markdownStyle } = createMarkdownStyles(theme, { compact, blockGap: 16 }); const bodySize = compact ? theme.fontSize.base : theme.fontSize.content; expect(markdownStyle.code).not.toHaveProperty("fontSize"); expect(markdownStyle.code).toHaveProperty("fontScale", theme.fontSize.code / bodySize); expect(markdownStyle.codeBlock?.fontSize).toBe(theme.fontSize.code); }, );