import { describe, expect, test } from "bun:test"; import { markdownToPlaintext, renderMarkdown, resolveMarkdownHref, } from "../src/services/markdown.ts"; describe("resolveMarkdownHref", () => { describe("protocol-absolute URLs (returns null)", () => { test("http", () => { expect(resolveMarkdownHref("", "http://example.com/img.png")).toBeNull(); expect(resolveMarkdownHref("docs", "http://example.com/img.png")).toBeNull(); }); test("https", () => { expect(resolveMarkdownHref("", "https://example.com/img.png")).toBeNull(); }); test("ftp", () => { expect(resolveMarkdownHref("", "ftp://files.example.com/")).toBeNull(); }); test("mailto", () => { expect(resolveMarkdownHref("", "mailto:foo@bar.com")).toBeNull(); }); test("data", () => { expect(resolveMarkdownHref("", "data:image/png;base64,abc123")).toBeNull(); }); test("ssh", () => { expect(resolveMarkdownHref("", "ssh://git@example.com/repo.git")).toBeNull(); }); test("custom protocol", () => { expect(resolveMarkdownHref("", "myapp://open/something")).toBeNull(); }); }); describe("anchor links (returns null)", () => { test("simple anchor", () => { expect(resolveMarkdownHref("", "#section-heading")).toBeNull(); }); test("anchor with any dir", () => { expect(resolveMarkdownHref("docs/guide", "#toc")).toBeNull(); }); test("bare hash", () => { expect(resolveMarkdownHref("", "#")).toBeNull(); }); }); describe("root-relative URLs (strips leading /)", () => { test("single file at root", () => { expect(resolveMarkdownHref("", "/img.png")).toBe("img.png"); }); test("subdir file — dir is ignored", () => { expect(resolveMarkdownHref("docs", "/subdir/img.png")).toBe("subdir/img.png"); }); test("deep path — any dir is ignored", () => { expect(resolveMarkdownHref("x/y/z", "/a/b/c.png")).toBe("a/b/c.png"); }); test("root-relative with no filename (trailing slash)", () => { expect(resolveMarkdownHref("docs", "/assets/")).toBe("assets/"); }); }); describe("path-relative from root dir (dir = '')", () => { test("bare filename", () => { expect(resolveMarkdownHref("", "img.png")).toBe("img.png"); }); test("./filename", () => { expect(resolveMarkdownHref("", "./img.png")).toBe("img.png"); }); test("subdir/file", () => { expect(resolveMarkdownHref("", "subdir/img.png")).toBe("subdir/img.png"); }); test("./subdir/file", () => { expect(resolveMarkdownHref("", "./subdir/img.png")).toBe("subdir/img.png"); }); test("../ from root clamps to root", () => { // URL API resolves http://x/../img.png → http://x/img.png expect(resolveMarkdownHref("", "../img.png")).toBe("img.png"); }); }); describe("path-relative from one-level dir (dir = 'docs')", () => { test("bare filename", () => { expect(resolveMarkdownHref("docs", "img.png")).toBe("docs/img.png"); }); test("./filename", () => { expect(resolveMarkdownHref("docs", "./img.png")).toBe("docs/img.png"); }); test("../ traversal to root", () => { expect(resolveMarkdownHref("docs", "../img.png")).toBe("img.png"); }); test("subdir/file", () => { expect(resolveMarkdownHref("docs", "subdir/img.png")).toBe("docs/subdir/img.png"); }); test("./subdir/file", () => { expect(resolveMarkdownHref("docs", "./subdir/img.png")).toBe("docs/subdir/img.png"); }); }); describe("path-relative from nested dir (dir = 'docs/guide')", () => { test("bare filename", () => { expect(resolveMarkdownHref("docs/guide", "img.png")).toBe("docs/guide/img.png"); }); test("one level up", () => { expect(resolveMarkdownHref("docs/guide", "../img.png")).toBe("docs/img.png"); }); test("two levels up", () => { expect(resolveMarkdownHref("docs/guide", "../../img.png")).toBe("img.png"); }); test("sibling directory", () => { expect(resolveMarkdownHref("docs/guide", "../assets/img.png")).toBe("docs/assets/img.png"); }); test("./filename", () => { expect(resolveMarkdownHref("docs/guide", "./img.png")).toBe("docs/guide/img.png"); }); test("deeper subdir", () => { expect(resolveMarkdownHref("docs/guide", "sub/img.png")).toBe("docs/guide/sub/img.png"); }); }); }); describe("renderMarkdown", () => { test("GFM table with column alignment", () => { expect(renderMarkdown("| a | b |\n|---|:-:|\n| 1 | 2 |")).toBe( '\n\n\n\n' + "\n\n\n
ab
12
\n", ); }); test("GFM strikethrough", () => { expect(renderMarkdown("~~gone~~")).toBe("

gone

\n"); }); test("GFM task list keeps the checkbox state through the sanitizer", () => { const html = renderMarkdown("- [x] done\n- [ ] todo"); expect(html).toContain(' { expect(renderMarkdown("see https://example.com ok")).toBe( '

see https://example.com ok

\n', ); }); test("fenced code keeps its language class", () => { expect(renderMarkdown("```js\nlet x=1;\n```")).toBe( '
let x=1;\n
\n', ); }); test("details/summary survive sanitization", () => { expect(renderMarkdown("
sbody
")).toBe( "
sbody
\n", ); }); test("script tags and event handlers are stripped", () => { expect(renderMarkdown("")).toBe( '\n', ); }); test("javascript: links are stripped", () => { expect(renderMarkdown("[x](javascript:alert(1))")).not.toContain("javascript:"); }); test("repo context rewrites relative links and images only", () => { const ctx = { repo: "myrepo", ref: "main", dir: "docs" }; expect( renderMarkdown( "[r](./a.md) [abs](/b.md) [ext](https://e.com) [an](#s) ![i](./x.png)", undefined, ctx, ), ).toBe( '

r' + ' abs' + ' ext' + ' an' + ' i

\n', ); }); test("without repo context relative URLs are left alone", () => { expect(renderMarkdown("[r](./a.md)")).toBe('

r

\n'); }); }); describe("markdownToPlaintext", () => { test("unordered list", () => { expect(markdownToPlaintext("- a\n- b")).toBe("- a\n- b"); }); test("ordered list keeps its start number", () => { expect(markdownToPlaintext("3. a\n4. b")).toBe("3. a\n4. b"); }); test("task list renders markers, not raw checkbox HTML", () => { expect(markdownToPlaintext("- [x] done\n- [ ] todo\n- plain")).toBe( "- [x] done\n- [ ] todo\n- plain", ); }); test("table cells are pipe-joined", () => { expect(markdownToPlaintext("| a | b |\n|---|---|\n| 1 | 2 |")).toBe("a | b\n1 | 2"); }); test("blockquote keeps its marker", () => { expect(markdownToPlaintext("> one\n> two")).toBe("> one\n> two"); }); test("inline markup is unwrapped, images become a label", () => { expect( markdownToPlaintext( "# Title\n\nSome **bold** [link](https://e.com) and `code`.\n\n---\n\n![alt](x.png)", ), ).toBe("Title\n\nSome bold link and `code`.\n\n---\n\n[Image: alt]"); }); test("block and inline raw HTML are stripped", () => { expect(markdownToPlaintext("a b c\n\n
d
")).toBe("a b c"); }); test("empty input", () => { expect(markdownToPlaintext("")).toBe(""); }); });