/** * Tests for the HTML → Markdown emitter. Each case is verified two ways: * 1. The emitted Markdown matches expectations (substring / regex / exact). * 2. The emitted Markdown is fed through `marked` (a real CommonMark/GFM * parser, also a runtime dependency) and the resulting HTML is asserted * to round-trip the structural intent. This is the closest "simulate * real" check we can do without spinning up Hugo / 11ty / Obsidian. */ import { describe, it, expect, beforeAll } from "vitest"; import { marked } from "marked"; import { htmlToMarkdown } from "../markdown-roundtrip"; beforeAll(() => { marked.setOptions({ async: false, gfm: true, breaks: false }); }); function mdToDoc(md: string): Document { const html = marked.parse(md) as string; return new DOMParser().parseFromString(`${html}`, "text/html"); } function roundtripTags(html: string): string[] { const md = htmlToMarkdown(html); const doc = mdToDoc(md); return Array.from(doc.body.children).map((c) => c.tagName.toLowerCase()); } describe("htmlToMarkdown — headings", () => { it("emits ATX headings for h1–h6", () => { for (let lvl = 1; lvl <= 6; lvl++) { const md = htmlToMarkdown(`Title ${lvl}`); expect(md).toContain(`${"#".repeat(lvl)} Title ${lvl}`); const doc = mdToDoc(md); const h = doc.body.querySelector(`h${lvl}`); expect(h?.textContent).toBe(`Title ${lvl}`); } }); }); describe("htmlToMarkdown — paragraphs and inline marks", () => { it("renders **strong**, *em*, ~~del~~", () => { const md = htmlToMarkdown( "

bold it gone

", ); expect(md).toMatch(/\*\*bold\*\*/); expect(md).toMatch(/\*it\*/); expect(md).toMatch(/~~gone~~/); const doc = mdToDoc(md); expect(doc.body.querySelector("strong")?.textContent).toBe("bold"); expect(doc.body.querySelector("em")?.textContent).toBe("it"); expect(doc.body.querySelector("del,s")?.textContent).toBe("gone"); }); it("escapes literal *, _, ~ in plain text so they don't become marks", () => { const md = htmlToMarkdown("

5 * 3 = 15 and _foo_ and ~~bar~~ literal

"); // None of these should round-trip as //. const doc = mdToDoc(md); expect(doc.body.querySelector("em")).toBeNull(); expect(doc.body.querySelector("strong")).toBeNull(); expect(doc.body.querySelector("del,s")).toBeNull(); expect(doc.body.textContent).toContain("5 * 3 = 15"); expect(doc.body.textContent).toContain("_foo_"); expect(doc.body.textContent).toContain("~~bar~~"); }); }); describe("htmlToMarkdown — inline code with backticks", () => { it("handles content containing a single backtick with a longer fence", () => { const md = htmlToMarkdown("

see a `b` c done

"); const doc = mdToDoc(md); const code = doc.body.querySelector("code"); expect(code).not.toBeNull(); // The textContent must include the literal backticks, not be split by them. expect(code!.textContent).toContain("`b`"); }); it("handles content starting and ending with a backtick", () => { const md = htmlToMarkdown("

`tick`

"); const doc = mdToDoc(md); const code = doc.body.querySelector("code"); expect(code!.textContent?.trim()).toBe("`tick`"); }); }); describe("htmlToMarkdown — fenced code blocks", () => { it("uses the language hint from class=language-*", () => { const md = htmlToMarkdown( '
const x: number = 1;
', ); expect(md).toMatch(/```ts\nconst x: number = 1;\n```/); const doc = mdToDoc(md); expect(doc.body.querySelector("pre code")?.textContent?.trim()).toBe( "const x: number = 1;", ); }); it("uses a longer fence when the body contains triple backticks", () => { const md = htmlToMarkdown( "
before\n```\nfake fence inside\n```\nafter
", ); // Body still has the original triple backticks; the outer fence is longer. expect(md).toContain("```\nfake fence inside\n```"); // Outer fence is at least 4 backticks long. expect(md).toMatch(/````+\n[\s\S]*```\nfake fence inside\n```/); const doc = mdToDoc(md); const code = doc.body.querySelector("pre code"); expect(code?.textContent).toContain("fake fence inside"); expect(code?.textContent).toContain("before"); expect(code?.textContent).toContain("after"); }); }); describe("htmlToMarkdown — links and images", () => { it("emits a plain [text](url) for simple links", () => { const md = htmlToMarkdown( '

site

', ); expect(md).toMatch(/\[site\]\(https:\/\/example\.com\)/); const a = mdToDoc(md).body.querySelector("a"); expect(a?.getAttribute("href")).toBe("https://example.com"); expect(a?.textContent).toBe("site"); }); it("wraps URLs containing spaces or parens with <…>", () => { const md = htmlToMarkdown( '

click

', ); expect(md).toContain("()"); // marked URL-encodes spaces during HTML rendering — verify the decoded // form so we're testing the source-of-truth (the markdown the user pastes // into Hugo/Obsidian preserves the unencoded URL). const a = mdToDoc(md).body.querySelector("a"); expect(decodeURI(a?.getAttribute("href") ?? "")).toBe( "https://example.com/foo (bar)/baz", ); }); it("escapes quotes and backslashes inside the title", () => { const md = htmlToMarkdown( '

t

', ); // Title quotes are backslash-escaped, so the link form remains valid. expect(md).toMatch(/"say \\"hi\\" \\\\back"/); const a = mdToDoc(md).body.querySelector("a"); expect(a?.getAttribute("title")).toBe('say "hi" \\back'); }); it("escapes [ and ] inside link text", () => { const md = htmlToMarkdown( '

[bracketed] text

', ); expect(md).toMatch(/\\\[bracketed\\\] text/); const a = mdToDoc(md).body.querySelector("a"); expect(a?.textContent).toBe("[bracketed] text"); }); it("renders images with alt, src and optional title", () => { const md = htmlToMarkdown( '

hello

', ); expect(md).toMatch(/!\[hello\]\(https:\/\/x\.com\/a\.png "t"\)/); const img = mdToDoc(md).body.querySelector("img"); expect(img?.getAttribute("src")).toBe("https://x.com/a.png"); expect(img?.getAttribute("alt")).toBe("hello"); expect(img?.getAttribute("title")).toBe("t"); }); it("preserves nested inside (badge-style link)", () => { const md = htmlToMarkdown( '

badge

', ); const doc = mdToDoc(md); const a = doc.body.querySelector("a"); expect(a?.getAttribute("href")).toBe( "https://repo.example/owner/proj", ); // The image must still be a child of the , not flattened to text. const img = a?.querySelector("img"); expect(img).not.toBeNull(); expect(img?.getAttribute("src")).toBe("https://shields.io/badge.svg"); expect(img?.getAttribute("alt")).toBe("badge"); }); it("preserves nested inside ", () => { const md = htmlToMarkdown( '

foo

', ); const doc = mdToDoc(md); const a = doc.body.querySelector("a"); const code = a?.querySelector("code"); expect(code).not.toBeNull(); expect(code?.textContent).toBe("foo"); // No stray backslashes inside the code span. expect(code?.textContent).not.toContain("\\"); }); }); describe("htmlToMarkdown — line-start block-marker escaping", () => { it("escapes a paragraph that starts with '1. ' so it stays a paragraph", () => { const tags = roundtripTags("

1. First item, not a list

"); expect(tags).toEqual(["p"]); }); it("escapes a paragraph starting with '# '", () => { const tags = roundtripTags("

# Not a heading

"); expect(tags).toEqual(["p"]); }); it("escapes a paragraph starting with '> '", () => { const tags = roundtripTags("

> Not a quote

"); expect(tags).toEqual(["p"]); }); it("escapes a paragraph starting with '- '", () => { const tags = roundtripTags("

- Not a bullet

"); expect(tags).toEqual(["p"]); }); it("does NOT escape mid-text markers", () => { // "#hashtag" mid-text is not a heading and should not be escaped. const md = htmlToMarkdown("

see #hashtag here

"); expect(md).not.toMatch(/\\#hashtag/); }); }); describe("htmlToMarkdown — lists", () => { it("renders a simple unordered list", () => { const md = htmlToMarkdown("
  • a
  • b
"); const doc = mdToDoc(md); const items = Array.from(doc.body.querySelectorAll("ul > li")).map( (li) => li.textContent?.trim(), ); expect(items).toEqual(["a", "b"]); }); it("renders a nested list with correct indentation", () => { const md = htmlToMarkdown( "
  • outer
    • inner-a
    • inner-b
  • other
", ); const doc = mdToDoc(md); const outer = doc.body.querySelector("ul"); const innerLis = outer ?.querySelector("li > ul") ?.querySelectorAll(":scope > li"); expect(innerLis?.length).toBe(2); expect(innerLis?.[0].textContent?.trim()).toBe("inner-a"); }); it("renders a list item with a paragraph and a code block inside", () => { const md = htmlToMarkdown( "
  1. step one

    x++;
  2. step two
", ); const doc = mdToDoc(md); const lis = doc.body.querySelectorAll("ol > li"); expect(lis.length).toBe(2); // First
  • contains both a paragraph and a fenced code block. expect(lis[0].querySelector("p")?.textContent?.trim()).toBe("step one"); expect(lis[0].querySelector("pre code")?.textContent?.trim()).toBe("x++;"); expect(lis[1].textContent?.trim()).toBe("step two"); }); }); describe("htmlToMarkdown — blockquotes, hr, tables, br", () => { it("renders blockquotes with > prefix", () => { const md = htmlToMarkdown( "

    line one

    line two

    ", ); const doc = mdToDoc(md); const bq = doc.body.querySelector("blockquote"); expect(bq).not.toBeNull(); expect(bq?.textContent).toMatch(/line one/); expect(bq?.textContent).toMatch(/line two/); }); it("renders a horizontal rule", () => { const md = htmlToMarkdown("

    before


    after

    "); const doc = mdToDoc(md); expect(doc.body.querySelector("hr")).not.toBeNull(); }); it("renders a GFM table", () => { const md = htmlToMarkdown( "
    AB
    12
    34
    ", ); const doc = mdToDoc(md); const headers = Array.from(doc.body.querySelectorAll("thead th")).map( (th) => th.textContent?.trim(), ); expect(headers).toEqual(["A", "B"]); const rows = Array.from(doc.body.querySelectorAll("tbody tr")).map((tr) => Array.from(tr.querySelectorAll("td")).map((td) => td.textContent?.trim()), ); expect(rows).toEqual([ ["1", "2"], ["3", "4"], ]); }); it("escapes pipes inside table cells", () => { const md = htmlToMarkdown( "
    a|b
    c|d
    ", ); expect(md).toMatch(/a\\\|b/); expect(md).toMatch(/c\\\|d/); }); it("keeps a cell containing
    on a single row (no smashed table)", () => { const md = htmlToMarkdown( "
    H
    line1
    line2
    next
    ", ); const doc = mdToDoc(md); const trs = doc.body.querySelectorAll("table tr"); // Header + 2 body rows = 3 total; if the
    had injected a literal \n, // marked would see only the header and the table would collapse. expect(trs.length).toBe(3); const firstBodyCell = doc.body.querySelector("tbody tr:first-child td"); expect(firstBodyCell?.textContent).toContain("line1"); expect(firstBodyCell?.textContent).toContain("line2"); expect(firstBodyCell?.querySelector("br")).not.toBeNull(); }); }); describe("htmlToMarkdown — full document smoke", () => { it("round-trips a deck-style document end-to-end", () => { const html = `

    Demo Deck

    An intro paragraph with bold and a tricky link.

    Steps

    1. Install
      npm i
    2. Run with a foo \`bar\` baz snippet

    Quote with 1. literal numeric prefix.

    `; const md = htmlToMarkdown(html); const doc = mdToDoc(md); expect(doc.body.querySelector("h1")?.textContent).toBe("Demo Deck"); expect(doc.body.querySelector("h2")?.textContent).toBe("Steps"); expect(doc.body.querySelector("ol > li pre code")?.textContent).toContain( "npm i", ); expect(doc.body.querySelector("blockquote p")?.textContent).toMatch( /1\. literal numeric prefix/, ); // Tricky-link href survives the angle-bracket wrap (decoded form, // since marked percent-encodes spaces in the rendered HTML). const a = doc.body.querySelector("a"); expect(decodeURI(a?.getAttribute("href") ?? "")).toBe( "https://example.com/page (v2)", ); }); });