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:
1 parent
311e992496
commit
2611d87f8c
14 files changed
+1707
-2
No files matched your search
@@ -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("");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user