import type { Loader, LoaderFile } from "../contracts/index.ts"; /** * Markdown support. * * A small, dependency free renderer that covers the subset used by content * collections: headings, paragraphs, lists, quotes, code and the usual inline * markup. The loader turns a markdown file into frontmatter and html. */ export interface Frontmatter { [key: string]: unknown; } /** Parses the frontmatter of a markdown file. */ export function parseFrontmatter(source: string): { frontmatter: Frontmatter; body: string; } { const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(source); if (!match) return { frontmatter: {}, body: source }; const frontmatter = parseYaml(match[1] as string); return { frontmatter, body: source.slice(match[0].length) }; } /** Parses a small subset of yaml: scalars, lists and nested maps. */ export function parseYaml(source: string): Frontmatter { const result: Frontmatter = {}; const lines = source.split("\n"); let index = 0; while (index < lines.length) { const line = (lines[index] as string).trimEnd(); index += 1; if (line.trim().length === 0 || line.trimStart().startsWith("#")) continue; const separator = line.indexOf(":"); if (separator === -1) continue; const key = line.slice(0, separator).trim(); const raw = line.slice(separator + 1).trim(); if (raw.length === 0) { const items: unknown[] = []; while (index < lines.length) { const next = (lines[index] as string).trim(); if (next.length === 0) { index += 1; continue; } if (!next.startsWith("- ")) break; items.push(parseScalar(next.slice(2).trim())); index += 1; } result[key] = items.length > 0 ? items : ""; continue; } result[key] = parseScalar(raw); } return result; } function parseScalar(raw: string): unknown { if (raw.startsWith("[") && raw.endsWith("]")) { return raw .slice(1, -1) .split(",") .map((entry) => parseScalar(entry.trim())) .filter((entry) => entry !== ""); } if (raw === "true") return true; if (raw === "false") return false; if (raw === "null") return null; if (raw !== "" && !Number.isNaN(Number(raw))) return Number(raw); return raw.replace(/^['"]|['"]$/g, ""); } /** Escapes the html entities of a text. */ export function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } /** * Renders markdown to html. * * The renderer is intentionally small, it covers the constructs used by the * content collections of a project. */ export function renderMarkdown(source: string): string { const lines = source.replace(/\r\n/g, "\n").split("\n"); const output: string[] = []; let index = 0; while (index < lines.length) { const line = lines[index] as string; if (line.trim().length === 0) { index += 1; continue; } if (/^\s*\$\$\s*$/.test(line)) { const formula: string[] = []; index += 1; while ( index < lines.length && !/^\s*\$\$\s*$/.test(lines[index] as string) ) { formula.push(lines[index] as string); index += 1; } if (index < lines.length) index += 1; output.push( `
${escapeHtml(code.join("\n"))}`,
);
continue;
}
if (/^\s*@@@\s*$/.test(line)) {
const graph: string[] = [];
index += 1;
while (
index < lines.length &&
!/^\s*@@@\s*$/.test(lines[index] as string)
) {
graph.push(lines[index] as string);
index += 1;
}
index += 1;
output.push(
`${escapeHtml(graph.join("\n"))}`,
);
continue;
}
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) {
const level = (heading[1] as string).length;
output.push(
`${renderMarkdown(quoted.join("\n"))}`, ); continue; } const bullet = /^\s*[-*+]\s+(.*)$/.exec(line); const ordered = /^\s*\d+[.)]\s+(.*)$/.exec(line); if (bullet || ordered) { const tag = bullet ? "ul" : "ol"; const items: unknown[] = []; while (index < lines.length) { const current = lines[index] as string; const match = bullet ? /^\s*[-*+]\s+(.*)$/.exec(current) : /^\s*\d+[.)]\s+(.*)$/.exec(current); if (!match) break; items.push(match[1] as string); index += 1; } const rendered = items .map((item) => `
${renderInline(paragraph.join(" "))}
`); } return output.join("\n"); } /** Renders the inline markup of a text. */ export function renderInline(source: string): string { const codes: string[] = []; const maths: string[] = []; let text = source.replace(/`([^`]+)`/g, (_match, code: string) => { codes.push(code); return `\uE000${codes.length - 1}\uE000`; }); text = text.replace(/(? { maths.push(renderLatex(formula)); return `\uE001${maths.length - 1}\uE001`; }); text = escapeHtml(text); text = text.replace( /!\[([^\]]*)\]\(([^)\s]+)\)/g, '${escapeHtml(codes[Number(index)] ?? "")}`;
});
return text.replace(
/\uE001(\d+)\uE001/g,
(_match, index: string) => maths[Number(index)] ?? "",
);
}
/** Renders a trusted LaTex formula to KaTeX HTML and MathML. */
export function renderLatex(source: string, displayMode = false): string {
return escapeHtml(
`${displayMode ? "$$" : "$"}${source}${displayMode ? "$$" : "$"}`,
);
}
export interface MarkdownLoaderOptions {
/** Directory the loader scans, relative to the project root. */
dir?: string;
}
/**
* Loader for markdown files.
*
* The loader turns a markdown file into its frontmatter and its rendered
* html, exactly like a content collection needs it.
*/
export function markdownLoader(_options: MarkdownLoaderOptions = {}): Loader {
return {
name: "markdown",
extensions: [".md", ".markdown"],
load(file: LoaderFile) {
const { frontmatter, body } = parseFrontmatter(file.source);
return {
data: frontmatter,
content: renderMarkdown(body),
};
},
};
}