1import { describe, expect, it } from "vitest";
2import {
3 renderPageMarkdown,
4 exportBlockText,
5 exportStripHtml,
6 sortByPosition,
7 exportFilename,
8 type ExportBlockRow
9} from "./page-export.js";
10
11const b = (over: Partial<ExportBlockRow> & { type: string }): ExportBlockRow => ({
12 id: "x",
13 content: {},
14 properties: {},
15 parentId: null,
16 position: "a0",
17 ...over
18});
19
20describe("exportStripHtml / exportBlockText", () => {
21 it("reads the canonical {text} object", () => {
22 expect(exportBlockText({ text: "hello" })).toBe("hello");
23 });
24 it("reads the web editor's HTML-string storage shape (stripHtml)", () => {
25 expect(exportBlockText("<p>hi <b>there</b></p>")).toBe("hi there");
26 expect(exportBlockText("<p>line1</p><p>line2</p>")).toBe("line1 line2");
27 });
28 it("reads {title} and falls back to empty for unknown shapes", () => {
29 expect(exportBlockText({ title: "T" })).toBe("T");
30 expect(exportBlockText({})).toBe("");
31 expect(exportBlockText(null)).toBe("");
32 });
33 it("stripHtml unescapes entities", () => {
34 expect(exportStripHtml("&<>"'")).toBe("&<>\"'");
35 });
36});
37
38describe("sortByPosition", () => {
39 it("orders lexicographically (fractional positions, never numeric)", () => {
40 const rows = [b({ type: "PARAGRAPH", position: "b1", id: "3" }), b({ type: "PARAGRAPH", position: "a0", id: "1" }), b({ type: "PARAGRAPH", position: "a1", id: "2" })];
41 expect(sortByPosition(rows).map((r) => r.id)).toEqual(["1", "2", "3"]);
42 });
43});
44
45describe("exportFilename", () => {
46 it("sanitizes path separators, control chars and runs of spaces", () => {
47 expect(exportFilename("My: Page? #1")).toBe("My-Page-1.md");
48 expect(exportFilename("a/b\\c*d")).toBe("abcd.md");
49 });
50 it("falls back to `page` for blank titles", () => {
51 expect(exportFilename(" ")).toBe("page.md");
52 });
53 it("caps the stem length", () => {
54 expect(exportFilename("x".repeat(200)).length).toBe(80 + 3);
55 });
56});
57
58describe("renderPageMarkdown", () => {
59 const block = (id: string, type: string, content: unknown, over: Partial<ExportBlockRow> = {}): ExportBlockRow =>
60 b({ id, type, content, ...over });
61
62 it("renders a title + description + text blocks in position order", () => {
63 const md = renderPageMarkdown({
64 title: "Doc",
65 icon: "📄",
66 description: "The intro",
67 blocks: [
68 block("h", "HEADING_1", "<p>Section</p>"),
69 block("p", "PARAGRAPH", "<p>Body text</p>")
70 ]
71 });
72 expect(md).toContain("# 📄 Doc");
73 expect(md).toContain("The intro");
74
75
76 expect(md).toContain("# Section");
77 expect(md).toContain("Body text");
78
79 expect(md.indexOf("Section")).toBeLessThan(md.indexOf("Body text"));
80 });
81
82 it("renders lists, todos, quotes and code fences", () => {
83 const md = renderPageMarkdown({
84 title: "T",
85 icon: null,
86 description: null,
87 blocks: [
88 block("1", "BULLETED_LIST", "<p>one</p>", { position: "a0" }),
89 block("2", "NUMBERED_LIST", "<p>two</p>", { position: "a1" }),
90 block("3", "TODO", "<p>task</p>", { position: "a2", properties: { checked: true } }),
91 block("4", "QUOTE", "<p>a quote</p>", { position: "a3" }),
92 block("5", "CODE", "<p>const x = 1;</p>", { position: "a4", properties: { language: "js" } })
93 ]
94 });
95 expect(md).toContain("- one");
96 expect(md).toContain("1. two");
97 expect(md).toContain("- [x] task");
98 expect(md).toContain("> a quote");
99 expect(md).toContain("```js\nconst x = 1;\n```");
100 });
101
102 it("nesting: TOGGLE children are indented under their parent", () => {
103 const md = renderPageMarkdown({
104 title: "T",
105 icon: null,
106 description: null,
107 blocks: [
108 block("t", "TOGGLE", "<p>Show more</p>", { position: "a0" }),
109 block("c1", "PARAGRAPH", "<p>child line</p>", { position: "a0", parentId: "t" })
110 ]
111 });
112 const head = md.indexOf("**Show more**");
113 const child = md.indexOf("child line");
114 expect(head).toBeGreaterThanOrEqual(0);
115 expect(child).toBeGreaterThan(head);
116
117 expect(md.slice(child - 2, child)).toBe(" ");
118 expect(md.slice(child - 3, child - 2)).toBe("\n");
119 });
120
121 it("non-text blocks degrade to stable placeholders (never dropped)", () => {
122 const md = renderPageMarkdown({
123 title: "T",
124 icon: null,
125 description: null,
126 blocks: [
127 block("i", "IMAGE", {}, { position: "a0" }),
128 block("b", "BOOKMARK", {}, { position: "a1", properties: { src: "https://x.com", title: "X" } }),
129 block("d", "DIVIDER", {}, { position: "a2" })
130 ]
131 });
132
133 expect(md).toContain("*[Image]*");
134 expect(md).toContain("*[Bookmark: X (https://x.com)]*");
135 expect(md).toContain("---");
136 });
137
138 it("includeChildren renders sub-pages under a ## heading; without it, they are omitted", () => {
139 const withChildren = renderPageMarkdown({
140 title: "Parent",
141 icon: null,
142 description: null,
143 blocks: [block("p", "PARAGRAPH", { text: "parent body" })],
144 children: [{ id: "c", title: "Child Page", blocks: [block("cp", "PARAGRAPH", { text: "child body" })] }],
145 includeChildren: true
146 });
147 expect(withChildren).toContain("## Child Page");
148 expect(withChildren).toContain("child body");
149
150 const without = renderPageMarkdown({
151 title: "Parent",
152 icon: null,
153 description: null,
154 blocks: [block("p", "PARAGRAPH", { text: "parent body" })],
155 children: [{ id: "c", title: "Child Page", blocks: [block("cp", "PARAGRAPH", { text: "child body" })] }],
156 includeChildren: false
157 });
158 expect(without).not.toContain("## Child Page");
159 expect(without).not.toContain("child body");
160 expect(without).toContain("parent body");
161 });
162
163 it("never drops a block: empty paragraphs render nothing but the file is still valid markdown", () => {
164 const md = renderPageMarkdown({
165 title: "OnlyEmpty",
166 icon: null,
167 description: null,
168 blocks: [block("s", "SPACER", {}, { position: "a0" })]
169 });
170 expect(md.trim().length).toBeGreaterThan(0);
171 });
172});
173