Skip to content
Source
Sign inSign up

markdownPipeline.test.ts

706 lines29.4 KB

1import { expect, it } from "vite-plus/test";
2import { type PluggableList, unified } from "unified";
3import rehypeRaw from "rehype-raw";
4import remarkParse from "remark-parse";
5import remarkRehype from "remark-rehype";
6import type { Link, Root, RootContent } from "mdast";
7import {
8 CHAT_MARKDOWN_REHYPE_PLUGINS,
9 CHAT_MARKDOWN_REMARK_PLUGINS,
10 CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS,
11 remarkAssistantInsights,
12 remarkEnableOrderedListNumbers,
13} from "./markdownPipeline.ts";
14​
15it.each([
16 ["default", CHAT_MARKDOWN_REMARK_PLUGINS],
17 ["with breaks", CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS],
18] as const)("preserves bare URL boundaries (%s)", (_, plugins) => {
19 const processor = unified().use(remarkParse).use(plugins);
20 const shape = (node: Root | RootContent): string => {
21 if (node.type === "text") return node.value;
22 if (node.type === "inlineCode") return `\`${node.value}\``;
23 if (node.type === "code") return `\`\`\`${node.value}\`\`\``;
24 const children = "children" in node ? node.children.map(shape).join("") : "";
25 if (node.type === "link") return `[${children}](${node.url})`;
26 if (node.type === "linkReference") return `[${children}][${node.identifier}]`;
27 if (node.type === "emphasis") return `*${children}*`;
28 if (node.type === "strong") return `**${children}**`;
29 if (node.type === "delete") return `~~${children}~~`;
30 return children;
31 };
32 const link = (url: string) => `[${url}](${/^www\./i.test(url) ? `http://${url}` : url})`;
33 for (const url of [
34 "https://x.com/dimasike_",
35 "http://example.com/name__",
36 "HTTPS://example.com/name_",
37 "https://example.com/a_(b)_",
38 "https://example.com/?name=value_",
39 "https://example.com/#section_",
40 "https://example.com/a_?name=value_#section_",
41 "https://example.com/name~",
42 "https://example.com/name~~",
43 "https://example.com/name_~",
44 "https://example.com/name~_",
45 "https://example.com/name__~~_",
46 "https://example.com/?name=value~",
47 "https://example.com/#section~",
48 "www.example.com/name_",
49 "www.example.com/name~_",
50 "WWW.example.com/name~~",
51 "https://example.com/name-",
52 "https://example.com/name/",
53 "https://example.com/name%5F",
54 "https://example.com/name%7E",
55 ]) {
56 for (const punctuation of ["", ".", ",", "!", "?", ":", ";", ")", "]", '"']) {
57 const markdown = `See ${url}${punctuation} next`;
58 expect(shape(processor.runSync(processor.parse(markdown), markdown) as Root), markdown).toBe(
59 `See ${link(url)}${punctuation} next`,
60 );
61 }
62 }
63 for (const [markdown, expected] of [
64 ["*https://x.com/dimasike_*", `*${link("https://x.com/dimasike_")}*`],
65 ["**https://x.com/dimasike_**", `**${link("https://x.com/dimasike_")}**`],
66 ["_https://x.com/dimasike_", `*${link("https://x.com/dimasike")}*`],
67 ["__https://x.com/dimasike__", `**${link("https://x.com/dimasike")}**`],
68 ["~~https://example.com/name_~~", `~~${link("https://example.com/name_")}~~`],
69 ["~~https://example.com/name~~", `~~${link("https://example.com/name")}~~`],
70 ["~https://example.com/name~", `~~${link("https://example.com/name")}~~`],
71 ["*https://example.com/name~*", `*${link("https://example.com/name~")}*`],
72 ["_https://example.com/name~_", `*${link("https://example.com/name~")}*`],
73 ["[test](https://example.com/name~)", "[test](https://example.com/name~)"],
74 ["[https://x.com/a](https://x.com/a)~_", `${link("https://x.com/a")}~_`],
75 ["<https://x.com/a>~_", `${link("https://x.com/a")}~_`],
76 ["[test][ref]~_\n\n[ref]: https://x.com/a~_", "[test][ref]~_"],
77 ["`https://example.com/name~_`", "`https://example.com/name~_`"],
78 ["```\nhttps://example.com/name~_\n```", "```https://example.com/name~_```"],
79 ["https\\://example.com/name~_", link("https://example.com/name~_")],
80 ["https://example.com/name. _~", `${link("https://example.com/name")}. _~`],
81 ["Mail person@example.com_", "Mail person@example.com_"],
82 ["Mail person@example.com ~_", "Mail [person@example.com](mailto:person@example.com) ~_"],
83 ["[test](https://x.com/dimasike_)", "[test](https://x.com/dimasike_)"],
84 ["[https://x.com/a](https://x.com/a)_", `${link("https://x.com/a")}_`],
85 ["<https://x.com/a>_", `${link("https://x.com/a")}_`],
86 ["[test][ref]_\n\n[ref]: https://x.com/a_", "[test][ref]_"],
87 ["`https://x.com/dimasike_`", "`https://x.com/dimasike_`"],
88 ["```\nhttps://x.com/dimasike_\n```", "```https://x.com/dimasike_```"],
89 ["https://x.com/a. _", `${link("https://x.com/a")}. _`],
90 ["https\\://x.com/dimasike_", link("https://x.com/dimasike_")],
91 ["https://x.com/a\\_", link("https://x.com/a\\_")],
92 ["https://x.com/a&#95;", `${link("https://x.com/a&#95")};`],
93 [
94 "可以看了:**http://localhost:18780**(已重启)。",
95 `可以看了:**${link("http://localhost:18780")}**(已重启)。`,
96 ],
97 ["打开 http://localhost:18780,然后刷新", `打开 ${link("http://localhost:18780")},然后刷新`],
98 ["URL:https://example.com/a。下一句", `URL:${link("https://example.com/a")}。下一句`],
99 ["(预览:http://localhost:18780)", `(预览:${link("http://localhost:18780")})`],
100 [
101 "能打开 http://localhost:3000?见 http://localhost:3001;「www.example.com」",
102 `能打开 ${link("http://localhost:3000")}?见 ${link("http://localhost:3001")};「${link("www.example.com")}」`,
103 ],
104 [
105 "__http://localhost:18780__,*http://localhost:3000*,~~http://localhost:3001~~。",
106 `**${link("http://localhost:18780")}**,*${link("http://localhost:3000")}*,~~${link("http://localhost:3001")}~~。`,
107 ],
108 [
109 "foo**http://localhost:18780**(bar)***http://localhost:3000***。",
110 `foo**${link("http://localhost:18780")}**(bar)***${link("http://localhost:3000")}***。`,
111 ],
112 [
113 "http://a.com。http://b.com,www.c.com、person@example.com。",
114 `${link("http://a.com")}。${link("http://b.com")},${link("www.c.com")}、[person@example.com](mailto:person@example.com)。`,
115 ],
116 [
117 "**http://localhost:18780**(重启)。 https://example.com/a。结束",
118 `**${link("http://localhost:18780")}**(重启)。 ${link("https://example.com/a")}。结束`,
119 ],
120 ["# **http://localhost:3000**,刷新", `**${link("http://localhost:3000")}**,刷新`],
121 [
122 "| a |\n| - |\n| **http://localhost:3000**,刷新 |",
123 `a**${link("http://localhost:3000")}**,刷新`,
124 ],
125 ["www.example.com/preview。下一句", `${link("www.example.com/preview")}。下一句`],
126 ["https://example.com/a_。下一句", `${link("https://example.com/a_")}。下一句`],
127 ["https://example.com/a.,下一句", `${link("https://example.com/a")}.,下一句`],
128 [
129 "https://example.com/search?q=hello,world",
130 `${link("https://example.com/search?q=hello")},world`,
131 ],
132 ["https://example.com/日本語,続き", link("https://example.com/日本語,続き")],
133 [
134 "https://zh.wikipedia.org/wiki/北京(城市)",
135 link("https://zh.wikipedia.org/wiki/北京(城市)"),
136 ],
137 ["https://example.com/a%EF%BC%8Cb", link("https://example.com/a%EF%BC%8Cb")],
138 ["<https://example.com/日本語,続き>", link("https://example.com/日本語,続き")],
139 ["[記事](https://example.com/a,b)", "[記事](https://example.com/a,b)"],
140 ["[http://example.com/a,b](http://example.com/a,b)", link("http://example.com/a,b")],
141 ["`https://example.com/a。次`", "`https://example.com/a。次`"],
142 [
143 "https://x.com/a_ https://x.com/b_",
144 `${link("https://x.com/a_")} ${link("https://x.com/b_")}`,
145 ],
146 ] as const) {
147 expect(shape(processor.runSync(processor.parse(markdown), markdown) as Root), markdown).toBe(
148 expected,
149 );
150 }
151 for (const url of ["https://x.com/dimasike_", "www.example.com/name~_"]) {
152 const markdown = `${url}, next`;
153 const boundary = { line: 1, column: url.length + 1, offset: url.length };
154 expect(processor.runSync(processor.parse(markdown), markdown)).toMatchObject({
155 children: [
156 {
157 children: [
158 {
159 type: "link",
160 url: /^www\./i.test(url) ? `http://${url}` : url,
161 position: { end: boundary },
162 children: [{ type: "text", value: url, position: { end: boundary } }],
163 },
164 { type: "text", value: ", next", position: { start: boundary } },
165 ],
166 },
167 ],
168 });
169 }
170});
171​
172it("ends bare URLs at CJK punctuation with source positions", () => {
173 const processor = unified().use(remarkParse).use(CHAT_MARKDOWN_REMARK_PLUGINS);
174 const parse = (markdown: string) => processor.runSync(processor.parse(markdown), markdown);
175 const at = (line: number, column: number, offset: number) => ({ line, column, offset });
176 const url = "http://localhost:18780";
177 expect(parse(`第一行\n可以看了:**${url}**(已重启)。`)).toMatchObject({
178 children: [
179 {
180 children: [
181 { type: "text", value: "第一行\n可以看了:", position: { end: at(2, 6, 9) } },
182 {
183 type: "strong",
184 position: { start: at(2, 6, 9), end: at(2, 32, 35) },
185 children: [
186 {
187 type: "link",
188 url,
189 position: { start: at(2, 8, 11), end: at(2, 30, 33) },
190 children: [{ type: "text", value: url, position: { end: at(2, 30, 33) } }],
191 },
192 ],
193 },
194 {
195 type: "text",
196 value: "(已重启)。",
197 position: { start: at(2, 32, 35), end: at(2, 38, 41) },
198 },
199 ],
200 },
201 ],
202 });
203 // Markup the URL swallowed comes back once the URL ends.
204 expect(parse(`打开 ${url},然后运行\`pnpm dev\``)).toMatchObject({
205 children: [
206 {
207 children: [
208 { type: "text", value: "打开 " },
209 { type: "link", url },
210 { type: "text", value: ",然后运行" },
211 { type: "inlineCode", value: "pnpm dev" },
212 ],
213 },
214 ],
215 });
216 // Every URL in one paragraph ends at its mark, with positions on its own line.
217 const lines = Array.from({ length: 300 }, (_, index) => `接口:http://localhost:${index},`);
218 const [paragraph] = (parse(lines.join("\n")) as Root).children;
219 const links =
220 paragraph?.type === "paragraph"
221 ? paragraph.children.filter((node): node is Link => node.type === "link")
222 : [];
223 expect(links.map((node) => node.url)).toEqual(
224 lines.map((_, index) => `http://localhost:${index}`),
225 );
226 const lastLine = lines.slice(0, -1).join("\n").length + 1;
227 expect(links.at(-1)).toMatchObject({
228 position: { start: at(300, 4, lastLine + 3), end: at(300, 24, lastLine + 23) },
229 });
230 // Only a table unescapes `\|` in code, so that cell keeps its parse and the URL is cut.
231 const descendants = (node: Root | RootContent): RootContent[] =>
232 "children" in node
233 ? (node.children as RootContent[]).flatMap((child) => [child, ...descendants(child)])
234 : [];
235 const cell = descendants(parse("| a |\n| - |\n| http://example.com, `a\\|b` |") as Root);
236 expect(cell.flatMap((node) => (node.type === "link" ? [node.url] : []))).toEqual([
237 "http://example.com",
238 ]);
239 expect(cell.flatMap((node) => (node.type === "inlineCode" ? [node.value] : []))).toEqual(["a|b"]);
240 // A task item's checkbox is not part of its paragraph's text.
241 const task = descendants(parse("- [x] http://example.com,刷新") as Root);
242 expect(task.flatMap((node) => (node.type === "text" ? [node.value] : []))).toEqual([
243 "http://example.com",
244 ",刷新",
245 ]);
246 // A list item's indentation stays out of a code span that crosses lines.
247 const code = descendants(parse("- `echo\n hello` http://example.com,结束") as Root);
248 expect(code.flatMap((node) => (node.type === "link" ? [node.url] : []))).toEqual([
249 "http://example.com",
250 ]);
251 expect(code.flatMap((node) => (node.type === "inlineCode" ? [node.value] : []))).toEqual([
252 "echo\nhello",
253 ]);
254 // A list item's continuation line parses the same on its own.
255 expect(parse(`- 第一行\n **${url}**(已重启)`)).toMatchObject({
256 children: [
257 {
258 children: [
259 {
260 children: [
261 {
262 children: [
263 { type: "text", value: "第一行\n" },
264 { type: "strong", children: [{ type: "link", url }] },
265 { type: "text", value: "(已重启)" },
266 ],
267 },
268 ],
269 },
270 ],
271 },
272 ],
273 });
274 // A quote's continuation line does not, so the URL ends and the asterisks stay as text.
275 expect(parse(`> 第一行\n> **${url}**(已重启)`)).toMatchObject({
276 children: [
277 {
278 children: [
279 {
280 children: [
281 { type: "text", value: "第一行\n**" },
282 { type: "link", url, position: { end: at(2, 27, 32) } },
283 { type: "text", value: "**(已重启)", position: { start: at(2, 27, 32) } },
284 ],
285 },
286 ],
287 },
288 ],
289 });
290});
291​
292const insightRule = "─".repeat(20);
293const insightFence = (body: string, wrap = "") =>
294 [`${wrap}★ Insight ${insightRule}${wrap}`, body, `${wrap}${insightRule}${wrap}`].join("\n");
295​
296it.each(["", "`"])("groups %j insight fences while preserving surrounding markdown", (wrap) => {
297 const markdown = [
298 "Before **bold** and [reference][src].",
299 "",
300 insightFence("- Also [reference][src].", wrap),
301 "",
302 "After.",
303 "",
304 '[src]:\n https://example.com\n "Source"',
305 ].join("\n");
306 const processor = unified()
307 .use(remarkParse)
308 .use(CHAT_MARKDOWN_REMARK_PLUGINS)
309 .use(remarkAssistantInsights)
310 .use(remarkRehype)
311 .use(CHAT_MARKDOWN_REHYPE_PLUGINS);
312 const tree = processor.runSync(processor.parse(markdown), markdown);
313 expect(
314 tree.children.filter((node) => node.type === "element").map((node) => node.tagName),
315 ).toEqual(["p", "blockquote", "p"]);
316 expect(
317 tree.children.find((node) => node.type === "element" && node.tagName === "blockquote"),
318 ).toMatchObject({ properties: { dataAlert: "insight" } });
319 expect(JSON.stringify(tree).match(/"href":"https:\/\/example.com"/g)).toHaveLength(2);
320 expect(JSON.stringify(tree)).toContain('"tagName":"strong"');
321 expect(JSON.stringify(tree)).not.toContain(insightRule);
322});
323​
324it.each([
325 ["```text", "★ Insight " + insightRule, "literal", insightRule, "```"].join("\n"),
326 ["- ```text", " ★ Insight " + insightRule, " literal", " " + insightRule, " ```"].join("\n"),
327 [" ★ Insight " + insightRule, " literal", " " + insightRule].join("\n"),
328 ["Paragraph.", " ★ Insight " + insightRule, " literal", " " + insightRule].join("\n"),
329 ["`example", "★ Insight " + insightRule, "literal", insightRule, "`"].join("\n"),
330 insightFence("quoted")
331 .split("\n")
332 .map((line) => "> " + line)
333 .join("\n"),
334])("leaves literal and nested insight examples unchanged: %s", (markdown) => {
335 const processor = unified().use(remarkParse).use(remarkAssistantInsights);
336 const tree = processor.parse(markdown);
337 expect(processor.runSync(structuredClone(tree), markdown)).toEqual(tree);
338});
339​
340it("groups streaming bodies and several insights without losing code or headings", () => {
341 const markdown = [
342 "## Setup",
343 "",
344 insightFence("```text\n" + insightRule + "\n```"),
345 "",
346 "## Setup",
347 "",
348 "★ Insight " + insightRule,
349 "Still **streaming**",
350 ].join("\n");
351 const processor = unified().use(remarkParse).use(remarkAssistantInsights);
352 const tree = processor.parse(markdown);
353 processor.runSync(tree, markdown);
354 expect(tree.children.map((node) => node.type)).toEqual([
355 "heading",
356 "blockquote",
357 "heading",
358 "blockquote",
359 ]);
360 expect(tree.children[1]).toMatchObject({ children: [{ type: "code", value: insightRule }] });
361 expect(tree.children[3]).toMatchObject({
362 children: [
363 { type: "paragraph", children: [{ type: "text", value: "Still " }, { type: "strong" }] },
364 ],
365 });
366});
367​
368it("keeps reference definitions out of unfinished streaming code", () => {
369 const markdown = `[src]: https://example.com\n\n★ Insight ${insightRule}\n\`\`\`ts\nconst value = 1`;
370 const processor = unified().use(remarkParse).use(remarkAssistantInsights);
371 const tree = processor.parse(markdown);
372 processor.runSync(tree, markdown);
373 expect(tree.children.find((node) => node.type === "blockquote")).toMatchObject({
374 children: [{ type: "code", value: "const value = 1" }],
375 });
376});
377​
378it("preserves footnotes and original code positions across a callout", () => {
379 const code = "```sh\necho hello\n```";
380 const markdown = ["Before[^n].", "", insightFence(code), "", "[^n]: Footnote."].join("\n");
381 const processor = unified()
382 .use(remarkParse)
383 .use(remarkAssistantInsights)
384 .use(CHAT_MARKDOWN_REMARK_PLUGINS);
385 const tree = processor.parse(markdown);
386 processor.runSync(tree, markdown);
387 expect(tree.children[0]).toMatchObject({
388 children: [
389 { type: "text", value: "Before" },
390 { type: "footnoteReference", identifier: "n" },
391 { type: "text", value: "." },
392 ],
393 });
394 expect(tree.children.at(-1)).toMatchObject({ type: "footnoteDefinition", identifier: "n" });
395 const note = tree.children.find((node) => node.type === "blockquote");
396 const block = note?.children[0];
397 expect(markdown.slice(block?.position?.start.offset, block?.position?.end.offset)).toBe(code);
398});
399​
400it("preserves in-app thread links through the shared Markdown sanitizer", () => {
401 const processor = unified()
402 .use(remarkParse)
403 .use(CHAT_MARKDOWN_REMARK_PLUGINS)
404 .use(remarkRehype, { allowDangerousHtml: true })
405 .use(CHAT_MARKDOWN_REHYPE_PLUGINS);
406 const tree = processor.runSync(
407 processor.parse(
408 '[Open thread](t3-thread://v1/thread)\n\n<t3-thread://v2/environment/thread:a%3A1>\n\n<a href="javascript:alert(1)">Unsafe</a>',
409 ),
410 );
411 expect(tree.children[0]).toMatchObject({
412 tagName: "p",
413 children: [
414 {
415 tagName: "a",
416 properties: { href: "t3-thread://v1/thread" },
417 children: [{ type: "text", value: "Open thread" }],
418 },
419 ],
420 });
421 expect(tree.children[2]).toMatchObject({
422 tagName: "p",
423 children: [
424 {
425 tagName: "a",
426 properties: { href: "t3-thread://v2/environment/thread:a%3A1" },
427 children: [{ type: "text", value: "t3-thread://v2/environment/thread:a%3A1" }],
428 },
429 ],
430 });
431 expect(JSON.stringify(tree)).not.toContain("javascript:");
432});
433​
434it("builds the tree rehype-raw builds for markdown without HTML", () => {
435 type Node = {
436 type: string;
437 tagName?: string;
438 value?: string;
439 position?: unknown;
440 data?: unknown;
441 children?: Node[];
442 };
443 // What the round trip changes that renders the same; see `needsHtmlParser`.
444 const comparable = (node: Node): Node => {
445 const { position, children, data, ...rest } = node;
446 const result: Node =
447 node.type === "text" || node.type === "root" ? rest : { ...rest, position, data };
448 if (!children) return result;
449 const merged: Node[] = [];
450 for (const child of children.map(comparable)) {
451 const last = merged.at(-1);
452 if (child.type === "text" && last?.type === "text") last.value += child.value ?? "";
453 else merged.push(child);
454 }
455 // Blank text inside a table moves in front of it; the JSX runtime drops it in tables.
456 const inTable = /^(?:table|thead|tbody|tfoot|tr)$/.test(node.tagName ?? "");
457 result.children = merged.filter(
458 (child, index) =>
459 child.type !== "text" ||
460 child.value?.trim() ||
461 !(inTable || merged[index + 1]?.tagName === "table"),
462 );
463 return result;
464 };
465 const tree = (rehypePlugins: typeof CHAT_MARKDOWN_REHYPE_PLUGINS, markdown: string) => {
466 const processor = unified()
467 .use(remarkParse)
468 .use(CHAT_MARKDOWN_REMARK_PLUGINS)
469 .use(remarkRehype, { allowDangerousHtml: true })
470 .use(rehypePlugins);
471 return comparable(processor.runSync(processor.parse(markdown)) as Node);
472 };
473 const [first, , ...rest] = CHAT_MARKDOWN_REHYPE_PLUGINS;
474 const withRaw = [first!, rehypeRaw, ...rest];
475 for (const markdown of [
476 "- [ ] open\n- [x] done\n\nNote[^1]\n\n[^1]: The note.",
477 "| a | b |\n| :- | -: |\n| `c` | ![d](./d.png) |\n\n```ts title=x\nconst a = 1;\n```",
478 // The HTML parser moves the footnote's link out of the label's link.
479 "[note[^x]](https://example.com)\n\n[^x]: text",
480 "**a** **b**\r\n> - | a |\r\n> | - |\r\n> | b |\r\n\n-\tx\n\n y",
481 ]) {
482 expect(tree(CHAT_MARKDOWN_REHYPE_PLUGINS, markdown)).toEqual(tree(withRaw, markdown));
483 }
484});
485​
486it("keeps local editor file links but not other schemes through the shared Markdown sanitizer", () => {
487 const processor = unified()
488 .use(remarkParse)
489 .use(CHAT_MARKDOWN_REMARK_PLUGINS)
490 .use(remarkRehype, { allowDangerousHtml: true })
491 .use(CHAT_MARKDOWN_REHYPE_PLUGINS);
492 const tree = processor.runSync(
493 processor.parse(
494 [
495 "[cursor](cursor://file/Users/me/repo/file.md:3)",
496 "[code](vscode://file/Users/me/repo/file.md:3:7)",
497 "[insiders](vscode-insiders://file/Users/me/repo/file.md)",
498 "[unknown](foo://file/Users/me/repo/file.md)",
499 "[zed](zed://file/Users/me/repo/file.md)",
500 ].join(" "),
501 ),
502 );
503 type Node = { tagName?: string; properties?: { href?: string }; children?: Node[] };
504 const hrefs = ((tree.children[0] as Node).children ?? [])
505 .filter((node) => node.tagName === "a")
506 .map((node) => node.properties?.href);
507 expect(hrefs).toEqual([
508 "cursor://file/Users/me/repo/file.md:3",
509 "vscode://file/Users/me/repo/file.md:3:7",
510 "vscode-insiders://file/Users/me/repo/file.md",
511 undefined,
512 undefined,
513 ]);
514});
515​
516it("keeps mixed-case local editor file links by lowercasing the scheme before sanitizing", () => {
517 const processor = unified()
518 .use(remarkParse)
519 .use(CHAT_MARKDOWN_REMARK_PLUGINS)
520 .use(remarkRehype, { allowDangerousHtml: true })
521 .use(CHAT_MARKDOWN_REHYPE_PLUGINS);
522 const tree = processor.runSync(
523 processor.parse(
524 [
525 "[upper](VSCODE://file/Users/me/repo/file.md:3:7)",
526 "[mixed](Cursor://file/Users/me/repo/file.md:3)",
527 "[other](FOO://file/Users/me/repo/file.md)",
528 ].join(" "),
529 ),
530 );
531 type Node = { tagName?: string; properties?: { href?: string }; children?: Node[] };
532 const hrefs = ((tree.children[0] as Node).children ?? [])
533 .filter((node) => node.tagName === "a")
534 .map((node) => node.properties?.href);
535 expect(hrefs).toEqual([
536 "vscode://file/Users/me/repo/file.md:3:7",
537 "cursor://file/Users/me/repo/file.md:3",
538 undefined,
539 ]);
540});
541​
542it("keeps backslashes that CommonMark would read as escapes in prose Windows paths", () => {
543 const processor = unified()
544 .use(remarkParse)
545 .use(CHAT_MARKDOWN_REMARK_PLUGINS)
546 .use(remarkRehype, { allowDangerousHtml: true })
547 .use(CHAT_MARKDOWN_REHYPE_PLUGINS);
548 type Node = { type: string; value?: string; tagName?: string; children?: Node[] };
549 const text = (node: Node): string =>
550 node.type === "text"
551 ? (node.value ?? "")
552 : node.tagName === "code"
553 ? `<code>${(node.children ?? []).map(text).join("")}</code>`
554 : (node.children ?? []).map(text).join("");
555 const render = (markdown: string) => text(processor.runSync(processor.parse(markdown)) as Node);
556​
557 expect(
558 render(String.raw`Saved to C:\Users\me\.t3\_build and (C:\.scratch) and C:/work\.config.`),
559 ).toBe(String.raw`Saved to C:\Users\me\.t3\_build and (C:\.scratch) and C:/work\.config.`);
560 expect(render(String.raw`Escaped C:\\Users\\me`)).toBe(String.raw`Escaped C:\\Users\\me`);
561 expect(render(String.raw`See C:\x and \*this\* and Note:\*that\*.`)).toBe(
562 String.raw`See C:\x and *this* and Note:*that*.`,
563 );
564 expect(render(String.raw`${"`"}C:\me\.t3${"`"} and C:\me\.t3`)).toBe(
565 String.raw`<code>C:\me\.t3</code> and C:\me\.t3`,
566 );
567});
568​
569it("keeps an image on its own line under a label but leaves inline images inline", () => {
570 const processor = unified()
571 .use(remarkParse)
572 .use(CHAT_MARKDOWN_REMARK_PLUGINS)
573 .use(remarkRehype, { allowDangerousHtml: true })
574 .use(CHAT_MARKDOWN_REHYPE_PLUGINS);
575 type Node = { type: string; value?: string; tagName?: string; children?: Node[] };
576 const shape = (node: Node): string =>
577 node.type === "text"
578 ? (node.value ?? "")
579 : node.tagName === "br" || node.tagName === "img"
580 ? `<${node.tagName}>`
581 : (node.children ?? []).map(shape).join("");
582 const render = (markdown: string) => shape(processor.runSync(processor.parse(markdown)) as Node);
583​
584 expect(
585 render("**Palette**\n![Palette](/tmp/palette.png)\n**Hero** (dark)\n![Hero](/tmp/hero.png)"),
586 ).toBe("Palette<br>\n<img><br>\nHero (dark)<br>\n<img>");
587 expect(render("Built [![CI](/ci.svg)](/ci)\nand wrapped")).toBe("Built <img>\nand wrapped");
588 expect(render("Hard-wrapped\nprose")).toBe("Hard-wrapped\nprose");
589});
590​
591it("keeps the number written on each ordered list item only when enabled", () => {
592 type Node = { tagName?: string; properties?: { value?: unknown }; children?: Node[] };
593 const values = (node: Node): unknown[] => [
594 ...(node.tagName === "li" ? [node.properties?.value] : []),
595 ...(node.children ?? []).flatMap(values),
596 ];
597 const renderer = (plugins: PluggableList) => {
598 const processor = unified()
599 .use(remarkParse)
600 .use(plugins)
601 .use(remarkRehype, { allowDangerousHtml: true });
602 return (markdown: string) =>
603 values(processor.runSync(processor.parse(markdown), markdown) as Node);
604 };
605 const render = renderer([
606 ...CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS,
607 remarkEnableOrderedListNumbers,
608 ]);
609​
610 expect(render("3. first\n5. second\n6. third")).toEqual([undefined, "5", undefined]);
611 expect(render("1. one\n1. one again")).toEqual([undefined, "1"]);
612 expect(render("> 1. quoted\n> 4. skipped")).toEqual([undefined, "4"]);
613 expect(render("1. outer\n 1. nested\n 7. jump\n9. back")).toEqual([
614 undefined,
615 undefined,
616 "7",
617 "9",
618 ]);
619 expect(render("1. one\n2. two\n- bullet")).toEqual([undefined, undefined, undefined]);
620 expect(render("- first block\n\n 3. first\n 5. second")).toEqual([
621 undefined,
622 undefined,
623 "5",
624 ]);
625 expect(
626 renderer(CHAT_MARKDOWN_REMARK_PLUGINS)(
627 "3. first\n5. second\n\n- first block\n\n 3. first\n 5. second",
628 ),
629 ).toEqual([undefined, undefined, undefined, undefined, undefined]);
630});
631​
632it("moves a <picture>'s color-scheme sources onto its <img>, which the renderer resolves", () => {
633 const processor = unified()
634 .use(remarkParse)
635 .use(CHAT_MARKDOWN_REMARK_PLUGINS)
636 .use(remarkRehype, { allowDangerousHtml: true })
637 .use(CHAT_MARKDOWN_REHYPE_PLUGINS);
638 type Node = { type: string; tagName?: string; properties?: object; children?: Node[] };
639 const tags = (node: Node): string[] => [
640 ...(node.tagName ? [node.tagName] : []),
641 ...(node.children ?? []).flatMap(tags),
642 ];
643 const images = (node: Node): Node[] =>
644 node.tagName === "img" ? [node] : (node.children ?? []).flatMap(images);
645 const tree = processor.runSync(
646 processor.parse(
647 [
648 "<picture>",
649 ' <source media="(prefers-color-scheme: dark)" srcset="docs/dark.webp 1x, docs/dark@2x.webp 2x">',
650 ' <source media="(prefers-color-scheme: dark)" srcset="docs/dark-unused.webp">',
651 ' <source media="(min-width: 600px)" srcset="docs/wide.webp">',
652 ' <img src="docs/light.webp" alt="Studio">',
653 "</picture>",
654 "",
655 '<picture><source media="(prefers-color-scheme: light)" srcset="javascript:alert(1)"><img src="docs/dark.webp"></picture>',
656 "",
657 String.raw`<picture><source media="(prefers-color-scheme: dark)" srcset="C:\shots\dark.png"><img src="C:\shots\light.png"></picture>`,
658 ].join("\n"),
659 ),
660 ) as Node;
661​
662 expect(tags(tree)).toEqual(["picture", "img", "p", "picture", "img", "p", "picture", "img"]);
663 expect(images(tree).map((image) => image.properties)).toEqual([
664 { src: "docs/light.webp", alt: "Studio", dataDarkSrc: "docs/dark.webp" },
665 { src: "docs/dark.webp" },
666 { dataLocalSrc: String.raw`C:\shots\light.png`, dataDarkSrc: "file:///C:/shots/dark.png" },
667 ]);
668});
669​
670it("links complete absolute paths in prose and skips shortened ones", () => {
671 const path =
672 "/tmp/claude-1000/-home-user--t3-worktrees-app-t3-abc123/session/scratchpad/notes.md";
673 const processor = unified().use(remarkParse).use(CHAT_MARKDOWN_REMARK_PLUGINS);
674 const links = (markdown: string) => {
675 const urls: string[] = [];
676 const visit = (node: Root | RootContent) => {
677 if (node.type === "link") urls.push(node.url);
678 if ("children" in node) node.children.forEach(visit);
679 };
680 visit(processor.runSync(processor.parse(markdown), markdown) as Root);
681 return urls;
682 };
683 expect(links(`Saved to ${path} and /tmp/other.txt.`)).toEqual([path, "/tmp/other.txt"]);
684 expect(links(`See (${path}:12).`)).toEqual([`${path}:12`]);
685 expect(links("See /tmp/report(1).txt. and /tmp/app/[id]/page.tsx.")).toEqual([
686 "/tmp/report(1).txt",
687 "/tmp/app/[id]/page.tsx",
688 ]);
689 expect(links("(see /tmp/claude-1000/...). and /tmp/claude-1000/….")).toEqual([]);
690 expect(links("Served at /index.html; GET /api/users.json; see /tmp/x.md:3.")).toEqual([
691 "/tmp/x.md:3",
692 ]);
693 expect(
694 links(
695 '<a title="</a>" href="https://example.com">/tmp/notes.md</a> and **<a href="x">/tmp/a.md</a>**',
696 ),
697 ).toEqual([]);
698 expect(
699 links("Config lives in /Users/me/Library/Application Support/T3 Code/settings.json now."),
700 ).toEqual([]);
701 expect(links(`/chat/settings and https://example.com${path}`)).toEqual([
702 `https://example.com${path}`,
703 ]);
704 expect(links(`[notes](${path}) and \`${path}\``)).toEqual([path]);
705 expect(links(`\`\`\`\n${path}\n\`\`\``)).toEqual([]);
706});