Skip to content

Commit 3079a48

Browse files
TMHSDigitalclaude
andcommitted
data: publish a JSON Schema for data.json and fail the build when it drifts
Field stability was promised in prose only. Now: - site/data.schema.json (draft 2020-12) describes every top-level key and entry field. Entries share one base, and unevaluatedProperties: false catches renamed fields. - The build checks the payload with lib/schema-check.mjs, a small dependency-free checker that refuses any keyword it does not enforce. It fails before writing anything if a field is renamed, retyped, added or dropped without a schema change. - The schema ships as dist/data.schema.json and is attached to dataset releases. docs/data.md and site/README.md say so. Ajv (draft 2020-12) also accepts the current data.json against the schema, and rejects a copy with a renamed field. Closes #213 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXRUqm7oDedPDk3QWtK
1 parent 0a9d90a commit 3079a48

7 files changed

Lines changed: 277 additions & 4 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: Dataset release
22

33
# A versioned snapshot of the catalog a tool can pin: tag vYYYY.MM.DD and
4-
# attach data.json, catalog.csv, the two feeds and llms.txt. Monthly, and on
4+
# attach data.json and its schema, catalog.csv, the two feeds and llms.txt. Monthly, and on
55
# demand from the Actions tab. The release notes are the newest section of
66
# docs/review-ledger.md. A tag that already exists is left alone.
77
#
@@ -97,12 +97,12 @@ jobs:
9797
fi
9898
{
9999
echo
100-
echo "Attached: \`data.json\` (full catalog), \`catalog.csv\`, \`feed.xml\` (new sources), \`reverified.xml\` (recent licence re-reads), \`llms.txt\`."
100+
echo "Attached: \`data.json\` (full catalog), \`data.schema.json\` (its JSON Schema), \`catalog.csv\`, \`feed.xml\` (new sources), \`reverified.xml\` (recent licence re-reads), \`llms.txt\`."
101101
echo "\`data.json\` carries \`\"version\": \"$CATALOG_VERSION\"\`; CITATION.cff on this tag cites the same version."
102102
echo "Field reference: https://github.com/${GITHUB_REPOSITORY}/blob/main/site/README.md"
103103
} >> release-notes.md
104104
gh release create "$CATALOG_VERSION" \
105105
--target "$(git rev-parse HEAD)" \
106106
--title "Catalog $CATALOG_VERSION" \
107107
--notes-file release-notes.md \
108-
site/dist/data.json site/dist/catalog.csv site/dist/feed.xml site/dist/reverified.xml site/dist/llms.txt
108+
site/dist/data.json site/dist/data.schema.json site/dist/catalog.csv site/dist/feed.xml site/dist/reverified.xml site/dist/llms.txt

‎docs/data.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The catalog is published as machine-readable files on every deploy. The metadata
99
| File | What it is |
1010
| --- | --- |
1111
| [`data.json`](https://tmhsdigital.github.io/Free-Game-Dev-Assets/data.json) | The whole catalog: every entry with its licence fields, plus categories, guides and counts |
12+
| [`data.schema.json`](https://tmhsdigital.github.io/Free-Game-Dev-Assets/data.schema.json) | JSON Schema (draft 2020-12) for `data.json`. The build fails if `data.json` does not match it |
1213
| [`catalog.csv`](https://tmhsdigital.github.io/Free-Game-Dev-Assets/catalog.csv) | One row per entry, for spreadsheets. Lists are joined with `; ` |
1314
| [`feed.xml`](https://tmhsdigital.github.io/Free-Game-Dev-Assets/feed.xml) | Atom feed of newly added sources |
1415
| [`reverified.xml`](https://tmhsdigital.github.io/Free-Game-Dev-Assets/reverified.xml) | Atom feed of entries whose licence was most recently re-read |
@@ -48,7 +49,7 @@ Each entry in `data.json` has these fields. `catalog.csv` has the ones marked CS
4849

4950
Top-level keys are `generatedAt`, `version`, `site`, `categories`, `guides`, `featured`, `stats` (counts of total, active, needs-review, deprecated and commercial entries), `filters` (`licenseFamilies` and `formatGroups`, the labels and groups behind the site's filters) and `entries`.
5051

51-
Field names are stable. New optional fields may appear, so ignore keys you do not know rather than failing on them.
52+
Field names are stable, and [`data.schema.json`](https://tmhsdigital.github.io/Free-Game-Dev-Assets/data.schema.json) states them as a JSON Schema the build enforces, so a field cannot be renamed or retyped without the schema changing too. New optional fields may appear, so ignore keys you do not know rather than failing on them (the schema itself rejects unknown keys, which is how the build catches drift).
5253

5354
## Examples
5455

‎site/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ and tools that want the metadata without scraping the page. The page itself load
5656
(`{ path, width, height, credit? }` of the entry's preview image relative to the site root, with `credit` on CC-BY images, or `null`), `licenseFamily` (the
5757
coarse licence group the site filters on), and two derived sort fields, `attributionClass`
5858
and `licenseRank`.
59+
- Its shape is [`site/data.schema.json`](data.schema.json) (JSON Schema, published as `data.schema.json`); the build checks the payload against it with `lib/schema-check.mjs` and fails on any mismatch, so a renamed or retyped field must change the schema in the same commit.
5960
- It is regenerated from `catalog/` on every push to `main`. Field names are stable;
6061
new optional fields may appear. The licence facts are only as current as each entry's
6162
`verified` date: re-check the source before you ship on one.

‎site/build.mjs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { listEntryFiles, idMatchesFile } from "./lib/entry-files.mjs";
2525
import { entryPageHtml, scriptJson } from "./lib/entry-page.mjs";
2626
import { LinkError, makeLinkResolver, caseMismatch } from "./lib/links.mjs";
2727
import { atomFeed, catalogCsv, homeJsonLd, sitemapXml } from "./lib/exports.mjs";
28+
import { schemaErrors } from "./lib/schema-check.mjs";
2829
import { llmsFullTxt, llmsTxt } from "./lib/llms.mjs";
2930
import { deprecationReason, MarkdownError, renderBlocks, renderInline, splitEntryBody } from "./lib/markdown.mjs";
3031
import { checkPage } from "./lib/page-checks.mjs";
@@ -611,6 +612,16 @@ function main() {
611612
entries,
612613
};
613614

615+
// data.json is held to its published schema (site/data.schema.json), so a
616+
// renamed or retyped field fails here, before anything is written.
617+
const schema = JSON.parse(fs.readFileSync(path.join(__dirname, "data.schema.json"), "utf8"));
618+
const schemaProblems = schemaErrors(schema, JSON.parse(JSON.stringify(payload)));
619+
if (schemaProblems.length) {
620+
console.error(`Build failed: data.json does not match site/data.schema.json (${schemaProblems.length}); update the schema with the change:`);
621+
for (const p of schemaProblems.slice(0, 20)) console.error(` - ${p}`);
622+
process.exit(1);
623+
}
624+
614625
// Empty dist rather than remove it: on Windows a server or editor holding the
615626
// folder open makes removing the folder itself fail with EPERM/EBUSY.
616627
ensureDir(DIST);
@@ -630,6 +641,7 @@ function main() {
630641
// data.json is the public machine-readable catalog (see site/README.md);
631642
// the page itself loads data.js.
632643
writeOut("data.json", JSON.stringify(payload, null, 2));
644+
copyOut(path.join(__dirname, "data.schema.json"), "data.schema.json");
633645
writeOut("data.js", `window.__CATALOG__ = ${JSON.stringify(payload)};\n`);
634646
writeOut("catalog.csv", catalogCsv(entries, config.site.siteUrl));
635647
writeOut("feed.xml", atomFeed({ entries, site: config.site, generatedAt: payload.generatedAt, kind: "added" }));

‎site/data.schema.json‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://tmhsdigital.github.io/Free-Game-Dev-Assets/data.schema.json",
4+
"title": "Free Game Dev Assets catalog (data.json)",
5+
"description": "The shape of data.json. The build checks every payload against this file, so a renamed or retyped field fails the build until the schema changes with it. New optional fields may appear in later versions: consumers should ignore keys they do not know.",
6+
"type": "object",
7+
"required": ["generatedAt", "version", "site", "categories", "guides", "featured", "stats", "filters", "entries"],
8+
"additionalProperties": false,
9+
"properties": {
10+
"generatedAt": { "type": "string", "description": "ISO 8601 build time" },
11+
"version": { "type": "string", "pattern": "^v\\d{4}\\.\\d{2}\\.\\d{2}", "description": "Calendar version, vYYYY.MM.DD" },
12+
"site": {
13+
"type": "object",
14+
"required": ["title", "tagline", "repo", "siteUrl"],
15+
"properties": {
16+
"title": { "type": "string" },
17+
"tagline": { "type": "string" },
18+
"repo": { "type": "string" },
19+
"baseUrl": { "type": "string" },
20+
"siteUrl": { "type": "string" }
21+
}
22+
},
23+
"categories": {
24+
"type": "object",
25+
"additionalProperties": {
26+
"type": "object",
27+
"required": ["label"],
28+
"properties": {
29+
"label": { "type": "string" },
30+
"blurb": { "type": "string" },
31+
"view": { "enum": ["cards", "list"] }
32+
}
33+
}
34+
},
35+
"guides": {
36+
"type": "array",
37+
"items": {
38+
"type": "object",
39+
"required": ["id", "title", "path"],
40+
"properties": {
41+
"id": { "type": "string" },
42+
"title": { "type": "string" },
43+
"path": { "type": "string" }
44+
}
45+
}
46+
},
47+
"featured": {
48+
"type": "array",
49+
"items": {
50+
"$ref": "#/$defs/entryBase",
51+
"properties": { "need": { "type": "string" } },
52+
"unevaluatedProperties": false
53+
}
54+
},
55+
"stats": {
56+
"type": "object",
57+
"required": ["total", "active", "needsReview", "deprecated", "commercialOk", "commercialVaries", "categories"],
58+
"additionalProperties": { "type": "integer", "minimum": 0 }
59+
},
60+
"filters": {
61+
"type": "object",
62+
"required": ["licenseFamilies", "formatGroups"],
63+
"properties": {
64+
"licenseFamilies": { "type": "object", "additionalProperties": { "type": "string" } },
65+
"formatGroups": {
66+
"type": "object",
67+
"additionalProperties": {
68+
"type": "object",
69+
"required": ["label", "formats"],
70+
"properties": {
71+
"label": { "type": "string" },
72+
"formats": { "$ref": "#/$defs/stringList" }
73+
}
74+
}
75+
}
76+
}
77+
},
78+
"entries": {
79+
"type": "array",
80+
"items": { "$ref": "#/$defs/entryBase", "unevaluatedProperties": false }
81+
}
82+
},
83+
"$defs": {
84+
"stringList": { "type": "array", "items": { "type": "string" } },
85+
"entryBase": {
86+
"type": "object",
87+
"required": [
88+
"id", "name", "url", "category", "subcategories", "license", "commercial", "attribution_required",
89+
"formats", "tags", "added", "verified", "status", "path", "page", "summary", "thumb",
90+
"licenseFamily", "attributionClass", "licenseRank"
91+
],
92+
"properties": {
93+
"id": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
94+
"name": { "type": "string" },
95+
"url": { "type": "string" },
96+
"category": { "type": "string" },
97+
"subcategories": { "$ref": "#/$defs/stringList" },
98+
"license": { "type": "string" },
99+
"license_spdx": { "type": "string" },
100+
"commercial": { "enum": [true, false, "varies", "unknown"] },
101+
"attribution_required": { "enum": [true, false, "unknown"] },
102+
"attribution_string": { "type": "string" },
103+
"publisher": { "type": "string" },
104+
"formats": { "$ref": "#/$defs/stringList" },
105+
"tags": { "$ref": "#/$defs/stringList" },
106+
"added": { "type": ["string", "null"], "description": "YYYY-MM-DD the entry joined the catalog" },
107+
"verified": { "type": ["string", "null"], "description": "YYYY-MM-DD the licence was last read at the source" },
108+
"status": { "enum": ["active", "needs-review", "deprecated"] },
109+
"path": { "type": "string" },
110+
"page": { "type": "string" },
111+
"summary": { "type": "string" },
112+
"thumb": {
113+
"type": ["object", "null"],
114+
"required": ["path", "width", "height"],
115+
"additionalProperties": false,
116+
"properties": {
117+
"path": { "type": "string" },
118+
"width": { "type": "integer", "minimum": 1 },
119+
"height": { "type": "integer", "minimum": 1 },
120+
"credit": { "type": "string" }
121+
}
122+
},
123+
"grid_dimensions": { "type": "string" },
124+
"camera_perspective": { "type": "string" },
125+
"hardware_tags": { "$ref": "#/$defs/stringList" },
126+
"maintenance": { "enum": ["archived", "inactive"] },
127+
"engine_lock": { "type": "string" },
128+
"ue_versions": { "type": "string" },
129+
"licenseFamily": { "type": "string" },
130+
"attributionClass": { "type": "string" },
131+
"licenseRank": { "type": "integer", "minimum": 0 }
132+
}
133+
}
134+
}
135+
}

‎site/lib/lib.test.mjs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,6 +1206,38 @@ eq("search: q from the URL is capped", readState(`?q=${"a".repeat(5000)}`, { q:
12061206
has("guide: images refused", threw, "g.md:3: an image is not supported");
12071207
}
12081208

1209+
/* data.json schema (schema-check.mjs + site/data.schema.json) -------------- */
1210+
{
1211+
const { schemaErrors } = await import("./schema-check.mjs");
1212+
const schema = JSON.parse(nodeFs.readFileSync(new URL("../data.schema.json", import.meta.url), "utf8"));
1213+
const entry = {
1214+
id: "a", name: "A", url: "https://a.example/", category: "2d", subcategories: ["x"], license: "CC0", commercial: true,
1215+
attribution_required: false, formats: ["PNG"], tags: [], added: "2026-01-01", verified: "2026-01-02", status: "active",
1216+
path: "catalog/2d/a.md", page: "entry/a/", summary: "", thumb: null, licenseFamily: "public-domain", attributionClass: "none", licenseRank: 0,
1217+
};
1218+
const payload = (over = {}) => ({
1219+
generatedAt: "2026-11-03T00:00:00.000Z", version: "v2026.11.03",
1220+
site: { title: "T", tagline: "t", repo: "r", siteUrl: "s" }, categories: { "2d": { label: "2D", view: "cards" } },
1221+
guides: [{ id: "g", title: "G", path: "docs/g.md" }], featured: [{ ...entry, need: "x" }],
1222+
stats: { total: 1, active: 1, needsReview: 0, deprecated: 0, commercialOk: 1, commercialVaries: 0, categories: 1 },
1223+
filters: { licenseFamilies: { cc0: "CC0" }, formatGroups: { image: { label: "Images", formats: ["PNG"] } } },
1224+
entries: [entry], ...over,
1225+
});
1226+
const errs = (p) => schemaErrors(schema, p).join(" | ");
1227+
eq("schema: a well-formed payload passes", errs(payload()), "");
1228+
eq("schema: a thumb object passes", errs(payload({ entries: [{ ...entry, thumb: { path: "thumbs/a.webp", width: 640, height: 360, credit: "c" } }] })), "");
1229+
has("schema: a renamed field fails", errs(payload({ entries: [{ ...entry, licence: "CC0", license: undefined }] })), 'unexpected key "licence"');
1230+
has("schema: a missing required field fails", errs(payload({ entries: [(({ summary, ...rest }) => rest)(entry)] })), 'missing required "summary"');
1231+
has("schema: a retyped field fails", errs(payload({ entries: [{ ...entry, licenseRank: "0" }] })), "$.entries[0].licenseRank: expected integer, got string");
1232+
has("schema: an unknown status fails", errs(payload({ entries: [{ ...entry, status: "retired" }] })), '"retired" is not one of');
1233+
has("schema: commercial outside its values fails", errs(payload({ entries: [{ ...entry, commercial: "yes" }] })), "$.entries[0].commercial");
1234+
has("schema: need is not an entry field", errs(payload({ entries: [{ ...entry, need: "x" }] })), 'unexpected key "need"');
1235+
has("schema: a bad id pattern fails", errs(payload({ entries: [{ ...entry, id: "Bad_ID" }] })), "does not match");
1236+
has("schema: a new top-level key fails", errs(payload({ extra: 1 })), 'unexpected key "extra"');
1237+
has("schema: a negative stat fails", errs(payload({ stats: { ...payload().stats, total: -1 } })), "is below 0");
1238+
throws("schema: an unsupported keyword is refused, not ignored", () => schemaErrors({ type: "string", maxLength: 3 }, "abcd"), 'unsupported keyword "maxLength"');
1239+
}
1240+
12091241
/* catalog ordering, grouping and chip counts (search.mjs, used by app.js) -- */
12101242
{
12111243
const { computeResults, sorterFor, shouldGroup, engineRank, SORTS } = await import("./search.mjs");

‎site/lib/schema-check.mjs‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* A small JSON Schema (draft 2020-12) checker for the keywords
3+
* site/data.schema.json uses, so the build can hold data.json to its
4+
* published schema with no dependency: type, enum, required, properties,
5+
* additionalProperties, unevaluatedProperties (false only, over properties
6+
* and one local $ref), items, pattern, minimum and local "#/$defs/..." $ref.
7+
* Any other keyword throws, so the schema cannot quietly use one this does
8+
* not enforce. Pure.
9+
*/
10+
11+
const KNOWN = new Set([
12+
"$schema", "$id", "$defs", "$ref", "title", "description",
13+
"type", "enum", "required", "properties", "additionalProperties", "unevaluatedProperties",
14+
"items", "pattern", "minimum",
15+
]);
16+
17+
function typeOf(v) {
18+
if (v === null) return "null";
19+
if (Array.isArray(v)) return "array";
20+
if (typeof v === "number") return Number.isInteger(v) ? "integer" : "number";
21+
return typeof v;
22+
}
23+
24+
function typeMatches(want, v) {
25+
const t = typeOf(v);
26+
return want === t || (want === "number" && t === "integer");
27+
}
28+
29+
function resolve(root, ref) {
30+
if (!ref.startsWith("#/")) throw new Error(`schema-check: only local $ref is supported, got ${ref}`);
31+
let node = root;
32+
for (const part of ref.slice(2).split("/")) {
33+
node = node?.[part];
34+
if (node === undefined) throw new Error(`schema-check: $ref ${ref} does not resolve`);
35+
}
36+
return node;
37+
}
38+
39+
const show = (v) => (typeof v === "string" ? JSON.stringify(v.length > 40 ? `${v.slice(0, 40)}…` : v) : JSON.stringify(v));
40+
41+
/** Errors as "path: message" strings; empty when `value` matches `schema`. */
42+
export function schemaErrors(schema, value, { root = schema, path = "$" } = {}) {
43+
const errors = [];
44+
for (const key of Object.keys(schema)) {
45+
if (!KNOWN.has(key)) throw new Error(`schema-check: unsupported keyword "${key}" at ${path}`);
46+
}
47+
if (schema.unevaluatedProperties !== undefined && schema.unevaluatedProperties !== false) {
48+
throw new Error(`schema-check: unevaluatedProperties must be false at ${path}`);
49+
}
50+
const ref = schema.$ref ? resolve(root, schema.$ref) : null;
51+
if (ref) errors.push(...schemaErrors(ref, value, { root, path }));
52+
53+
if (schema.type !== undefined) {
54+
const types = [].concat(schema.type);
55+
if (!types.some((t) => typeMatches(t, value))) {
56+
errors.push(`${path}: expected ${types.join(" or ")}, got ${typeOf(value)}`);
57+
return errors;
58+
}
59+
}
60+
if (schema.enum && !schema.enum.some((e) => e === value)) {
61+
errors.push(`${path}: ${show(value)} is not one of ${schema.enum.map((e) => JSON.stringify(e)).join(", ")}`);
62+
}
63+
if (typeof value === "string" && schema.pattern && !new RegExp(schema.pattern, "u").test(value)) {
64+
errors.push(`${path}: ${show(value)} does not match ${schema.pattern}`);
65+
}
66+
if (typeof value === "number" && schema.minimum !== undefined && value < schema.minimum) {
67+
errors.push(`${path}: ${value} is below ${schema.minimum}`);
68+
}
69+
if (Array.isArray(value) && schema.items) {
70+
value.forEach((item, i) => errors.push(...schemaErrors(schema.items, item, { root, path: `${path}[${i}]` })));
71+
}
72+
if (typeOf(value) === "object") {
73+
for (const key of schema.required || []) {
74+
if (!Object.prototype.hasOwnProperty.call(value, key)) errors.push(`${path}: missing required "${key}"`);
75+
}
76+
const props = schema.properties || {};
77+
for (const [key, sub] of Object.entries(props)) {
78+
if (Object.prototype.hasOwnProperty.call(value, key)) errors.push(...schemaErrors(sub, value[key], { root, path: `${path}.${key}` }));
79+
}
80+
const extra = Object.keys(value).filter((k) => !Object.prototype.hasOwnProperty.call(props, k));
81+
if (schema.additionalProperties === false) {
82+
for (const k of extra) errors.push(`${path}: unexpected key "${k}"`);
83+
} else if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
84+
for (const k of extra) errors.push(...schemaErrors(schema.additionalProperties, value[k], { root, path: `${path}.${k}` }));
85+
}
86+
if (schema.unevaluatedProperties === false) {
87+
const evaluated = new Set([...Object.keys(props), ...Object.keys(ref?.properties || {})]);
88+
for (const k of Object.keys(value)) if (!evaluated.has(k)) errors.push(`${path}: unexpected key "${k}"`);
89+
}
90+
}
91+
return errors;
92+
}

0 commit comments

Comments
 (0)