diff --git a/.gitignore b/.gitignore index 5e2d351..26d3ca5 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ site/dist/ CLAUDE.md ITERATE.md DEEP-RESEARCH.md + +# Local superpowers workflow artifacts +.superpowers/ diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..f9da03d --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,29 @@ +# Roadmap + +Where help is most useful. The rule behind all of it: a wrong licence claim is worse than a +missing entry, so freshness and accuracy come before volume. + +## Open to contributors + +- **Thin categories:** video (8 entries), animation (12) and environment (13). See + [CONTRIBUTING.md](CONTRIBUTING.md) and the "new source" issue form. +- **Optional fields:** `camera_perspective` and `grid_dimensions` on 2D entries + ([#41](https://github.com/TMHSDigital/Free-Game-Dev-Assets/issues/41)). Small, well-scoped, a + good first PR. +- **Starter stacks:** audio, a game-jam kit, Unity, Unreal and VR are not covered yet. Use the + "request an asset" form to add to a stack's Gaps list. +- **Corrections:** a licence that changed or a link that moved. The weekly link check opens an + issue listing them. + +## Planned + +- Re-verification in smaller, regular batches, so the July 2026 verifications do not all expire + together in mid-2027. +- A changelog page generated from [docs/review-ledger.md](docs/review-ledger.md). +- Client-side search over the catalog, not only filters. +- An accessibility pass over the site. + +## Done + +- Machine-readable exports: `data.json`, `catalog.csv` and an Atom feed (`feed.xml`) of recently + verified entries. diff --git a/site/README.md b/site/README.md index 96279ff..c7aa160 100644 --- a/site/README.md +++ b/site/README.md @@ -15,6 +15,16 @@ Static GitHub Pages UI for this catalog. | An entry's body (Notes, Evidence) | Its page at `entry//` | | `stacks/.md` | Its page at `stack//`, the homepage Starter stacks list, and "Used in" on each picked entry | +## Other exports + +Each deploy also publishes +[`catalog.csv`](https://tmhsdigital.github.io/Free-Game-Dev-Assets/catalog.csv) (one row +per entry: id, name, url, category, licence, SPDX, commercial, attribution, formats, tags, +verified, status, page; list fields are joined with `; `) and an Atom feed, +[`feed.xml`](https://tmhsdigital.github.io/Free-Game-Dev-Assets/feed.xml), of the 40 most +recently verified entries. Entries carry no "added" date, so the feed is keyed on +`verified`, the day the licence was last read from the source. + ## Public JSON Every deploy publishes the whole catalog as diff --git a/site/build.mjs b/site/build.mjs index 81fb3d3..9c80fb8 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -10,6 +10,7 @@ import { commercialLabel, esc, latestAllowedDate, MAINTENANCE_NOTES, PERSPECTIVE import { parseFrontmatter, summaryFromBody } from "./lib/frontmatter.mjs"; import { entryPageHtml } from "./lib/entry-page.mjs"; import { LinkError, makeLinkResolver } from "./lib/links.mjs"; +import { atomFeed, catalogCsv } from "./lib/exports.mjs"; import { llmsFullTxt, llmsTxt } from "./lib/llms.mjs"; import { deprecationReason, MarkdownError, renderBlocks, renderInline, splitEntryBody } from "./lib/markdown.mjs"; import { checkPage } from "./lib/page-checks.mjs"; @@ -286,6 +287,7 @@ function headMetaHtml(site, stats, generatedAt, hasCard) { // robots.txt is only read at a host's root, so under a project Pages path // its Sitemap line is never seen; point at the sitemap from the page too. ``, + ``, ``, ``, ``, @@ -562,7 +564,10 @@ function main() { entries, }; - if (fs.existsSync(DIST)) fs.rmSync(DIST, { recursive: true, force: true }); + // Empty dist rather than remove it: on Windows a server or editor holding the + // folder open makes removing the folder itself fail with EPERM/EBUSY. + fs.mkdirSync(DIST, { recursive: true }); + for (const name of fs.readdirSync(DIST)) fs.rmSync(path.join(DIST, name), { recursive: true, force: true }); copyDir(PUBLIC, DIST); // The shortlist builds its CREDITS file with the same code as the stack pages. fs.copyFileSync(path.join(__dirname, "lib", "owes.mjs"), path.join(DIST, "owes.js")); @@ -575,6 +580,11 @@ function main() { path.join(DIST, "data.js"), `window.__CATALOG__ = ${JSON.stringify(payload)};\n` ); + fs.writeFileSync(path.join(DIST, "catalog.csv"), catalogCsv(entries, config.site.siteUrl)); + fs.writeFileSync( + path.join(DIST, "feed.xml"), + atomFeed({ entries, site: config.site, generatedAt: payload.generatedAt }) + ); // Prerender. The served HTML must be complete without JavaScript: this is a // catalog whose whole value is that its entries can be found. diff --git a/site/lib/exports.mjs b/site/lib/exports.mjs new file mode 100644 index 0000000..6575eda --- /dev/null +++ b/site/lib/exports.mjs @@ -0,0 +1,73 @@ +/** + * Flat exports of the catalog: catalog.csv for spreadsheets and feed.xml (Atom) + * for readers. data.json stays the full machine-readable catalog. + */ +import { esc } from "./shared.mjs"; + +const CSV_COLUMNS = [ + "id", + "name", + "url", + "category", + "license", + "license_spdx", + "commercial", + "attribution_required", + "formats", + "tags", + "verified", + "status", + "page", +]; + +/** One CSV field, quoted when it holds a comma, quote or line break. */ +function csvField(value) { + const text = Array.isArray(value) ? value.join("; ") : value === undefined || value === null ? "" : String(value); + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +export function catalogCsv(entries, siteUrl) { + const base = siteUrl.replace(/\/+$/, ""); + const rows = entries.map((e) => + CSV_COLUMNS.map((col) => csvField(col === "page" ? `${base}/${e.page}` : e[col])).join(",") + ); + return `${CSV_COLUMNS.join(",")}\r\n${rows.join("\r\n")}\r\n`; +} + +/** + * Atom feed of the most recently verified entries. Entries carry no "added" + * date, so `verified` (the day the licence was last read from the source) is + * the only honest timestamp. + */ +export function atomFeed({ entries, site, generatedAt, limit = 40 }) { + const base = site.siteUrl.replace(/\/+$/, ""); + const recent = entries + .filter((e) => e.status !== "deprecated" && e.verified) + .sort((a, b) => b.verified.localeCompare(a.verified) || a.name.localeCompare(b.name)) + .slice(0, limit); + const items = recent.map((e) => { + const link = `${base}/${e.page}`; + return [ + " ", + ` ${esc(link)}`, + ` ${esc(e.name)}`, + ` `, + ` ${esc(e.verified)}T00:00:00Z`, + ` ${esc(`${e.license}. ${e.summary || ""}`.trim())}`, + " ", + ].join("\n"); + }); + return [ + '', + '', + ` ${esc(base)}/`, + ` ${esc(site.title)}: recently verified`, + ` ${esc(site.tagline)}`, + ` `, + ` `, + ` ${esc(generatedAt)}`, + ...items, + "", + "", + ].join("\n"); +} diff --git a/site/lib/lib.test.mjs b/site/lib/lib.test.mjs index 07f3a5c..0c854e3 100644 --- a/site/lib/lib.test.mjs +++ b/site/lib/lib.test.mjs @@ -11,6 +11,7 @@ import { STATUS_NOTES, verifiedAge, } from "./shared.mjs"; +import { atomFeed, catalogCsv } from "./exports.mjs"; let passed = 0; const failures = []; @@ -511,6 +512,22 @@ eq("no 30 February", isRealDate("2026-02-30"), false); eq("shape only is not enough", isRealDate("2026-9-1"), false); eq("latest allowed is tomorrow in UTC", latestAllowedDate(Date.parse("2026-09-25T23:30:00Z")), "2026-09-26"); +/* exports ------------------------------------------------------------------ */ +const sample = [ + { id: "a", name: 'Say "hi", ok', url: "https://x.test/a", category: "2d", license: "CC0", commercial: true, formats: ["PNG", "SVG"], tags: [], verified: "2026-09-01", status: "active", page: "entry/a/", summary: "A & B" }, + { id: "b", name: "Old", url: "https://x.test/b", category: "2d", license: "MIT", verified: "2026-01-01", status: "deprecated", page: "entry/b/" }, + { id: "c", name: "New", url: "https://x.test/c", category: "3d", license: "CC0", verified: "2026-09-20", status: "active", page: "entry/c/", summary: "" }, +]; +const csv = catalogCsv(sample, "https://s.test/site/"); +has("csv header", csv, "id,name,url,category"); +has("csv quotes commas and quotes", csv, '"Say ""hi"", ok"'); +has("csv joins lists", csv, "PNG; SVG"); +has("csv absolute page url", csv, "https://s.test/site/entry/a/"); +const feed = atomFeed({ entries: sample, site: { title: "T", tagline: "t", siteUrl: "https://s.test/site/" }, generatedAt: "2026-10-01T00:00:00.000Z" }); +has("feed escapes summary", feed, "CC0. A & B"); +lacks("feed skips deprecated", feed, "entry/b/"); +eq("feed newest first", feed.indexOf("entry/c/") < feed.indexOf("entry/a/"), true); + /* report (keep last) ------------------------------------------------------ */ if (failures.length) { console.error(`lib.test failed (${failures.length}):`);