Files
InkNote/frontend/src/export/markdown.test.ts
T
marcandClaude Opus 5 c86c63922a Quotes that hold more than a sentence, and one list of marks
A quote may hold whole blocks, and they now have room: the rule that gives
the writing its rhythm reaches only the editor's own children, so inside a
quote a heading, a list and a closing line stood jammed together. Leaving as
Markdown they were written on consecutive lines, which every other reader
folds into one — the sentence after a quoted list became more of the last
bullet. They are divided by a quoted blank line now, the same rule the top
level has had since 11 August.

The marks were two copies of one list, in two dialogs, and only one of them
had ever been extended. One list, one grid, twice as many marks, divided
under headings — and anything else can be typed into the search box, which
on a phone is where the emoji keyboard lives.

The changelog is a third of its length: one entry per day rather than one
per image, since a round that repaired what an earlier round the same day
broke reads as noise afterwards. Every digest is still listed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:15:55 +02:00

246 lines
8.9 KiB
TypeScript

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("divides the blocks inside a quote, so a list does not swallow what follows", () => {
const quote = {
type: "blockquote",
content: [
{ type: "heading", attrs: { level: 3 }, content: [text("Quartalszahlen")] },
{
type: "bulletList",
content: [
{ type: "listItem", content: [para(text("Umsatz"))] },
{ type: "listItem", content: [para(text("Gewinn"))] },
],
},
para(text("Alles nach Plan.")),
],
} as DocNode;
// The dividing line is quoted as well: an empty line would end the quote.
expect(md(doc(quote))).toBe(
"> ### Quartalszahlen\n>\n> - Umsatz\n> - Gewinn\n>\n> Alles nach Plan."
);
});
it("quotes a quote inside a quote", () => {
const inner = { type: "blockquote", content: [para(text("innen"))] } as DocNode;
const outer = { type: "blockquote", content: [para(text("außen")), inner] } as DocNode;
expect(md(doc(outer))).toBe("> außen\n>\n> > innen");
});
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("drawing");
});
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("Note");
expect(safeFilename("///")).toBe("---");
});
it("caps the length", () => {
expect(safeFilename("x".repeat(200)).length).toBe(80);
});
});