Skip to content

Commit af137c3

Browse files
TMHSDigitalclaude
andcommitted
site: build starter stack pages and link them from the homepage, entries, sitemap and llms files
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ
1 parent 47424ac commit af137c3

5 files changed

Lines changed: 149 additions & 14 deletions

File tree

‎site/build.mjs‎

Lines changed: 104 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import { LinkError, makeLinkResolver } from "./lib/links.mjs";
1212
import { llmsFullTxt, llmsTxt } from "./lib/llms.mjs";
1313
import { deprecationReason, MarkdownError, renderBlocks, renderInline, splitEntryBody } from "./lib/markdown.mjs";
1414
import { checkPage } from "./lib/page-checks.mjs";
15+
import { checkStacks } from "./checks.mjs";
16+
import { stackPageHtml } from "./lib/stack-page.mjs";
17+
import { licenceTerms, owes, parseStack, pickPath } from "./lib/stacks.mjs";
1518

1619
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1720
const ROOT = path.resolve(__dirname, "..");
@@ -20,6 +23,8 @@ const PUBLIC = path.join(__dirname, "public");
2023
const DIST = path.join(__dirname, "dist");
2124
const CONFIG_PATH = path.join(__dirname, "config.json");
2225
const VOCAB_PATH = path.join(__dirname, "license-vocabulary.json");
26+
const SPDX_ALLOWED_PATH = path.join(__dirname, "spdx-allowed.json");
27+
const STACKS = path.join(ROOT, "stacks");
2328
// Social preview image: a first-party screenshot of this site, kept with the
2429
// other first-party stills (the validator allows binaries there) and copied
2530
// into dist at build time.
@@ -329,10 +334,11 @@ function headMetaHtml(site, stats, generatedAt, hasCard) {
329334
].join("\n ");
330335
}
331336

332-
function sitemapXml(site, entries) {
337+
function sitemapXml(site, entries, stacks = []) {
333338
const base = site.siteUrl.replace(/\/+$/, "");
334339
const urls = [
335340
` <url><loc>${esc(base)}/</loc></url>`,
341+
...stacks.map((s) => ` <url><loc>${esc(base)}/stack/${esc(s.meta.id)}/</loc><lastmod>${esc(s.meta.walked)}</lastmod></url>`),
336342
...entries.map(
337343
(e) =>
338344
` <url><loc>${esc(base)}/entry/${esc(e.id)}/</loc>${e.verified ? `<lastmod>${esc(e.verified)}</lastmod>` : ""}</url>`
@@ -382,19 +388,87 @@ function copyDir(src, dest) {
382388
}
383389
}
384390

385-
/** Returns entry => link resolver for links in that entry's body. */
386-
function linkResolvers(entries, config) {
391+
/** Returns file => link resolver for links in that file's body (`file.path` is repo-relative). */
392+
function linkResolvers(entries, config, entryBase = "../") {
387393
const idByPath = new Map(entries.map((e) => [e.path, e.id]));
388394
const kindOf = (repoPath) => {
389395
const full = path.join(ROOT, repoPath);
390396
if (!fs.existsSync(full)) return null;
391397
return fs.statSync(full).isDirectory() ? "dir" : "file";
392398
};
393-
return (entry) => makeLinkResolver({ entryPath: entry.path, idByPath, repo: config.site.repo, kindOf });
399+
return (file) => makeLinkResolver({ entryPath: file.path, idByPath, repo: config.site.repo, kindOf, entryBase });
400+
}
401+
402+
/** Reads stacks/*.md, checks them (V15) and resolves each pick to its entry. */
403+
function loadStacks(entries, vocab, spdxAllowed) {
404+
if (!fs.existsSync(STACKS)) return { stacks: [], errors: [] };
405+
const files = fs
406+
.readdirSync(STACKS)
407+
.filter((n) => n.endsWith(".md") && n !== "README.md")
408+
.sort()
409+
.map((n) => ({ rel: `stacks/${n}`, text: fs.readFileSync(path.join(STACKS, n), "utf8") }));
410+
const terms = licenceTerms(vocab, spdxAllowed);
411+
const byPath = new Map(entries.map((e) => [e.path, e]));
412+
const errors = checkStacks(files, byPath, terms, new Date().toISOString().slice(0, 10));
413+
if (errors.length) return { stacks: [], errors };
414+
const stacks = files.map(({ rel, text }) => {
415+
const parsed = parseStack(text, { file: rel, terms });
416+
const picked = parsed.sections.flatMap((s) =>
417+
s.picks.map((p) => ({ ...p, section: s.name, entry: byPath.get(pickPath(rel, p.href)) }))
418+
);
419+
return { ...parsed, rel, picked };
420+
});
421+
return { stacks, errors };
422+
}
423+
424+
/** Writes dist/stack/<id>/index.html for every stack. */
425+
function writeStackPages({ stacks, entries, config, stats, stamp, hasCard, now }) {
426+
const errors = [];
427+
const written = [];
428+
const resolverFor = linkResolvers(entries, config, "../../entry/");
429+
for (const stack of stacks) {
430+
try {
431+
const resolveLink = resolverFor({ path: stack.rel });
432+
const leadHtml = renderBlocks(stack.lead, { file: stack.rel, resolveLink, firstLine: stack.leadLine });
433+
const sections = stack.sections.map((s) => ({
434+
name: s.name,
435+
rows: stack.picked
436+
.filter((p) => p.section === s.name)
437+
.map((p) => ({ need: p.need, entry: p.entry, whyHtml: renderInline(p.why, resolveLink) })),
438+
}));
439+
const gapsHtml = stack.gaps.map((g) => renderInline(g.text, resolveLink));
440+
const html = stackPageHtml({
441+
stack,
442+
sections,
443+
gapsHtml,
444+
leadHtml,
445+
owed: owes(stack.picked),
446+
site: config.site,
447+
stamp,
448+
total: stats.total,
449+
hasCard,
450+
now,
451+
});
452+
const dir = path.join(DIST, "stack", stack.meta.id);
453+
fs.mkdirSync(dir, { recursive: true });
454+
fs.writeFileSync(path.join(dir, "index.html"), html);
455+
written.push(`stack/${stack.meta.id}/index.html`);
456+
} catch (err) {
457+
if (err instanceof MarkdownError || err instanceof LinkError) errors.push(err.message);
458+
else throw err;
459+
}
460+
}
461+
return { errors, written };
462+
}
463+
464+
function stackRowsHtml(stacks) {
465+
return stacks
466+
.map((s) => `<li><a href="stack/${esc(s.meta.id)}/">${esc(s.meta.title)}<span>${esc(s.meta.task)}</span></a></li>`)
467+
.join("\n");
394468
}
395469

396470
/** Writes dist/entry/<id>/index.html for every entry. */
397-
function writeEntryPages({ entries, bodies, config, stats, stamp, hasCard, now }) {
471+
function writeEntryPages({ entries, bodies, config, stats, stamp, hasCard, now, usedIn = new Map() }) {
398472
const errors = [];
399473
const written = [];
400474
const resolverFor = linkResolvers(entries, config);
@@ -428,6 +502,7 @@ function writeEntryPages({ entries, bodies, config, stats, stamp, hasCard, now }
428502
total: stats.total,
429503
hasCard,
430504
now,
505+
stacks: usedIn.get(entry.id) || [],
431506
});
432507
const dir = path.join(DIST, "entry", entry.id);
433508
fs.mkdirSync(dir, { recursive: true });
@@ -456,6 +531,17 @@ function main() {
456531
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
457532
const vocab = JSON.parse(fs.readFileSync(VOCAB_PATH, "utf8"));
458533
const { entries, errors, bodies } = loadEntries(vocab);
534+
const spdxAllowed = JSON.parse(fs.readFileSync(SPDX_ALLOWED_PATH, "utf8"));
535+
const { stacks, errors: stackErrors } = loadStacks(entries, vocab, spdxAllowed);
536+
// Entry id => the stacks that pick it, for "Used in" on entry pages.
537+
const usedIn = new Map();
538+
for (const s of stacks) {
539+
for (const p of s.picked) {
540+
const list = usedIn.get(p.entry.id) || [];
541+
if (!list.some((x) => x.id === s.meta.id)) list.push({ id: s.meta.id, title: s.meta.title });
542+
usedIn.set(p.entry.id, list);
543+
}
544+
}
459545

460546
if (errors.length) {
461547
console.error("Build warnings:");
@@ -514,6 +600,7 @@ function main() {
514600
const visible = entries.filter((e) => e.status !== "deprecated");
515601
const substitutions = {
516602
HEAD_META: headMetaHtml(config.site, stats, payload.generatedAt, hasCard),
603+
STACK_ROWS: stackRowsHtml(stacks),
517604
STARTER_ROWS: starterRowsHtml(featured),
518605
ENTRY_ROWS: groupedRowsHtml(visible, config.categories, repoUrl, now),
519606
CATEGORY_CHIPS: categoryChipsHtml(visible, config.categories),
@@ -533,12 +620,18 @@ function main() {
533620
}
534621
fs.writeFileSync(indexPath, html);
535622

536-
fs.writeFileSync(path.join(DIST, "sitemap.xml"), sitemapXml(config.site, visible));
623+
fs.writeFileSync(path.join(DIST, "sitemap.xml"), sitemapXml(config.site, visible, stacks));
537624
fs.writeFileSync(path.join(DIST, "robots.txt"), robotsTxt(config.site));
538625
fs.writeFileSync(path.join(DIST, "404.html"), notFoundHtml(config.site));
539626

540-
const pages = writeEntryPages({ entries, bodies, config, stats, stamp, hasCard, now });
541-
const pageErrors = [...pages.errors, ...checkPages(["index.html", ...pages.written])];
627+
const pages = writeEntryPages({ entries, bodies, config, stats, stamp, hasCard, now, usedIn });
628+
const stackPages = writeStackPages({ stacks, entries, config, stats, stamp, hasCard, now });
629+
const pageErrors = [
630+
...stackErrors,
631+
...pages.errors,
632+
...stackPages.errors,
633+
...checkPages(["index.html", ...pages.written, ...stackPages.written]),
634+
];
542635
if (pageErrors.length) {
543636
console.error(`Page build failed (${pageErrors.length}):`);
544637
for (const e of pageErrors.slice(0, 50)) console.error(` - ${e}`);
@@ -547,7 +640,7 @@ function main() {
547640

548641
// After the page gate: every body link has resolved by now, so the llms
549642
// files cannot hit a link error of their own.
550-
fs.writeFileSync(path.join(DIST, "llms.txt"), llmsTxt({ entries, site: config.site, categories: config.categories }));
643+
fs.writeFileSync(path.join(DIST, "llms.txt"), llmsTxt({ entries, site: config.site, categories: config.categories, stacks }));
551644
fs.writeFileSync(
552645
path.join(DIST, "llms-full.txt"),
553646
llmsFullTxt({
@@ -556,14 +649,15 @@ function main() {
556649
categories: config.categories,
557650
bodies,
558651
resolverFor: linkResolvers(entries, config),
652+
stacks,
559653
})
560654
);
561655

562656
console.log(
563657
`Built ${entries.length} entries → site/dist (${stats.active} active, ${stats.commercialOk} commercial-ok, ${stats.commercialVaries} per-file)`
564658
);
565659
console.log(
566-
`Prerendered ${visible.length} entry rows into index.html; wrote ${pages.written.length} entry pages, sitemap.xml, robots.txt, 404.html`
660+
`Prerendered ${visible.length} entry rows into index.html; wrote ${pages.written.length} entry pages, ${stackPages.written.length} stack pages, sitemap.xml, robots.txt, 404.html`
567661
);
568662
if (errors.length) process.exitCode = 0; // soft-fail missing fields as warnings
569663
}

‎site/lib/entry-page.mjs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ function credit(entry) {
5555
return { value: "Unclear: read the Notes", script: false, block: "" };
5656
}
5757

58-
export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml, prev, next, site, categoryLabel, stamp, total, hasCard, now }) {
58+
export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml, prev, next, site, categoryLabel, stamp, total, hasCard, now, stacks = [] }) {
5959
const repo = site.repo;
6060
const url = entryPageUrl(site, entry.id);
6161
const title = `${entry.name} (${entry.license}) | ${site.title}`;
@@ -99,6 +99,9 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,
9999
${next ? `<a class="btn-ghost" href="../${esc(next.id)}/" rel="next">${esc(next.name)} <span aria-hidden="true">&rarr;</span></a>` : "<span></span>"}
100100
</nav>`
101101
: "";
102+
const usedIn = stacks.length
103+
? `<p class="used-in">Used in: ${stacks.map((s) => `<a href="../../stack/${esc(s.id)}/">${esc(s.title)}</a>`).join(", ")}</p>`
104+
: "";
102105
return `<!DOCTYPE html>
103106
<html lang="en">
104107
<head>
@@ -129,6 +132,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,
129132
<a class="brand" href="../../">Free Game Dev Assets</a>
130133
<nav class="topnav" aria-label="Primary">
131134
<a href="../../#catalog">Catalog</a>
135+
<a href="../../#stacks">Stacks</a>
132136
<a href="../../#starters">Starters</a>
133137
<a href="../../#guides">Guides</a>
134138
<a href="${esc(repo)}" rel="noopener noreferrer">GitHub</a>
@@ -155,6 +159,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,
155159
<a class="btn-ghost" href="${esc(`${repo}/blob/main/${entry.path}`)}" rel="noopener noreferrer">View the file on GitHub</a>
156160
<a class="btn-ghost" href="${esc(issue)}" rel="noopener noreferrer">Report a problem with this entry</a>
157161
</p>
162+
${usedIn}
158163
<div class="entry-content">${restHtml}</div>
159164
${pager}
160165
</main>

‎site/lib/lib.test.mjs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,18 @@ lacks("stack json-ld cannot close its script", sld, "</script");
307307
eq("stack json-ld lists the picks", JSON.parse(sld).itemListElement[0].url, "https://x.test/s/entry/a/");
308308
eq("stack json-ld name survives", stackJsonLd(stackMeta, [{ rows: [] }], site).name, 'Make a "thing" </script>');
309309

310+
/* stacks: links in --------------------------------------------------------- */
311+
const pageWithStacks = entryPageHtml({ entry: base, leadHtml: "<p>Lead</p>", restHtml: "", deprecatedReasonHtml: null, prev: null, next: null, site, categoryLabel: "2D", stamp: "2026-09-24", total: 319, hasCard: true, now: Date.parse("2026-09-24T00:00:00Z"), stacks: [{ id: "s1", title: "Make <X>" }] });
312+
has("used in links the stack", pageWithStacks, 'Used in: <a href="../../stack/s1/">Make &lt;X&gt;</a>');
313+
lacks("no used in without stacks", p, "Used in");
314+
const llmsStacks = [{ meta: { id: "s1", title: "Make X", task: "A task.", walked: "2026-09-01" }, picked: [{ need: "Tiles", entry: llmsEntries[0] }] }];
315+
const lts = llmsTxt({ entries: llmsEntries, site, categories: cats, stacks: llmsStacks });
316+
has("llms lists stacks", lts, "## Starter stacks\n\n- [Make X](https://x.test/s/stack/s1/): A task.");
317+
lacks("llms without stacks has no stacks section", lt, "Starter stacks");
318+
const lfs = llmsFullTxt({ entries: llmsEntries, site, categories: cats, bodies: new Map(), stacks: llmsStacks });
319+
has("full lists each pick", lfs, "- Tiles: Alpha (https://x.test/s/entry/a1/); CC0; commercial OK; no credit required; active");
320+
has("full lists credits", lfs, "Credits to ship: none");
321+
310322
/* report (keep last) ------------------------------------------------------ */
311323
if (failures.length) {
312324
console.error(`lib.test failed (${failures.length}):`);

‎site/lib/llms.mjs‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/** Plain-text indexes of the catalog for AI assistants (llmstxt.org format). */
22
import { sectionMarkdown, splitEntryBody } from "./markdown.mjs";
3-
import { commercialLabel, entryPageUrl } from "./shared.mjs";
3+
import { commercialLabel, entryPageUrl, stackPageUrl } from "./shared.mjs";
4+
import { owes } from "./stacks.mjs";
45

56
/** The first paragraph of the body before its first `##`, lines joined. */
67
function leadParagraph(body) {
@@ -46,17 +47,31 @@ function groups(entries, categories) {
4647
.filter((g) => g.items.length);
4748
}
4849

49-
export function llmsTxt({ entries, site, categories }) {
50+
export function llmsTxt({ entries, site, categories, stacks = [] }) {
5051
const out = [header(site)];
52+
if (stacks.length) {
53+
out.push(`\n## Starter stacks\n`);
54+
for (const s of stacks) out.push(`- [${s.meta.title}](${stackPageUrl(site, s.meta.id)}): ${s.meta.task}`);
55+
}
5156
for (const g of groups(entries, categories)) {
5257
out.push(`\n## ${g.label}\n`);
5358
for (const e of g.items) out.push(`- [${e.name}](${entryPageUrl(site, e.id)}): ${facts(e)}`);
5459
}
5560
return `${out.join("\n")}\n`;
5661
}
5762

58-
export function llmsFullTxt({ entries, site, categories, bodies, resolverFor }) {
63+
export function llmsFullTxt({ entries, site, categories, bodies, resolverFor, stacks = [] }) {
5964
const out = [header(site)];
65+
if (stacks.length) {
66+
out.push(`\n## Starter stacks\n`);
67+
for (const s of stacks) {
68+
const credits = owes(s.picked).credits.map((c) => c.entry.name);
69+
const picks = s.picked.map((p) => `- ${p.need}: ${p.entry.name} (${entryPageUrl(site, p.entry.id)}); ${facts(p.entry)}`);
70+
out.push(
71+
`### ${s.meta.title}\n\nPage: ${stackPageUrl(site, s.meta.id)}\nTask: ${s.meta.task}\nWalked: ${s.meta.walked}\n\n${picks.join("\n")}\n\nCredits to ship: ${credits.length ? credits.join(", ") : "none"}\n`
72+
);
73+
}
74+
}
6075
for (const g of groups(entries, categories)) {
6176
out.push(`\n## ${g.label}\n`);
6277
for (const e of g.items) {

‎site/public/index.html‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<a class="brand" href="#top">Free Game Dev Assets</a>
2626
<nav class="topnav" aria-label="Primary">
2727
<a href="#catalog">Catalog</a>
28+
<a href="#stacks">Stacks</a>
2829
<a href="#starters">Starters</a>
2930
<a href="#guides">Guides</a>
3031
<a id="repo-link" href="https://github.com/TMHSDigital/Free-Game-Dev-Assets" rel="noopener noreferrer">GitHub</a>
@@ -44,6 +45,14 @@ <h1 id="hero-title">Free Game Dev Assets</h1>
4445
</p>
4546
</section>
4647

48+
<section class="section" id="stacks" aria-labelledby="stacks-title">
49+
<div class="section-head">
50+
<h2 id="stacks-title">Starter stacks</h2>
51+
<p>One pick per need for a kind of game, and what the whole set owes.</p>
52+
</div>
53+
<ul class="stack-list"><!--STACK_ROWS--></ul>
54+
</section>
55+
4756
<section class="section" id="starters" aria-labelledby="starters-title">
4857
<div class="section-head">
4958
<h2 id="starters-title">Safe starting points</h2>

0 commit comments

Comments
 (0)