~james/core

core/web/src/lib/markup/markdown.test.ts -rw-r--r-- 12.8 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
import { describe, expect, it } from "vitest";
import { isMarkdownFile } from "#lib/markup/format.ts";
import { renderMarkdown } from "#lib/markup/markdown.ts";
import type { MarkupContext } from "#lib/markup/paths.ts";

const ctx: MarkupContext = { repo: "ada.test/infra", ref: "main", host: "tangled.org" };
const render = (source: string, overrides: Partial<MarkupContext> = {}) =>
	renderMarkdown(source, { ...ctx, ...overrides });

describe("isMarkdownFile", () => {
	it("knows the markdown extensions from the plain text ones", async () => {
		expect(isMarkdownFile("README.md")).toBe(true);
		expect(isMarkdownFile("readme.MARKDOWN")).toBe(true);
		expect(isMarkdownFile("readme.mkd")).toBe(true);
		expect(isMarkdownFile("README")).toBe(false);
		expect(isMarkdownFile("README.rst")).toBe(false);
	});
});

describe("renderMarkdown", () => {
	it("renders gfm tables and strikethrough", async () => {
		const html = await render("| a | b |\n| - | - |\n| 1 | 2 |\n\n~~gone~~");
		expect(html).toContain("<table>");
		expect(html).toContain("<s>gone</s>");
	});

	it("gives headings github's slugs and an anchor link", async () => {
		const html = await render("## Hello, World!");
		expect(html).toContain('id="hello-world"');
		expect(html).toContain('<a class="anchor" href="#hello-world">#</a>');
	});

	it("points relative links at the file browser", async () => {
		expect(await render("[docs](./docs/setup.md)")).toContain(
			'href="/ada.test/infra/tree/main/docs/setup.md"'
		);
	});

	it("resolves a link against the directory the document is in", async () => {
		const html = await render("[sibling](../other.md)", { dir: "docs/guide" });
		expect(html).toContain('href="/ada.test/infra/tree/main/docs/other.md"');
	});

	it("treats an absolute path as repo relative, not host relative", async () => {
		expect(await render("[root](/LICENSE)", { dir: "docs" })).toContain(
			'href="/ada.test/infra/tree/main/LICENSE"'
		);
	});

	it("keeps a fragment on a rewritten link", async () => {
		expect(await render("[part](./setup.md#install)")).toContain(
			'href="/ada.test/infra/tree/main/setup.md#install"'
		);
	});

	it("leaves fragments, mail and absolute urls alone", async () => {
		const html = await render("[a](#top) [b](mailto:ada@test) [c](https://example.com)");
		expect(html).toContain('href="#top"');
		expect(html).toContain('href="mailto:ada@test"');
		expect(html).toContain('href="https://example.com"');
	});

	it("marks off-site links as untrusted", async () => {
		expect(await render("[c](https://example.com)")).toContain(
			'rel="nofollow noopener noreferrer"'
		);
		expect(await render("[a](./x.md)")).not.toContain("nofollow");
	});

	it("points relative images at the raw file, including raw html ones", async () => {
		expect(await render("![logo](assets/logo.png)")).toContain(
			'src="/ada.test/infra/raw/main/assets/logo.png"'
		);
		expect(await render('<img src="assets/logo.png" alt="logo">')).toContain(
			'src="/ada.test/infra/raw/main/assets/logo.png"'
		);
	});

	it("loads images lazily and decodes them off the main path, whatever the source says", async () => {
		expect(await render("![logo](a.png)")).toContain('loading="lazy" decoding="async"');
		const html = await render('<img src="a.png" loading="eager" decoding="sync">');
		expect(html).toContain('loading="lazy"');
		expect(html).toContain('decoding="async"');
		expect(html).not.toContain("eager");
	});

	it("rewrites every candidate in a srcset", async () => {
		const html = await render('<picture><source srcset="a.png 1x, b.png 2x"></picture>');
		expect(html).toContain(
			'srcset="/ada.test/infra/raw/main/a.png 1x, /ada.test/infra/raw/main/b.png 2x"'
		);
	});

	it("leaves off-site images alone when there is no camo", async () => {
		expect(await render("![x](https://example.com/a.png)")).toContain(
			'src="https://example.com/a.png"'
		);
	});

	describe("with camo", () => {
		const camo = (source: string) => render(source, { camo: true });
		// hex of https://example.com/a.png, which is what camo signs
		const hex = "68747470733a2f2f6578616d706c652e636f6d2f612e706e67";

		it("sends an off-site image to the route that signs for camo", async () => {
			expect(await camo("![x](https://example.com/a.png)")).toContain(`src="/camo/${hex}"`);
		});

		it("proxies raw html images and srcset candidates too", async () => {
			expect(await camo('<img src="https://example.com/a.png">')).toContain(
				`src="/camo/${hex}"`
			);
			expect(await camo('<source srcset="https://example.com/a.png 2x">')).toContain(
				`srcset="/camo/${hex} 2x"`
			);
		});

		it("leaves our own images and repo files direct", async () => {
			expect(await camo("![x](https://tangled.org/a.png)")).toContain(
				'src="https://tangled.org/a.png"'
			);
			expect(await camo("![x](assets/a.png)")).toContain(
				'src="/ada.test/infra/raw/main/assets/a.png"'
			);
		});

		it("does not proxy links, only what the page loads by itself", async () => {
			expect(await camo("[x](https://example.com/a.png)")).toContain(
				'href="https://example.com/a.png"'
			);
		});
	});

	it("links a bare handle to its profile", async () => {
		const html = await render("thanks @ada.test for the fix");
		expect(html).toContain('<a href="/ada.test" class="mention">@ada.test</a>');
	});

	it("leaves a handle inside a link label as text", async () => {
		expect(await render("[ask @ada.test](https://example.com)")).not.toContain("mention");
	});

	it("does not read an email address as a mention", async () => {
		expect(await render("mail ada@ada.test now")).not.toContain("mention");
	});

	it("shortens our own commit urls to a sha", async () => {
		const url =
			"https://tangled.org/ada.test/infra/commit/0c4d0e9b07940033721395a434b5873f0fb9e6c8";
		expect(await render(url)).toContain("<code>0c4d0e9b</code>");
	});

	it("leaves a commit url that has its own label", async () => {
		const html = await render(
			"[the fix](https://tangled.org/ada.test/infra/commit/0c4d0e9b07940033721395a434b5873f0fb9e6c8)"
		);
		expect(html).toContain("the fix");
		expect(html).not.toContain("<code>");
	});

	it("leaves another host's commit urls as urls", async () => {
		const url = "https://github.com/ada/infra/commit/0c4d0e9b07940033721395a434b5873f0fb9e6c8";
		expect(await render(url)).not.toContain("<code>");
	});

	it("renders task lists as disabled checkboxes", async () => {
		const html = await render("- [x] done\n- [ ] not");
		expect(html).toContain('type="checkbox"');
		expect(html).toContain('checked="checked"');
		expect(html).toContain('disabled="disabled"');
	});

	it("renders footnotes with their backlinks", async () => {
		const html = await render("a claim[^1]\n\n[^1]: the source");
		expect(html).toContain('class="footnote-ref"');
		expect(html).toContain('class="footnote-backref"');
		expect(html).toContain("the source");
	});

	it("renders github style alerts", async () => {
		const html = await render("> [!WARNING]\n> careful");
		expect(html).toContain('class="markdown-alert markdown-alert-warning"');
		expect(html).toContain("careful");
	});

	it("renders emoji shortcodes", async () => {
		expect(await render("ship it :tada:")).toContain("🎉");
	});

	it("keeps the language on a fenced block", async () => {
		expect(await render("```rust\nfn main() {}\n```")).toContain('class="language-rust"');
	});

	it("highlights a fenced block but keeps its language class", async () => {
		const html = await render("```rust\nfn main() {}\n```");
		expect(html).toContain('class="language-rust"');
		expect(html).toContain("--diffs-token-");
		expect(html).toContain("<span");
	});

	it("highlights a block that carries source lines", async () => {
		const html = await render("```rust\nfn main() {}\n```", { sourceLines: true });
		expect(html).toContain('data-line="0"');
		expect(html).toContain("--diffs-token-");
	});

	it("leaves fenced blocks without a known language plain", async () => {
		const unknown = await render("```nosuchlang\nhello\n```");
		expect(unknown).toContain('class="language-nosuchlang"');
		expect(unknown).not.toContain("--diffs-token-");

		const plain = await render("```\nhello\n```");
		expect(plain).not.toContain("--diffs-token-");
	});

	it("leaves oversized blocks plain instead of stalling ssr", async () => {
		const html = await render("```rust\n" + "x".repeat(210_000) + "\n```");
		expect(html).toContain('class="language-rust"');
		expect(html).not.toContain("--diffs-token-");
	});

	it("draws a mermaid block as an svg on the server", async () => {
		const html = await render("```mermaid\nflowchart LR\n  A --> B\n```");
		expect(html).toMatch(/^<div data-mermaid><svg [^]*<\/svg><\/div>/);
		expect(html).not.toContain("<pre");
		expect(html).not.toContain("--diffs-token-");
	});

	it("keeps the source line on a drawn mermaid block", async () => {
		const html = await render("```mermaid\nflowchart LR\n  A --> B\n```", {
			sourceLines: true
		});
		expect(html).toContain('<div data-line="0" data-mermaid><svg ');
	});

	it("draws the same svg for the same source", async () => {
		const source = "```mermaid\nsequenceDiagram\n  A->>B: hi\n```";
		expect(await render(source)).toBe(await render(source));
	});

	it("leaves a mermaid block that does not parse as a pre", async () => {
		const html = await render("```mermaid\nnot a diagram --> at all\n```", {
			sourceLines: true
		});
		expect(html).toContain(
			'<pre data-line="0" class="mermaid">not a diagram --&gt; at all\n</pre>'
		);
		expect(html).not.toContain("<svg");
	});

	it("leaves an oversized mermaid block as a pre", async () => {
		const label = "x".repeat(20_000);
		const html = await render(`\`\`\`mermaid\nflowchart LR\n  A[${label}] --> B\n\`\`\``);
		expect(html).toContain('<pre class="mermaid">');
		expect(html).not.toContain("<svg");
	});

	it("draws only the first few mermaid blocks of a document", async () => {
		const block = "```mermaid\nflowchart LR\n  A --> B\n```\n\n";
		const html = await render(block.repeat(12));
		expect(html.match(/<svg /g)).toHaveLength(10);
		expect(html.match(/<pre class="mermaid">/g)).toHaveLength(2);
	});

	it("stops drawing once a document's mermaid source adds up", async () => {
		const label = "x".repeat(15_000);
		const block = `\`\`\`mermaid\nflowchart LR\n  A[${label}] --> B\n\`\`\`\n\n`;
		const html = await render(block.repeat(5));
		expect(html.match(/<svg /g)).toHaveLength(3);
		expect(html.match(/<pre class="mermaid">/g)).toHaveLength(2);
	});

	it("does not let a diagram's text become markup", async () => {
		const source =
			'flowchart LR\n  A["<script>alert(1)</script><img src=x onerror=alert(2)>"] --> B';
		const html = await render("```mermaid\n" + source + "\n```");
		expect(html).toContain("<svg ");
		expect(html).not.toContain("<script");
		expect(html).not.toContain("<img");
		expect(html).not.toContain("onerror=");
	});

	it("does not draw a mermaid pre that carries markup of its own", async () => {
		const html = await render(
			'<pre class="mermaid">flowchart LR\n<b onclick="x()">A</b> --> B</pre>'
		);
		expect(html).not.toContain("<svg");
		expect(html).not.toContain("onclick");
	});

	it("does not let highlighted code smuggle styles past the sanitizer", async () => {
		const html = await render("```rust\nfn main() {}\n```");
		expect(html).not.toContain("background-color");
		expect(html).not.toContain("shiki");
	});

	it("strips scripts, event handlers and javascript urls", async () => {
		expect(await render("<script>alert(1)</script>")).not.toContain("alert");
		expect(await render('<img src="x" onerror="alert(1)">')).not.toContain("onerror");
		// markdown-it refuses the destination outright, so this stays plain text
		expect(await render("[x](javascript:alert(1))")).not.toContain("<a");
		expect(await render('<a href="data:text/html,hi">x</a>')).not.toContain("data:");
	});

	it("drops classes it does not recognise", async () => {
		expect(await render('<div class="fixed inset-0 bg-black">boo</div>')).not.toContain(
			"inset-0"
		);
	});

	it("drops inputs that are not task list checkboxes", async () => {
		expect(await render('<input type="password" name="p">')).not.toContain("<input");
	});

	it("keeps the html a readme actually uses for layout", async () => {
		const html = await render(
			'<div align="center"><h1>infra</h1></div>\n\n<details><summary>more</summary>\n\nhidden\n\n</details>'
		);
		expect(html).toContain('<div align="center">');
		expect(html).toContain("<details><summary>more</summary>");
	});

	it("tags blocks with their source line only when asked", async () => {
		const source = "# Title\n\ntext\n\n- one\n- two\n\n```\ncode\n```";
		const lines = await render(source, { sourceLines: true });
		expect(lines).toContain('<h1 id="title" data-line="0">');
		expect(lines).toContain('<p data-line="2">');
		expect(lines).toContain('<li data-line="5">');
		expect(lines).toContain('data-line="7"');
		expect(await render(source)).not.toContain("data-line");
	});

	it("drops source lines written as raw html", async () => {
		expect(await render('<p data-line="9">x</p>')).not.toContain("data-line");
	});
});