Files
InkNote/frontend/src/export/markdown.ts
T
marcandClaude Opus 5 fed53772aa Set the note left, count its pages, choose its typeface, align its text
The column sat in the middle, so it moved as the window was resized and the
start of every line landed somewhere new. It is against the margin now.

The toolbar counts pages in the words the PDF reader uses, with a hairline where
each ends. Pages are drawn, never stored: the note stays one document, Markdown
has no page, and a check asserts none reaches the stored body. The current page
is the one under the middle of the window — measuring from the top claimed "page
1" while you stood at the foot of a two-page note, because a window is shorter
than a page.

The typeface is a per-account setting rather than a per-browser one, because a
choice about how your own notes are set should follow you to the phone. The
server takes serif, sans or mono and drops anything else; that column is written
by a browser, so it is an input like any other.

Alignment is a small extension of our own — one attribute, a parse rule, a render
rule — so the schema every stored note is validated against stays in files this
project owns. Markdown has no alignment: what CommonMark allows is embedded HTML,
so non-left blocks travel as `<div align="…">`, the one form most readers still
honour, and the importer puts it back on the paragraphs. Left is never written.

The full run caught a crash I had put in: PdfWorkspace used `noteFont` without
taking it as a prop, which the build cannot see and the type checker does not
reach in .jsx. It killed the notes panel beside a PDF, and `offline` failed with
it because the whole app was broken.

224 unit tests, 263 browser checks across twelve suites.

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

268 lines
9.1 KiB
TypeScript

/**
* A note as Markdown.
*
* This exists so the notes are not trapped in one program. It is a plain
* function over the stored document — no editor, no DOM — which is also why it
* can be unit tested and reused by the PDF path.
*
* Where Markdown has no equivalent the export says so rather than dropping
* something silently: a drawing becomes a marker line, an attachment becomes a
* link to a file that sits beside the note in the archive.
*/
import type { DocNode } from "../editor/content";
export interface MarkdownOptions {
/** Heading line for the note, usually its title. Omit to export the body only. */
title?: string;
/** ISO timestamp rendered under the title. */
updatedAt?: string;
/** Note has a drawing that Markdown cannot hold. */
hasDrawing?: boolean;
/** Maps an attachment id to a path in the archive, e.g. `images/ab12.png`. */
attachmentPath?: (id: string) => string;
/** Maps a linked note id to a path, so links between exported notes still work. */
notePath?: (id: string) => string | null;
}
/* ── inline ── */
/**
* Escapes only what actually changes meaning mid-line.
*
* Escaping every punctuation mark that Markdown ever uses turns an ordinary
* sentence into `Hallo\. Welt\!`, which is unreadable — and readability is the
* whole point of exporting to Markdown rather than JSON. Characters that only
* matter at the start of a line are handled by `escapeLineStart` instead.
*/
const escapeText = (s: string) => s.replace(/([\\`*_[\]|])/g, "\\$1");
/** `- x`, `# x`, `1. x` and friends turn a paragraph into a block if left alone. */
const escapeLineStart = (line: string) =>
line.replace(/^(\s*)([-+>#]|\d+\.)(\s)/, (_m, pad, marker, space) => `${pad}\\${marker}${space}`);
function inline(nodes: DocNode[] | undefined, o: MarkdownOptions): string {
if (!Array.isArray(nodes)) return "";
return nodes.map((n) => inlineNode(n, o)).join("");
}
function inlineNode(n: DocNode, o: MarkdownOptions): string {
if (n.type === "hardBreak") return " \n";
if (n.type === "noteLink") {
const label = n.attrs?.label || "Untitled";
const target = o.notePath?.(n.attrs?.noteId);
// A link to a note that was not exported would be a dead file reference,
// so it degrades to the plain name it points at.
return target ? `[${escapeText(label)}](${target})` : escapeText(label);
}
if (n.type === "image") {
const alt = escapeText(n.attrs?.alt || "");
const id = n.attrs?.attachmentId;
const src = id ? o.attachmentPath?.(id) : n.attrs?.src;
return src ? `![${alt}](${src})` : `![${alt}]()`;
}
if (n.type !== "text" || !n.text) return "";
let out = escapeText(n.text);
// Applied innermost-first so `**_text_**` nests the way the marks were stored.
for (const mark of (n as any).marks || []) {
switch (mark.type) {
case "bold":
out = `**${out}**`;
break;
case "italic":
out = `*${out}*`;
break;
case "strike":
out = `~~${out}~~`;
break;
case "code":
// Backslash escapes are not honoured inside code spans.
out = `\`${n.text}\``;
break;
case "underline":
// Markdown has no underline; HTML is the least surprising stand-in.
out = `<u>${out}</u>`;
break;
case "link":
out = `[${out}](${mark.attrs?.href || ""})`;
break;
default:
break;
}
}
return out;
}
/* ── blocks ── */
/**
* Markdown has no alignment.
*
* It is often said to "basically be HTML"; it is not. What CommonMark allows is
* *embedded* HTML, so a centred paragraph can only travel as a tag. `align` is
* used rather than a style attribute because that is the one form GitHub and
* most other readers still honour — and a reader that does not simply shows the
* text, which is the right way to fail.
*
* Left-aligned blocks are written plainly. Wrapping every paragraph in a div
* would turn an ordinary note into markup for the sake of a default.
*/
function withAlignment(n: DocNode, lines: string[]): string[] {
const value = n.attrs?.textAlign;
if (!value || value === "left" || lines.every((l) => !l.trim())) return lines;
return [`<div align="${value}">`, "", ...lines, "", "</div>"];
}
function block(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
switch (n.type) {
case "paragraph": {
const text = inline(n.content, o);
return text.trim() ? withAlignment(n, [escapeLineStart(indent + text)]) : [""];
}
case "heading": {
const level = Math.min(6, Math.max(1, Number(n.attrs?.level) || 1));
return withAlignment(n, [`${"#".repeat(level)} ${inline(n.content, o)}`]);
}
case "blockquote":
return children(n, o)
.join("\n")
.split("\n")
.map((l) => (l ? `> ${l}` : ">"));
case "codeBlock": {
const lang = n.attrs?.language || "";
const body = (n.content || []).map((c) => c.text || "").join("");
return ["```" + lang, ...body.split("\n"), "```"];
}
case "horizontalRule":
return ["---"];
case "bulletList":
return list(n, o, indent, () => "- ");
case "orderedList": {
const start = Number(n.attrs?.start) || 1;
return list(n, o, indent, (i) => `${start + i}. `);
}
case "taskList":
return list(n, o, indent, (_i, item) => `- [${item.attrs?.checked ? "x" : " "}] `);
case "table":
return table(n, o);
case "image":
case "noteLink":
return [indent + inlineNode(n, o)];
default:
// An unknown node still contributes its children rather than vanishing.
return children(n, o);
}
}
function children(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
return (n.content || []).flatMap((c) => block(c, o, indent));
}
/** Renders list items, indenting anything nested inside them. */
function list(
n: DocNode,
o: MarkdownOptions,
indent: string,
marker: (index: number, item: DocNode) => string
): string[] {
const out: string[] = [];
(n.content || []).forEach((item, i) => {
const bullet = marker(i, item);
const lines = (item.content || []).flatMap((c) => block(c, o, ""));
const [first = "", ...rest] = lines.filter((l, idx) => l !== "" || idx > 0);
out.push(indent + bullet + first.trim());
// Continuation lines line up under the text, not under the marker.
const pad = indent + " ".repeat(bullet.length);
for (const line of rest) out.push(line ? pad + line.trim() : "");
});
return out;
}
function table(n: DocNode, o: MarkdownOptions): string[] {
const rows = (n.content || []).map((row) =>
(row.content || []).map((cell) =>
// A cell may hold several paragraphs; Markdown tables are one line per row.
children(cell, o)
// Pipes are already escaped by escapeText; a table row just needs the
// cell flattened to a single line.
.join(" ")
.replace(/\s*\n\s*/g, " ")
.trim()
)
);
if (rows.length === 0) return [];
const width = Math.max(...rows.map((r) => r.length));
const pad = (r: string[]) => [...r, ...Array(width - r.length).fill("")];
const line = (r: string[]) => `| ${pad(r).join(" | ")} |`;
const headerIsReal = (n.content?.[0]?.content || []).some((c) => c.type === "tableHeader");
const [first, ...rest] = rows;
return headerIsReal
? [line(first!), `|${" --- |".repeat(width)}`, ...rest.map(line)]
: // Markdown has no headerless table; an empty header row keeps it a table.
[`|${" |".repeat(width)}`, `|${" --- |".repeat(width)}`, ...rows.map(line)];
}
/* ── entry point ── */
export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): string {
const lines: string[] = [];
if (options.title) {
lines.push(`# ${options.title}`, "");
if (options.updatedAt) {
lines.push(`*${new Date(options.updatedAt).toLocaleString()}*`, "");
}
}
/* A blank line between top-level blocks.
*
* Without one, Markdown runs adjacent blocks together: a bullet list written
* straight after another list becomes more items of the first, and a quote or
* a code fence on the line after a list item is read as part of that item.
* That is not a quirk of one reader — it is what the format says, so the file
* rendered wrongly everywhere, and reading it back in produced a document that
* did not match the one exported. */
const blocks = (doc.content || []).flatMap((n) => {
const rendered = block(n, options);
return rendered.length && rendered.some((l) => l !== "") ? [...rendered, ""] : [];
});
lines.push(...blocks);
if (options.hasDrawing) {
lines.push("", "> ✏️ This note has a drawing, which Markdown cannot hold.");
lines.push("> It sits beside this file as a PNG.");
}
// Collapse runs of blank lines; the block rules emit them generously.
return lines
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/** Filename-safe version of a note title. */
export function safeFilename(title: string, fallback = "Note"): string {
const cleaned = (title || "")
.replace(/[/\\?%*:|"<>]/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80);
return cleaned || fallback;
}