markdown.test.ts
⎇
Raw
1import { describe, expect, test } from "bun:test";
2import {
3 markdownToPlaintext,
4 renderMarkdown,
5 resolveMarkdownHref,
6} from "../src/services/markdown.ts";
7
8describe("resolveMarkdownHref", () => {
9 describe("protocol-absolute URLs (returns null)", () => {
10 test("http", () => {
11 expect(resolveMarkdownHref("", "http://example.com/img.png")).toBeNull();
12 expect(resolveMarkdownHref("docs", "http://example.com/img.png")).toBeNull();
13 });
14 test("https", () => {
15 expect(resolveMarkdownHref("", "https://example.com/img.png")).toBeNull();
16 });
17 test("ftp", () => {
18 expect(resolveMarkdownHref("", "ftp://files.example.com/")).toBeNull();
19 });
20 test("mailto", () => {
21 expect(resolveMarkdownHref("", "mailto:foo@bar.com")).toBeNull();
22 });
23 test("data", () => {
24 expect(resolveMarkdownHref("", "data:image/png;base64,abc123")).toBeNull();
25 });
26 test("ssh", () => {
27 expect(resolveMarkdownHref("", "ssh://git@example.com/repo.git")).toBeNull();
28 });
29 test("custom protocol", () => {
30 expect(resolveMarkdownHref("", "myapp://open/something")).toBeNull();
31 });
32 });
33
34 describe("anchor links (returns null)", () => {
35 test("simple anchor", () => {
36 expect(resolveMarkdownHref("", "#section-heading")).toBeNull();
37 });
38 test("anchor with any dir", () => {
39 expect(resolveMarkdownHref("docs/guide", "#toc")).toBeNull();
40 });
41 test("bare hash", () => {
42 expect(resolveMarkdownHref("", "#")).toBeNull();
43 });
44 });
45
46 describe("root-relative URLs (strips leading /)", () => {
47 test("single file at root", () => {
48 expect(resolveMarkdownHref("", "/img.png")).toBe("img.png");
49 });
50 test("subdir file — dir is ignored", () => {
51 expect(resolveMarkdownHref("docs", "/subdir/img.png")).toBe("subdir/img.png");
52 });
53 test("deep path — any dir is ignored", () => {
54 expect(resolveMarkdownHref("x/y/z", "/a/b/c.png")).toBe("a/b/c.png");
55 });
56 test("root-relative with no filename (trailing slash)", () => {
57 expect(resolveMarkdownHref("docs", "/assets/")).toBe("assets/");
58 });
59 });
60
61 describe("path-relative from root dir (dir = '')", () => {
62 test("bare filename", () => {
63 expect(resolveMarkdownHref("", "img.png")).toBe("img.png");
64 });
65 test("./filename", () => {
66 expect(resolveMarkdownHref("", "./img.png")).toBe("img.png");
67 });
68 test("subdir/file", () => {
69 expect(resolveMarkdownHref("", "subdir/img.png")).toBe("subdir/img.png");
70 });
71 test("./subdir/file", () => {
72 expect(resolveMarkdownHref("", "./subdir/img.png")).toBe("subdir/img.png");
73 });
74 test("../ from root clamps to root", () => {
75 // URL API resolves http://x/../img.png → http://x/img.png
76 expect(resolveMarkdownHref("", "../img.png")).toBe("img.png");
77 });
78 });
79
80 describe("path-relative from one-level dir (dir = 'docs')", () => {
81 test("bare filename", () => {
82 expect(resolveMarkdownHref("docs", "img.png")).toBe("docs/img.png");
83 });
84 test("./filename", () => {
85 expect(resolveMarkdownHref("docs", "./img.png")).toBe("docs/img.png");
86 });
87 test("../ traversal to root", () => {
88 expect(resolveMarkdownHref("docs", "../img.png")).toBe("img.png");
89 });
90 test("subdir/file", () => {
91 expect(resolveMarkdownHref("docs", "subdir/img.png")).toBe("docs/subdir/img.png");
92 });
93 test("./subdir/file", () => {
94 expect(resolveMarkdownHref("docs", "./subdir/img.png")).toBe("docs/subdir/img.png");
95 });
96 });
97
98 describe("path-relative from nested dir (dir = 'docs/guide')", () => {
99 test("bare filename", () => {
100 expect(resolveMarkdownHref("docs/guide", "img.png")).toBe("docs/guide/img.png");
101 });
102 test("one level up", () => {
103 expect(resolveMarkdownHref("docs/guide", "../img.png")).toBe("docs/img.png");
104 });
105 test("two levels up", () => {
106 expect(resolveMarkdownHref("docs/guide", "../../img.png")).toBe("img.png");
107 });
108 test("sibling directory", () => {
109 expect(resolveMarkdownHref("docs/guide", "../assets/img.png")).toBe("docs/assets/img.png");
110 });
111 test("./filename", () => {
112 expect(resolveMarkdownHref("docs/guide", "./img.png")).toBe("docs/guide/img.png");
113 });
114 test("deeper subdir", () => {
115 expect(resolveMarkdownHref("docs/guide", "sub/img.png")).toBe("docs/guide/sub/img.png");
116 });
117 });
118});
119
120describe("renderMarkdown", () => {
121 test("GFM table with column alignment", () => {
122 expect(renderMarkdown("| a | b |\n|---|:-:|\n| 1 | 2 |")).toBe(
123 '<table>\n<thead>\n<tr><th>a</th><th align="center">b</th></tr>\n</thead>\n' +
124 "<tbody>\n<tr><td>1</td><td align=\"center\">2</td></tr>\n</tbody>\n</table>\n",
125 );
126 });
127
128 test("GFM strikethrough", () => {
129 expect(renderMarkdown("~~gone~~")).toBe("<p><del>gone</del></p>\n");
130 });
131
132 test("GFM task list keeps the checkbox state through the sanitizer", () => {
133 const html = renderMarkdown("- [x] done\n- [ ] todo");
134 expect(html).toContain('<input type="checkbox"');
135 expect(html).toContain("checked");
136 expect(html).toContain("disabled");
137 // The CSS hooks these class names for spacing.
138 expect(html).toContain('class="task-list-item"');
139 expect(html).toContain("task-list-item-checkbox");
140 });
141
142 test("bare URLs are autolinked", () => {
143 expect(renderMarkdown("see https://example.com ok")).toBe(
144 '<p>see <a href="https://example.com">https://example.com</a> ok</p>\n',
145 );
146 });
147
148 test("fenced code keeps its language class", () => {
149 expect(renderMarkdown("```js\nlet x=1;\n```")).toBe(
150 '<pre><code class="language-js">let x=1;\n</code></pre>\n',
151 );
152 });
153
154 test("details/summary survive sanitization", () => {
155 expect(renderMarkdown("<details><summary>s</summary>body</details>")).toBe(
156 "<details><summary>s</summary>body</details>\n",
157 );
158 });
159
160 test("script tags and event handlers are stripped", () => {
161 expect(renderMarkdown("<script>alert(1)</script><img src=x onerror=alert(1)>")).toBe(
162 '<img src="x">\n',
163 );
164 });
165
166 test("javascript: links are stripped", () => {
167 expect(renderMarkdown("[x](javascript:alert(1))")).not.toContain("javascript:");
168 });
169
170 test("repo context rewrites relative links and images only", () => {
171 const ctx = { repo: "myrepo", ref: "main", dir: "docs" };
172 expect(
173 renderMarkdown(
174 "[r](./a.md) [abs](/b.md) [ext](https://e.com) [an](#s) ![i](./x.png)",
175 undefined,
176 ctx,
177 ),
178 ).toBe(
179 '<p><a href="/myrepo/blob/main/docs/a.md">r</a>' +
180 ' <a href="/myrepo/blob/main/b.md">abs</a>' +
181 ' <a href="https://e.com">ext</a>' +
182 ' <a href="#s">an</a>' +
183 ' <img src="/myrepo/raw/main/docs/x.png" alt="i"></p>\n',
184 );
185 });
186
187 test("without repo context relative URLs are left alone", () => {
188 expect(renderMarkdown("[r](./a.md)")).toBe('<p><a href="./a.md">r</a></p>\n');
189 });
190});
191
192describe("markdownToPlaintext", () => {
193 test("unordered list", () => {
194 expect(markdownToPlaintext("- a\n- b")).toBe("- a\n- b");
195 });
196
197 test("ordered list keeps its start number", () => {
198 expect(markdownToPlaintext("3. a\n4. b")).toBe("3. a\n4. b");
199 });
200
201 test("task list renders markers, not raw checkbox HTML", () => {
202 expect(markdownToPlaintext("- [x] done\n- [ ] todo\n- plain")).toBe(
203 "- [x] done\n- [ ] todo\n- plain",
204 );
205 });
206
207 test("table cells are pipe-joined", () => {
208 expect(markdownToPlaintext("| a | b |\n|---|---|\n| 1 | 2 |")).toBe("a | b\n1 | 2");
209 });
210
211 test("blockquote keeps its marker", () => {
212 expect(markdownToPlaintext("> one\n> two")).toBe("> one\n> two");
213 });
214
215 test("inline markup is unwrapped, images become a label", () => {
216 expect(
217 markdownToPlaintext(
218 "# Title\n\nSome **bold** [link](https://e.com) and `code`.\n\n---\n\n![alt](x.png)",
219 ),
220 ).toBe("Title\n\nSome bold link and `code`.\n\n---\n\n[Image: alt]");
221 });
222
223 test("block and inline raw HTML are stripped", () => {
224 expect(markdownToPlaintext("a <b>b</b> c\n\n<div>d</div>")).toBe("a b c");
225 });
226
227 test("empty input", () => {
228 expect(markdownToPlaintext("")).toBe("");
229 });
230});
231