Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ site/dist/
CLAUDE.md
ITERATE.md
DEEP-RESEARCH.md

# Local superpowers workflow artifacts
.superpowers/
29 changes: 29 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ Static GitHub Pages UI for this catalog.
| An entry's body (Notes, Evidence) | Its page at `entry/<id>/` |
| `stacks/<id>.md` | Its page at `stack/<id>/`, 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
Expand Down
12 changes: 11 additions & 1 deletion site/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
`<link rel="sitemap" type="application/xml" href="${esc(url.replace(/\/+$/, ""))}/sitemap.xml" />`,
`<link rel="alternate" type="application/atom+xml" title="${esc(title)}: recently verified" href="${esc(url.replace(/\/+$/, ""))}/feed.xml" />`,
`<meta name="theme-color" content="#1a4d3e" media="(prefers-color-scheme: light)" />`,
`<meta name="theme-color" content="#0f1216" media="(prefers-color-scheme: dark)" />`,
`<meta property="og:type" content="website" />`,
Expand Down Expand Up @@ -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"));
Expand All @@ -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.
Expand Down
73 changes: 73 additions & 0 deletions site/lib/exports.mjs
Original file line number Diff line number Diff line change
@@ -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 [
" <entry>",
` <id>${esc(link)}</id>`,
` <title>${esc(e.name)}</title>`,
` <link href="${esc(link)}" />`,
` <updated>${esc(e.verified)}T00:00:00Z</updated>`,
` <summary>${esc(`${e.license}. ${e.summary || ""}`.trim())}</summary>`,
" </entry>",
].join("\n");
});
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<feed xmlns="http://www.w3.org/2005/Atom">',
` <id>${esc(base)}/</id>`,
` <title>${esc(site.title)}: recently verified</title>`,
` <subtitle>${esc(site.tagline)}</subtitle>`,
` <link href="${esc(base)}/" />`,
` <link rel="self" href="${esc(base)}/feed.xml" />`,
` <updated>${esc(generatedAt)}</updated>`,
...items,
"</feed>",
"",
].join("\n");
}
17 changes: 17 additions & 0 deletions site/lib/lib.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
STATUS_NOTES,
verifiedAge,
} from "./shared.mjs";
import { atomFeed, catalogCsv } from "./exports.mjs";

let passed = 0;
const failures = [];
Expand Down Expand Up @@ -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 &amp; 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}):`);
Expand Down
Loading