Skip to content

Commit 1b852b7

Browse files
TMHSDigitalclaude
andcommitted
site: prerender the catalog and fix the deprecated filter (batch D)
Prerender. build.mjs now writes the catalog rows, starter table, guides, head metadata and a build stamp into index.html. The served page contained zero entry names before this, so a catalog whose entire value is discoverability was invisible to search engines and empty without JavaScript. 292 rows are now in the HTML; app.js hydrates over them for search, filters and sort. Also generates sitemap.xml, robots.txt and a 404 page. Two defects found by reading app.js rather than by checklist: Deprecated entries rendered under the "Active" checkbox. matches() gated status "deprecated" on state.active, so purple-planet and freepd, whose own bodies say the sites are dead, sat in the default view beside live sources. Deprecated now has its own toggle, off by default. Every entry title was an <h3> inside a <button>, which is invalid: button takes phrasing content. Heading navigation over 294 entries was unreliable. The card is an <article> with a heading link, which also gives each entry a permalink. Also in this batch: verified age on every row with a colour cue at 180 and 365 days. This is the repo's main credibility signal and it was buried in a modal. Filter, search and sort state in the query string, restored on load, so a filtered view is shareable. #entry-<id> opens that entry. Active filter chips with individual removal and clear-all; empty state that offers the reset. Sort by name, license permissiveness (from the vocabulary's attribution class) or verified date. Copy button for attribution_string, with a select-the-text fallback when the clipboard is refused. Label legend in the catalog view instead of only in catalog/README.md. aria-pressed on category chips, a skip link, and one focus-visible treatment for every focusable control. Dark scheme via prefers-color-scheme. Measured in a browser at 1200px and 375px, both schemes: no console errors, all 16 sampled text styles pass WCAG AA in light (worst 5.29) and dark (worst 6.93), tap targets 44px on mobile, focus returns to the trigger when the dialog closes, and the starter table's nowrap license column no longer forces horizontal page scroll at 375px. Not done, deliberately: virtualization, pagination and search debounce. Measured at 294 entries and below the threshold where they pay for themselves. og:image is omitted rather than faked; it needs a real image file, and the binary-asset rule keeps one out of the repo for now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ
1 parent c7a1059 commit 1b852b7

5 files changed

Lines changed: 921 additions & 109 deletions

File tree

‎site/build.mjs‎

Lines changed: 212 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,28 @@ const CATALOG = path.join(ROOT, "catalog");
1313
const PUBLIC = path.join(__dirname, "public");
1414
const DIST = path.join(__dirname, "dist");
1515
const CONFIG_PATH = path.join(__dirname, "config.json");
16+
const VOCAB_PATH = path.join(__dirname, "license-vocabulary.json");
17+
18+
/** Sort rank for "license permissiveness": least owed first. */
19+
const ATTRIBUTION_RANK = { none: 0, notice: 1, required: 2, any: 3 };
20+
21+
/** Age buckets for the verified-date cue. Days. */
22+
const VERIFIED_FRESH_DAYS = 180;
23+
const VERIFIED_AGING_DAYS = 365;
24+
25+
function verifiedAge(verified, now) {
26+
if (!verified) return { days: null, bucket: "unknown" };
27+
const t = Date.parse(`${verified}T00:00:00Z`);
28+
if (Number.isNaN(t)) return { days: null, bucket: "unknown" };
29+
const days = Math.max(0, Math.floor((now - t) / 86400000));
30+
const bucket =
31+
days <= VERIFIED_FRESH_DAYS
32+
? "fresh"
33+
: days <= VERIFIED_AGING_DAYS
34+
? "aging"
35+
: "stale";
36+
return { days, bucket };
37+
}
1638

1739
function parseScalar(raw) {
1840
const v = raw.trim();
@@ -90,7 +112,7 @@ function walkMarkdown(dir, out = []) {
90112
return out;
91113
}
92114

93-
function loadEntries() {
115+
function loadEntries(vocab) {
94116
const files = walkMarkdown(CATALOG);
95117
const entries = [];
96118
const errors = [];
@@ -143,13 +165,163 @@ function loadEntries() {
143165
...(Array.isArray(meta.hardware_tags) ? { hardware_tags: meta.hardware_tags } : {}),
144166
...(meta.attribution_string ? { attribution_string: String(meta.attribution_string) } : {}),
145167
...(meta.publisher ? { publisher: String(meta.publisher) } : {}),
168+
attributionClass: vocab.licenses[String(meta.license)]?.attribution || "any",
169+
licenseRank:
170+
ATTRIBUTION_RANK[
171+
vocab.licenses[String(meta.license)]?.attribution || "any"
172+
],
146173
});
147174
}
148175

149176
entries.sort((a, b) => a.name.localeCompare(b.name));
150177
return { entries, errors };
151178
}
152179

180+
function esc(str) {
181+
return String(str)
182+
.replaceAll("&", "&amp;")
183+
.replaceAll("<", "&lt;")
184+
.replaceAll(">", "&gt;")
185+
.replaceAll('"', "&quot;")
186+
.replaceAll("'", "&#39;");
187+
}
188+
189+
function commercialLabel(v) {
190+
if (v === true) return "commercial OK";
191+
if (v === false) return "non-commercial";
192+
if (v === "varies") return "per-file review";
193+
return "commercial ?";
194+
}
195+
196+
function edgeVar(entry) {
197+
if (entry.commercial === true) return "var(--ok)";
198+
if (entry.commercial === false) return "var(--danger)";
199+
if (entry.commercial === "varies") return "var(--warn)";
200+
return "var(--unknown)";
201+
}
202+
203+
/**
204+
* Prerendered entry row. The same markup app.js produces on hydrate, so the
205+
* page is complete and indexable before any script runs. The card is an
206+
* <article> with a real heading link, not a <button> wrapping a heading:
207+
* that gives every entry a permalink and keeps heading navigation working.
208+
*/
209+
function entryRowHtml(entry, repo, now) {
210+
const age = verifiedAge(entry.verified, now);
211+
const ageText =
212+
age.days === null
213+
? "verified date unknown"
214+
: `verified ${entry.verified} (${age.days}d ago)`;
215+
const flags = [entry.status, commercialLabel(entry.commercial), entry.license]
216+
.map(esc)
217+
.join(" &middot; ");
218+
const formats = (entry.formats || [])
219+
.slice(0, 3)
220+
.map((f) => `<span>${esc(f)}</span>`)
221+
.join("");
222+
return `<article class="entry-card" id="entry-${esc(entry.id)}" data-id="${esc(entry.id)}" data-status="${esc(entry.status)}" style="--edge:${edgeVar(entry)}">
223+
<span class="entry-edge" aria-hidden="true"></span>
224+
<div class="entry-body">
225+
<div class="entry-top">
226+
<h3><a class="entry-link" href="#entry-${esc(entry.id)}" data-id="${esc(entry.id)}">${esc(entry.name)}</a></h3>
227+
<span class="entry-flags">${flags}</span>
228+
</div>
229+
<p>${esc(entry.summary || "")}</p>
230+
<div class="meta-line">
231+
<span>${esc(entry.category)}</span>${formats}
232+
<span class="verified is-${age.bucket}" data-verified="${esc(entry.verified || "")}" title="License last checked at the source">${esc(ageText)}</span>
233+
</div>
234+
<div class="entry-links">
235+
<a href="${esc(entry.url)}" rel="noopener noreferrer">Open source</a>
236+
<a href="${esc(`${repo}/blob/main/${entry.path}`)}" rel="noopener noreferrer">Entry and evidence</a>
237+
</div>
238+
</div>
239+
</article>`;
240+
}
241+
242+
function starterRowsHtml(featured) {
243+
return featured
244+
.map(
245+
(e) => `<tr>
246+
<td class="need">${esc(e.need || "")}</td>
247+
<td><a href="${esc(e.url)}" rel="noopener noreferrer">${esc(e.name)}</a></td>
248+
<td class="license">${esc(e.license)} &middot; ${esc(commercialLabel(e.commercial))}</td>
249+
</tr>`
250+
)
251+
.join("\n");
252+
}
253+
254+
function guideRowsHtml(guides, repo) {
255+
return (guides || [])
256+
.map(
257+
(g) =>
258+
`<li><a href="${esc(`${repo}/blob/main/${g.path}`)}" rel="noopener noreferrer">${esc(g.title)}<span>${esc(g.path)}</span></a></li>`
259+
)
260+
.join("\n");
261+
}
262+
263+
function headMetaHtml(site, stats, generatedAt) {
264+
const url = site.siteUrl;
265+
const title = site.title;
266+
const desc = site.tagline;
267+
return [
268+
`<link rel="canonical" href="${esc(url)}" />`,
269+
`<meta name="theme-color" content="#1a4d3e" media="(prefers-color-scheme: light)" />`,
270+
`<meta name="theme-color" content="#0f1216" media="(prefers-color-scheme: dark)" />`,
271+
`<meta property="og:type" content="website" />`,
272+
`<meta property="og:site_name" content="${esc(title)}" />`,
273+
`<meta property="og:title" content="${esc(title)}" />`,
274+
`<meta property="og:description" content="${esc(desc)}" />`,
275+
`<meta property="og:url" content="${esc(url)}" />`,
276+
`<meta name="twitter:card" content="summary" />`,
277+
`<meta name="twitter:title" content="${esc(title)}" />`,
278+
`<meta name="twitter:description" content="${esc(desc)}" />`,
279+
`<meta name="generator" content="site/build.mjs ${esc(generatedAt)}" />`,
280+
`<meta name="catalog:entries" content="${stats.total}" />`,
281+
].join("\n ");
282+
}
283+
284+
function sitemapXml(site, entries) {
285+
const base = site.siteUrl.replace(/\/+$/, "");
286+
const urls = [
287+
` <url><loc>${esc(base)}/</loc></url>`,
288+
...entries.map(
289+
(e) => ` <url><loc>${esc(base)}/#entry-${esc(e.id)}</loc></url>`
290+
),
291+
];
292+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join("\n")}\n</urlset>\n`;
293+
}
294+
295+
function robotsTxt(site) {
296+
const base = site.siteUrl.replace(/\/+$/, "");
297+
return `User-agent: *\nAllow: /\n\nSitemap: ${base}/sitemap.xml\n`;
298+
}
299+
300+
function notFoundHtml(site) {
301+
const base = site.siteUrl.replace(/\/+$/, "");
302+
return `<!DOCTYPE html>
303+
<html lang="en">
304+
<head>
305+
<meta charset="utf-8" />
306+
<meta name="viewport" content="width=device-width, initial-scale=1" />
307+
<title>Not found - ${esc(site.title)}</title>
308+
<meta name="robots" content="noindex" />
309+
<link rel="icon" href="${esc(base)}/favicon.svg" type="image/svg+xml" />
310+
<link rel="stylesheet" href="${esc(base)}/styles.css" />
311+
</head>
312+
<body>
313+
<main class="section">
314+
<h1>Not found</h1>
315+
<p>That page is not part of this catalog.</p>
316+
<p class="hero-actions">
317+
<a class="btn" href="${esc(base)}/">Back to the catalog</a>
318+
</p>
319+
</main>
320+
</body>
321+
</html>
322+
`;
323+
}
324+
153325
function copyDir(src, dest) {
154326
fs.mkdirSync(dest, { recursive: true });
155327
for (const name of fs.readdirSync(src)) {
@@ -162,13 +334,15 @@ function copyDir(src, dest) {
162334

163335
function main() {
164336
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
165-
const { entries, errors } = loadEntries();
337+
const vocab = JSON.parse(fs.readFileSync(VOCAB_PATH, "utf8"));
338+
const { entries, errors } = loadEntries(vocab);
166339

167340
if (errors.length) {
168341
console.error("Build warnings:");
169342
for (const e of errors) console.error(` - ${e}`);
170343
}
171344

345+
const repoUrl = config.site.repo;
172346
const featuredRaw = config.featured || [];
173347
const featured = featuredRaw
174348
.map((item) => {
@@ -184,6 +358,7 @@ function main() {
184358
total: entries.length,
185359
active: entries.filter((e) => e.status === "active").length,
186360
needsReview: entries.filter((e) => e.status === "needs-review").length,
361+
deprecated: entries.filter((e) => e.status === "deprecated").length,
187362
commercialOk: entries.filter((e) => e.commercial === true).length,
188363
commercialVaries: entries.filter((e) => e.commercial === "varies").length,
189364
categories: Object.keys(config.categories).length,
@@ -207,9 +382,44 @@ function main() {
207382
`window.__CATALOG__ = ${JSON.stringify(payload)};\n`
208383
);
209384

385+
// Prerender. The served HTML must be complete without JavaScript: this is a
386+
// catalog whose whole value is that its entries can be found.
387+
const now = Date.now();
388+
const indexPath = path.join(DIST, "index.html");
389+
const stamp = payload.generatedAt.slice(0, 10);
390+
const visible = entries.filter((e) => e.status !== "deprecated");
391+
const substitutions = {
392+
HEAD_META: headMetaHtml(config.site, stats, payload.generatedAt),
393+
STARTER_ROWS: starterRowsHtml(featured),
394+
ENTRY_ROWS: visible
395+
.map((e) => entryRowHtml(e, repoUrl, now))
396+
.join("\n"),
397+
GUIDE_ROWS: guideRowsHtml(config.guides, repoUrl),
398+
CATALOG_BLURB: `${stats.total} sources &middot; ${stats.active} active &middot; ${stats.commercialOk} commercial-ok &middot; ${stats.commercialVaries} per-file &middot; ${stats.deprecated} deprecated`,
399+
RESULT_COUNT: `${visible.length} / ${stats.total}`,
400+
FOOTER_STAMP: `Built ${esc(stamp)} from ${stats.total} catalog entries.`,
401+
};
402+
let html = fs.readFileSync(indexPath, "utf8");
403+
for (const [key, value] of Object.entries(substitutions)) {
404+
const marker = `<!--${key}-->`;
405+
if (!html.includes(marker)) {
406+
console.error(` - index.html is missing the ${marker} marker`);
407+
continue;
408+
}
409+
html = html.replace(marker, value);
410+
}
411+
fs.writeFileSync(indexPath, html);
412+
413+
fs.writeFileSync(path.join(DIST, "sitemap.xml"), sitemapXml(config.site, visible));
414+
fs.writeFileSync(path.join(DIST, "robots.txt"), robotsTxt(config.site));
415+
fs.writeFileSync(path.join(DIST, "404.html"), notFoundHtml(config.site));
416+
210417
console.log(
211418
`Built ${entries.length} entries → site/dist (${stats.active} active, ${stats.commercialOk} commercial-ok, ${stats.commercialVaries} per-file)`
212419
);
420+
console.log(
421+
`Prerendered ${visible.length} entry rows into index.html; wrote sitemap.xml, robots.txt, 404.html`
422+
);
213423
if (errors.length) process.exitCode = 0; // soft-fail missing fields as warnings
214424
}
215425

‎site/config.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"title": "Free Game Dev Assets",
44
"tagline": "Free assets and tools for commercial games — with license metadata.",
55
"repo": "https://github.com/TMHSDigital/Free-Game-Dev-Assets",
6-
"baseUrl": "/Free-Game-Dev-Assets"
6+
"baseUrl": "/Free-Game-Dev-Assets",
7+
"siteUrl": "https://tmhsdigital.github.io/Free-Game-Dev-Assets/"
78
},
89
"expectedEntryCount": 294,
910
"categories": {

0 commit comments

Comments
 (0)