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

+218
View File
@@ -0,0 +1,218 @@
import { describe, it, expect } from "vitest";
import { noteToMarkdown, safeFilename } from "./markdown";
import type { DocNode } from "../editor/content";
const doc = (...content: DocNode[]): DocNode => ({ type: "doc", content });
const para = (...content: DocNode[]): DocNode => ({ type: "paragraph", content });
const text = (t: string, marks?: { type: string; attrs?: Record<string, unknown> }[]): DocNode =>
({ type: "text", text: t, ...(marks ? { marks } : {}) }) as DocNode;
const md = (d: DocNode, o = {}) => noteToMarkdown(d, o);
describe("text and marks", () => {
it("writes a plain paragraph", () => {
expect(md(doc(para(text("Hallo Welt"))))).toBe("Hallo Welt");
});
it("renders bold, italic and strikethrough", () => {
expect(md(doc(para(text("fett", [{ type: "bold" }]))))).toBe("**fett**");
expect(md(doc(para(text("kursiv", [{ type: "italic" }]))))).toBe("*kursiv*");
expect(md(doc(para(text("weg", [{ type: "strike" }]))))).toBe("~~weg~~");
});
it("falls back to HTML for underline, which Markdown lacks", () => {
expect(md(doc(para(text("unter", [{ type: "underline" }]))))).toBe("<u>unter</u>");
});
it("keeps a web link", () => {
const out = md(doc(para(text("hier", [{ type: "link", attrs: { href: "https://example.com" } }]))));
expect(out).toBe("[hier](https://example.com)");
});
it("escapes characters that would otherwise become syntax mid-line", () => {
expect(md(doc(para(text("2 * 3 _und_ [mehr]"))))).toBe("2 \\* 3 \\_und\\_ \\[mehr\\]");
});
it("leaves ordinary punctuation alone, so the result stays readable", () => {
expect(md(doc(para(text("Hallo. Alles gut! Preis: 5-10 EUR (ca.)"))))).toBe(
"Hallo. Alles gut! Preis: 5-10 EUR (ca.)"
);
});
it("escapes a leading marker so a paragraph does not become a list", () => {
expect(md(doc(para(text("- kein Listenpunkt"))))).toBe("\\- kein Listenpunkt");
expect(md(doc(para(text("1. kein Punkt"))))).toBe("\\1. kein Punkt");
expect(md(doc(para(text("# keine Ueberschrift"))))).toBe("\\# keine Ueberschrift");
});
it("does not escape inside a code span, where backslashes are literal", () => {
expect(md(doc(para(text("a_b", [{ type: "code" }]))))).toBe("`a_b`");
});
it("turns a hard break into a Markdown line break", () => {
expect(md(doc(para(text("eins"), { type: "hardBreak" }, text("zwei"))))).toBe("eins \nzwei");
});
});
describe("blocks", () => {
it("writes headings at their level", () => {
expect(md(doc({ type: "heading", attrs: { level: 2 }, content: [text("Titel")] }))).toBe("## Titel");
});
it("clamps an out-of-range heading level instead of emitting nonsense", () => {
expect(md(doc({ type: "heading", attrs: { level: 99 }, content: [text("X")] }))).toBe("###### X");
});
it("writes a bullet list", () => {
const list = {
type: "bulletList",
content: [
{ type: "listItem", content: [para(text("eins"))] },
{ type: "listItem", content: [para(text("zwei"))] },
],
};
expect(md(doc(list))).toBe("- eins\n- zwei");
});
it("numbers an ordered list from its start attribute", () => {
const list = {
type: "orderedList",
attrs: { start: 3 },
content: [
{ type: "listItem", content: [para(text("drei"))] },
{ type: "listItem", content: [para(text("vier"))] },
],
};
expect(md(doc(list))).toBe("3. drei\n4. vier");
});
it("writes task state as a checkbox, so it survives the trip", () => {
const list = {
type: "taskList",
content: [
{ type: "taskItem", attrs: { checked: true }, content: [para(text("erledigt"))] },
{ type: "taskItem", attrs: { checked: false }, content: [para(text("offen"))] },
],
};
expect(md(doc(list))).toBe("- [x] erledigt\n- [ ] offen");
});
it("writes a quote with every line prefixed", () => {
const quote = { type: "blockquote", content: [para(text("zitiert"))] };
expect(md(doc(quote))).toBe("> zitiert");
});
it("fences a code block and keeps its language", () => {
const code = { type: "codeBlock", attrs: { language: "js" }, content: [text("const a = 1;")] };
expect(md(doc(code))).toBe("```js\nconst a = 1;\n```");
});
it("writes a horizontal rule", () => {
expect(md(doc({ type: "horizontalRule" }))).toBe("---");
});
});
describe("tables", () => {
const cell = (t: string, type = "tableCell"): DocNode => ({ type, content: [para(text(t))] });
it("writes a header row and separator", () => {
const table = {
type: "table",
content: [
{ type: "tableRow", content: [cell("A", "tableHeader"), cell("B", "tableHeader")] },
{ type: "tableRow", content: [cell("1"), cell("2")] },
],
};
expect(md(doc(table))).toBe("| A | B |\n| --- | --- |\n| 1 | 2 |");
});
it("gives a headerless table an empty header, since Markdown has no other form", () => {
const table = {
type: "table",
content: [{ type: "tableRow", content: [cell("1"), cell("2")] }],
};
const out = md(doc(table)).split("\n");
expect(out[1]).toBe("| --- | --- |");
expect(out[2]).toBe("| 1 | 2 |");
});
it("escapes a pipe inside a cell so the table does not break apart", () => {
const table = {
type: "table",
content: [{ type: "tableRow", content: [cell("a|b", "tableHeader")] }],
};
expect(md(doc(table))).toContain("a\\|b");
expect(md(doc(table))).not.toContain("a\\\\|b");
});
it("pads a short row so the columns stay aligned", () => {
const table = {
type: "table",
content: [
{ type: "tableRow", content: [cell("A", "tableHeader"), cell("B", "tableHeader")] },
{ type: "tableRow", content: [cell("1")] },
],
};
expect(md(doc(table))).toContain("| 1 | |");
});
});
describe("images and note links", () => {
it("points an attachment at its file in the archive", () => {
const img = { type: "image", attrs: { attachmentId: "abc", alt: "Bild" } };
const out = md(doc(img), { attachmentPath: (id: string) => `images/${id}.png` });
expect(out).toBe("![Bild](images/abc.png)");
});
it("links to another exported note", () => {
const link = para({ type: "noteLink", attrs: { noteId: "n1", label: "Andere Notiz" } });
const out = md(doc(link), { notePath: (id: string) => `${id}.md` });
expect(out).toBe("[Andere Notiz](n1.md)");
});
it("degrades a link to a note that was not exported, rather than leaving a dead file link", () => {
const link = para({ type: "noteLink", attrs: { noteId: "n1", label: "Andere Notiz" } });
expect(md(doc(link), { notePath: () => null })).toBe("Andere Notiz");
});
});
describe("document framing", () => {
it("puts the title in a heading", () => {
expect(md(doc(para(text("Text"))), { title: "Meine Notiz" })).toBe("# Meine Notiz\n\nText");
});
it("says when a drawing could not come along, instead of dropping it silently", () => {
const out = md(doc(para(text("Text"))), { hasDrawing: true });
expect(out).toContain("Zeichnung");
});
it("collapses the blank lines the block rules emit generously", () => {
const out = md(doc(para(text("a")), para(), para(), para(text("b"))));
expect(out).not.toMatch(/\n{3,}/);
});
it("survives an empty document", () => {
expect(md(doc())).toBe("");
expect(md({ type: "doc" })).toBe("");
});
it("keeps the children of a node type it does not know", () => {
const weird = { type: "somethingNew", content: [para(text("trotzdem da"))] };
expect(md(doc(weird))).toBe("trotzdem da");
});
});
describe("safeFilename", () => {
it("strips characters a filesystem would refuse", () => {
expect(safeFilename('a/b\\c:d*e?f"g<h>i|j')).toBe("a-b-c-d-e-f-g-h-i-j");
});
it("falls back when nothing usable is left", () => {
expect(safeFilename("")).toBe("Notiz");
expect(safeFilename("///")).toBe("---");
});
it("caps the length", () => {
expect(safeFilename("x".repeat(200)).length).toBe(80);
});
});