wip: course website (incomplete — Task 15 in progress)

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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 05:18:08 +01:00
co-authored by Claude Opus 5.5
parent 1b8fdb0d96
commit d41496655e
7 changed files with 399 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.astro/
src/content/docs/course/
+28
View File
@@ -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" } },
],
}),
],
});
+19
View File
@@ -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"
}
}
+245
View File
@@ -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<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, "&lt;");
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`);
}
+95
View File
@@ -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(/<Tabs syncKey="lang">/g).length, 1);
assert.match(out, /<TabItem label="Rust">\n\n```rust\nfn a\(\) \{\}\n```\n\n<\/TabItem>\n<TabItem label="TypeScript">\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 <!-- Task 17: re-verify --> b {x} and `{kept}` <details><summary>S</summary> 1 < 2");
assert.doesNotMatch(out, /<!--/);
assert.match(out, /a b \\\{x\\\} and `\{kept\}` <details><summary>S<\/summary> 1 &lt; 2/);
const root = fixture({ "01-a": `# A\n\n<!-- hidden -->\n\n${FENCE}rust\nif a < b { <!-- keep --> }\n${FENCE}\n` });
const page = run(root)("01-a.mdx");
assert.doesNotMatch(page, /hidden/);
assert.match(page, /if a < b \{ <!-- keep --> \}/); // 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"]);
});
+7
View File
@@ -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() }),
};
+1
View File
@@ -0,0 +1 @@
{ "extends": "astro/tsconfigs/strict", "include": [".astro/types.d.ts", "**/*"], "exclude": ["dist"] }