Files
InkNote/frontend/src/export/markdown.test.ts
T
marcandClaude Opus 5 9507761bce Colour, a page you can see the edge of, and a document on its own
Five of the nine things on the list, and one finding that came out of them.

Colour: a marker pen and an ink colour, written as marks here rather than
installed — the schema of a note decides what can be stored in it forever. A
highlight is a <mark> so a reader that drops the colour still shows the word
as marked, and marked text is painted dark whatever the theme. Both travel as
HTML in Markdown, like underline, and both are read back.

The PDF fills the screen: the browser's own full screen for the window frame,
an overlay for the sidebar and the list, Escape and a visible button for the
way out.

The dashboard leads with the recent notes, sorted by time whatever order the
notebooks are in. A tap on the blank part of a page puts the caret at the
nearest position instead of nowhere. A drag from the left edge shows the
sidebar. The page has an edge on a phone, where it had none.

The finding: at the speed a robot types, a long note loses keystrokes between
the editor and the pagination measuring after each one — 87 of 220
paragraphs. Nothing is lost at 50 characters a second, so the page-count
check now pastes its bulk text rather than measuring typing throughput. And
the history suite asked for "the first note in the list" without waiting for
the note to reach the server; it asks by name now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:40:21 +02:00

262 lines
9.6 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("carries a highlight as <mark>, with its colour when it has one", () => {
expect(md(doc(para(text("gelb", [{ type: "highlight", attrs: { color: "#fff3a3" } }]))))).toBe(
'<mark style="background-color: #fff3a3">gelb</mark>'
);
// A marker without a colour is still a marker, and that is what most other
// writers emit.
expect(md(doc(para(text("markiert", [{ type: "highlight" }]))))).toBe("<mark>markiert</mark>");
});
it("carries a text colour as a span, and nothing at all without one", () => {
expect(md(doc(para(text("rot", [{ type: "textColor", attrs: { color: "#d1242f" } }]))))).toBe(
'<span style="color: #d1242f">rot</span>'
);
expect(md(doc(para(text("schlicht", [{ type: "textColor" }]))))).toBe("schlicht");
});
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);
});
});