markdown.test.ts
| 1 | import { describe, expect, test } from "bun:test"; |
| 2 | import { |
| 3 | markdownToPlaintext, |
| 4 | renderMarkdown, |
| 5 | resolveMarkdownHref, |
| 6 | } from "../src/services/markdown.ts"; |
| 7 | |
| 8 | describe("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 | |
| 120 | describe("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) ", |
| 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 | |
| 192 | describe("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", |
| 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 |