Skip to content
Source
Sign inSign up

markdownInlineFileLinks.test.ts

217 lines8.9 KB

1import { describe, expect, it } from "vite-plus/test";
2import { unified } from "unified";
3import remarkParse from "remark-parse";
4import remarkGfm from "remark-gfm";
5import type { Root, RootContent } from "mdast";
6import { ENRICHED_INLINE_FILE_LINK_REGEX } from "./enrichedLinkPresentation";
7import {
8 markdownForNativeRendering,
9 markdownWithInlineFileLinks,
10 markdownWithHtml,
11} from "./markdownDocument";
12​
13const rewrite = (markdown: string) =>
14 markdownWithInlineFileLinks(markdown, ENRICHED_INLINE_FILE_LINK_REGEX);
15​
16describe("absolute paths in prose", () => {
17 it("become links without corrupting the source around them", () => {
18 const path =
19 "/tmp/claude-1000/-home-user--t3-worktrees-app-t3-abc123/session/scratchpad/notes.md";
20 expect(rewrite(`Saved to ${path}.`)).toBe(`Saved to [${path}](<${path}>).`);
21 expect(
22 markdownForNativeRendering(
23 "Intro /tmp/notes.md\n| a | c |\n|---|---|\n| `./a\\|b.ts` | b |",
24 ENRICHED_INLINE_FILE_LINK_REGEX,
25 ),
26 ).toContain("[/tmp/notes.md](</tmp/notes.md>)");
27 expect(rewrite(String.raw`See \* and /tmp/notes.md.`)).toBe(
28 String.raw`See \* and [/tmp/notes.md](</tmp/notes.md>).`,
29 );
30 for (const decoded of [String.raw`See /tmp/a\_b.md.`, "See /tmp/a&copy;.md."])
31 expect(rewrite(decoded)).toBe(decoded);
32 });
33});
34​
35describe("inline code that names a file", () => {
36 it.each([
37 [
38 "file links",
39 "See `src/math.ts:2`, `C:\\repo\\a.ts`, `./src/a&copy;.ts`, and [`./README.md`](readme).",
40 ],
41 [
42 "tables",
43 "Intro `src/math.ts:2`\n| `./README.md` | Fit |\n|---|---|\n| `./a.ts` | A |\n\nMore\n| a |\n|---|\n| b |",
44 ],
45 ["escaped pipe in file header", "Intro text\n| `./a\\|b.ts` | c |\n|---|---|\n| a | b |"],
46 ["escaped pipe in file body", "Intro text\n| a | c |\n|---|---|\n| `./a\\|b.ts` | b |"],
47 [
48 "escaped pipe after a normal file span",
49 "Intro `./normal.ts`\n| `./a\\|b.ts` | c |\n|---|---|\n| a | b |",
50 ],
51 [
52 "unmatched delimiter before escaped pipe in header",
53 "Intro `./normal.ts`\n| `x``./a\\|b.ts`` | c |\n|---|---|\n|a|b|",
54 ],
55 [
56 "unmatched delimiter before escaped pipe in body",
57 "Intro `./normal.ts`\n| a | c |\n|---|---|\n| `x``./a\\|b.ts`` | b |",
58 ],
59 ["tabbed quote header", ">\tIntro `./a.ts`\n>\t| `./b.ts` | c |\n>\t|---|---|\n>\t| a | b |"],
60 ["lazy quote header", "> Intro\n`./README.md` | file\n> | - | - |\n> | ok | yes |"],
61 ["lazy list header", "- Intro\n`./README.md` | file\n | - | - |\n | ok | yes |"],
62 [
63 "HTML",
64 '<details>\n<summary>Files</summary>\n\nSee `src/math.ts:2`\n| `./README.md` |\n|---|\n| ok |\n<img alt="A &amp; B" src="https://example.com/a.png?a=1&amp;b=2">\n</details>',
65 ],
66 [
67 "nested fences",
68 '> - Example\n> ````md\n> ```ts\n> `src/math.ts:2`\n> <img src="literal.png">\n> | a |\n> |---|\n> ```\n> ````\n\nSee `./README.md`.',
69 ],
70 [
71 "HTML fences",
72 '<details>\n<summary>Example</summary>\n\n````md\n```html\n<img src="literal.png">\n```\n`src/math.ts`\n````\n\nSee `./README.md`\n</details>',
73 ],
74 ])("preserves the two-pass output for every streamed prefix: %s", (_name, fixture) => {
75 for (const ending of ["\n", "\r\n", "\r"]) {
76 const document = fixture.replaceAll("\n", ending);
77 for (let length = 0; length <= document.length; length++) {
78 const prefix = document.slice(0, length);
79 const expected = previousTableBreaks(
80 previousFileLinks(markdownWithHtml(prefix), ENRICHED_INLINE_FILE_LINK_REGEX),
81 );
82 expect(
83 markdownForNativeRendering(prefix, ENRICHED_INLINE_FILE_LINK_REGEX),
84 JSON.stringify(prefix),
85 ).toBe(expected);
86 }
87 }
88 });
89​
90 it("becomes a link to the same path", () => {
91 expect(rewrite("See `/tmp/claude-1000/...`.")).toBe("See `/tmp/claude-1000/...`.");
92 expect(rewrite("See `bin/python3.12`.")).toBe("See [bin/python3.12](<bin/python3.12>).");
93 expect(rewrite("See `src/math.ts:2` and `./README.md`.")).toBe(
94 "See [src/math.ts:2](<src/math.ts:2>) and [./README.md](<./README.md>).",
95 );
96 for (const path of ["src/數學.ts:2", "數學/math.ts:2", "數學.ts:2", "src/數學.ts:2-4"]) {
97 expect(rewrite(`See \`${path}\`.`), path).toBe(`See [${path}](<${path}>).`);
98 }
99 });
100​
101 it("keeps commands, identifiers and URLs as code", () => {
102 const markdown = "Run `vp test run`, call `useMemo`, open `https://example.com/a.ts`.";
103 expect(rewrite(markdown)).toBe(markdown);
104 });
105​
106 it("leaves fenced code blocks and code already inside a link alone", () => {
107 const markdown = "```\nsrc/math.ts:2\n```\n\n[`src/math.ts`](src/math.ts)";
108 expect(rewrite(markdown)).toBe(markdown);
109 });
110​
111 it("links to the exact path when it holds backslashes or ampersands", () => {
112 for (const path of ["C:\\repo\\_private\\file.ts", "./src/a&copy;.ts", "./src/[id].ts"]) {
113 const paragraph = parser.parse(rewrite(`\`${path}\``)).children[0];
114 const link = paragraph?.type === "paragraph" ? paragraph.children[0] : undefined;
115 expect(link?.type === "link" && link.url).toBe(path);
116 expect(
117 link?.type === "link" && link.children[0]?.type === "text" && link.children[0].value,
118 ).toBe(path);
119 }
120 });
121});
122​
123// Freeze the pre-optimization passes so shared transform regressions cannot change the oracle.
124const parser = unified().use(remarkParse).use(remarkGfm);
125type MarkdownNode = Root | RootContent;
126const INLINE_CODE_SPAN = /`+([^`\r\n]+)`+/g;
127​
128function previousFileLinks(markdown: string, fileLink: RegExp): string {
129 if (![...markdown.matchAll(INLINE_CODE_SPAN)].some(([, code]) => fileLink.test(code!.trim())))
130 return markdown;
131 const edits: Array<{ start: number; end: number; content: string }> = [];
132 const visit = (node: MarkdownNode) => {
133 if (node.type === "inlineCode") {
134 const start = node.position?.start.offset;
135 const end = node.position?.end.offset;
136 const escaped = node.value.replace(/[\\[\]&]/g, "\\$&");
137 if (
138 start !== undefined &&
139 end !== undefined &&
140 fileLink.test(node.value) &&
141 !/[<>]/.test(node.value)
142 )
143 edits.push({
144 start,
145 end,
146 content: `[${escaped}](<${escaped}>)`,
147 });
148 } else if (node.type !== "link" && node.type !== "linkReference" && "children" in node)
149 node.children.forEach(visit);
150 };
151 visit(parser.parse(markdown));
152 for (const edit of edits.toReversed())
153 markdown = markdown.slice(0, edit.start) + edit.content + markdown.slice(edit.end);
154 return markdown;
156​
157// A delimiter row such as `|---|:--:|`, possibly inside a blockquote or list item.
158function isTableDelimiterRow(line: string): boolean {
159 if (!line.includes("-")) return false;
160 const row = line.replace(/^[ \t>]+/, "").trim();
161 return row
162 .replace(/^\||\|$/g, "")
163 .split("|")
164 .every((cell) => /^:?-+:?$/.test(cell.trim()));
166​
167// Cheap pre-check for a delimiter row two lines below text, so most messages skip the parse.
168function mayHaveTableUnderText(markdown: string): boolean {
169 if (!markdown.includes("|")) return false;
170 const lines = markdown.split(/\r\n|\r|\n/);
171 for (let index = 2; index < lines.length; index += 1) {
172 if (isTableDelimiterRow(lines[index]!) && /[^\s>]/.test(lines[index - 2]!)) return true;
173 }
174 return false;
176​
177/**
178 * GFM lets a table interrupt a paragraph, but the native parser (md4c) only starts a table
179 * on a paragraph's first line, so a table right under a line of text renders as one
180 * run-on paragraph. Insert the blank line md4c needs before each such table.
181 */
182function previousTableBreaks(markdown: string): string {
183 if (!mayHaveTableUnderText(markdown)) return markdown;
184 const lineStarts: number[] = [];
185 const visit = (node: MarkdownNode) => {
186 if (!("children" in node)) return;
187 node.children.forEach((child, index) => {
188 const previous = node.children[index - 1];
189 const start = child.position?.start;
190 if (
191 child.type === "table" &&
192 previous?.type === "paragraph" &&
193 start?.offset !== undefined &&
194 previous.position?.end.line === start.line - 1
195 )
196 lineStarts.push(
197 Math.max(
198 markdown.lastIndexOf("\n", start.offset - 1),
199 markdown.lastIndexOf("\r", start.offset - 1),
200 ) + 1,
201 );
202 visit(child);
203 });
204 };
205 visit(parser.parse(markdown));
206 for (const lineStart of lineStarts.toReversed()) {
207 // The header row may be a lazy line without its container markers (`>` or list indent),
208 // so copy the delimiter row's markers onto the blank line and the header row.
209 const [, header = "", cells = "", ending = "\n", markers = ""] =
210 /^([ \t>]*)([^\r\n]*)(\r\n|\r|\n)([ \t>]*)/.exec(markdown.slice(lineStart)) ?? [];
211 markdown =
212 markdown.slice(0, lineStart) +
213 `${markers.trimEnd()}${ending}${markers}${cells}` +
214 markdown.slice(lineStart + header.length + cells.length);
215 }
216 return markdown;