/** * 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(`bold it gone
5 * 3 = 15 and _foo_ and ~~bar~~ literal
"); // None of these should round-trip as //see a `b` c done
`tick`
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(
'',
);
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(
'',
);
expect(md).toContain("(
nested inside ", () => {
const md = htmlToMarkdown(
'',
);
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(
"step one
x++;
- 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(
"A B 1 2 3 4
",
);
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
- Install
npm i
- 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)",
);
});
});