Skip to content
Source
Sign inSign up

markdownHtml.test.ts

409 lines17.4 KB

1import { describe, expect, it } from "vite-plus/test";
2import {
3 markdownForNativeRendering,
4 markdownHtmlContent,
5 markdownWithHtml,
6 markdownWithFileEmbeds,
7 parseMarkdownDocument,
8 type MarkdownNode,
9} from "./markdownDocument";
10import { markdownImageSources } from "./markdownImageSources";
11import { markdownFileEmbedLabel, resolveMarkdownMediaPreview } from "./markdownMedia";
12import { resolveMarkdownLinkPresentation } from "./markdownLinks";
13import { ENRICHED_INLINE_FILE_LINK_REGEX } from "./enrichedLinkPresentation";
14import { EnvironmentId, ThreadId } from "@t3tools/contracts";
15import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
16​
17describe("HTML in native Markdown", () => {
18 it("preserves screenshot URLs and captions from GitHub HTML image embeds", () => {
19 expect(
20 markdownHtmlContent(
21 '<img width="900" alt="Before > after [settings]" src="https://example.com/before.png?a=1&amp;b=2" />',
22 ),
23 ).toBe("![Before > after \\[settings\\]](<https://example.com/before.png?a=1\\&b=2>)");
24 expect(
25 markdownHtmlContent(
26 "<a href='https://example.com'><img src='https://example.com/banner.svg' alt='Review' /></a>",
27 ),
28 ).toBe("[![Review](<https://example.com/banner.svg>)](<https://example.com>)");
29 });
30​
31 it("decodes HTML attributes once and preserves entity-shaped query values through Markdown", () => {
32 const markdown =
33 '<img alt="&amp;lt;" src="https://example.com/shot.png?label=&amp;lt;&amp;sig=a%26b">';
34 expect(markdownWithHtml(markdown)).toBe(
35 "![\\&lt;](<https://example.com/shot.png?label=\\&lt;\\&sig=a%26b>)",
36 );
37 expect(markdownImageSources(markdown)).toEqual([
38 "https://example.com/shot.png?label=&lt;&sig=a%26b",
39 ]);
40 const paragraph = parseMarkdownDocument(markdown).children[0];
41 expect(paragraph?.type).toBe("paragraph");
42 if (paragraph?.type === "paragraph")
43 expect(paragraph.children[0]).toMatchObject({ type: "image", alt: "&lt;" });
44 });
45​
46 it("ignores code delimiters in HTML attributes and comments when collecting screenshots", () => {
47 const markdown =
48 '<details data-example="`">\n<summary>Proof</summary>\n<img src="https://example.com/shot.png" alt="Screenshot">\n<!-- ` -->\n</details>';
49 expect(markdownHtmlContent(markdown)).toContain(
50 "![Screenshot](<https://example.com/shot.png>)",
51 );
52 expect(markdownImageSources(markdown)).toEqual(["https://example.com/shot.png"]);
53 expect(markdownWithHtml(markdown)).not.toContain("<img");
54 });
55​
56 it("removes bot metadata and details wrappers while keeping the content", () => {
57 const markdown =
58 "<!-- generated bot metadata -->\n<details>\n<summary>Walkthrough</summary>\n\n## Changes\n\nText\n\n</details>";
59 const result = markdownWithHtml(markdown);
60 expect(result).not.toContain("<!--");
61 expect(result).not.toContain("<details>");
62 expect(result).toContain("**Walkthrough**");
63 expect(result).toContain("## Changes");
64 expect(parseMarkdownDocument(markdown).children.some((node) => node.type === "html")).toBe(
65 false,
66 );
67 });
68​
69 it("collects HTML and Markdown screenshots while leaving inline and fenced code literal", () => {
70 const markdown =
71 '```html\n<img src="literal.png">\n```\n\nUse `<img src="inline.png">` here.\n\n<img src="one.png">\n\n![Two](two.png)\n\n<img src="one.png">';
72 expect(markdownImageSources(markdown)).toEqual(["one.png", "two.png"]);
73 const result = markdownWithHtml(markdown);
74 expect(result).toContain('```html\n<img src="literal.png">\n```');
75 expect(result).toContain('`<img src="inline.png">`');
76 });
77​
78 it("treats an unclosed fence in an HTML block as code through the end", () => {
79 // Sized so the quadratic rescan cannot finish within the test timeout.
80 const fences = Array.from({ length: 40_000 }, () => "```x y").join("\n");
81 const markdown = `<details>\n<img src="before.png">\n${fences}\n<img src="after.png">\n</details>`;
82 const result = markdownHtmlContent(markdown);
83 expect(result).toContain("![](<before.png>)");
84 expect(result).not.toContain("<details>");
85 expect(result).toContain('<img src="after.png">\n</details>');
86 });
87​
88 it("keeps <br> line breaks in a table cell without breaking the row", () => {
89 const markdown =
90 "| Status | Preview URL |\n| - | - |\n| Done | <a href='https://commit.example.com'>Commit</a><br><a href='https://branch.example.com'>Branch</a> |\n| • One<br/>• Two<BR />**Three** | `<br>` |";
91 // md4c decodes the reference to a newline in the cell's text, as remark does.
92 expect(markdownForNativeRendering(markdown, ENRICHED_INLINE_FILE_LINK_REGEX)).toContain(
93 "| • One&#10;• Two&#10;**Three** | `<br>` |",
94 );
95 const table = parseMarkdownDocument(markdown).children[0];
96 expect(table?.type).toBe("table");
97 if (table?.type !== "table") return;
98 expect(table.children).toHaveLength(3);
99 expect(table.children.map((row) => row.children.length)).toEqual([2, 2, 2]);
100 expect(table.children[1]?.children[1]?.children).toMatchObject([
101 { type: "link", url: "https://commit.example.com", children: [{ value: "Commit" }] },
102 { type: "text", value: "\n" },
103 { type: "link", url: "https://branch.example.com", children: [{ value: "Branch" }] },
104 ]);
105 expect(table.children[2]?.children).toMatchObject([
106 { children: [{ value: "• One\n• Two\n" }, { type: "strong" }] },
107 { children: [{ type: "inlineCode", value: "<br>" }] },
108 ]);
109 });
110​
111 it.each([
112 "one<p>two</p>",
113 "one<div>two</div>",
114 "one<summary>two</summary>",
115 "<p>one</p><p>two</p>",
116 ])("keeps a table row intact when a cell contains block HTML: %s", async (cell) => {
117 const native = await parseMarkdown(
118 markdownForNativeRendering(
119 `| A | B |\n| - | - |\n| ${cell} | x |`,
120 ENRICHED_INLINE_FILE_LINK_REGEX,
121 ),
122 );
123 const text = (node: { content?: string; children?: unknown[] }): string =>
124 (node.content ?? "") +
125 (node.children ?? []).map((child) => text(child as typeof node)).join("");
126 expect(native.children).toHaveLength(1);
127 expect(native.children?.[0]?.children?.[1]?.children?.[0]?.children?.map(text)).toEqual([
128 expect.stringMatching(/one\s*two/),
129 "x",
130 ]);
131 });
132​
133 it("keeps a table row intact when an anchor href contains a pipe", () => {
134 const document = parseMarkdownDocument(
135 "| Preview | Logs |\n| - | - |\n| <a href='https://example.com/?a=1&#124;2'>Open</a> | done |",
136 );
137 const table = document.children[0];
138 expect(table?.type).toBe("table");
139 if (table?.type !== "table") return;
140 expect(table.children[1]?.children).toMatchObject([
141 { children: [{ type: "link", url: "https://example.com/?a=1%7C2" }] },
142 { children: [{ value: "done" }] },
143 ]);
144 });
145​
146 it("keeps anchor text unlinked for unsafe hrefs", () => {
147 expect(markdownWithHtml('Run <a href="javascript:alert(1)">this</a> now')).toBe("Run this now");
148 });
149​
150 it("keeps anchor labels and escaped bangs intact across nested formatting and table cells", () => {
151 const markdown =
152 '> **!<a href="https://x.test">[one]</a>** and <span>tail</span>\n\n| A |\n| - |\n| !<a href="https://x.test">x[y]</a><br><img src="x.png"> |';
153 expect(markdownWithHtml(markdown)).toBe(
154 "> **\\![\\[one\\]](<https://x.test>)** and tail\n\n| A |\n| - |\n| \\![x\\[y\\]](<https://x.test>)&#10;![](<x.png>) |",
155 );
156 expect(
157 markdownWithHtml(
158 String.raw`Before \!<a href="https://x.test">escaped</a> \\!<a href="mailto:a@b">even</a>`,
159 ),
160 ).toBe(String.raw`Before \![escaped](<https://x.test>) \\\![even](<mailto:a@b>)`);
161 });
162​
163 it("converts separated HTML without changing code examples or an unfinished tag", () => {
164 const literal = '`<br>`\n\n```html\n<img src="literal.png">\n```\n\n<broken';
165 expect(markdownWithHtml(literal)).toBe(literal);
166 const rows = Array.from(
167 { length: 100 },
168 (_, index) => `row ${index} <span>text</span><br>!<a href="https://x.test/${index}">go</a>`,
169 );
170 const expected = Array.from(
171 { length: 100 },
172 (_, index) => `row ${index} text \n\\![go](<https://x.test/${index}>)`,
173 );
174 expect(markdownWithHtml(rows.join("\n\n") + "\n\n" + literal)).toBe(
175 expected.join("\n\n") + "\n\n" + literal,
176 );
177 });
178});
179​
180describe("file embeds in native thread and file Markdown", () => {
181 const input = {
182 environmentId: EnvironmentId.make("environment-1"),
183 threadId: ThreadId.make("thread-1"),
184 workspaceRoot: "/repo",
185 };
186 const assets = (markdown: string) => {
187 const result: MarkdownNode[] = [];
188 const visit = (node: MarkdownNode) => {
189 if (node.type === "link" || node.type === "image") result.push(node);
190 if ("children" in node) node.children.forEach(visit);
191 };
192 visit(parseMarkdownDocument(markdown));
193 return result;
194 };
195​
196 it.each([
197 [
198 "[![sheet](./report.csv)](https://example.com/download)",
199 "report.csv",
200 "https://example.com/download",
201 ],
202 ["[![report](./report.pdf)](./report.pdf)", "report.pdf", "./report.pdf"],
203 [
204 "[![sheet][f]][download]\n\n[f]: ./report.csv\n[download]: https://example.com/download",
205 "report.csv",
206 "https://example.com/download",
207 ],
208 ])("keeps the enclosing link for %s", async (source, filename, target) => {
209 const converted = markdownWithFileEmbeds(source, (href, standalone) =>
210 markdownFileEmbedLabel(href, input, standalone),
211 );
212 expect((await parseMarkdown(converted.markdown)).children).toMatchObject([
213 {
214 type: "Paragraph",
215 children: [
216 {
217 type: "Link",
218 attributes: { url: target },
219 children: [{ type: "Text", content: filename }],
220 },
221 ],
222 },
223 ]);
224 });
225​
226 it.each(["f", "F", "File Label", "file label"])(
227 "uses the first reference definition for %s",
228 async (identifier) => {
229 const source = `![sheet][${identifier}]\n\n[${identifier.toLowerCase()}]: ./report.csv\n[${identifier.toUpperCase()}]: ./report.xlsx`;
230 const converted = markdownWithFileEmbeds(source, (href, standalone) =>
231 markdownFileEmbedLabel(href, input, standalone),
232 );
233 expect(converted.fileHrefs).toEqual(new Set(["./report.csv"]));
234 expect((await parseMarkdown(converted.markdown)).children).toMatchObject([
235 {
236 type: "Paragraph",
237 children: [
238 {
239 type: "Link",
240 attributes: { url: "./report.csv" },
241 children: [{ type: "Text", content: "report.csv" }],
242 },
243 ],
244 },
245 ]);
246 expect((await parseMarkdown(source)).children).toMatchObject([
247 { type: "Paragraph", children: [{ type: "Image", attributes: { url: "./report.csv" } }] },
248 ]);
249 },
250 );
251​
252 it.each([
253 "report.csv",
254 "report.tsv",
255 "report.pdf",
256 "report.docx",
257 "report.xlsx",
258 "report.pptx",
259 "report.unknown",
260 "main.tf",
261 "schema.prisma",
262 "index.astro",
263 "main.zig",
264 "Containerfile",
265 "sound.mp3",
266 "clip.mp4",
267 ])("uses filename links throughout prose, lists, and tables for %s", (name) => {
268 for (const href of [
269 `./${name}`,
270 `/tmp/${name}`,
271 `file:///tmp/${name}`,
272 `https://cdn.example.com/${name}?signature=a%2fb`,
273 ]) {
274 if (name === "Containerfile" && href.startsWith("https:")) continue;
275 for (const source of [
276 `download: ![sheet](<${href}>)`,
277 `- ![sheet](<${href}>)`,
278 `| file |\n| --- |\n| ![sheet](<${href}>) |`,
279 `download: ![sheet][file]\n\n[file]: <${href}>`,
280 `download: <img src="${href}" alt="sheet">`,
281 ]) {
282 const converted = markdownWithFileEmbeds(markdownWithHtml(source), (url, standalone) =>
283 markdownFileEmbedLabel(url, input, standalone),
284 );
285 const image = href.startsWith("https:") && /\.(unknown|tf|prisma|astro|zig)$/.test(name);
286 if (image) expect(converted.markdown).toBe(markdownWithHtml(source));
287 else
288 expect(assets(converted.markdown)).toMatchObject([
289 { type: "link", url: href, children: [{ type: "text", value: name }] },
290 ]);
291 expect(converted.fileHrefs.has(href)).toBe(!image);
292 const preview = resolveMarkdownMediaPreview(href, { ...input, imageEmbed: true });
293 if (
294 !href.startsWith("https:") &&
295 !name.endsWith(".pdf") &&
296 !name.endsWith(".mp3") &&
297 !name.endsWith(".mp4")
298 ) {
299 expect(preview?.kind).toBe("file");
300 expect(resolveMarkdownLinkPresentation(href)).toMatchObject({ kind: "file" });
301 }
302 }
303 }
304 });
305​
306 it("retains standalone audio players and image/video embeds in threads", () => {
307 const source =
308 "![sound](./sound.mp3)\n\n![image](./image.png)\n\n![clip](./clip.mp4)\n\ninline ![photo](./photo.png)";
309 const converted = markdownWithFileEmbeds(source, (href, standalone) =>
310 markdownFileEmbedLabel(href, input, standalone),
311 );
312 expect(converted.markdown).toBe(source);
313 expect(converted.fileHrefs.size).toBe(0);
314 expect(resolveMarkdownMediaPreview("./sound.mp3", input)?.kind).toBe("audio");
315 });
316​
317 it("uses audio filenames in file previews and draft workspaces", () => {
318 const draft = { ...input, threadId: undefined, workspaceRoot: "/repo/docs" };
319 const converted = markdownWithFileEmbeds("![sound](./sound.mp3)", (href) =>
320 markdownFileEmbedLabel(href, draft, false),
321 );
322 expect(assets(converted.markdown)).toMatchObject([
323 { type: "link", url: "./sound.mp3", children: [{ value: "sound.mp3" }] },
324 ]);
325 expect(resolveMarkdownMediaPreview("./sound.mp3", draft)?.source).toMatchObject({
326 resource: { _tag: "draft-workspace-file", cwd: "/repo/docs", path: "/repo/docs/./sound.mp3" },
327 });
328 });
329​
330 it("keeps captured images on the image path and documents on filename links", () => {
331 const captured = { ...input, captured: true };
332 const converted = markdownWithFileEmbeds(
333 "![photo](./photo.png)\n\n![clip](./clip.mp4)\n\n![sheet](./report.csv)",
334 (href, standalone) => markdownFileEmbedLabel(href, captured, standalone),
335 );
336 expect(assets(converted.markdown)).toMatchObject([
337 { type: "image", url: "./photo.png" },
338 { type: "image", url: "./clip.mp4" },
339 { type: "link", url: "./report.csv", children: [{ value: "report.csv" }] },
340 ]);
341 expect(resolveMarkdownMediaPreview("./report.csv", captured)).toBeNull();
342 });
343​
344 it("preserves table structure and filename punctuation", () => {
345 const source = "| file |\n| --- |\n| ![sheet](./report%5B1%5D%26%7C.csv) |";
346 const converted = markdownWithFileEmbeds(source, (href, standalone) =>
347 markdownFileEmbedLabel(href, input, standalone),
348 );
349 expect(parseMarkdownDocument(converted.markdown).children[0]?.type).toBe("table");
350 expect(assets(converted.markdown)).toMatchObject([
351 { type: "link", url: "./report%5B1%5D%26%7C.csv", children: [{ value: "report[1]&|.csv" }] },
352 ]);
353 });
354​
355 it("keeps authored positions, signed URLs, and code examples literal", () => {
356 const href = "https://cdn.example.com/report%23one%2520.csv?name=&lt;&signature=a%2fb";
357 const source = `![report](<${href.replaceAll("&", "\\&")}>)\n\n![source](./main.tf#L42C3)\n\n\`![sheet](./literal.csv)\`\n\n\`\`\`md\n![sheet](./fenced.csv)\n\`\`\``;
358 const converted = markdownWithFileEmbeds(source, (url, standalone) =>
359 markdownFileEmbedLabel(url, input, standalone),
360 );
361 expect(assets(converted.markdown)).toMatchObject([
362 { type: "link", url: href, children: [{ value: "report#one%20.csv" }] },
363 { type: "link", url: "./main.tf#L42C3", children: [{ value: "main.tf · L42:C3" }] },
364 ]);
365 expect(converted.markdown).toContain("`![sheet](./literal.csv)`");
366 expect(converted.markdown).toContain("```md\n![sheet](./fenced.csv)\n```");
367 expect(converted.fileHrefs.has(href)).toBe(true);
368 });
369});
370​
371describe("Windows paths in native Markdown", () => {
372 // regression: #23722 Markdown escapes ate the separator before `.t3` and `_build`
373 it("keeps backslashes in Windows image and link destinations", async () => {
374 const image = String.raw`C:\work\.t3\_build\shot.png`;
375 const link = String.raw`\\server\share\.t3\notes.md`;
376 const markdown = [
377 `![shot](${image} "Shot")`,
378 String.raw`![paren](C:\a\)b.png)`,
379 `[notes](<${link}>)`,
380 `<img src="${image}">`,
381 String.raw`![amp](C:\R&D\.t3\a&amp;b.png)`,
382 String.raw`<img src="C:\R&amp;D\.t3\b.png">`,
383 "> ![quoted](\n> " + image + ")",
384 ].join("\n\n");
385​
386 const native = await parseMarkdown(
387 markdownForNativeRendering(markdown, ENRICHED_INLINE_FILE_LINK_REGEX),
388 );
389 const urls: string[] = [];
390 const collect = (node: { attributes?: { url?: string }; children?: unknown[] }) => {
391 if (node.attributes?.url) urls.push(node.attributes.url);
392 node.children?.forEach((child) => collect(child as typeof node));
393 };
394 collect(native);
395 const paren = String.raw`C:\a\)b.png`;
396 const amp = String.raw`C:\R&D\.t3\a&b.png`;
397 const htmlAmp = String.raw`C:\R&D\.t3\b.png`;
398 expect(urls).toEqual([image, paren, link, image, amp, htmlAmp, image]);
399 expect(markdownImageSources(markdown)).toEqual([image, paren, amp, htmlAmp]);
400 });
401});
402​
403it("keeps backslashes in drive paths in mobile Markdown prose", () => {
404 const path = String.raw`Saved to C:\Users\me\.t3\_build\shot.png and C:/work\.config.`;
405​
406 expect(parseMarkdownDocument(String.raw`${path} Then \*this\*.`).children).toMatchObject([
407 { type: "paragraph", children: [{ type: "text", value: `${path} Then *this*.` }] },
408 ]);
409});