276 lines
7.7 KiB
TypeScript
276 lines
7.7 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, "&")
|
|
.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(`<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),
|
|
};
|
|
},
|
|
};
|
|
}
|