Skip to content

Commit 3cb4905

Browse files
Add GitHub Pages catalog site driven by entry frontmatter.
Scan catalog markdown on deploy so new entries update the browse UI without hand-maintaining the site. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4b76146 commit 3cb4905

11 files changed

Lines changed: 1236 additions & 3 deletions

File tree

.github/workflows/pages.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: Deploy GitHub Pages
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- "catalog/**"
8+
- "docs/**"
9+
- "site/**"
10+
- ".github/workflows/pages.yml"
11+
workflow_dispatch:
12+
13+
permissions:
14+
contents: read
15+
pages: write
16+
id-token: write
17+
18+
concurrency:
19+
group: pages
20+
cancel-in-progress: true
21+
22+
jobs:
23+
build:
24+
runs-on: ubuntu-latest
25+
steps:
26+
- name: Checkout
27+
uses: actions/checkout@v4
28+
29+
- name: Setup Node
30+
uses: actions/setup-node@v4
31+
with:
32+
node-version: "20"
33+
34+
- name: Build site
35+
run: node site/build.mjs
36+
37+
- name: Upload Pages artifact
38+
uses: actions/upload-pages-artifact@v3
39+
with:
40+
path: site/dist
41+
42+
deploy:
43+
needs: build
44+
runs-on: ubuntu-latest
45+
environment:
46+
name: github-pages
47+
url: ${{ steps.deployment.outputs.page_url }}
48+
steps:
49+
- name: Deploy to GitHub Pages
50+
id: deployment
51+
uses: actions/deploy-pages@v4

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ Desktop.ini
2020
*.log
2121
__pycache__/
2222
.cache/
23+
24+
# Generated GitHub Pages output (built in CI)
25+
site/dist/

CONTRIBUTING.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@ This repo catalogs **links and metadata** for free (preferably commercially usab
1818

1919
## Adding an entry
2020

21+
One new markdown file is enough — the website rebuilds from frontmatter on deploy.
22+
2123
1. Copy [`catalog/TEMPLATE.md`](catalog/TEMPLATE.md).
2224
2. Save it as `catalog/<category>/<id>.md` using a short kebab-case `id`.
2325
3. Fill every frontmatter field. Prefer primary URLs over mirror/aggregator pages.
24-
4. Add one line to the matching category `README.md` index.
25-
5. Verify the license on the live source page the day you submit.
26+
4. Verify the license on the live source page the day you submit.
27+
5. Optional: add a line to the matching category `README.md` for GitHub browsing (the site does not require this).
28+
6. Optional: add the `id` to `site/config.json``featured` to pin it under Safe starting points.
2629

2730
### Frontmatter rules
2831

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This repository **indexes and documents** sources. It does **not** rehost third-
88

99
## Contents
1010

11+
- [Website](#website)
1112
- [Browse the catalog](#browse-the-catalog)
1213
- [Quick start](#quick-start)
1314
- [How entries work](#how-entries-work)
@@ -21,6 +22,21 @@ This repository **indexes and documents** sources. It does **not** rehost third-
2122

2223
---
2324

25+
## Website
26+
27+
**https://tmhsdigital.github.io/Free-Game-Dev-Assets/**
28+
29+
The site is generated from catalog frontmatter on every push (GitHub Actions → Pages). Add or edit one file under `catalog/<category>/<id>.md` and the browse UI, filters, and counts update automatically.
30+
31+
Local preview:
32+
33+
```bash
34+
node site/build.mjs
35+
npx --yes serve site/dist
36+
```
37+
38+
---
39+
2440
## Browse the catalog
2541

2642
| Category | Focus | Index |
@@ -41,7 +57,7 @@ Master index: [`catalog/README.md`](catalog/README.md)
4157

4258
## Quick start
4359

44-
1. Open a category folder above (or the [master index](catalog/README.md)).
60+
1. Use the [website](https://tmhsdigital.github.io/Free-Game-Dev-Assets/) or open a category folder (or the [master index](catalog/README.md)).
4561
2. Prefer entries with `status: active` and `commercial: true`.
4662
3. Follow the source URL and **re-check the live license** before shipping.
4763
4. Keep attribution / notices files for anything that isn’t CC0.
@@ -154,6 +170,8 @@ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the entry checklist and verificatio
154170
├── docs/
155171
│ ├── licenses.md / provenance.md / high-risk.md / ai-assets.md
156172
│ ├── trust-score.md / research-index.md / godot-budget-stack.md
173+
├── site/ ← GitHub Pages source (build scans catalog/)
174+
│ ├── build.mjs / config.json / public/
157175
└── catalog/
158176
├── TEMPLATE.md
159177
├── 3d/ environment/ 2d/ characters/

site/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Site
2+
3+
Static GitHub Pages UI for this catalog.
4+
5+
## Single source of truth
6+
7+
`site/build.mjs` scans every `catalog/**/*.md` (except `README.md` / `TEMPLATE.md`), reads YAML frontmatter, and writes `site/dist/data.js` + static assets.
8+
9+
| You edit | What updates |
10+
| --- | --- |
11+
| `catalog/<category>/<id>.md` | Catalog list, search, filters, counts |
12+
| `site/config.json``featured` | Safe starting points table (`id` + `need`) |
13+
| `site/config.json``categories` / `guides` | Labels and guide links |
14+
| `site/public/*` | Page chrome / design |
15+
16+
## Commands
17+
18+
```bash
19+
node site/build.mjs
20+
npx --yes serve site/dist
21+
```
22+
23+
Deploy: `.github/workflows/pages.yml` (source = GitHub Actions).

site/build.mjs

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Build GitHub Pages site from catalog markdown frontmatter.
4+
* Add/edit one catalog/<category>/<id>.md → rebuild regenerates everything.
5+
*/
6+
import fs from "node:fs";
7+
import path from "node:path";
8+
import { fileURLToPath } from "node:url";
9+
10+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
11+
const ROOT = path.resolve(__dirname, "..");
12+
const CATALOG = path.join(ROOT, "catalog");
13+
const PUBLIC = path.join(__dirname, "public");
14+
const DIST = path.join(__dirname, "dist");
15+
const CONFIG_PATH = path.join(__dirname, "config.json");
16+
17+
function parseScalar(raw) {
18+
const v = raw.trim();
19+
if (v === "true") return true;
20+
if (v === "false") return false;
21+
if (v === "null" || v === "~" || v === "") return null;
22+
if (
23+
(v.startsWith('"') && v.endsWith('"')) ||
24+
(v.startsWith("'") && v.endsWith("'"))
25+
) {
26+
return v.slice(1, -1);
27+
}
28+
if (v.startsWith("[") && v.endsWith("]")) {
29+
const inner = v.slice(1, -1).trim();
30+
if (!inner) return [];
31+
return inner.split(",").map((part) => {
32+
const s = part.trim();
33+
if (
34+
(s.startsWith('"') && s.endsWith('"')) ||
35+
(s.startsWith("'") && s.endsWith("'"))
36+
) {
37+
return s.slice(1, -1);
38+
}
39+
return s;
40+
});
41+
}
42+
return v;
43+
}
44+
45+
function parseFrontmatter(text) {
46+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
47+
if (!match) return null;
48+
const meta = {};
49+
for (const line of match[1].split(/\r?\n/)) {
50+
if (!line.trim() || line.trim().startsWith("#")) continue;
51+
const idx = line.indexOf(":");
52+
if (idx === -1) continue;
53+
const key = line.slice(0, idx).trim();
54+
meta[key] = parseScalar(line.slice(idx + 1));
55+
}
56+
return { meta, body: match[2].trim() };
57+
}
58+
59+
function stripMd(text) {
60+
return text
61+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
62+
.replace(/[*_`#]/g, "")
63+
.replace(/\s+/g, " ")
64+
.trim();
65+
}
66+
67+
function summaryFromBody(body) {
68+
const chunks = body
69+
.split(/\n\s*\n/)
70+
.map((p) => p.trim())
71+
.filter((p) => p && !p.startsWith("#"));
72+
const first = chunks[0] || "";
73+
const clean = stripMd(first);
74+
return clean.length > 220 ? `${clean.slice(0, 217)}…` : clean;
75+
}
76+
77+
function walkMarkdown(dir, out = []) {
78+
if (!fs.existsSync(dir)) return out;
79+
for (const name of fs.readdirSync(dir)) {
80+
const full = path.join(dir, name);
81+
const stat = fs.statSync(full);
82+
if (stat.isDirectory()) {
83+
walkMarkdown(full, out);
84+
continue;
85+
}
86+
if (!name.endsWith(".md")) continue;
87+
if (name === "README.md" || name === "TEMPLATE.md") continue;
88+
out.push(full);
89+
}
90+
return out;
91+
}
92+
93+
function loadEntries() {
94+
const files = walkMarkdown(CATALOG);
95+
const entries = [];
96+
const errors = [];
97+
98+
for (const file of files) {
99+
const text = fs.readFileSync(file, "utf8");
100+
const parsed = parseFrontmatter(text);
101+
if (!parsed) {
102+
errors.push(`No frontmatter: ${path.relative(ROOT, file)}`);
103+
continue;
104+
}
105+
const { meta, body } = parsed;
106+
const rel = path.relative(ROOT, file).split(path.sep).join("/");
107+
const required = ["id", "name", "url", "category", "license", "status"];
108+
const missing = required.filter((k) => meta[k] === undefined || meta[k] === null || meta[k] === "");
109+
if (missing.length) {
110+
errors.push(`${rel} missing: ${missing.join(", ")}`);
111+
continue;
112+
}
113+
114+
entries.push({
115+
id: String(meta.id),
116+
name: String(meta.name),
117+
url: String(meta.url),
118+
category: String(meta.category),
119+
subcategories: Array.isArray(meta.subcategories) ? meta.subcategories : [],
120+
license: String(meta.license),
121+
commercial: meta.commercial === true ? true : meta.commercial === false ? false : "unknown",
122+
attribution_required:
123+
meta.attribution_required === true
124+
? true
125+
: meta.attribution_required === false
126+
? false
127+
: "unknown",
128+
formats: Array.isArray(meta.formats) ? meta.formats : [],
129+
tags: Array.isArray(meta.tags) ? meta.tags : [],
130+
verified: meta.verified ? String(meta.verified) : null,
131+
status: String(meta.status),
132+
path: rel,
133+
summary: summaryFromBody(body),
134+
});
135+
}
136+
137+
entries.sort((a, b) => a.name.localeCompare(b.name));
138+
return { entries, errors };
139+
}
140+
141+
function copyDir(src, dest) {
142+
fs.mkdirSync(dest, { recursive: true });
143+
for (const name of fs.readdirSync(src)) {
144+
const from = path.join(src, name);
145+
const to = path.join(dest, name);
146+
if (fs.statSync(from).isDirectory()) copyDir(from, to);
147+
else fs.copyFileSync(from, to);
148+
}
149+
}
150+
151+
function main() {
152+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
153+
const { entries, errors } = loadEntries();
154+
155+
if (errors.length) {
156+
console.error("Build warnings:");
157+
for (const e of errors) console.error(` - ${e}`);
158+
}
159+
160+
const featuredRaw = config.featured || [];
161+
const featured = featuredRaw
162+
.map((item) => {
163+
const id = typeof item === "string" ? item : item?.id;
164+
const need = typeof item === "string" ? null : item?.need || null;
165+
const entry = entries.find((e) => e.id === id);
166+
if (!entry) return null;
167+
return { ...entry, need };
168+
})
169+
.filter(Boolean);
170+
171+
const stats = {
172+
total: entries.length,
173+
active: entries.filter((e) => e.status === "active").length,
174+
needsReview: entries.filter((e) => e.status === "needs-review").length,
175+
commercialOk: entries.filter((e) => e.commercial === true).length,
176+
categories: Object.keys(config.categories).length,
177+
};
178+
179+
const payload = {
180+
generatedAt: new Date().toISOString(),
181+
site: config.site,
182+
categories: config.categories,
183+
guides: config.guides,
184+
featured,
185+
stats,
186+
entries,
187+
};
188+
189+
if (fs.existsSync(DIST)) fs.rmSync(DIST, { recursive: true, force: true });
190+
copyDir(PUBLIC, DIST);
191+
fs.writeFileSync(path.join(DIST, "data.json"), JSON.stringify(payload, null, 2));
192+
fs.writeFileSync(
193+
path.join(DIST, "data.js"),
194+
`window.__CATALOG__ = ${JSON.stringify(payload)};\n`
195+
);
196+
197+
console.log(
198+
`Built ${entries.length} entries → site/dist (${stats.active} active, ${stats.commercialOk} commercial-ok)`
199+
);
200+
if (errors.length) process.exitCode = 0; // soft-fail missing fields as warnings
201+
}
202+
203+
main();

site/config.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"site": {
3+
"title": "Free Game Dev Assets",
4+
"tagline": "Free assets and tools for commercial games — with license metadata.",
5+
"repo": "https://github.com/TMHSDigital/Free-Game-Dev-Assets",
6+
"baseUrl": "/Free-Game-Dev-Assets"
7+
},
8+
"categories": {
9+
"3d": { "label": "3D", "blurb": "Models, scans, PBR textures" },
10+
"environment": { "label": "Environment", "blurb": "HDRI, terrain, geodata" },
11+
"2d": { "label": "2D", "blurb": "Sprites, UI, icons, palettes" },
12+
"characters": { "label": "Characters", "blurb": "Generators & modular humanoids" },
13+
"audio": { "label": "Audio", "blurb": "SFX, music, foley, IRs" },
14+
"animation": { "label": "Animation", "blurb": "MoCap & character clips" },
15+
"shaders-vfx": { "label": "Shaders & VFX", "blurb": "Shaders, particles, FX" },
16+
"fonts": { "label": "Fonts", "blurb": "OFL / commercial-ok type" },
17+
"tools": { "label": "Tools", "blurb": "Editors, pipeline, TTS, add-ons" }
18+
},
19+
"featured": [
20+
{ "id": "kenney", "need": "Modular low-poly 3D / UI" },
21+
{ "id": "quaternius", "need": "Rigged low-poly characters" },
22+
{ "id": "kaykit", "need": "Atlas-optimized kits" },
23+
{ "id": "ambientcg", "need": "Seamless PBR materials" },
24+
{ "id": "poly-haven", "need": "HDRIs / calibrated env" },
25+
{ "id": "texturecan", "need": "Extra PBR + SBSAR" },
26+
{ "id": "lucide-icons", "need": "UI icons (brand-free)" },
27+
{ "id": "glitch-archive", "need": "Hand-drawn 2D vectors" },
28+
{ "id": "xelu-input-prompts", "need": "Input prompt icons" },
29+
{ "id": "sonniss-gdc", "need": "Pro SFX dumps" },
30+
{ "id": "incompetech", "need": "Attribution music" },
31+
{ "id": "departure-mono", "need": "Pixel / terminal font" },
32+
{ "id": "noto-sans", "need": "Localization fonts" }
33+
],
34+
"guides": [
35+
{ "id": "licenses", "title": "Licenses", "path": "docs/licenses.md" },
36+
{ "id": "provenance", "title": "Provenance", "path": "docs/provenance.md" },
37+
{ "id": "high-risk", "title": "High risk", "path": "docs/high-risk.md" },
38+
{ "id": "ai-assets", "title": "AI assets", "path": "docs/ai-assets.md" },
39+
{ "id": "trust-score", "title": "Trust score", "path": "docs/trust-score.md" },
40+
{ "id": "godot-budget-stack", "title": "Godot budget stack", "path": "docs/godot-budget-stack.md" }
41+
]
42+
}

0 commit comments

Comments
 (0)