// 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, 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(//g, ""); s = s.replace(/[{}]/g, (c) => `\\${c}`); s = s.replace(/<(?!\/?(?:details|summary)\s*>)/g, "<"); return s; }) .join(""); } // ---- code tabs -------------------------------------------------------------- function tabsFor(rust, ts) { const block = (c) => c.lines.join("\n"); return [ ``, ``, ``, block(rust), ``, ``, ``, ``, block(ts), ``, ``, ``, ].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`); }