import { describe, expect, it } from "vitest"; import { noteToMarkdown } from "../export/markdown"; import { inlineNodes, markdownToNote } from "./markdownImport"; import type { DocNode } from "../editor/content"; const doc = (md: string) => markdownToNote(md).doc; const first = (md: string) => doc(md).content![0]!; const plain = (n: DocNode): string => { if (n.text) return n.text; return (n.content ?? []).map(plain).join(""); }; describe("blocks", () => { it("reads headings, capping them at the three levels the editor has", () => { expect(first("# Titel\n\nrumpf").type).toBe("paragraph"); // level 1 becomes the title expect(first("## Zwei")).toMatchObject({ type: "heading", attrs: { level: 2 } }); expect(first("##### Fünf")).toMatchObject({ type: "heading", attrs: { level: 3 } }); }); it("keeps a fenced code block verbatim, markup and all", () => { const block = first("```js\nconst a = **not bold**;\n```"); expect(block).toMatchObject({ type: "codeBlock", attrs: { language: "js" } }); expect(plain(block)).toBe("const a = **not bold**;"); }); it("reads a horizontal rule", () => { expect(first("---").type).toBe("horizontalRule"); }); it("reads a blockquote, including the blocks inside it", () => { const quote = first("> ## Kopf\n> Text"); expect(quote.type).toBe("blockquote"); expect(quote.content![0]!.type).toBe("heading"); }); it("joins the lines of a paragraph", () => { expect(plain(first("erste Zeile\nzweite Zeile"))).toBe("erste Zeile zweite Zeile"); }); it("stops a paragraph at the block that follows it", () => { const content = doc("Absatz\n## Überschrift").content!; expect(content.map((n) => n.type)).toEqual(["paragraph", "heading"]); }); }); describe("lists", () => { it("reads a bullet list", () => { const list = first("- eins\n- zwei"); expect(list.type).toBe("bulletList"); expect(list.content).toHaveLength(2); expect(plain(list.content![1]!)).toBe("zwei"); }); it("reads an ordered list and keeps where it starts", () => { expect(first("3. drei\n4. vier")).toMatchObject({ type: "orderedList", attrs: { start: 3 } }); }); it("reads a task list with its boxes", () => { const list = first("- [x] erledigt\n- [ ] offen"); expect(list.type).toBe("taskList"); expect(list.content![0]).toMatchObject({ type: "taskItem", attrs: { checked: true } }); expect(list.content![1]).toMatchObject({ type: "taskItem", attrs: { checked: false } }); expect(plain(list.content![0]!)).toBe("erledigt"); }); it("splits where a list stops having checkboxes, rather than losing them", () => { // The editor has no node for "a list where some items have a box", so the // run ends and a second list begins. The alternative — one list with a // literal "[x]" in the text — throws away what the boxes meant. const content = doc("- [x] erledigt\n- einfach").content!; expect(content.map((n) => n.type)).toEqual(["taskList", "bulletList"]); }); it("nests by indentation", () => { const outer = first("- oben\n - innen"); const inner = outer.content![0]!.content!.find((n) => n.type === "bulletList"); expect(inner).toBeDefined(); expect(plain(inner!)).toBe("innen"); }); }); describe("tables", () => { it("reads a table with a header row", () => { const table = first("| A | B |\n| --- | --- |\n| 1 | 2 |"); expect(table.type).toBe("table"); expect(table.content).toHaveLength(2); expect(table.content![0]!.content![0]!.type).toBe("tableHeader"); expect(plain(table.content![1]!.content![1]!)).toBe("2"); }); it("recognises the empty header the exporter writes for a headerless table", () => { const table = first("| | |\n| --- | --- |\n| 1 | 2 |"); expect(table.content).toHaveLength(1); expect(table.content![0]!.content![0]!.type).toBe("tableCell"); }); it("pads a short row so every row has the same number of cells", () => { const table = first("| A | B |\n| --- | --- |\n| 1 |"); expect(table.content![1]!.content).toHaveLength(2); }); }); describe("inline", () => { const marksOf = (md: string) => inlineNodes(md).map((n) => (n as any).marks?.map((m: any) => m.type) ?? []); it("reads bold, italic, strikethrough and code", () => { expect(marksOf("**b**")).toEqual([["bold"]]); expect(marksOf("*i*")).toEqual([["italic"]]); expect(marksOf("~~s~~")).toEqual([["strike"]]); expect(marksOf("`c`")).toEqual([["code"]]); }); it("nests marks", () => { expect(marksOf("**_beides_**")).toEqual([["bold", "italic"]]); }); it("leaves underscores inside a word alone", () => { // snake_case_names are the most common thing a naive parser mangles. expect(plain({ content: inlineNodes("snake_case_name") } as DocNode)).toBe("snake_case_name"); expect(marksOf("snake_case_name")).toEqual([[]]); }); it("does not read markup inside a code span", () => { expect(plain({ content: inlineNodes("`**roh**`") } as DocNode)).toBe("**roh**"); }); it("reads a link", () => { const [node] = inlineNodes("[Beispiel](https://example.com)"); expect((node as any).marks[0]).toMatchObject({ type: "link", attrs: { href: "https://example.com" } }); }); it("reads an image and reports its path", () => { const parsed = markdownToNote("![Bild](bilder/abc.png)"); expect(parsed.imagePaths).toEqual(["bilder/abc.png"]); }); it("reads the HTML underline the exporter falls back to", () => { expect(marksOf("x")).toEqual([["underline"]]); }); it("treats a lone asterisk as a character, not an unterminated mark", () => { expect(plain({ content: inlineNodes("2 * 3 = 6") } as DocNode)).toBe("2 * 3 = 6"); }); it("undoes the exporter's escapes without touching a Windows path", () => { expect(plain({ content: inlineNodes("a \\* b") } as DocNode)).toBe("a * b"); expect(plain({ content: inlineNodes("C:\\temp\\neu") } as DocNode)).toBe("C:\\temp\\neu"); }); }); describe("the title", () => { it("comes from a leading level-one heading", () => { expect(markdownToNote("# Mein Titel\n\nText").title).toBe("Mein Titel"); }); it("drops the timestamp line the exporter writes under it", () => { const parsed = markdownToNote("# T\n\n*07.08.2026, 12:00:00*\n\nText"); expect(parsed.doc.content).toHaveLength(1); expect(plain(parsed.doc.content![0]!)).toBe("Text"); }); it("is absent when the file does not start with one", () => { expect(markdownToNote("Nur Text").title).toBeNull(); }); it("keeps a level-one heading further down as a heading", () => { const parsed = markdownToNote("Text\n\n# Später"); expect(parsed.title).toBeNull(); expect(parsed.doc.content!.map((n) => n.type)).toEqual(["paragraph", "heading"]); }); }); describe("an empty file", () => { it("still produces a document the editor can open", () => { expect(markdownToNote("").doc).toEqual({ type: "doc", content: [{ type: "paragraph" }] }); }); }); /* The pair matters more than either half: what leaves has to be able to come * back. These run a document through the exporter and read the result. */ describe("round trip", () => { const roundTrip = (source: DocNode) => markdownToNote(noteToMarkdown(source)).doc; it("brings back headings, lists, tasks, quotes, code and rules", () => { const source: DocNode = { type: "doc", content: [ { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Kopf" }] }, { type: "paragraph", content: [{ type: "text", text: "Ein Absatz." }] }, { type: "bulletList", content: [ { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "eins" }] }] }, { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "zwei" }] }] }, ], }, { type: "taskList", content: [ { type: "taskItem", attrs: { checked: true }, content: [{ type: "paragraph", content: [{ type: "text", text: "getan" }] }], }, ], }, { type: "blockquote", content: [{ type: "paragraph", content: [{ type: "text", text: "zitiert" }] }], }, { type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "const a = 1;" }] }, { type: "horizontalRule" }, ], }; const back = roundTrip(source); expect(back.content!.map((n) => n.type)).toEqual([ "heading", "paragraph", "bulletList", "taskList", "blockquote", "codeBlock", "horizontalRule", ]); expect(plain(back)).toContain("getan"); expect(back.content![5]!.content![0]!.text).toBe("const a = 1;"); }); it("brings back the marks on a sentence", () => { const source: DocNode = { type: "doc", content: [ { type: "paragraph", content: [ { type: "text", text: "fett", marks: [{ type: "bold" }] } as DocNode, { type: "text", text: " und " }, { type: "text", text: "kursiv", marks: [{ type: "italic" }] } as DocNode, ], }, ], }; const back = roundTrip(source); expect(plain(back)).toBe("fett und kursiv"); const marks = back.content![0]!.content!.map((n) => (n as any).marks?.[0]?.type); expect(marks).toEqual(["bold", undefined, "italic"]); }); it("brings back a table with its header", () => { const cell = (type: string, text: string): DocNode => ({ type, content: [{ type: "paragraph", content: [{ type: "text", text }] }], }); const source: DocNode = { type: "doc", content: [ { type: "table", content: [ { type: "tableRow", content: [cell("tableHeader", "A"), cell("tableHeader", "B")] }, { type: "tableRow", content: [cell("tableCell", "1"), cell("tableCell", "2")] }, ], }, ], }; const back = roundTrip(source); const table = back.content![0]!; expect(table.type).toBe("table"); expect(table.content![0]!.content![0]!.type).toBe("tableHeader"); expect(plain(table)).toContain("2"); }); it("keeps a title through the round trip instead of doubling it into the body", () => { const source: DocNode = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Inhalt" }] }], }; const md = noteToMarkdown(source, { title: "Einkaufsliste", updatedAt: "2026-08-07T10:00:00Z" }); const parsed = markdownToNote(md); expect(parsed.title).toBe("Einkaufsliste"); expect(plain(parsed.doc)).toBe("Inhalt"); }); it("keeps a picture's path so it can be matched back to the archive", () => { const source: DocNode = { type: "doc", content: [{ type: "image", attrs: { attachmentId: "abc123", alt: "Foto" } }], }; const md = noteToMarkdown(source, { attachmentPath: (id) => `bilder/${id}.png` }); expect(markdownToNote(md).imagePaths).toEqual(["bilder/abc123.png"]); }); });