/** * T-180 (Phase 8) — page/content MARKDOWN EXPORT. * * `GET /pages/:id/export/markdown?includeChildren=1` renders a page (+ its * sub-pages, opt-in) to Markdown for download. The serializer is a PURE * function over rows shaped like `PageService.captureSubtree` output, so it * is unit-testable without Postgres and reuses the exact fetch the template * snapshot uses (one query for the page's blocks, one per child page). * * Text extraction mirrors the AI module's `blockText`/`stripHtml` * (`modules/ai/prompts.ts`) — both the canonical `{ text }` object and the * web editor's HTML-string storage shape must read (TASK-0123 precedent). * Non-text blocks degrade to stable placeholders (see the per-type notes) — * a content export must not silently drop a block the author can see. * * No dependency added: this file is stdlib-only and imports nothing from * the AI module (keeps the pages module loosely coupled, AGENTS rule 22). */ export interface ExportBlockRow { id: string; type: string; content: unknown; properties: unknown; parentId: string | null; position: string; } export interface ExportChildRow { id: string; title: string; blocks: ExportBlockRow[]; } export interface ExportInput { title: string; icon: string | null; description: string | null; blocks: ExportBlockRow[]; children?: ExportChildRow[]; includeChildren?: boolean; } /** Strip the minimal tag set the InlineEditor round-trips (mirrors the AI * module's `stripHtml` — duplicated here to avoid a pages→ai import). */ export function exportStripHtml(input: string): string { return input .replace(/<[^>]*>/g, " ") .replace(/ /g, " ") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/[ \t]+/g, " ") .replace(/\n{3,}/g, "\n\n") .trim(); } /** One block's display text (object `{text|title}` or HTML string). */ export function exportBlockText(content: unknown): string { if (typeof content === "string") return exportStripHtml(content); if (typeof content !== "object" || content === null) return ""; const c = content as { text?: unknown; title?: unknown }; const raw = typeof c.text === "string" ? c.text : typeof c.title === "string" ? c.title : ""; return exportStripHtml(raw); } /** * Sort sibling blocks by fractional position (lexicographic — the same * ordering `position` uses everywhere; NEVER numeric). */ export function sortByPosition(rows: T[]): T[] { return [...rows].sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0)); } /** Split a page's blocks into top-level (parentId null) and child maps. */ function topLevelAndChildren(blocks: ExportBlockRow[]): { top: ExportBlockRow[]; byParent: Map } { const top: ExportBlockRow[] = []; const byParent = new Map(); for (const b of sortByPosition(blocks)) { if (b.parentId === null || b.parentId === undefined) top.push(b); else { const arr = byParent.get(b.parentId) ?? []; arr.push(b); byParent.set(b.parentId, arr); } } return { top, byParent }; } /** * Render one block to markdown. `nest` = current heading indent depth * (TOGGLE/COLUMN children indent). Media/placeholder blocks are inline on * their own line. `byParent` lets TOGGLE/COLUMN_LIST/COLUMN recurse. */ function renderBlock( b: ExportBlockRow, byParent: Map, nest: number ): string { const props = (b.properties && typeof b.properties === "object" ? b.properties : {}) as Record; const indent = " ".repeat(nest); const text = exportBlockText(b.content); switch (b.type) { case "HEADING_1": return `${indent}# ${text}`; case "HEADING_2": return `${indent}## ${text}`; case "HEADING_3": return `${indent}### ${text}`; case "QUOTE": return `${indent}> ${text}`; case "CALLOUT": return `${indent}> **Note:** ${text}`; case "BULLETED_LIST": return `${indent}- ${text}`; case "NUMBERED_LIST": return `${indent}1. ${text}`; case "TODO": return `${indent}- [${props.checked === true ? "x" : " "}] ${text}`; case "TOGGLE": { // A toggle exports as a bold line + its children indented one level // (markdown has no collapsible container). const kids = byParent.get(b.id) ?? []; const head = `${indent}**${text}**`; const body = kids.map((k) => renderBlock(k, byParent, nest + 1)).join("\n"); return body ? `${head}\n${body}` : head; } case "CODE": { // Live rows store `{databaseId}` for embedded-database code fences and // the language in `properties.language` (verified against the DB). const lang = typeof props.language === "string" ? props.language : ""; return `${indent}\`\`\`${lang}\n${text}\n\`\`\``; } case "EQUATION": return `${indent}$${text}$`; case "DIVIDER": return `${indent}---`; case "SPACER": return ""; case "IMAGE": case "VIDEO": case "AUDIO": case "FILE": case "BOOKMARK": { // properties carry the media ref (`src`, `name`, `fileId`) in live // rows; render a stable, non-dropping placeholder with what we have. const name = typeof props.name === "string" ? props.name : typeof props.title === "string" ? props.title : ""; const src = typeof props.src === "string" ? props.src : ""; const label = b.type.charAt(0) + b.type.slice(1).toLowerCase(); return `${indent}*[${label}${name ? `: ${name}` : ""}${src ? ` (${src})` : ""}]*`; } case "EMBED": return `${indent}*[Embedded content]*`; case "DATABASE": case "LINKED_DATABASE": { // A database page's records live on the page itself; the block is the // embedded view — export the page's own records separately is out of // scope (record export = the CSV endpoint). return `${indent}*[Database]*`; } case "TABLE": case "CHART": case "FORM": case "TOC": return `${indent}*[${b.type.charAt(0) + b.type.slice(1).toLowerCase()}]*`; case "COLUMN_LIST": case "COLUMN": { // Column layout collapses to its children (markdown has no columns). const kids = byParent.get(b.id) ?? []; return kids.map((k) => renderBlock(k, byParent, nest)).join("\n"); } case "SYNCED_BLOCK": { // Live rows store `{}` content + `properties.targetBlockId`; the // target is resolved by the caller (export does not chase sync links // across pages — a synced block prints as a placeholder). const target = typeof props.targetBlockId === "string" ? props.targetBlockId : ""; return `${indent}*[Synced block${target ? ` → ${target}` : ""}]*`; } case "CHILD_PAGE": case "CHILD_DATABASE": return text ? `${indent}- ${text}` : `${indent}*[Child page]*`; case "PARAGRAPH": default: // indent is empty at nest 0, so this is a no-op for top-level text // but preserves nesting depth for children of TOGGLE/COLUMN. return `${indent}${text}`; } } /** * A filesystem-safe download filename for a page's markdown export. * Sanitizes the title down to a stem (no path separators / control chars), * falls back to `page`, and appends `.md`. */ export function exportFilename(title: string): string { // Sanitize path separators / reserved chars / control chars, collapse runs // of whitespace to a single hyphen, drop leading/trailing hyphens, and cap // the stem. A title that is only punctuation/whitespace falls back to // `page` (so the download is never `-.md`). const stem = title .replace(/[\\/:*?"<>|#\u0000-\u001f]/g, "") .replace(/\s+/g, "-") .replace(/^-+|-+$/g, "") .replace(/-{2,}/g, "-") .slice(0, 80); return `${stem || "page"}.md`; } /** * Render a page (+ optional sub-pages) to markdown. Pure; the caller * authenticates (VIEW) before invoking and audits the export. */ export function renderPageMarkdown(input: ExportInput): string { const lines: string[] = []; const title = input.icon && input.title ? `${input.icon} ${input.title}` : input.title || "Untitled"; lines.push(`# ${title}`); if (input.description) lines.push("", input.description); const { top, byParent } = topLevelAndChildren(input.blocks); for (const b of top) { const rendered = renderBlock(b, byParent, 0); if (rendered) lines.push(rendered); } if (input.includeChildren && input.children && input.children.length > 0) { for (const child of input.children) { lines.push("", `## ${child.title}`); const { top: cTop, byParent: cBy } = topLevelAndChildren(child.blocks); for (const b of cTop) { const rendered = renderBlock(b, cBy, 0); if (rendered) lines.push(rendered); } } } return lines.join("\n").replace(/\n{3,}/g, "\n\n") + "\n"; }