Files
systemic-game-design-course/site/scripts/sync-content.mjs
T
Lucas WintherandClaude Opus 5.5 fb3d280be7 feat: playable SpellGrid page on the site (the highest TS reference, unmodified, in the browser)
The sync step copies the reference's engine, harness and content into
site/src/game/generated/ and replaces only content/index.ts (node:fs) with a
re-export of a browser content module (static imports of cards, words, seams,
levels). /play/ has a level picker, text-tile grid, keyboard and buttons, cards
then tiles, the chooser as buttons, rules, minions, the log tree, Undo by
replay and Restart. test:site plays l3 to the win under plain Node. Lessons
5, 10-13 and the home page link the page; lesson links to site pages go
through their source file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:22:50 +01:00

339 lines
13 KiB
JavaScript

// Generates site/src/content/docs/course/** from course/**/lesson.md and course/harness.md,
// and site/src/game/generated/** (the play page's game) from the highest TypeScript reference.
// Nothing is copied by hand: the lessons and the references stay the single source of truth.
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, rmSync, cpSync } 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: to a lesson's page, to a site page's URL, or to the file in
* the repository. `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("/");
// A page of the site itself (site/src/pages/play.astro) links to its URL (/play/).
const page = rel.match(/^site\/src\/pages\/(.+)\.astro$/);
if (page) {
const url = page[1] === "index" ? "/" : `/${page[1].replace(/\/index$/, "")}/`;
return `](${url}${anchor ? "#" + anchor : ""}${title})`;
}
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;");
// 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);
}
// ---- the playable SpellGrid (/play/) ------------------------------------------
/** First module whose workspace gets course/cards (as COURSE_CARDS_FROM in tools/lib.mjs). */
const COURSE_CARDS_FROM = 5;
/** The idea of each level, from the module's lesson: `**Level 3, "Title".** Idea. Hand: ...` */
export function levelIdeas(lessonMd) {
const ideas = new Map();
for (const m of lessonMd.matchAll(/^\*\*Level (\d+), "([^"]+)"\.\*\*\s+(.*)$/gm)) {
const idea = m[3].replace(/\s*Hand: [^.]*\.\s*$/, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
ideas.set(Number(m[1]), { title: m[2], idea });
}
return ideas;
}
/**
* Generates the sources of the play page into `outDir` (site/src/game/generated):
* the TypeScript reference of the highest module that has one, copied unmodified
* (`engine/`, `harness.ts`, and `content/` except its `index.ts`, which reads the cards
* with node:fs); a `content/index.ts` that re-exports the browser content module
* (`site/src/game/content.browser.ts`); and, for that module to import statically,
* `cards.json` (the cards a workspace at that module has, in file-name order) and
* `levels.json` (every level, with its title and idea from the lesson).
*/
export function syncGame({ repoRoot, outDir }) {
const courseDir = join(repoRoot, "course");
const modules = readdirSync(courseDir).filter((d) => /^\d\d-/.test(d)).sort();
const module = modules.filter((d) => existsSync(join(courseDir, d, "reference/ts/src/harness.ts"))).at(-1);
if (!module) throw new Error("no module has a TypeScript reference with a harness");
const upTo = modules.filter((d) => d <= module);
const src = join(courseDir, module, "reference/ts/src");
rmSync(outDir, { recursive: true, force: true });
mkdirSync(join(outDir, "content"), { recursive: true });
cpSync(join(src, "engine"), join(outDir, "engine"), { recursive: true });
cpSync(join(src, "harness.ts"), join(outDir, "harness.ts"));
for (const f of readdirSync(join(src, "content"))) {
if (f !== "index.ts") cpSync(join(src, "content", f), join(outDir, "content", f));
}
writeFileSync(
join(outDir, "content", "index.ts"),
`// Generated by site/scripts/sync-content.mjs: the browser's content module, in place of\n` +
`// course/${module}/reference/ts/src/content/index.ts (which reads the cards with node:fs).\n` +
`export * from "../../content.browser";\n`,
);
// Cards: as tools/play-spellgrid.mjs stages them, one directory over another, in file-name order.
const cardDirs = [
...(Number(module.slice(0, 2)) >= COURSE_CARDS_FROM ? [join(courseDir, "cards")] : []),
...upTo.map((d) => join(courseDir, d, "cards")),
].filter((d) => existsSync(d));
const cardFiles = new Map();
for (const dir of cardDirs) {
for (const f of readdirSync(dir)) if (f.endsWith(".json")) cardFiles.set(f, join(dir, f));
}
const cards = [...cardFiles.keys()].sort().map((f) => JSON.parse(readFileSync(cardFiles.get(f), "utf8")));
writeFileSync(join(outDir, "cards.json"), JSON.stringify(cards));
const levels = [];
for (const d of upTo) {
const dir = join(courseDir, d, "levels");
if (!existsSync(dir)) continue;
const lesson = join(courseDir, d, "lesson.md");
const ideas = levelIdeas(existsSync(lesson) ? readFileSync(lesson, "utf8") : "");
const files = readdirSync(dir)
.filter((f) => f.endsWith(".json") && f !== "proofs.json")
.sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
for (const f of files) {
const name = f.slice(0, -".json".length);
const n = Number(name.match(/^l(\d+)/)?.[1]);
const about = ideas.get(n);
if (!about) throw new Error(`course/${d}/lesson.md: no **Level ${n}, "Title".** line for ${f}`);
levels.push({ name, ...about, level: JSON.parse(readFileSync(join(dir, f), "utf8")) });
}
}
writeFileSync(join(outDir, "levels.json"), JSON.stringify(levels));
return { module, cards: cards.length, levels: levels.length };
}
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`);
const game = syncGame({ repoRoot, outDir: resolve(here, "../src/game/generated") });
console.log(`sync-content: the play page runs course/${game.module} (${game.cards} cards, ${game.levels} levels)`);
}