/**
* A small, safe Markdown parser: source -> AST -> (React elements | HTML string).
*
* Supported: ATX headings, paragraphs, **strong**, *em*, ~~del~~, `code`, fenced code with a language,
* blockquotes, ordered/unordered/task lists (nested by indentation), GFM tables, horizontal rules,
* links, images and <https://autolinks>.
*
* Safety model: raw HTML is never interpreted (it stays text), and every URL goes through `safeUrl`, which only
* allows http(s), mailto and relative links. Rendering to React escapes text automatically; `toHtml` escapes by hand.
*/
export type Inline =
| { type: "text"; value: string }
| { type: "strong" | "em" | "del"; children: Inline[] }
| { type: "code"; value: string }
| { type: "link"; href: string | null; rawHref: string; children: Inline[] }
| { type: "image"; src: string | null; rawSrc: string; alt: string }
| { type: "br" };
export type ListItem = { checked: boolean | null; children: Block[] };
export type Block =
| { type: "heading"; depth: 1 | 2 | 3 | 4 | 5 | 6; id: string; children: Inline[]; line: number }
| { type: "paragraph"; children: Inline[]; line: number }
| { type: "code"; lang: string; value: string; line: number }
| { type: "blockquote"; children: Block[]; line: number }
| { type: "list"; ordered: boolean; start: number; tight: boolean; items: ListItem[]; line: number }
| { type: "table"; align: ("left" | "center" | "right" | null)[]; head: Inline[][]; rows: Inline[][][]; line: number }
| { type: "hr"; line: number };
/* ------------------------------------------------------------------ */
/* URLs */
/* ------------------------------------------------------------------ */
/** Allow http(s), mailto, and relative URLs. Anything with another scheme (javascript:, data:, vbscript:) is dropped. */
export function safeUrl(raw: string): string | null {
const url = raw.trim().replace(/^<|>$/g, "");
// Strip control characters and whitespace that browsers ignore inside schemes ("java\nscript:").
const compact = url.replace(/[\u0000- \u007f-\u009f]/g, "");
if (/^(https?:|mailto:)/i.test(compact)) return url;
if (/^[a-z][a-z0-9+.-]*:/i.test(compact)) return null;
return url;
}
export function slugify(text: string): string {
return (
text
.toLowerCase()
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.replace(/[\s-]+/g, "-")
.slice(0, 80) || "section"
);
}
/* ------------------------------------------------------------------ */
/* Inline parser */
/* ------------------------------------------------------------------ */
const ESCAPABLE = "\\`*_{}[]()#+-.!|~<>";
/** Find the closing `]` that matches the `[` at `open`, honouring nesting and escapes. */
function matchBracket(s: string, open: number): number {
let depth = 0;
for (let i = open; i < s.length; i++) {
if (s[i] === "\\") {
i++;
continue;
}
if (s[i] === "[") depth++;
else if (s[i] === "]" && --depth === 0) return i;
}
return -1;
}
/** Parse `(url "title")` starting at `open`; returns the url and the index after `)`. */
function parseDestination(s: string, open: number): { url: string; end: number } | null {
if (s[open] !== "(") return null;
let depth = 0;
for (let i = open; i < s.length; i++) {
if (s[i] === "(") depth++;
else if (s[i] === ")" && --depth === 0) {
const inner = s.slice(open + 1, i).trim();
const url = inner.replace(/\s+"[^"]*"$/, "").trim();
return { url, end: i + 1 };
}
}
return null;
}
export function parseInline(src: string): Inline[] {
const out: Inline[] = [];
let text = "";
const flush = () => {
if (text) out.push({ type: "text", value: text });
text = "";
};
for (let i = 0; i < src.length; ) {
const c = src[i]!;
if (c === "\\" && i + 1 < src.length && ESCAPABLE.includes(src[i + 1]!)) {
text += src[i + 1];
i += 2;
continue;
}
// Hard break: two spaces or a backslash before a newline.
if (c === "\n") {
if (/ {2,}$/.test(text)) {
text = text.replace(/ +$/, "");
flush();
out.push({ type: "br" });
} else text += " ";
i++;
continue;
}
if (c === "`") {
const run = /^`+/.exec(src.slice(i))![0];
const close = src.indexOf(run, i + run.length);
if (close !== -1) {
flush();
out.push({ type: "code", value: src.slice(i + run.length, close).replace(/^ (.*) $/, "$1") });
i = close + run.length;
continue;
}
text += run;
i += run.length;
continue;
}
if (c === "<") {
const m = /^<(https?:\/\/[^\s<>]+|mailto:[^\s<>]+)>/.exec(src.slice(i));
if (m) {
flush();
out.push({ type: "link", href: safeUrl(m[1]!), rawHref: m[1]!, children: [{ type: "text", value: m[1]!.replace(/^mailto:/, "") }] });
i += m[0].length;
continue;
}
}
if (c === "!" && src[i + 1] === "[") {
const close = matchBracket(src, i + 1);
const dest = close !== -1 ? parseDestination(src, close + 1) : null;
if (dest) {
flush();
out.push({ type: "image", src: safeUrl(dest.url), rawSrc: dest.url, alt: src.slice(i + 2, close) });
i = dest.end;
continue;
}
}
if (c === "[") {
const close = matchBracket(src, i);
const dest = close !== -1 ? parseDestination(src, close + 1) : null;
if (dest) {
flush();
out.push({ type: "link", href: safeUrl(dest.url), rawHref: dest.url, children: parseInline(src.slice(i + 1, close)) });
i = dest.end;
continue;
}
}
// Emphasis: **x** / __x__ (strong), *x* / _x_ (em), ~~x~~ (del). The content must not start or end with a space.
const delim = src.startsWith("**", i) ? "**" : src.startsWith("__", i) ? "__" : src.startsWith("~~", i) ? "~~" : c === "*" || c === "_" ? c : null;
if (delim) {
const intraword = delim[0] === "_" && /\w/.test(src[i - 1] ?? "");
let close = src.indexOf(delim, i + delim.length);
// For single * or _, skip over a double delimiter so "*a **b** c*" nests correctly.
while (delim.length === 1 && close !== -1 && src[close + 1] === delim) close = src.indexOf(delim, close + 2);
const inner = close === -1 ? "" : src.slice(i + delim.length, close);
const closesWord = delim[0] === "_" && /\w/.test(src[close + delim.length] ?? "");
if (!intraword && !closesWord && inner && !/^\s|\s$/.test(inner)) {
flush();
const type = delim === "~~" ? "del" : delim.length === 2 ? "strong" : "em";
out.push({ type, children: parseInline(inner) });
i = close + delim.length;
continue;
}
}
text += c;
i++;
}
flush();
return out;
}
export function inlineText(nodes: Inline[]): string {
return nodes
.map((n) => {
if (n.type === "text" || n.type === "code") return n.value;
if (n.type === "image") return n.alt;
if (n.type === "br") return " ";
return inlineText(n.children);
})
.join("");
}
/* ------------------------------------------------------------------ */
/* Block parser */
/* ------------------------------------------------------------------ */
const RE = {
fence: /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]*)\s*$/,
heading: /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/,
hr: /^ {0,3}([-*_])(\s*\1){2,}\s*$/,
quote: /^ {0,3}> ?/,
bullet: /^( {0,3})([-*+])\s+(.*)$/,
ordered: /^( {0,3})(\d{1,9})[.)]\s+(.*)$/,
tableSep: /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/,
blank: /^\s*$/,
};
const splitRow = (line: string) =>
line
.trim()
.replace(/^\||\|$/g, "")
.split(/(?<!\\)\|/)
.map((c) => c.trim().replace(/\\\|/g, "|"));
function startsBlock(line: string, next: string | undefined): boolean {
return (
RE.fence.test(line) ||
RE.heading.test(line) ||
RE.hr.test(line) ||
RE.quote.test(line) ||
RE.bullet.test(line) ||
RE.ordered.test(line) ||
(line.includes("|") && next !== undefined && RE.tableSep.test(next))
);
}
type Ctx = { ids: Map<string, number> };
function uniqueId(ctx: Ctx, text: string) {
const base = slugify(text);
const n = ctx.ids.get(base) ?? 0;
ctx.ids.set(base, n + 1);
return n ? `${base}-${n}` : base;
}
function parseLines(lines: string[], ctx: Ctx, offset: number): Block[] {
const blocks: Block[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i]!;
const lineNo = offset + i + 1;
if (RE.blank.test(line)) {
i++;
continue;
}
const fence = RE.fence.exec(line);
if (fence) {
const marker = fence[1]!;
const body: string[] = [];
i++;
while (i < lines.length && !new RegExp(`^ {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}\\s*$`).test(lines[i]!)) body.push(lines[i++]!);
i++; // closing fence (or end of input: an unclosed fence runs to the end, like CommonMark)
blocks.push({ type: "code", lang: fence[2] ?? "", value: body.join("\n"), line: lineNo });
continue;
}
const h = RE.heading.exec(line);
if (h) {
const children = parseInline(h[2]!);
blocks.push({ type: "heading", depth: h[1]!.length as 1 | 2 | 3 | 4 | 5 | 6, id: uniqueId(ctx, inlineText(children)), children, line: lineNo });
i++;
continue;
}
if (RE.hr.test(line)) {
blocks.push({ type: "hr", line: lineNo });
i++;
continue;
}
if (RE.quote.test(line)) {
const inner: string[] = [];
while (i < lines.length && (RE.quote.test(lines[i]!) || (!RE.blank.test(lines[i]!) && inner.length && !startsBlock(lines[i]!, lines[i + 1])))) {
inner.push(lines[i]!.replace(RE.quote, ""));
i++;
}
blocks.push({ type: "blockquote", children: parseLines(inner, ctx, offset + i - inner.length), line: lineNo });
continue;
}
const bullet = RE.bullet.exec(line);
const ordered = RE.ordered.exec(line);
if (bullet || ordered) {
const isOrdered = !!ordered;
const itemRe = isOrdered ? RE.ordered : RE.bullet;
const items: ListItem[] = [];
let tight = true;
let start = isOrdered ? Number(ordered![2]) : 1;
if (!Number.isFinite(start)) start = 1;
while (i < lines.length) {
const m = itemRe.exec(lines[i]!);
if (!m) break;
const indent = m[1]!.length + (isOrdered ? m[2]!.length + 2 : 2);
const content = [m[3]!];
const itemLine = offset + i + 1;
i++;
// Continuation: indented lines, or blank lines followed by indented lines.
while (i < lines.length) {
const l = lines[i]!;
if (RE.blank.test(l)) {
const nextIndented = lines[i + 1] !== undefined && /^\s{2,}\S/.test(lines[i + 1]!);
if (!nextIndented) break;
tight = false;
content.push("");
i++;
continue;
}
const lead = /^\s*/.exec(l)![0].length;
if (lead >= Math.min(indent, 2)) content.push(l.slice(Math.min(lead, indent)));
else if (!startsBlock(l, lines[i + 1]) && content.at(-1) !== "") content.push(l); // lazy paragraph continuation
else break;
i++;
}
if (i < lines.length && RE.blank.test(lines[i]!) && lines[i + 1] !== undefined && itemRe.test(lines[i + 1]!)) {
tight = false;
i++;
}
let checked: boolean | null = null;
const task = /^\[([ xX])\]\s+/.exec(content[0]!);
if (task) {
checked = task[1] !== " ";
content[0] = content[0]!.slice(task[0].length);
}
items.push({ checked, children: parseLines(content, ctx, itemLine - 1) });
}
blocks.push({ type: "list", ordered: isOrdered, start, tight, items, line: lineNo });
continue;
}
if (line.includes("|") && lines[i + 1] !== undefined && RE.tableSep.test(lines[i + 1]!)) {
const head = splitRow(line);
const align = splitRow(lines[i + 1]!).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : null));
i += 2;
const rows: Inline[][][] = [];
while (i < lines.length && lines[i]!.includes("|") && !RE.blank.test(lines[i]!)) {
const cells = splitRow(lines[i]!);
rows.push(head.map((_, k) => parseInline(cells[k] ?? "")));
i++;
}
blocks.push({ type: "table", align: head.map((_, k) => align[k] ?? null), head: head.map((c) => parseInline(c)), rows, line: lineNo });
continue;
}
// Paragraph: until a blank line or the start of another block.
const para: string[] = [line];
i++;
while (i < lines.length && !RE.blank.test(lines[i]!) && !startsBlock(lines[i]!, lines[i + 1])) para.push(lines[i++]!);
blocks.push({ type: "paragraph", children: parseInline(para.join("\n").trim()), line: lineNo });
}
return blocks;
}
/** Parse a Markdown body. `lineOffset` lets reported line numbers account for frontmatter above the body. */
export function parseMarkdown(src: string, lineOffset = 0): Block[] {
return parseLines(src.replace(/\r\n?/g, "\n").replace(/\t/g, " ").split("\n"), { ids: new Map() }, lineOffset);
}
/* ------------------------------------------------------------------ */
/* HTML output (for the export tab and RSS) */
/* ------------------------------------------------------------------ */
export const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
function inlineHtml(nodes: Inline[]): string {
return nodes
.map((n) => {
switch (n.type) {
case "text":
return escapeHtml(n.value);
case "code":
return `<code>${escapeHtml(n.value)}</code>`;
case "strong":
return `<strong>${inlineHtml(n.children)}</strong>`;
case "em":
return `<em>${inlineHtml(n.children)}</em>`;
case "del":
return `<del>${inlineHtml(n.children)}</del>`;
case "br":
return "<br>";
case "link":
return n.href ? `<a href="${escapeHtml(n.href)}"${/^https?:/i.test(n.href) ? ' rel="noopener noreferrer"' : ""}>${inlineHtml(n.children)}</a>` : inlineHtml(n.children);
case "image":
return n.src ? `<img src="${escapeHtml(n.src)}" alt="${escapeHtml(n.alt)}" loading="lazy">` : "";
}
})
.join("");
}
export function toHtml(blocks: Block[], indent = ""): string {
return blocks
.map((b) => {
switch (b.type) {
case "heading":
return `${indent}<h${b.depth} id="${b.id}">${inlineHtml(b.children)}</h${b.depth}>`;
case "paragraph":
return `${indent}<p>${inlineHtml(b.children)}</p>`;
case "code":
return `${indent}<pre><code${b.lang ? ` class="language-${escapeHtml(b.lang)}"` : ""}>${escapeHtml(b.value)}</code></pre>`;
case "hr":
return `${indent}<hr>`;
case "blockquote":
return `${indent}<blockquote>\n${toHtml(b.children, indent + " ")}\n${indent}</blockquote>`;
case "list": {
const tag = b.ordered ? "ol" : "ul";
const start = b.ordered && b.start !== 1 ? ` start="${b.start}"` : "";
const items = b.items
.map((it) => {
const box = it.checked === null ? "" : `<input type="checkbox" disabled${it.checked ? " checked" : ""}> `;
const only = it.children.length === 1 && it.children[0]!.type === "paragraph" && b.tight;
// Tight lists render their paragraphs without <p>, like CommonMark.
const inner = b.tight
? it.children.map((c) => (c.type === "paragraph" ? `${indent} ${inlineHtml(c.children)}` : toHtml([c], indent + " "))).join("\n")
: toHtml(it.children, indent + " ");
const body = only ? inlineHtml((it.children[0] as { children: Inline[] }).children) : `\n${inner}\n${indent} `;
return `${indent} <li>${box}${body}</li>`;
})
.join("\n");
return `${indent}<${tag}${start}>\n${items}\n${indent}</${tag}>`;
}
case "table": {
const cell = (tag: string, k: number, c: Inline[]) => `<${tag}${b.align[k] ? ` style="text-align:${b.align[k]}"` : ""}>${inlineHtml(c)}</${tag}>`;
const head = `<tr>${b.head.map((c, k) => cell("th", k, c)).join("")}</tr>`;
const rows = b.rows.map((r) => `${indent} <tr>${r.map((c, k) => cell("td", k, c)).join("")}</tr>`).join("\n");
return `${indent}<table>\n${indent} <thead>${head}</thead>\n${indent} <tbody>\n${rows}\n${indent} </tbody>\n${indent}</table>`;
}
}
})
.join("\n");
}
/* ------------------------------------------------------------------ */
/* Tiny syntax highlighter for code blocks */
/* ------------------------------------------------------------------ */
export type Token = { kind: "kw" | "str" | "com" | "num" | "fn" | "plain"; value: string };
const KEYWORDS: Record<string, string[]> = {
js: ["const", "let", "var", "function", "return", "if", "else", "for", "while", "import", "from", "export", "default", "async", "await", "new", "class", "extends", "type", "interface", "true", "false", "null", "undefined", "throw", "try", "catch"],
css: ["@media", "@import", "!important"],
bash: ["npm", "npx", "pnpm", "cd", "git", "echo", "export", "sudo"],
sql: ["select", "from", "where", "insert", "into", "values", "update", "set", "delete", "create", "table", "index", "on", "join", "left", "group", "by", "order", "limit", "and", "or", "not", "null", "primary", "key"],
};
const LANG_ALIAS: Record<string, keyof typeof KEYWORDS> = { js: "js", jsx: "js", ts: "js", tsx: "js", javascript: "js", typescript: "js", json: "js", css: "css", bash: "bash", sh: "bash", shell: "bash", sql: "sql" };
export function highlight(code: string, lang: string): Token[] {
const family = LANG_ALIAS[lang.toLowerCase()];
if (!family) return [{ kind: "plain", value: code }];
const kw = new Set(KEYWORDS[family]);
const comment = family === "sql" ? /^--[^\n]*/ : family === "bash" ? /^#[^\n]*/ : /^(\/\/[^\n]*|\/\*[\s\S]*?\*\/)/;
const tokens: Token[] = [];
let rest = code;
const push = (kind: Token["kind"], value: string) => {
const last = tokens.at(-1);
if (last && last.kind === kind && kind === "plain") last.value += value;
else tokens.push({ kind, value });
};
while (rest) {
let m: RegExpExecArray | null;
if ((m = comment.exec(rest))) push("com", m[0]);
else if ((m = /^("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)/.exec(rest))) push("str", m[0]);
else if ((m = /^\b\d+(\.\d+)?\b/.exec(rest))) push("num", m[0]);
else if ((m = /^[@!]?[A-Za-z_$][\w$-]*/.exec(rest))) {
const word = m[0];
if (kw.has(family === "sql" ? word.toLowerCase() : word)) push("kw", word);
else if (rest[word.length] === "(") push("fn", word);
else push("plain", word);
} else {
push("plain", rest[0]!);
rest = rest.slice(1);
continue;
}
rest = rest.slice(m[0].length);
}
return tokens;
}