import { describe, expect, it } from "vitest"; import { renderPageMarkdown, exportBlockText, exportStripHtml, sortByPosition, exportFilename, type ExportBlockRow } from "./page-export.js"; const b = (over: Partial & { type: string }): ExportBlockRow => ({ id: "x", content: {}, properties: {}, parentId: null, position: "a0", ...over }); describe("exportStripHtml / exportBlockText", () => { it("reads the canonical {text} object", () => { expect(exportBlockText({ text: "hello" })).toBe("hello"); }); it("reads the web editor's HTML-string storage shape (stripHtml)", () => { expect(exportBlockText("

hi there

")).toBe("hi there"); expect(exportBlockText("

line1

line2

")).toBe("line1 line2"); }); it("reads {title} and falls back to empty for unknown shapes", () => { expect(exportBlockText({ title: "T" })).toBe("T"); expect(exportBlockText({})).toBe(""); expect(exportBlockText(null)).toBe(""); }); it("stripHtml unescapes entities", () => { expect(exportStripHtml("&<>"'")).toBe("&<>\"'"); }); }); describe("sortByPosition", () => { it("orders lexicographically (fractional positions, never numeric)", () => { const rows = [b({ type: "PARAGRAPH", position: "b1", id: "3" }), b({ type: "PARAGRAPH", position: "a0", id: "1" }), b({ type: "PARAGRAPH", position: "a1", id: "2" })]; expect(sortByPosition(rows).map((r) => r.id)).toEqual(["1", "2", "3"]); }); }); describe("exportFilename", () => { it("sanitizes path separators, control chars and runs of spaces", () => { expect(exportFilename("My: Page? #1")).toBe("My-Page-1.md"); expect(exportFilename("a/b\\c*d")).toBe("abcd.md"); }); it("falls back to `page` for blank titles", () => { expect(exportFilename(" ")).toBe("page.md"); }); it("caps the stem length", () => { expect(exportFilename("x".repeat(200)).length).toBe(80 + 3); // 80 + ".md" }); }); describe("renderPageMarkdown", () => { const block = (id: string, type: string, content: unknown, over: Partial = {}): ExportBlockRow => b({ id, type, content, ...over }); it("renders a title + description + text blocks in position order", () => { const md = renderPageMarkdown({ title: "Doc", icon: "📄", description: "The intro", blocks: [ block("h", "HEADING_1", "

Section

"), // live rows store HTML strings (web editor storage) block("p", "PARAGRAPH", "

Body text

") ] }); expect(md).toContain("# 📄 Doc"); expect(md).toContain("The intro"); // literal level mapping: a body HEADING_1 is `# ` (the page title is also // `# ` — a plain export, no level shifting; recorded design choice) expect(md).toContain("# Section"); expect(md).toContain("Body text"); // position order: heading (a0) before paragraph (a1) expect(md.indexOf("Section")).toBeLessThan(md.indexOf("Body text")); }); it("renders lists, todos, quotes and code fences", () => { const md = renderPageMarkdown({ title: "T", icon: null, description: null, blocks: [ block("1", "BULLETED_LIST", "

one

", { position: "a0" }), block("2", "NUMBERED_LIST", "

two

", { position: "a1" }), block("3", "TODO", "

task

", { position: "a2", properties: { checked: true } }), block("4", "QUOTE", "

a quote

", { position: "a3" }), block("5", "CODE", "

const x = 1;

", { position: "a4", properties: { language: "js" } }) ] }); expect(md).toContain("- one"); expect(md).toContain("1. two"); expect(md).toContain("- [x] task"); expect(md).toContain("> a quote"); expect(md).toContain("```js\nconst x = 1;\n```"); }); it("nesting: TOGGLE children are indented under their parent", () => { const md = renderPageMarkdown({ title: "T", icon: null, description: null, blocks: [ block("t", "TOGGLE", "

Show more

", { position: "a0" }), block("c1", "PARAGRAPH", "

child line

", { position: "a0", parentId: "t" }) ] }); const head = md.indexOf("**Show more**"); const child = md.indexOf("child line"); expect(head).toBeGreaterThanOrEqual(0); expect(child).toBeGreaterThan(head); // the child is indented (two leading spaces = one nesting level) expect(md.slice(child - 2, child)).toBe(" "); expect(md.slice(child - 3, child - 2)).toBe("\n"); }); it("non-text blocks degrade to stable placeholders (never dropped)", () => { const md = renderPageMarkdown({ title: "T", icon: null, description: null, blocks: [ block("i", "IMAGE", {}, { position: "a0" }), block("b", "BOOKMARK", {}, { position: "a1", properties: { src: "https://x.com", title: "X" } }), block("d", "DIVIDER", {}, { position: "a2" }) ] }); // labels are Capitalized (IMAGE→Image, BOOKMARK→Bookmark) — stable, never dropped expect(md).toContain("*[Image]*"); expect(md).toContain("*[Bookmark: X (https://x.com)]*"); expect(md).toContain("---"); }); it("includeChildren renders sub-pages under a ## heading; without it, they are omitted", () => { const withChildren = renderPageMarkdown({ title: "Parent", icon: null, description: null, blocks: [block("p", "PARAGRAPH", { text: "parent body" })], children: [{ id: "c", title: "Child Page", blocks: [block("cp", "PARAGRAPH", { text: "child body" })] }], includeChildren: true }); expect(withChildren).toContain("## Child Page"); expect(withChildren).toContain("child body"); const without = renderPageMarkdown({ title: "Parent", icon: null, description: null, blocks: [block("p", "PARAGRAPH", { text: "parent body" })], children: [{ id: "c", title: "Child Page", blocks: [block("cp", "PARAGRAPH", { text: "child body" })] }], includeChildren: false }); expect(without).not.toContain("## Child Page"); expect(without).not.toContain("child body"); expect(without).toContain("parent body"); }); it("never drops a block: empty paragraphs render nothing but the file is still valid markdown", () => { const md = renderPageMarkdown({ title: "OnlyEmpty", icon: null, description: null, blocks: [block("s", "SPACER", {}, { position: "a0" })] }); expect(md.trim().length).toBeGreaterThan(0); // at least the title }); });