/** * A note as Markdown. * * This exists so the notes are not trapped in one program. It is a plain * function over the stored document — no editor, no DOM — which is also why it * can be unit tested and reused by the PDF path. * * Where Markdown has no equivalent the export says so rather than dropping * something silently: a drawing becomes a marker line, an attachment becomes a * link to a file that sits beside the note in the archive. */ import type { DocNode } from "../editor/content"; export interface MarkdownOptions { /** Heading line for the note, usually its title. Omit to export the body only. */ title?: string; /** ISO timestamp rendered under the title. */ updatedAt?: string; /** Note has a drawing that Markdown cannot hold. */ hasDrawing?: boolean; /** Maps an attachment id to a path in the archive, e.g. `images/ab12.png`. */ attachmentPath?: (id: string) => string; /** Maps a linked note id to a path, so links between exported notes still work. */ notePath?: (id: string) => string | null; } /* ── inline ── */ /** * Escapes only what actually changes meaning mid-line. * * Escaping every punctuation mark that Markdown ever uses turns an ordinary * sentence into `Hallo\. Welt\!`, which is unreadable — and readability is the * whole point of exporting to Markdown rather than JSON. Characters that only * matter at the start of a line are handled by `escapeLineStart` instead. */ const escapeText = (s: string) => s.replace(/([\\`*_[\]|])/g, "\\$1"); /** `- x`, `# x`, `1. x` and friends turn a paragraph into a block if left alone. */ const escapeLineStart = (line: string) => line.replace(/^(\s*)([-+>#]|\d+\.)(\s)/, (_m, pad, marker, space) => `${pad}\\${marker}${space}`); function inline(nodes: DocNode[] | undefined, o: MarkdownOptions): string { if (!Array.isArray(nodes)) return ""; return nodes.map((n) => inlineNode(n, o)).join(""); } function inlineNode(n: DocNode, o: MarkdownOptions): string { if (n.type === "hardBreak") return " \n"; if (n.type === "noteLink") { const label = n.attrs?.label || "Untitled"; const target = o.notePath?.(n.attrs?.noteId); // A link to a note that was not exported would be a dead file reference, // so it degrades to the plain name it points at. return target ? `[${escapeText(label)}](${target})` : escapeText(label); } if (n.type === "image") { const alt = escapeText(n.attrs?.alt || ""); const id = n.attrs?.attachmentId; const src = id ? o.attachmentPath?.(id) : n.attrs?.src; return src ? `![${alt}](${src})` : `![${alt}]()`; } if (n.type !== "text" || !n.text) return ""; let out = escapeText(n.text); // Applied innermost-first so `**_text_**` nests the way the marks were stored. for (const mark of (n as any).marks || []) { switch (mark.type) { case "bold": out = `**${out}**`; break; case "italic": out = `*${out}*`; break; case "strike": out = `~~${out}~~`; break; case "code": // Backslash escapes are not honoured inside code spans. out = `\`${n.text}\``; break; case "underline": // Markdown has no underline; HTML is the least surprising stand-in. out = `${out}`; break; case "link": out = `[${out}](${mark.attrs?.href || ""})`; break; default: break; } } return out; } /* ── blocks ── */ /** * Markdown has no alignment. * * It is often said to "basically be HTML"; it is not. What CommonMark allows is * *embedded* HTML, so a centred paragraph can only travel as a tag. `align` is * used rather than a style attribute because that is the one form GitHub and * most other readers still honour — and a reader that does not simply shows the * text, which is the right way to fail. * * Left-aligned blocks are written plainly. Wrapping every paragraph in a div * would turn an ordinary note into markup for the sake of a default. */ function withAlignment(n: DocNode, lines: string[]): string[] { const value = n.attrs?.textAlign; if (!value || value === "left" || lines.every((l) => !l.trim())) return lines; return [`
`, "", ...lines, "", "
"]; } function block(n: DocNode, o: MarkdownOptions, indent = ""): string[] { switch (n.type) { case "paragraph": { const text = inline(n.content, o); return text.trim() ? withAlignment(n, [escapeLineStart(indent + text)]) : [""]; } case "heading": { const level = Math.min(6, Math.max(1, Number(n.attrs?.level) || 1)); return withAlignment(n, [`${"#".repeat(level)} ${inline(n.content, o)}`]); } case "blockquote": return children(n, o) .join("\n") .split("\n") .map((l) => (l ? `> ${l}` : ">")); case "codeBlock": { const lang = n.attrs?.language || ""; const body = (n.content || []).map((c) => c.text || "").join(""); return ["```" + lang, ...body.split("\n"), "```"]; } case "horizontalRule": return ["---"]; case "bulletList": return list(n, o, indent, () => "- "); case "orderedList": { const start = Number(n.attrs?.start) || 1; return list(n, o, indent, (i) => `${start + i}. `); } case "taskList": return list(n, o, indent, (_i, item) => `- [${item.attrs?.checked ? "x" : " "}] `); case "table": return table(n, o); case "image": case "noteLink": return [indent + inlineNode(n, o)]; default: // An unknown node still contributes its children rather than vanishing. return children(n, o); } } function children(n: DocNode, o: MarkdownOptions, indent = ""): string[] { return (n.content || []).flatMap((c) => block(c, o, indent)); } /** Renders list items, indenting anything nested inside them. */ function list( n: DocNode, o: MarkdownOptions, indent: string, marker: (index: number, item: DocNode) => string ): string[] { const out: string[] = []; (n.content || []).forEach((item, i) => { const bullet = marker(i, item); const lines = (item.content || []).flatMap((c) => block(c, o, "")); const [first = "", ...rest] = lines.filter((l, idx) => l !== "" || idx > 0); out.push(indent + bullet + first.trim()); // Continuation lines line up under the text, not under the marker. const pad = indent + " ".repeat(bullet.length); for (const line of rest) out.push(line ? pad + line.trim() : ""); }); return out; } function table(n: DocNode, o: MarkdownOptions): string[] { const rows = (n.content || []).map((row) => (row.content || []).map((cell) => // A cell may hold several paragraphs; Markdown tables are one line per row. children(cell, o) // Pipes are already escaped by escapeText; a table row just needs the // cell flattened to a single line. .join(" ") .replace(/\s*\n\s*/g, " ") .trim() ) ); if (rows.length === 0) return []; const width = Math.max(...rows.map((r) => r.length)); const pad = (r: string[]) => [...r, ...Array(width - r.length).fill("")]; const line = (r: string[]) => `| ${pad(r).join(" | ")} |`; const headerIsReal = (n.content?.[0]?.content || []).some((c) => c.type === "tableHeader"); const [first, ...rest] = rows; return headerIsReal ? [line(first!), `|${" --- |".repeat(width)}`, ...rest.map(line)] : // Markdown has no headerless table; an empty header row keeps it a table. [`|${" |".repeat(width)}`, `|${" --- |".repeat(width)}`, ...rows.map(line)]; } /* ── entry point ── */ export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): string { const lines: string[] = []; if (options.title) { lines.push(`# ${options.title}`, ""); if (options.updatedAt) { lines.push(`*${new Date(options.updatedAt).toLocaleString()}*`, ""); } } /* A blank line between top-level blocks. * * Without one, Markdown runs adjacent blocks together: a bullet list written * straight after another list becomes more items of the first, and a quote or * a code fence on the line after a list item is read as part of that item. * That is not a quirk of one reader — it is what the format says, so the file * rendered wrongly everywhere, and reading it back in produced a document that * did not match the one exported. */ const blocks = (doc.content || []).flatMap((n) => { const rendered = block(n, options); return rendered.length && rendered.some((l) => l !== "") ? [...rendered, ""] : []; }); lines.push(...blocks); if (options.hasDrawing) { lines.push("", "> ✏️ This note has a drawing, which Markdown cannot hold."); lines.push("> It sits beside this file as a PNG."); } // Collapse runs of blank lines; the block rules emit them generously. return lines .join("\n") .replace(/\n{3,}/g, "\n\n") .trim(); } /** Filename-safe version of a note title. */ export function safeFilename(title: string, fallback = "Note"): string { const cleaned = (title || "") .replace(/[/\\?%*:|"<>]/g, "-") .replace(/\s+/g, " ") .trim() .slice(0, 80); return cleaned || fallback; }