Export notes as Markdown or PDF

The reason to build this is not a missing feature — it is that trusting a
program you maintain alone with years of notes should not mean the notes
can only ever be read by that program.

Reachable from a note's toolbar, a notebook's context menu, and Account →
Alle Notizen exportieren.

Markdown carries over everything with an equivalent: headings, lists,
quotes, code, tables, and task state as `- [x]`. A link to another note in
the same export becomes a relative link to its file; a link to a note
outside it degrades to the plain title rather than leaving a dead
reference. Notes with images or a drawing arrive as a ZIP so the files
travel along, and a multi-note export gets a README index.

PDF goes through the browser's print dialog rather than a bundled PDF
library — the browser already has a good one, and it is the only format
where drawings sit inside the document. Markdown gets them as a PNG
beside the text, with a line saying so, because dropping them silently
would be the wrong kind of quiet.

Two decisions worth recording:

- the ZIP writer is eighty lines here rather than a dependency. Note text
  is small and images are already compressed, so compression bought
  nothing, and stored entries are a handful of fixed-layout records.
- Markdown escaping only covers what changes meaning mid-line, plus a
  leading list or heading marker. Escaping every character Markdown ever
  uses turns a sentence into `Hallo\. Alles gut\!`, which defeats the
  point of choosing a readable format.

42 unit tests on the serialiser and the archive format, plus a browser
suite that unpacks a real download and reads the bytes back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 14:46:53 +02:00
1 parent 311e992496
commit 2611d87f8c
14 files changed
+1707 -2

No files matched your search

+237
View File
@@ -0,0 +1,237 @@
/**
* 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 = `<u>${out}</u>`;
break;
case "link":
out = `[${out}](${mark.attrs?.href || ""})`;
break;
default:
break;
}
}
return out;
}
/* ── blocks ── */
function block(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
switch (n.type) {
case "paragraph": {
const text = inline(n.content, o);
return text.trim() ? [escapeLineStart(indent + text)] : [""];
}
case "heading": {
const level = Math.min(6, Math.max(1, Number(n.attrs?.level) || 1));
return [`${"#".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("de-DE")}*`, "");
}
}
lines.push(...children(doc, options));
if (options.hasDrawing) {
lines.push("", "> ✏️ Diese Notiz enthält eine Zeichnung, die Markdown nicht abbilden kann.");
lines.push("> Sie liegt als PNG neben der Datei.");
}
// 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 = "Notiz"): string {
const cleaned = (title || "")
.replace(/[/\\?%*:|"<>]/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80);
return cleaned || fallback;
}