/**
* 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}]()`;
}
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 = `${out}`;
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 [`
`, "", ...lines, "", "
"];
}
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;
}