muddle/views/markdown.ts
2026-10-23 09:12:49 -04:00

291 lines
7.8 KiB
TypeScript

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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* 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(
`<div class="muddle-math">${renderLatex(formula.join("\n"), true)}</div>`,
);
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(
`<pre><code${className}>${escapeHtml(code.join("\n"))}</code></pre>`,
);
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(
`<pre class="mermaid" data-muddle-animate="true">${escapeHtml(graph.join("\n"))}</pre>`,
);
continue;
}
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) {
const level = (heading[1] as string).length;
output.push(
`<h${level}>${renderInline(heading[2] as string)}</h${level}>`,
);
index += 1;
continue;
}
if (/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(line)) {
output.push("<hr>");
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(
`<blockquote>${renderMarkdown(quoted.join("\n"))}</blockquote>`,
);
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) => `<li>${renderInline(String(item))}</li>`)
.join("");
output.push(`<${tag}>${rendered}</${tag}>`);
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(`<p>${renderInline(paragraph.join(" "))}</p>`);
}
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(/(?<!\\)\$([^$\n]+?)\$/g, (_match, formula: string) => {
maths.push(renderLatex(formula));
return `\uE001${maths.length - 1}\uE001`;
});
text = escapeHtml(text);
text = text.replace(
/!\[([^\]]*)\]\(([^)\s]+)\)/g,
'<img src="$2" alt="$1" loading="lazy">',
);
text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>');
text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
text = text.replace(/__([^_]+)__/g, "<strong>$1</strong>");
text = text.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
text = text.replace(/~~([^~]+)~~/g, "<del>$1</del>");
text = text.replace(/\uE000(\d+)\uE000/g, (_match, index: string) => {
return `<code>${escapeHtml(codes[Number(index)] ?? "")}</code>`;
});
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),
};
},
};
}