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( `
${renderLatex(formula.join("\n"), true)}
`, ); continue; } const fence = /^\s*```(\w*)\s*$/.exec(line); if (fence) { const language = fence[1] ?? ""; const code: string[] = []; index += 1; while ( index < lines.length && !/^\s*```\s*$/.test(lines[index] as string) ) { code.push(lines[index] as string); index += 1; } index += 1; const className = language ? ` class="language-${language}"` : ""; 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( `${renderInline(heading[2] as string)}`, ); index += 1; continue; } if (/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(line)) { output.push("
"); index += 1; continue; } const quote = /^\s*>\s?(.*)$/.exec(line); if (quote) { const quoted: string[] = []; while (index < lines.length) { const next = /^\s*>\s?(.*)$/.exec(lines[index] as string); if (!next) break; quoted.push(next[1] as string); index += 1; } 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(String(item))}
  • `) .join(""); output.push(`<${tag}>${rendered}`); continue; } const paragraph: string[] = []; while (index < lines.length) { const current = lines[index] as string; if (current.trim().length === 0) break; if (/^(#{1,6})\s+/.test(current)) break; if (/^\s*([-*+]\s|\d+[.)]\s|>)/.test(current)) break; paragraph.push(current.trim()); index += 1; } output.push(`

    ${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, '$1', ); text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '$1'); text = text.replace(/\*\*([^*]+)\*\*/g, "$1"); text = text.replace(/__([^_]+)__/g, "$1"); text = text.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2"); text = text.replace(/~~([^~]+)~~/g, "$1"); text = text.replace(/\uE000(\d+)\uE000/g, (_match, index: string) => { return `${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), }; }, }; }