From d41496655eeb369ca4765a85c0c8fddd1a3a2e4b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 29 Oct 2026 05:18:08 +0100 Subject: [PATCH] =?UTF-8?q?wip:=20course=20website=20(incomplete=20?= =?UTF-8?q?=E2=80=94=20Task=2015=20in=20progress)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partial: Astro Starlight config, sync-content.mjs + 6 passing tests. Missing: pages (index, disclaimer, about), harness page, lockfile, verified build. See SDD ledger. Co-Authored-By: Claude Opus 5.5 --- site/.gitignore | 4 + site/astro.config.mjs | 28 ++++ site/package.json | 19 +++ site/scripts/sync-content.mjs | 245 +++++++++++++++++++++++++++++ site/scripts/sync-content.test.mjs | 95 +++++++++++ site/src/content.config.ts | 7 + site/tsconfig.json | 1 + 7 files changed, 399 insertions(+) create mode 100644 site/.gitignore create mode 100644 site/astro.config.mjs create mode 100644 site/package.json create mode 100644 site/scripts/sync-content.mjs create mode 100644 site/scripts/sync-content.test.mjs create mode 100644 site/src/content.config.ts create mode 100644 site/tsconfig.json diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 0000000..0fbd153 --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.astro/ +src/content/docs/course/ diff --git a/site/astro.config.mjs b/site/astro.config.mjs new file mode 100644 index 0000000..c1cc9a1 --- /dev/null +++ b/site/astro.config.mjs @@ -0,0 +1,28 @@ +import { defineConfig } from "astro/config"; +import starlight from "@astrojs/starlight"; + +// The site is served at the root of its own subdomain. Set COURSE_HOST at build time +// (e.g. COURSE_HOST=course.example.com); the default is a placeholder. +const host = process.env.COURSE_HOST ?? "course.example.com"; + +export default defineConfig({ + site: `https://${host}`, + base: "/", + integrations: [ + starlight({ + title: "Systemic Design Course", + description: "A free, hands-on course on extensible systemic game design, in Rust and TypeScript.", + social: [ + { + icon: "external", + label: "Source repository", + href: "https://gitea.lucaswinther.info/lucasw89/systemic-game-design-course", + }, + ], + sidebar: [ + { label: "Start here", items: ["disclaimer", "about"] }, + { label: "Modules", autogenerate: { directory: "course" } }, + ], + }), + ], +}); diff --git a/site/package.json b/site/package.json new file mode 100644 index 0000000..3e3bcbd --- /dev/null +++ b/site/package.json @@ -0,0 +1,19 @@ +{ + "name": "systemic-design-course-site", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "sync": "node scripts/sync-content.mjs", + "predev": "npm run sync", + "dev": "astro dev", + "prebuild": "npm run sync", + "build": "astro build", + "preview": "astro preview", + "test": "node --test scripts/*.test.mjs" + }, + "dependencies": { + "@astrojs/starlight": "0.42.7", + "astro": "7.4.4" + } +} diff --git a/site/scripts/sync-content.mjs b/site/scripts/sync-content.mjs new file mode 100644 index 0000000..188beca --- /dev/null +++ b/site/scripts/sync-content.mjs @@ -0,0 +1,245 @@ +// 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`); +} diff --git a/site/scripts/sync-content.test.mjs b/site/scripts/sync-content.test.mjs new file mode 100644 index 0000000..0cdf83a --- /dev/null +++ b/site/scripts/sync-content.test.mjs @@ -0,0 +1,95 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { syncContent, pairCodeTabs, mdxSafeProse, slugify, BLOB_BASE } from "./sync-content.mjs"; + +function fixture(lessons, extra = {}) { + const root = mkdtempSync(join(tmpdir(), "sync-")); + for (const [dir, md] of Object.entries(lessons)) { + mkdirSync(join(root, "course", dir), { recursive: true }); + writeFileSync(join(root, "course", dir, "lesson.md"), md); + } + for (const [f, md] of Object.entries(extra)) { + mkdirSync(join(root, f, ".."), { recursive: true }); + writeFileSync(join(root, f), md); + } + return root; +} +const run = (root) => { + const out = join(root, "out"); + syncContent({ repoRoot: root, outDir: out }); + return (f) => readFileSync(join(out, f), "utf8"); +}; + +test("sync_rewrites_and_checks_links", () => { + const root = fixture( + { + "01-a": "# Module 1: A\n\nSee [two](../02-x/lesson.md) and [anchor](../02-x/lesson.md#expansion-drop), [tool](../../tools/t.mjs), [sc](./scenarios.md), [ext](https://example.com/a).\n", + "02-x": "# Module 2: X\n\n## Expansion Drop\n\nback to [one](../01-a/lesson.md).\n", + }, + { "tools/t.mjs": "//", "course/01-a/scenarios.md": "# s\n" }, + ); + const page = run(root); + const one = page("01-a.mdx"); + assert.match(one, /title: "Module 1: A"/); + assert.match(one, /order: 1/); + assert.doesNotMatch(one, /^# Module/m); + assert.match(one, /\[two\]\(\/course\/02-x\/\)/); + assert.match(one, /\[anchor\]\(\/course\/02-x\/#expansion-drop\)/); + assert.match(one, new RegExp(`\\[tool\\]\\(${BLOB_BASE}tools/t\\.mjs\\)`)); + assert.match(one, new RegExp(`\\[sc\\]\\(${BLOB_BASE}course/01-a/scenarios\\.md\\)`)); + assert.match(one, /\(https:\/\/example\.com\/a\)/); + + const dead = fixture({ "01-a": "# A\n\n[gone](../09-none/lesson.md)\n" }); + assert.throws(() => run(dead), /dead link/); + const deadFile = fixture({ "01-a": "# A\n\n[gone](./missing.md)\n" }); + assert.throws(() => run(deadFile), /dead link/); +}); + +test("sync_rejects_dead_anchors", () => { + const cross = fixture({ "01-a": "# A\n\n[x](../02-x/lesson.md#nope)\n", "02-x": "# X\n\n## Real\n" }); + assert.throws(() => run(cross), /dead anchor/); + const own = fixture({ "01-a": "# A\n\n## Play it\n\n[ok](#play-it) [bad](#play-it-now)\n" }); + assert.throws(() => run(own), /dead anchor #play-it-now/); +}); + +test("slugify_matches_heading_ids", () => { + assert.equal(slugify("Hint 1 — nudge"), "hint-1--nudge"); + assert.equal(slugify("The `trap` in the wild"), "the-trap-in-the-wild"); +}); + +const FENCE = "```"; +test("sync_pairs_code_tabs", () => { + const md = `intro\n\n${FENCE}rust\nfn a() {}\n${FENCE}\n${FENCE}ts\nfunction a() {}\n${FENCE}\n\nlone:\n\n${FENCE}rust\nfn b() {}\n${FENCE}\n\ntext\n\n${FENCE}sh\nx\n${FENCE}\n${FENCE}sh\ny\n${FENCE}\n`; + const { md: out, used } = pairCodeTabs(md); + assert.ok(used); + assert.equal(out.match(//g).length, 1); + assert.match(out, /\n\n```rust\nfn a\(\) \{\}\n```\n\n<\/TabItem>\n\n\n```ts\nfunction a/); + assert.match(out, /lone:\n\n```rust\nfn b/); // lone block untouched + assert.match(out, /```sh\nx\n```\n```sh\ny\n```/); // adjacent non-rust/ts blocks untouched + + const root = fixture({ "01-a": `# A\n\n${FENCE}rust\nr\n${FENCE}\n${FENCE}ts\nt\n${FENCE}\n` }); + assert.match(run(root)("01-a.mdx"), /^import \{ Tabs, TabItem \} from "@astrojs\/starlight\/components";$/m); + const none = fixture({ "01-a": `# A\n\n${FENCE}rust\nr\n${FENCE}\n` }); + assert.doesNotMatch(run(none)("01-a.mdx"), /import/); +}); + +test("sync_strips_comments_and_escapes_mdx", () => { + const out = mdxSafeProse("a b {x} and `{kept}`
S 1 < 2"); + assert.doesNotMatch(out, /\n\n${FENCE}rust\nif a < b { }\n${FENCE}\n` }); + const page = run(root)("01-a.mdx"); + assert.doesNotMatch(page, /hidden/); + assert.match(page, /if a < b \{ \}/); // code untouched +}); + +test("sync_writes_harness_and_ignores_missing_dirs", () => { + const root = fixture({ "01-a": "# A\n\n[h](../harness.md)\n" }, { "course/harness.md": "# Harness contract\n" }); + const page = run(root); + assert.match(page("01-a.mdx"), /\(\/course\/harness\/\)/); + assert.match(page("harness.mdx"), /title: "Harness contract"/); + assert.deepEqual(readdirSync(join(root, "out")).sort(), ["01-a.mdx", "harness.mdx"]); +}); diff --git a/site/src/content.config.ts b/site/src/content.config.ts new file mode 100644 index 0000000..7fbcf2c --- /dev/null +++ b/site/src/content.config.ts @@ -0,0 +1,7 @@ +import { defineCollection } from "astro:content"; +import { docsLoader } from "@astrojs/starlight/loaders"; +import { docsSchema } from "@astrojs/starlight/schema"; + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), +}; diff --git a/site/tsconfig.json b/site/tsconfig.json new file mode 100644 index 0000000..000824a --- /dev/null +++ b/site/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "astro/tsconfigs/strict", "include": [".astro/types.d.ts", "**/*"], "exclude": ["dist"] }