// reads docs/*.md and renders them. the markdown stays the only source: this adds heading // ids, turns links between docs into site urls, highlights code, and fails the build on a // link to a doc or heading that doesn't exist. import fs from "node:fs"; import path from "node:path"; import { markdownToHast } from "satteri"; import GithubSlugger from "github-slugger"; import { visit, SKIP } from "unist-util-visit"; import { toString } from "hast-util-to-string"; import { toHtml } from "hast-util-to-html"; import { createHighlighter } from "shiki"; import katex from "katex"; import type { Root, Element, ElementContent } from "hast"; import { placements, SETTINGS_PAGES, type Placement } from "./nav"; import { buildSettings, type SettingsSection } from "./settings"; export const DOCS_DIR = path.resolve(process.env.HYDRANT_DOCS_DIR ?? path.join(process.cwd(), "../docs")); export interface Heading { depth: number; text: string; id: string; } export interface Doc { /** path under docs/ without .md, e.g. "api/filter" */ id: string; title: string; url: string; place: Placement; headings: Heading[]; /** set on settings pages (configuration), in page order */ settings?: SettingsSection[]; html: string; /** the rendered tree, for pages that restructure their own content */ tree: Root; } /** README is a section's own page, everything else keeps its path */ export function urlFor(id: string): string { if (id === "README") return "/overview/"; if (id.endsWith("/README")) return `/${id.slice(0, -"/README".length)}/`; return `/${id}/`; } function listDocs(dir: string, prefix = ""): string[] { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { const rel = prefix ? `${prefix}/${e.name}` : e.name; if (e.isDirectory()) return listDocs(path.join(dir, e.name), rel); return e.name.endsWith(".md") ? [rel.slice(0, -3)] : []; }); } export function frontmatter(src: string): { title?: string; body: string } { const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src); if (!m) return { body: src }; const title = /^title:\s*(.+?)\s*$/m.exec(m[1])?.[1]; return { title, body: src.slice(m[0].length) }; } const isElement = (n: unknown, tag?: string): n is Element => (n as Element)?.type === "element" && (!tag || (n as Element).tagName === tag); /** a doc without frontmatter (the specs) is titled by its first h1, which then leaves the body */ function takeTitle(tree: Root): string | undefined { const i = tree.children.findIndex((n) => isElement(n, "h1")); if (i < 0) return undefined; const title = toString(tree.children[i] as Element).replace(/^Spec:\s*/, ""); tree.children.splice(i, 1); return title; } function addHeadingIds(tree: Root): Heading[] { const slugger = new GithubSlugger(); const out: Heading[] = []; visit(tree, "element", (node: Element) => { const m = /^h([1-6])$/.exec(node.tagName); if (!m) return; const text = toString(node); const id = slugger.slug(text); node.properties.id = id; out.push({ depth: Number(m[1]), text, id }); return SKIP; }); return out; } const EXTERNAL = /^[a-z][a-z0-9+.-]*:/i; function rewriteLinks(tree: Root, from: string, ids: Map>, errors: string[]) { visit(tree, "element", (node: Element) => { if (node.tagName !== "a") return; const href = String(node.properties.href ?? ""); if (!href || EXTERNAL.test(href)) return; const [file, hash = ""] = href.split("#"); let target = from; if (file) { if (!file.endsWith(".md")) { errors.push(`${from}.md links to ${href}, which isn't a doc`); return; } target = path.posix.normalize(path.posix.join(path.posix.dirname(from), file.slice(0, -3))); } const anchors = ids.get(target); if (!anchors) { errors.push(`${from}.md links to ${href}, but there's no docs/${target}.md`); return; } if (hash && !anchors.has(hash)) { errors.push(`${from}.md links to ${href}, but docs/${target}.md has no heading #${hash}`); return; } node.properties.href = file ? urlFor(target) + (hash ? `#${hash}` : "") : `#${hash}`; }); } type Highlighter = Awaited>; function highlightCode(tree: Root, hl: Highlighter) { const langs = new Set(hl.getLoadedLanguages()); visit(tree, "element", (node: Element, index, parent) => { if (node.tagName !== "pre" || !parent || index === undefined) return; const code = node.children.find((c): c is Element => isElement(c, "code")); if (!code) return; const cls = ((code.properties.className as string[] | undefined) ?? []).find((c) => c.startsWith("language-")); const lang = cls?.slice("language-".length); const text = toString(code).replace(/\n$/, ""); const highlighted = hl.codeToHast(text, { lang: lang && langs.has(lang) ? lang : "text", theme: "hydrant", }); const pre = highlighted.children[0] as Element; // shiki writes its own `class` too, which would come out as a second class attribute delete pre.properties.class; pre.properties.className = ["code"]; delete pre.properties.style; delete pre.properties.tabindex; if (lang) pre.properties.dataLang = lang; parent.children[index] = { type: "element", tagName: "div", properties: { className: ["codebox"] }, children: [pre] }; return SKIP; }); } /** a paragraph that is all $$ math is a display block on github and tangled, even written * `$$a\nb$$`. the parser only knows the fenced `$$\na\nb\n$$` form (and otherwise reads to * the end of the doc looking for the closing fence), so fence those first. code is left alone */ function fenceMath(md: string): string { return md .split(/(^```[\s\S]*?^```)/m) .map((part, i) => (i % 2 ? part : part.replace(/(^|\n\n)\$\$(?!\n)((?:(?!\n\n)[\s\S])+?)\$\$(?=\n\n|\n?$)/g, "$1$$$$\n$2\n$$$$"))) .join(""); } /** $$ math as mathml, which browsers draw themselves, so there are no math fonts or css to * ship */ function renderMath(tree: Root): string[] { const errors: string[] = []; visit(tree, "element", (node: Element, index, parent) => { const cls = (node.properties.className as string[] | undefined) ?? []; if (node.tagName !== "code" || !cls.includes("language-math") || !parent || index === undefined) return; const alone = isElement(parent, "pre"); let html: string; try { html = katex.renderToString(toString(node), { displayMode: alone, output: "mathml", throwOnError: true }); } catch (e) { errors.push(`math that doesn't render: ${(e as Error).message}`); return; } const raw = { type: "raw", value: html } as unknown as ElementContent; if (!alone) { parent.children[index] = raw; return; } Object.assign(parent, { tagName: "div", properties: { className: ["math"] }, children: [raw] }); }); return errors; } /** wide tables scroll inside the paper instead of stretching it */ function wrapTables(tree: Root) { visit(tree, "element", (node: Element, index, parent) => { if (node.tagName !== "table" || !parent || index === undefined) return; parent.children[index] = { type: "element", tagName: "div", properties: { className: ["tablewrap"] }, children: [node] }; return SKIP; }); } // code colours are the palette's --code-* variables. shiki only takes real colours, so each // role gets a stand-in (#000001, #000002, ...) that's swapped for its variable on the way out. const ROLES: { role: string; scope: string[]; fontStyle?: string }[] = [ { role: "text", scope: [] }, { role: "comment", scope: ["comment", "punctuation.definition.comment"], fontStyle: "italic" }, { role: "string", scope: ["string", "string.quoted", "markup.inline.raw"] }, { role: "constant", scope: ["constant.numeric", "constant.language", "constant.character"] }, { role: "keyword", scope: ["keyword", "storage", "storage.type", "keyword.control"], fontStyle: "bold" }, { role: "function", scope: ["entity.name.function", "support.function"] }, { role: "type", scope: ["entity.name.type", "support.type", "entity.name.class"] }, { role: "property", scope: ["support.type.property-name", "meta.object-literal.key", "entity.name.tag"] }, { role: "parameter", scope: ["variable.parameter", "variable.other.option"] }, ]; const standIn = (i: number) => `#${(i + 1).toString(16).padStart(6, "0")}`; const THEME = { name: "hydrant", type: "light" as const, colors: { "editor.foreground": standIn(0) }, tokenColors: ROLES.slice(1).map(({ scope, fontStyle }, i) => ({ scope, settings: { foreground: standIn(i + 1), fontStyle } })), colorReplacements: Object.fromEntries(ROLES.map(({ role }, i) => [standIn(i), `var(--code-${role})`])), }; let cache: Promise> | undefined; /** built once per build; in dev every request reads the docs again, so edits show on reload */ export function loadDocs(): Promise> { if (import.meta.env.DEV) return build(); cache ??= build(); return cache; } async function build(): Promise> { const nav = placements(); const errors: string[] = []; const found = listDocs(DOCS_DIR).sort(); for (const id of found) if (!nav.has(id)) errors.push(`docs/${id}.md isn't in site/src/lib/nav.ts, so it has no place on the site`); for (const id of nav.keys()) if (!found.includes(id)) errors.push(`site/src/lib/nav.ts lists ${id}, but there's no docs/${id}.md`); const parsed = found.filter((id) => nav.has(id)).map((id) => { const { title: fmTitle, body } = frontmatter(fs.readFileSync(path.join(DOCS_DIR, `${id}.md`), "utf8")); // only $$ is math, so a lone $ in prose stays a dollar sign const tree = markdownToHast(fenceMath(body), { features: { math: { singleDollarTextMath: false } } }) as Root; for (const e of renderMath(tree)) errors.push(`docs/${id}.md: ${e}`); const title = fmTitle ?? takeTitle(tree) ?? id; const headings = addHeadingIds(tree); let settings: SettingsSection[] | undefined; try { settings = SETTINGS_PAGES.includes(id) ? buildSettings(tree, id) : undefined; } catch (e) { errors.push((e as Error).message); } return { id, title, tree, headings, settings }; }); // a setting is linkable like a heading: configuration.md#FIREHOSE_WORKERS const ids = new Map(parsed.map((d) => [d.id, new Set([...d.headings.map((h) => h.id), ...(d.settings ?? []).flatMap((s) => s.settings.map((x) => x.key))])])); for (const d of parsed) rewriteLinks(d.tree, d.id, ids, errors); if (errors.length) throw new Error(`the docs don't build:\n ${errors.join("\n ")}`); const hl = await createHighlighter({ themes: [THEME], langs: ["bash", "json", "rust", "go", "toml"] }); const out = new Map(); for (const d of parsed) { highlightCode(d.tree, hl); wrapTables(d.tree); out.set(d.id, { ...d, url: urlFor(d.id), place: nav.get(d.id)!, html: toHtml(d.tree, { allowDangerousHtml: true }) }); } return out; }