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 }[]): 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("unter"); }); 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"gi|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); }); });