1
19
20export interface ExportBlockRow {
21 id: string;
22 type: string;
23 content: unknown;
24 properties: unknown;
25 parentId: string | null;
26 position: string;
27}
28
29export interface ExportChildRow {
30 id: string;
31 title: string;
32 blocks: ExportBlockRow[];
33}
34
35export interface ExportInput {
36 title: string;
37 icon: string | null;
38 description: string | null;
39 blocks: ExportBlockRow[];
40 children?: ExportChildRow[];
41 includeChildren?: boolean;
42}
43
44
46export function exportStripHtml(input: string): string {
47 return input
48 .replace(/<[^>]*>/g, " ")
49 .replace(/ /g, " ")
50 .replace(/&/g, "&")
51 .replace(/</g, "<")
52 .replace(/>/g, ">")
53 .replace(/"/g, '"')
54 .replace(/'/g, "'")
55 .replace(/[ \t]+/g, " ")
56 .replace(/\n{3,}/g, "\n\n")
57 .trim();
58}
59
60
61export function exportBlockText(content: unknown): string {
62 if (typeof content === "string") return exportStripHtml(content);
63 if (typeof content !== "object" || content === null) return "";
64 const c = content as { text?: unknown; title?: unknown };
65 const raw = typeof c.text === "string" ? c.text : typeof c.title === "string" ? c.title : "";
66 return exportStripHtml(raw);
67}
68
69
73export function sortByPosition<T extends { position: string }>(rows: T[]): T[] {
74 return [...rows].sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0));
75}
76
77
78function topLevelAndChildren(blocks: ExportBlockRow[]): { top: ExportBlockRow[]; byParent: Map<string, ExportBlockRow[]> } {
79 const top: ExportBlockRow[] = [];
80 const byParent = new Map<string, ExportBlockRow[]>();
81 for (const b of sortByPosition(blocks)) {
82 if (b.parentId === null || b.parentId === undefined) top.push(b);
83 else {
84 const arr = byParent.get(b.parentId) ?? [];
85 arr.push(b);
86 byParent.set(b.parentId, arr);
87 }
88 }
89 return { top, byParent };
90}
91
92
97function renderBlock(
98 b: ExportBlockRow,
99 byParent: Map<string, ExportBlockRow[]>,
100 nest: number
101): string {
102 const props = (b.properties && typeof b.properties === "object" ? b.properties : {}) as Record<string, unknown>;
103 const indent = " ".repeat(nest);
104 const text = exportBlockText(b.content);
105 switch (b.type) {
106 case "HEADING_1":
107 return `${indent}# ${text}`;
108 case "HEADING_2":
109 return `${indent}## ${text}`;
110 case "HEADING_3":
111 return `${indent}### ${text}`;
112 case "QUOTE":
113 return `${indent}> ${text}`;
114 case "CALLOUT":
115 return `${indent}> **Note:** ${text}`;
116 case "BULLETED_LIST":
117 return `${indent}- ${text}`;
118 case "NUMBERED_LIST":
119 return `${indent}1. ${text}`;
120 case "TODO":
121 return `${indent}- [${props.checked === true ? "x" : " "}] ${text}`;
122 case "TOGGLE": {
123
124
125 const kids = byParent.get(b.id) ?? [];
126 const head = `${indent}**${text}**`;
127 const body = kids.map((k) => renderBlock(k, byParent, nest + 1)).join("\n");
128 return body ? `${head}\n${body}` : head;
129 }
130 case "CODE": {
131
132
133 const lang = typeof props.language === "string" ? props.language : "";
134 return `${indent}\`\`\`${lang}\n${text}\n\`\`\``;
135 }
136 case "EQUATION":
137 return `${indent}$${text}$`;
138 case "DIVIDER":
139 return `${indent}---`;
140 case "SPACER":
141 return "";
142 case "IMAGE":
143 case "VIDEO":
144 case "AUDIO":
145 case "FILE":
146 case "BOOKMARK": {
147
148
149 const name = typeof props.name === "string" ? props.name : typeof props.title === "string" ? props.title : "";
150 const src = typeof props.src === "string" ? props.src : "";
151 const label = b.type.charAt(0) + b.type.slice(1).toLowerCase();
152 return `${indent}*[${label}${name ? `: ${name}` : ""}${src ? ` (${src})` : ""}]*`;
153 }
154 case "EMBED":
155 return `${indent}*[Embedded content]*`;
156 case "DATABASE":
157 case "LINKED_DATABASE": {
158
159
160
161 return `${indent}*[Database]*`;
162 }
163 case "TABLE":
164 case "CHART":
165 case "FORM":
166 case "TOC":
167 return `${indent}*[${b.type.charAt(0) + b.type.slice(1).toLowerCase()}]*`;
168 case "COLUMN_LIST":
169 case "COLUMN": {
170
171 const kids = byParent.get(b.id) ?? [];
172 return kids.map((k) => renderBlock(k, byParent, nest)).join("\n");
173 }
174 case "SYNCED_BLOCK": {
175
176
177
178 const target = typeof props.targetBlockId === "string" ? props.targetBlockId : "";
179 return `${indent}*[Synced block${target ? ` → ${target}` : ""}]*`;
180 }
181 case "CHILD_PAGE":
182 case "CHILD_DATABASE":
183 return text ? `${indent}- ${text}` : `${indent}*[Child page]*`;
184 case "PARAGRAPH":
185 default:
186
187
188 return `${indent}${text}`;
189 }
190}
191
192
197export function exportFilename(title: string): string {
198
199
200
201
202 const stem = title
203 .replace(/[\\/:*?"<>|#\u0000-\u001f]/g, "")
204 .replace(/\s+/g, "-")
205 .replace(/^-+|-+$/g, "")
206 .replace(/-{2,}/g, "-")
207 .slice(0, 80);
208 return `${stem || "page"}.md`;
209}
210
211
215export function renderPageMarkdown(input: ExportInput): string {
216 const lines: string[] = [];
217 const title = input.icon && input.title ? `${input.icon} ${input.title}` : input.title || "Untitled";
218 lines.push(`# ${title}`);
219 if (input.description) lines.push("", input.description);
220 const { top, byParent } = topLevelAndChildren(input.blocks);
221 for (const b of top) {
222 const rendered = renderBlock(b, byParent, 0);
223 if (rendered) lines.push(rendered);
224 }
225 if (input.includeChildren && input.children && input.children.length > 0) {
226 for (const child of input.children) {
227 lines.push("", `## ${child.title}`);
228 const { top: cTop, byParent: cBy } = topLevelAndChildren(child.blocks);
229 for (const b of cTop) {
230 const rendered = renderBlock(b, cBy, 0);
231 if (rendered) lines.push(rendered);
232 }
233 }
234 }
235 return lines.join("\n").replace(/\n{3,}/g, "\n\n") + "\n";
236}
237