Pages (index, disclaimer, about), lockfile, root test:site script, fix for Starlight 0.42 sidebar config and <details> rendering in generated MDX. Co-Authored-By: Claude Opus 5.5 <[email protected]>
250 lines
8.7 KiB
JavaScript
250 lines
8.7 KiB
JavaScript
// Generates site/src/content/docs/course/** from course/**/lesson.md and course/harness.md.
|
|
// Nothing is copied by hand: the lessons stay the single source of truth.
|
|
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, rmSync } from "node:fs";
|
|
import { dirname, join, resolve, relative, sep } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const REPO_URL = "https://gitea.lucaswinther.info/lucasw89/systemic-game-design-course";
|
|
export const BLOB_BASE = `${REPO_URL}/src/branch/main/`;
|
|
|
|
const IMPORT_LINE = `import { Tabs, TabItem } from "@astrojs/starlight/components";`;
|
|
const FENCE_OPEN = /^(\s*)(`{3,}|~{3,})(.*)$/;
|
|
|
|
// ---- headings / slugs -------------------------------------------------------
|
|
|
|
function stripInlineMarkdown(text) {
|
|
return text
|
|
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
.replace(/[`*_~]/g, "")
|
|
.replace(/<[^>]+>/g, "");
|
|
}
|
|
|
|
// Approximates github-slugger, which Starlight uses for heading ids.
|
|
export function slugify(text) {
|
|
return stripInlineMarkdown(text)
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^\p{L}\p{N}\p{M}\- _]/gu, "")
|
|
.replace(/ /g, "-");
|
|
}
|
|
|
|
/** Split markdown into alternating prose / fenced-code chunks. */
|
|
export function splitFences(md) {
|
|
const lines = md.split("\n");
|
|
const chunks = [];
|
|
let cur = { code: false, lines: [] };
|
|
let fence = null;
|
|
for (const line of lines) {
|
|
if (!fence) {
|
|
const m = line.match(FENCE_OPEN);
|
|
if (m && !(m[2][0] === "`" && m[3].includes("`"))) {
|
|
if (cur.lines.length) chunks.push(cur);
|
|
cur = { code: true, lines: [line], lang: m[3].trim().split(/\s+/)[0] ?? "", indent: m[1] };
|
|
fence = { ch: m[2][0], len: m[2].length };
|
|
continue;
|
|
}
|
|
cur.lines.push(line);
|
|
} else {
|
|
cur.lines.push(line);
|
|
const m = line.match(/^\s*(`{3,}|~{3,})\s*$/);
|
|
if (m && m[1][0] === fence.ch && m[1].length >= fence.len) {
|
|
chunks.push(cur);
|
|
cur = { code: false, lines: [] };
|
|
fence = null;
|
|
}
|
|
}
|
|
}
|
|
if (cur.lines.length) chunks.push(cur);
|
|
return chunks;
|
|
}
|
|
|
|
export function headingSlugs(md) {
|
|
const seen = new Map();
|
|
const out = new Set();
|
|
for (const c of splitFences(md)) {
|
|
if (c.code) continue;
|
|
for (const line of c.lines) {
|
|
const m = line.match(/^#{1,6}\s+(.*?)\s*#*\s*$/);
|
|
if (!m) continue;
|
|
const base = slugify(m[1]);
|
|
const n = seen.get(base) ?? 0;
|
|
seen.set(base, n + 1);
|
|
out.add(n === 0 ? base : `${base}-${n}`);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---- links ------------------------------------------------------------------
|
|
|
|
const LINK = /\]\(([^)\s]+)((?:\s+"[^"]*")?)\)/g;
|
|
|
|
/**
|
|
* Rewrites relative links. `ctx` = { repoRoot, file (absolute path of the source), lessonFiles: Map<abs path, url>, readMd }.
|
|
*/
|
|
export function rewriteLinks(prose, ctx) {
|
|
return prose.replace(LINK, (whole, href, title) => {
|
|
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//")) return whole;
|
|
const hashAt = href.indexOf("#");
|
|
const path = hashAt === -1 ? href : href.slice(0, hashAt);
|
|
const anchor = hashAt === -1 ? "" : href.slice(hashAt + 1);
|
|
const where = relative(ctx.repoRoot, ctx.file);
|
|
if (path === "") {
|
|
if (anchor && !ctx.ownSlugs.has(anchor)) {
|
|
throw new Error(`${where}: dead anchor #${anchor}`);
|
|
}
|
|
return whole;
|
|
}
|
|
const target = resolve(dirname(ctx.file), path);
|
|
if (!target.startsWith(ctx.repoRoot + sep) || !existsSync(target)) {
|
|
throw new Error(`${where}: dead link ${href}`);
|
|
}
|
|
const siteUrl = ctx.lessonFiles.get(target);
|
|
if (siteUrl) {
|
|
if (anchor && !headingSlugs(ctx.readMd(target)).has(anchor)) {
|
|
throw new Error(`${where}: dead anchor ${href} (no heading "${anchor}" in ${relative(ctx.repoRoot, target)})`);
|
|
}
|
|
return `](${siteUrl}${anchor ? "#" + anchor : ""}${title})`;
|
|
}
|
|
const rel = relative(ctx.repoRoot, target).split(sep).join("/");
|
|
return `](${BLOB_BASE}${rel}${anchor ? "#" + anchor : ""}${title})`;
|
|
});
|
|
}
|
|
|
|
// ---- MDX safety -------------------------------------------------------------
|
|
|
|
const INLINE_CODE = /(`+)[\s\S]*?[^`]\1(?!`)|(`+)[^`]\2(?!`)/g;
|
|
|
|
/** Escape text that MDX would treat as JSX/expressions, and drop HTML comments. Inline code is untouched. */
|
|
export function mdxSafeProse(prose) {
|
|
const parts = [];
|
|
let last = 0;
|
|
for (const m of prose.matchAll(INLINE_CODE)) {
|
|
parts.push({ code: false, s: prose.slice(last, m.index) });
|
|
parts.push({ code: true, s: m[0] });
|
|
last = m.index + m[0].length;
|
|
}
|
|
parts.push({ code: false, s: prose.slice(last) });
|
|
return parts
|
|
.map((p) => {
|
|
if (p.code) return p.s;
|
|
let s = p.s.replace(/<!--[\s\S]*?-->/g, "");
|
|
s = s.replace(/[{}]/g, (c) => `\\${c}`);
|
|
s = s.replace(/<(?!\/?(?:details|summary)\s*>)/g, "<");
|
|
// Put each details/summary tag on its own line with blank lines around it, so the
|
|
// markdown inside <details> is parsed as markdown rather than as JSX text.
|
|
s = s.replace(/[ \t]*(<details>|<\/details>|<summary>[^\n]*?<\/summary>)[ \t]*/g, "\n\n$1\n\n");
|
|
s = s.replace(/\n{3,}/g, "\n\n");
|
|
return s;
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
// ---- code tabs --------------------------------------------------------------
|
|
|
|
function tabsFor(rust, ts) {
|
|
const block = (c) => c.lines.join("\n");
|
|
return [
|
|
`<Tabs syncKey="lang">`,
|
|
`<TabItem label="Rust">`,
|
|
``,
|
|
block(rust),
|
|
``,
|
|
`</TabItem>`,
|
|
`<TabItem label="TypeScript">`,
|
|
``,
|
|
block(ts),
|
|
``,
|
|
`</TabItem>`,
|
|
`</Tabs>`,
|
|
].join("\n");
|
|
}
|
|
|
|
const isBlank = (c) => !c.code && c.lines.every((l) => l.trim() === "");
|
|
|
|
/** Turn adjacent ```rust + ```ts fences (only blank lines between) into Starlight tabs. */
|
|
export function pairCodeTabs(md) {
|
|
const chunks = splitFences(md);
|
|
const out = [];
|
|
let used = false;
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
const c = chunks[i];
|
|
if (c.code && c.lang === "rust" && c.indent === "") {
|
|
let j = i + 1;
|
|
while (j < chunks.length && isBlank(chunks[j])) j++;
|
|
const n = chunks[j];
|
|
if (n && n.code && (n.lang === "ts" || n.lang === "typescript") && n.indent === "") {
|
|
out.push({ code: false, lines: ["", tabsFor(c, n), ""] });
|
|
used = true;
|
|
i = j;
|
|
continue;
|
|
}
|
|
}
|
|
out.push(c);
|
|
}
|
|
return { md: out.map((c) => c.lines.join("\n")).join("\n"), used };
|
|
}
|
|
|
|
// ---- one document -----------------------------------------------------------
|
|
|
|
export function transformDoc(src, { order, ctx }) {
|
|
const h1 = src.match(/^#\s+(.+?)\s*$/m);
|
|
if (!h1) throw new Error(`${relative(ctx.repoRoot, ctx.file)}: no H1 title`);
|
|
const title = h1[1];
|
|
const body = src.replace(h1[0], "").replace(/^\n+/, "");
|
|
ctx.ownSlugs = headingSlugs(src);
|
|
|
|
const processed = splitFences(body)
|
|
.map((c) => {
|
|
if (c.code) return c.lines.join("\n");
|
|
const text = c.lines.join("\n");
|
|
return mdxSafeProse(rewriteLinks(text, ctx));
|
|
})
|
|
.join("\n");
|
|
|
|
const { md, used } = pairCodeTabs(processed);
|
|
const front = [
|
|
"---",
|
|
`title: ${JSON.stringify(stripInlineMarkdown(title))}`,
|
|
"sidebar:",
|
|
` order: ${order}`,
|
|
"---",
|
|
"",
|
|
].join("\n");
|
|
return `${front}${used ? IMPORT_LINE + "\n\n" : ""}${md.replace(/\n+$/, "")}\n`;
|
|
}
|
|
|
|
// ---- whole course -----------------------------------------------------------
|
|
|
|
export function syncContent({ repoRoot, outDir }) {
|
|
const courseDir = join(repoRoot, "course");
|
|
const modules = readdirSync(courseDir)
|
|
.filter((d) => /^\d\d-/.test(d) && existsSync(join(courseDir, d, "lesson.md")))
|
|
.sort();
|
|
const lessonFiles = new Map();
|
|
for (const d of modules) lessonFiles.set(join(courseDir, d, "lesson.md"), `/course/${d}/`);
|
|
const harness = join(courseDir, "harness.md");
|
|
const jobs = modules.map((d) => ({ file: join(courseDir, d, "lesson.md"), out: `${d}.mdx`, order: Number(d.slice(0, 2)) }));
|
|
if (existsSync(harness)) {
|
|
lessonFiles.set(harness, "/course/harness/");
|
|
jobs.push({ file: harness, out: "harness.mdx", order: 100 });
|
|
}
|
|
const cache = new Map();
|
|
const readMd = (f) => cache.get(f) ?? (cache.set(f, readFileSync(f, "utf8")), cache.get(f));
|
|
rmSync(outDir, { recursive: true, force: true });
|
|
mkdirSync(outDir, { recursive: true });
|
|
for (const j of jobs) {
|
|
const ctx = { repoRoot, file: j.file, lessonFiles, readMd };
|
|
writeFileSync(join(outDir, j.out), transformDoc(readMd(j.file), { order: j.order, ctx }));
|
|
}
|
|
return jobs.map((j) => j.out);
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = resolve(here, "../..");
|
|
const outDir = resolve(here, "../src/content/docs/course");
|
|
const written = syncContent({ repoRoot, outDir });
|
|
console.log(`sync-content: wrote ${written.length} pages`);
|
|
}
|