From cd37ff1d2f14b677fe476de0eb5752bd079284d7 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:50:54 -0400 Subject: [PATCH 01/31] ci: run on every push, Node 24 from .nvmrc, current action majors - Drop the push path filters. They left out stacks/**, so a stacks-only push was neither validated nor deployed until the Monday rebuild, and pages.yml also left out README.md, which the validator reads. The run takes seconds, so filtering saved nothing. - Pages calls the CI workflow (workflow_call) instead of repeating its steps, then builds and deploys. - Node 24 (current LTS) via .nvmrc for both; Node 20 is past end of life. - checkout v7, setup-node v7, upload-pages-artifact v5, deploy-pages v5. - ci.yml gets permissions: contents: read. - Dependabot keeps the action versions current, monthly. Fixes #1 Fixes #2 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- .github/dependabot.yml | 8 ++++++++ .github/workflows/ci.yml | 18 +++++++++--------- .github/workflows/pages.yml | 28 ++++++++++------------------ .nvmrc | 1 + 4 files changed, 28 insertions(+), 27 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .nvmrc diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..32d5e31 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + commit-message: + prefix: ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf78f94..682b4b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,27 +1,27 @@ name: Validate catalog +# No path filters: the build reads catalog/, stacks/, docs/, site/ and the +# top-level Markdown files, and the whole run takes seconds. on: pull_request: push: branches: [main] - paths: - - "catalog/**" - - "docs/**" - - "site/**" - - "README.md" - - ".github/workflows/ci.yml" + workflow_call: + +permissions: + contents: read jobs: validate: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: - node-version: "20" + node-version-file: .nvmrc - name: Test catalog checks run: node site/checks.test.mjs diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 7be55a3..85c4d72 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -3,11 +3,6 @@ name: Deploy GitHub Pages on: push: branches: [main] - paths: - - "catalog/**" - - "docs/**" - - "site/**" - - ".github/workflows/pages.yml" workflow_dispatch: # Weekly rebuild so the freshness ages never lag the calendar by more than # about a week when nothing is pushed. @@ -24,30 +19,27 @@ concurrency: cancel-in-progress: true jobs: + # The same tests, validation and page checks as a pull request runs. + validate: + uses: ./.github/workflows/ci.yml + build: + needs: validate runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: - node-version: "20" - - - name: Test catalog checks - run: node site/checks.test.mjs - - name: Test site build modules - run: node site/lib/lib.test.mjs - - - name: Validate catalog - run: node site/validate.mjs + node-version-file: .nvmrc - name: Build site run: node site/build.mjs - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: site/dist @@ -60,4 +52,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 From 5cda9727fcd9eb3657705bea511b7d807693e360 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:12:45 -0400 Subject: [PATCH 02/31] repo: the new-source form offers the video category catalog/video/ has six entries, but the form's category dropdown stopped at tools. The validator now checks the dropdown against the catalog folders (next commit). The correction and new-source labels the forms apply now exist in the repo. Fixes #5 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- .github/ISSUE_TEMPLATE/new-source.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/new-source.yml b/.github/ISSUE_TEMPLATE/new-source.yml index 786fbd7..891bb49 100644 --- a/.github/ISSUE_TEMPLATE/new-source.yml +++ b/.github/ISSUE_TEMPLATE/new-source.yml @@ -31,6 +31,7 @@ body: - characters - audio - animation + - video - environment - shaders-vfx - fonts From cb7a2d335dc89df237bc34a13b8ba6c8d3d8f29e Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:12:47 -0400 Subject: [PATCH 03/31] site: one frontmatter parser, stricter checks, and a build that fails loudly Parser (lib/frontmatter.mjs, used by build and validate): - build.mjs and validate.mjs each had a parser and they had drifted: validate kept \" escapes in ccmixter's attribution string, and its evidenceSection treated a following "## Extras" as Evidence. - Flow lists split on commas inside quotes and never unquoted items; block lists read as null; a repeated key won silently. Now: quoted commas stay in one item, block lists parse, and a repeated key or a line that is not key: value is an error with its line number. - A byte-order mark is ignored instead of hiding the entry. Build: - Summaries come from the page's own inline renderer with tags dropped, so underscores and # stay literal: "Rig_Medium", not "RigMedium", and "KHR_draco_mesh_compression". Five summaries change. - An entry that cannot be read, or a missing index.html marker, fails the build instead of printing a warning and publishing a site with the entry missing. The exitCode = 0 line did nothing and is gone. Checks: - V16: url must be http(s). javascript: and data: URLs passed and were rendered as the "Go to source" link. - verified, Evidence and walked dates must be real calendar dates; 2025-13-01 passed and 2026-02-30 rolled over to 2 March. - Evidence dates inside URLs no longer count, so a link to .../2026-09-20-notes cannot hide a bumped verified date. No entry's newest Evidence date changes. - "In the future" means after tomorrow in UTC, so a contributor east of UTC can write their own today. - V17: catalog folders, config.json categories and the new-source form must list the same categories. A folder missing from the config vanished from the homepage without a word. - V18: a link fragment into a Markdown file must name one of its headings. All 13 in the repo resolve. - The binary-file scan skips hidden folders and _scratch, which are not repo content (it failed on local git worktrees under .claude/). Markdown renderer: - Link hrefs keep one level of balanced parens (Foo_(bar)). - Italic that would cross a bold boundary stays literal instead of producing . - Heading ids are unique on the page and avoid the template's own ids. Tests: 21 lib assertions and 19 check assertions added. Fixes #7 Fixes #8 Fixes #9 Fixes #10 Fixes #11 Fixes #12 Fixes #13 Fixes #14 Fixes #15 Fixes #30 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- site/build.mjs | 95 +++++++--------------------- site/checks.mjs | 112 +++++++++++++++++++++++++++++---- site/checks.test.mjs | 75 +++++++++++++++++++++- site/lib/frontmatter.mjs | 133 +++++++++++++++++++++++++++++++++++++++ site/lib/lib.test.mjs | 49 ++++++++++++++- site/lib/markdown.mjs | 41 ++++++++++-- site/lib/shared.mjs | 21 +++++++ site/lib/stacks.mjs | 5 +- site/validate.mjs | 130 +++++++++++++++++++------------------- 9 files changed, 498 insertions(+), 163 deletions(-) create mode 100644 site/lib/frontmatter.mjs diff --git a/site/build.mjs b/site/build.mjs index 40729fe..7e1cd16 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -6,7 +6,8 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { commercialLabel, esc, PERSPECTIVE_LABELS, unquoteScalar, verifiedAge } from "./lib/shared.mjs"; +import { commercialLabel, esc, latestAllowedDate, PERSPECTIVE_LABELS, verifiedAge } from "./lib/shared.mjs"; +import { parseFrontmatter, summaryFromBody } from "./lib/frontmatter.mjs"; import { entryPageHtml } from "./lib/entry-page.mjs"; import { LinkError, makeLinkResolver } from "./lib/links.mjs"; import { llmsFullTxt, llmsTxt } from "./lib/llms.mjs"; @@ -34,66 +35,6 @@ const OG_CARD_SRC = path.join(ROOT, "docs", "images", "readme", OG_CARD_NAME); /** Sort rank for "license permissiveness": least owed first. */ const ATTRIBUTION_RANK = { none: 0, notice: 1, required: 2, any: 3 }; -function parseScalar(raw) { - const v = raw.trim(); - if (v === "true") return true; - if (v === "false") return false; - if (v === "null" || v === "~" || v === "") return null; - if ( - (v.startsWith('"') && v.endsWith('"')) || - (v.startsWith("'") && v.endsWith("'")) - ) { - return unquoteScalar(v); - } - if (v.startsWith("[") && v.endsWith("]")) { - const inner = v.slice(1, -1).trim(); - if (!inner) return []; - return inner.split(",").map((part) => { - const s = part.trim(); - if ( - (s.startsWith('"') && s.endsWith('"')) || - (s.startsWith("'") && s.endsWith("'")) - ) { - return s.slice(1, -1); - } - return s; - }); - } - return v; -} - -function parseFrontmatter(text) { - const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); - if (!match) return null; - const meta = {}; - for (const line of match[1].split(/\r?\n/)) { - if (!line.trim() || line.trim().startsWith("#")) continue; - const idx = line.indexOf(":"); - if (idx === -1) continue; - const key = line.slice(0, idx).trim(); - meta[key] = parseScalar(line.slice(idx + 1)); - } - return { meta, body: match[2].trim() }; -} - -function stripMd(text) { - return text - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") - .replace(/[*_`#]/g, "") - .replace(/\s+/g, " ") - .trim(); -} - -function summaryFromBody(body) { - const chunks = body - .split(/\n\s*\n/) - .map((p) => p.trim()) - .filter((p) => p && !p.startsWith("#")); - const first = chunks[0] || ""; - const clean = stripMd(first); - return clean.length > 220 ? `${clean.slice(0, 217)}…` : clean; -} - function walkMarkdown(dir, out = []) { if (!fs.existsSync(dir)) return out; for (const name of fs.readdirSync(dir)) { @@ -118,13 +59,18 @@ function loadEntries(vocab) { for (const file of files) { const text = fs.readFileSync(file, "utf8"); + const rel = path.relative(ROOT, file).split(path.sep).join("/"); const parsed = parseFrontmatter(text); if (!parsed) { - errors.push(`No frontmatter: ${path.relative(ROOT, file)}`); + errors.push(`${rel} has no frontmatter`); continue; } - const { meta, body } = parsed; - const rel = path.relative(ROOT, file).split(path.sep).join("/"); + if (parsed.errors.length) { + errors.push(...parsed.errors.map((e) => `${rel} frontmatter ${e}`)); + continue; + } + const { meta } = parsed; + const body = parsed.body.trim(); const required = ["id", "name", "url", "category", "license", "status"]; const missing = required.filter((k) => meta[k] === undefined || meta[k] === null || meta[k] === ""); if (missing.length) { @@ -407,7 +353,7 @@ function loadStacks(entries, vocab, spdxAllowed) { const byPath = new Map(entries.map((e) => [e.path, e])); const errors = [ ...stray.map((rel) => `${rel}: stacks live directly in stacks/; the build does not read subfolders`), - ...checkStacks(files, byPath, terms, new Date().toISOString().slice(0, 10)), + ...checkStacks(files, byPath, terms, latestAllowedDate()), ]; if (errors.length) return { stacks: [], errors }; const stacks = files.map(({ rel, text }) => { @@ -542,9 +488,12 @@ function main() { } } + // An entry that cannot be read must stop the build: carrying on would + // publish a site with that entry, and any featured slot for it, missing. if (errors.length) { - console.error("Build warnings:"); + console.error(`Build failed (${errors.length}):`); for (const e of errors) console.error(` - ${e}`); + process.exit(1); } const repoUrl = config.site.repo; @@ -611,13 +560,14 @@ function main() { FOOTER_STAMP: `Built ${esc(stamp)} from ${stats.total} catalog entries.`, }; let html = fs.readFileSync(indexPath, "utf8"); + const missingMarkers = Object.keys(substitutions).filter((key) => !html.includes(``)); + if (missingMarkers.length) { + console.error(`Build failed: site/public/index.html is missing ${missingMarkers.map((k) => ``).join(", ")}`); + process.exit(1); + } for (const [key, value] of Object.entries(substitutions)) { - const marker = ``; - if (!html.includes(marker)) { - console.error(` - index.html is missing the ${marker} marker`); - continue; - } - html = html.replace(marker, value); + // A function replacement, so a "$" in the value is never read as a pattern. + html = html.replace(``, () => value); } fs.writeFileSync(indexPath, html); @@ -665,7 +615,6 @@ function main() { console.log( `Prerendered ${visible.length} entry rows into index.html; wrote ${pages.written.length} entry pages, ${stackPages.written.length} stack pages, sitemap.xml, robots.txt, 404.html, freshness/index.html` ); - if (errors.length) process.exitCode = 0; // soft-fail missing fields as warnings } main(); diff --git a/site/checks.mjs b/site/checks.mjs index 14f1968..b561d06 100644 --- a/site/checks.mjs +++ b/site/checks.mjs @@ -7,10 +7,27 @@ */ import path from "node:path"; +import { evidenceSection } from "./lib/frontmatter.mjs"; +import { isRealDate } from "./lib/shared.mjs"; import { parseStack, pickPath, StackError } from "./lib/stacks.mjs"; -const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; -const DATE_ANY_RE = /\d{4}-\d{2}-\d{2}/g; +export { evidenceSection }; + +// A date standing on its own, not part of a longer token such as a path. +const DATE_TOKEN_RE = /(?]*>/g, "") + .replace(/https?:\/\/[^\s)>\]]+/g, ""); + return [...prose.matchAll(DATE_TOKEN_RE)].map((m) => m[1]).sort(); +} /** Generic hosts and distributors that are never a rights holder. */ export const GENERIC_HOSTS = new Set([ @@ -51,15 +68,6 @@ function empty(v) { return false; } -/** The `## Evidence` section of an entry body, or null. */ -export function evidenceSection(body) { - const idx = body.search(/^## Evidence\s*$/m); - if (idx === -1) return null; - const rest = body.slice(idx); - const next = rest.search(/\n## (?!Evidence)/); - return next === -1 ? rest : rest.slice(0, next); -} - /* ------------------------------------------------------------------ V1 */ /** `license` must be a value the vocabulary documents. */ export function checkLicenseVocabulary(rel, meta, vocab) { @@ -151,17 +159,20 @@ export function checkEvidenceDates(rel, meta, body, today) { const errors = []; const ev = evidenceSection(body); if (!ev) return errors; // presence is validate.mjs's job, and only for active - const dates = [...ev.matchAll(DATE_ANY_RE)].map((m) => m[0]).sort(); + const dates = evidenceDates(ev); if (!dates.length) { errors.push(`${rel} ## Evidence section carries no YYYY-MM-DD date`); return errors; } + for (const d of new Set(dates)) { + if (!isRealDate(d)) errors.push(`${rel} Evidence date ${d} is not a real calendar date`); + } const newest = dates[dates.length - 1]; if (newest > today) { errors.push(`${rel} Evidence date ${newest} is in the future`); } const verified = empty(meta.verified) ? null : String(meta.verified); - if (verified && DATE_RE.test(verified) && verified > newest) { + if (verified && isRealDate(verified) && verified > newest) { errors.push( `${rel} verified ${verified} is newer than its newest Evidence date ${newest}. ` + `A new verified date needs a dated Evidence line from the same check` @@ -419,6 +430,81 @@ export function checkCategoryReadmeRows(categoryName, readmeText, entries) { return errors; } +/* ----------------------------------------------------------------- V16 */ +/** + * `url` becomes the "Go to source" link on every page, so it must be a web + * address. A `javascript:` or `data:` value would render as a live link, and + * escaping HTML does nothing about a scheme. + */ +export function checkEntryUrl(rel, meta) { + const errors = []; + if (empty(meta.url)) return errors; // missing fields are reported elsewhere + let url; + try { + url = new URL(String(meta.url)); + } catch { + errors.push(`${rel} url "${meta.url}" is not an absolute URL`); + return errors; + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + errors.push(`${rel} url must be https:// (or http:// where the source has no https), not ${url.protocol}`); + } else if (!url.hostname) { + errors.push(`${rel} url "${meta.url}" has no host`); + } + return errors; +} + +/* ----------------------------------------------------------------- V17 */ +/** + * The catalog folders, `site/config.json` categories and the new-source + * issue form must list the same categories. A folder missing from the + * config still gets entry pages but vanishes from the homepage, the pager + * and llms.txt, and nothing said so. + */ +export function checkCategorySets({ dirs, configured, formOptions }) { + const errors = []; + const diff = (a, b) => [...a].filter((x) => !b.has(x)).sort(); + const d = new Set(dirs); + const c = new Set(configured); + for (const cat of diff(d, c)) errors.push(`catalog/${cat}/ is not in site/config.json categories, so the homepage would not list it`); + for (const cat of diff(c, d)) errors.push(`site/config.json category "${cat}" has no catalog/${cat}/ folder`); + if (formOptions) { + const f = new Set(formOptions); + for (const cat of diff(d, f)) errors.push(`.github/ISSUE_TEMPLATE/new-source.yml category options leave out "${cat}"`); + for (const cat of diff(f, d)) errors.push(`.github/ISSUE_TEMPLATE/new-source.yml offers category "${cat}", which is not a catalog folder`); + } + return errors; +} + +/* ----------------------------------------------------------------- V18 */ +/** + * The fragment ids GitHub gives a Markdown file's headings, plus any explicit + * `id`/`name` attributes. The README's "Start here" table links category + * README headings, and renaming a heading silently broke such a link. + */ +export function markdownAnchors(text) { + const anchors = new Set(); + const seen = new Map(); + let fenced = false; + for (const line of String(text).split(/\r?\n/)) { + if (/^\s*```/.test(line)) fenced = !fenced; + if (fenced) continue; + const h = line.match(/^#{1,6}\s+(.*?)\s*#*\s*$/); + if (!h) continue; + const base = h[1] + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/<[^>]+>/g, "") + .toLowerCase() + .replace(/[^\p{L}\p{N}\s_-]/gu, "") + .replace(/ /g, "-"); + const n = seen.get(base) || 0; + seen.set(base, n + 1); + anchors.add(n ? `${base}-${n}` : base); + } + for (const m of String(text).matchAll(/\s(?:id|name)="([^"]+)"/g)) anchors.add(m[1]); + return anchors; +} + /* ------------------------------------------------------------------ V6 */ /** * The category count tables in README.md and catalog/README.md, and the diff --git a/site/checks.test.mjs b/site/checks.test.mjs index b841d03..54e6e58 100644 --- a/site/checks.test.mjs +++ b/site/checks.test.mjs @@ -13,9 +13,13 @@ import { checkActiveIsSettled, checkAttributionConsistency, checkCategoryReadmeRows, + checkCategorySets, checkCountTables, checkDeprecationReason, + checkEntryUrl, checkEvidenceDates, + evidenceDates, + markdownAnchors, checkLicenseVocabulary, checkPublisherConsistency, checkSpdxConsistency, @@ -49,6 +53,11 @@ function rejects(label, errors, needle) { passed += 1; } +function eq(label, actual, expected) { + if (actual === expected) passed += 1; + else failures.push(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); +} + function accepts(label, errors) { if (errors.length) { failures.push(`${label}: expected no error, got: ${errors.join(" | ")}`); @@ -558,10 +567,74 @@ rejects("V15 rejects a licence in a why sentence", oneStack(stackText("- **Tiles rejects("V15 rejects a walked date in the future", oneStack(stackText(goodPick, { walked: "2099-01-01" })), "walked 2099-01-01 is in the future"); rejects("V15 rejects an unknown section", oneStack(stackText(goodPick, { section: "## Levels" })), 'section "Levels" is not one of'); +/* V8: evidence dates are prose dates, and real ------------------------- */ +const V8_META = { verified: "2026-09-01" }; +accepts( + "V8 ignores a date inside a link target", + checkEvidenceDates("ok.md", V8_META, "\n## Evidence\n\n- [Notes](https://x.test/releases/2026-09-20-notes) (2026-09-01): \"CC0\"\n", TODAY) +); +accepts( + "V8 ignores a date inside a bare URL", + checkEvidenceDates("ok.md", V8_META, "\n## Evidence\n\n- Live page (2026-09-01): https://x.test/2099-01-01/post\n", TODAY) +); +rejects( + "V8 still sees a bumped verified behind a URL date", + checkEvidenceDates("bad.md", { verified: "2026-09-20" }, "\n## Evidence\n\n- Live page (2026-09-01): https://x.test/2026-09-20/\n", TODAY), + "newer than its newest Evidence date 2026-09-01" +); +rejects( + "V8 rejects an impossible Evidence date", + checkEvidenceDates("bad.md", { verified: "2026-02-01" }, EV("2026-02-30"), TODAY), + "2026-02-30 is not a real calendar date" +); +eq( + "evidenceDates reads prose dates only", + evidenceDates("- [a](https://x/2020-01-01) (2026-01-02) and 2026-01-03").join(","), + "2026-01-02,2026-01-03" +); + +/* V16: url scheme ------------------------------------------------------ */ +rejects("V16 rejects javascript:", checkEntryUrl("bad.md", { url: "javascript:alert(1)" }), "must be https://"); +rejects("V16 rejects data:", checkEntryUrl("bad.md", { url: "data:text/html," }), "must be https://"); +rejects("V16 rejects a relative url", checkEntryUrl("bad.md", { url: "/downloads" }), "is not an absolute URL"); +accepts("V16 accepts https", checkEntryUrl("ok.md", { url: "https://kenney.nl/assets" })); +accepts("V16 accepts http where a source has no https", checkEntryUrl("ok.md", { url: "http://www.makehumancommunity.org/" })); + +/* V17: category sets --------------------------------------------------- */ +accepts( + "V17 accepts matching sets", + checkCategorySets({ dirs: ["2d", "3d"], configured: ["3d", "2d"], formOptions: ["2d", "3d"] }) +); +rejects( + "V17 rejects a folder missing from config", + checkCategorySets({ dirs: ["2d", "video"], configured: ["2d"], formOptions: null }), + "catalog/video/ is not in site/config.json" +); +rejects( + "V17 rejects a config category with no folder", + checkCategorySets({ dirs: ["2d"], configured: ["2d", "vfx"], formOptions: null }), + 'category "vfx" has no catalog/vfx/ folder' +); +rejects( + "V17 rejects an issue form missing a category", + checkCategorySets({ dirs: ["2d", "video"], configured: ["2d", "video"], formOptions: ["2d"] }), + 'leave out "video"' +); + +/* V18: markdown anchors ------------------------------------------------ */ +const anchors = markdownAnchors( + "# Top\n\n## Choosing a pixel tileset\n\n## Notes\n\n## Notes\n\n```\n## Not a heading\n```\n\n### Fonts & `OFL` [link](x.md)\n\n\n" +); +eq("V18 slugs a heading", anchors.has("choosing-a-pixel-tileset"), true); +eq("V18 numbers a repeated heading", anchors.has("notes") && anchors.has("notes-1"), true); +eq("V18 skips fenced code", anchors.has("not-a-heading"), false); +eq("V18 drops punctuation and link syntax", anchors.has("fonts--ofl-link"), true); +eq("V18 reads explicit ids", anchors.has("custom"), true); + /* ----------------------------------------------------------------------- */ if (failures.length) { console.error(`checks.test failed (${failures.length}):`); for (const f of failures) console.error(` - ${f}`); process.exit(1); } -console.log(`checks.test ok: ${passed} assertions across 15 checks`); +console.log(`checks.test ok: ${passed} assertions across 18 checks`); diff --git a/site/lib/frontmatter.mjs b/site/lib/frontmatter.mjs new file mode 100644 index 0000000..37af725 --- /dev/null +++ b/site/lib/frontmatter.mjs @@ -0,0 +1,133 @@ +/** + * Entry frontmatter and body helpers, shared by build.mjs and validate.mjs so + * the two can never read the same file differently. (They used to carry a + * copy each, and the copies had drifted: one undid quote escapes and the + * other did not.) + * + * The parser reads exactly the YAML subset entries use: `key: value`, quoted + * scalars, flow lists (`[a, "b, c"]`), block lists (`key:` then ` - item`) + * and `#` comment lines. Anything else, and a key given twice, is reported + * with its line number rather than guessed at. + */ +import { renderInline } from "./markdown.mjs"; +import { unquoteScalar } from "./shared.mjs"; + +/** Splits the inside of a flow list on commas that are not inside quotes. */ +function splitFlowList(inner) { + const parts = []; + let cur = ""; + let quote = null; + for (let i = 0; i < inner.length; i += 1) { + const ch = inner[i]; + if (quote) { + cur += ch; + if (quote === '"' && ch === "\\" && i + 1 < inner.length) cur += inner[++i]; + else if (ch === quote) quote = null; + } else if (ch === '"' || ch === "'") { + quote = ch; + cur += ch; + } else if (ch === ",") { + parts.push(cur); + cur = ""; + } else cur += ch; + } + parts.push(cur); + return parts.map((p) => unquoteScalar(p.trim())); +} + +export function parseScalar(raw) { + const v = raw.trim(); + if (v === "true") return true; + if (v === "false") return false; + if (v === "null" || v === "~" || v === "") return null; + if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) { + return unquoteScalar(v); + } + if (v.startsWith("[") && v.endsWith("]")) { + const inner = v.slice(1, -1).trim(); + return inner ? splitFlowList(inner) : []; + } + return v; +} + +/** + * `{ meta, body, errors }`, or null when the text has no frontmatter block. + * `errors` are "line N: ..." strings; the caller prefixes the file. + */ +export function parseFrontmatter(text) { + // A byte-order mark is invisible in an editor; ignore it. + const match = String(text) + .replace(/^/, "") + .match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/); + if (!match) return null; + const meta = {}; + const errors = []; + const lines = match[1].split(/\r?\n/); + let listKey = null; + lines.forEach((line, i) => { + const lineNo = i + 2; // line 1 is the opening --- + if (!line.trim() || line.trim().startsWith("#")) return; + const item = line.match(/^\s+-\s+(.*)$/); + if (item) { + if (listKey === null) errors.push(`line ${lineNo}: list item without a key above it`); + else meta[listKey].push(parseScalar(item[1])); + return; + } + listKey = null; + const idx = line.indexOf(":"); + if (idx === -1 || /^\s/.test(line)) { + errors.push(`line ${lineNo}: "${line.trim()}" is not a key: value line`); + return; + } + const key = line.slice(0, idx).trim(); + if (Object.prototype.hasOwnProperty.call(meta, key)) { + errors.push(`line ${lineNo}: key "${key}" is given twice`); + return; + } + const value = parseScalar(line.slice(idx + 1)); + const next = lines[i + 1]; + if (value === null && next !== undefined && /^\s+-\s/.test(next)) { + meta[key] = []; + listKey = key; + return; + } + meta[key] = value; + }); + return { meta, body: match[2] || "", errors }; +} + +/** The `## Evidence` section of an entry body, up to the next `## ` heading, or null. */ +export function evidenceSection(body) { + const idx = body.search(/^## Evidence\s*$/m); + if (idx === -1) return null; + const rest = body.slice(idx); + const next = rest.search(/\n## (?!Evidence\s*$)/m); + return next === -1 ? rest : rest.slice(0, next); +} + +const ENTITIES = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'" }; + +/** + * Plain text of a line of entry markdown. It goes through the same inline + * renderer as the entry page and drops the tags, so it strips exactly the + * markup the page renders: underscores and `#` stay literal (Rig_Medium, C#). + */ +export function plainText(markdown) { + const html = renderInline(markdown, (href) => ({ href, external: true })); + return html + .replace(/<[^>]+>/g, "") + .replace(/&(amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m]) + .replace(/\s+/g, " ") + .trim(); +} + +/** The first paragraph of an entry body as plain text, at most 220 characters. */ +export function summaryFromBody(body) { + const first = + String(body) + .split(/\n\s*\n/) + .map((p) => p.trim()) + .find((p) => p && !p.startsWith("#")) || ""; + const clean = plainText(first); + return clean.length > 220 ? `${clean.slice(0, 217)}…` : clean; +} diff --git a/site/lib/lib.test.mjs b/site/lib/lib.test.mjs index e272919..37f95bd 100644 --- a/site/lib/lib.test.mjs +++ b/site/lib/lib.test.mjs @@ -240,7 +240,7 @@ throws("pick without a why", badStack((m) => m.replace("). Idle, run and jump.", throws("licence in a why sentence", badStack((m) => m.replace("Five loops.", "Five CC0 loops.")), 'stacks/s1.md:20: the why sentence names a licence ("CC0")'); throws("licence phrase in a why sentence", badStack((m) => m.replace("Five loops.", "Five public domain loops.")), '("public domain")'); throws("missing walked", badStack((m) => m.replace("walked: 2026-09-20\n", "")), "frontmatter is missing walked"); -throws("bad walked date", badStack((m) => m.replace("2026-09-20", "20 Sept")), "walked is not YYYY-MM-DD"); +throws("bad walked date", badStack((m) => m.replace("2026-09-20", "20 Sept")), "walked is not a real YYYY-MM-DD date"); throws("empty pick section", badStack((m) => m.replace("- **Music, no credit:** [Chips](../catalog/audio/subspaceaudio-5-chiptunes.md). Five loops.", "")), 'section "Audio" has no picks'); throws("gaps line not a bullet", badStack((m) => m.replace("- No parallax layers yet.", "No parallax layers yet.")), "stacks/s1.md:24: a Gaps line is a bullet"); eq("licence terms skip plain words", licenceTerms({ licenses: { custom: {}, MIT: { spdx: "MIT" }, CC0: { spdx: "CC0-1.0" } } }, ["OFL-1.1"]).sort().join(","), "CC0,CC0-1.0,MIT,OFL-1.1"); @@ -425,6 +425,53 @@ has("llms points at freshness", lt, "Licence freshness, every entry by check dat has("recent window wording counts the build day", fp, "Checked on 2026-09-25 or in the 30 days before it, newest first."); has("empty recent window wording counts the build day", freshnessPageHtml({ stats: freshnessStats([fe("a", "Alpha", 90)], fNow), site, stamp: "2026-09-25", total: 1, hasCard: false }), "None checked on 2026-09-25 or in the 30 days before it."); +/* frontmatter -------------------------------------------------------------- */ +import { evidenceSection, parseFrontmatter, plainText, summaryFromBody } from "./frontmatter.mjs"; +import { isRealDate, latestAllowedDate } from "./shared.mjs"; +const fm = (lines, body = "Body.\n") => parseFrontmatter(`---\n${lines.join("\n")}\n---\n${body}`); +const f1 = fm([ + "id: a", + 'attribution_string: "Music \\"{title}\\" by {artist}"', + 'tags: ["sci-fi, space", ui, \'it\'\'s\']', + "formats:", + " - PNG", + ' - "SVG"', + "# a comment", + "commercial: true", +]); +eq("frontmatter undoes quote escapes", f1.meta.attribution_string, 'Music "{title}" by {artist}'); +eq("frontmatter keeps a quoted comma inside one list item", JSON.stringify(f1.meta.tags), JSON.stringify(["sci-fi, space", "ui", "it's"])); +eq("frontmatter reads a block list", JSON.stringify(f1.meta.formats), JSON.stringify(["PNG", "SVG"])); +eq("frontmatter reads booleans", f1.meta.commercial, true); +eq("frontmatter has no errors on good input", f1.errors.length, 0); +eq("frontmatter ignores a byte-order mark", parseFrontmatter("---\nid: a\n---\nB")?.meta.id, "a"); +eq("frontmatter with CRLF line endings", parseFrontmatter("---\r\nid: a\r\nname: B\r\n---\r\nBody")?.meta.name, "B"); +has("frontmatter rejects a duplicate key", fm(["id: a", "id: b"]).errors.join(), 'line 3: key "id" is given twice'); +has("frontmatter rejects a line that is not key: value", fm(["id: a", "just words"]).errors.join(), 'line 3: "just words" is not a key: value line'); +eq("no frontmatter is null", parseFrontmatter("# Title\n"), null); +eq("evidence stops at the next heading, whatever its letter", evidenceSection("## Evidence\n\n- e (2026-01-01)\n\n## Extras\n\n- x"), "## Evidence\n\n- e (2026-01-01)\n"); +eq("summary keeps underscores", summaryFromBody("Split by Rig_Medium and **KHR_draco**.\n\n## Notes"), "Split by Rig_Medium and KHR_draco."); +eq("summary keeps C# and link text", summaryFromBody("# T\n\nA [C#](x.md) library, `code`, *em*."), "A C# library, code, em."); +eq("plain text undoes escapes", plainText("A \\*literal\\* & "), "A *literal* & "); +eq("summary is capped at 220", summaryFromBody("word ".repeat(80)).length, 218); + +/* markdown: review fixes --------------------------------------------------- */ +eq("link href keeps balanced parens", renderInline("[Foo](https://en.wikipedia.org/wiki/Foo_(bar)) next", passLink), 'Foo next'); +eq("misnested emphasis stays literal", renderInline("**a *b** c*", passLink), "a *b c*"); +eq("italic around bold still works", renderInline("*a **b** c*", passLink), "a b c"); +const dupIds = rb("## Notes\n\nx\n\n## Notes\n\ny\n\n## Content\n\nz"); +has("first heading keeps its id", dupIds, '

'); +has("repeated heading gets a suffix", dupIds, '

'); +has("heading avoids a template id", dupIds, '

'); + +/* dates -------------------------------------------------------------------- */ +eq("real date", isRealDate("2026-02-28"), true); +eq("leap day", isRealDate("2028-02-29"), true); +eq("no month 13", isRealDate("2025-13-01"), false); +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"); + /* report (keep last) ------------------------------------------------------ */ if (failures.length) { console.error(`lib.test failed (${failures.length}):`); diff --git a/site/lib/markdown.mjs b/site/lib/markdown.mjs index 9e30dc3..c83277d 100644 --- a/site/lib/markdown.mjs +++ b/site/lib/markdown.mjs @@ -11,13 +11,36 @@ export class MarkdownError extends Error {} const slot = (i) => `\u0000${i}\u0000`; const SLOT_RE = /\u0000(\d+)\u0000/g; -/** Bold and italic on already-escaped text. */ +const count = (str, sub) => str.split(sub).length - 1; + +/** + * Bold and italic on already-escaped text. Italic that would cross a bold + * boundary (`**a *b** c*`) stays literal: wrapping it would nest the tags + * wrongly. + */ function marks(escaped) { return escaped .replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, "$1") - .replace(/(^|[^*\w])\*(?=\S)([^*]+?)(?<=\S)\*(?![*\w])/g, "$1$2"); + .replace(/(^|[^*\w])\*(?=\S)([^*]+?)(?<=\S)\*(?![*\w])/g, (whole, pre, inner) => + count(inner, "") === count(inner, "") ? `${pre}${inner}` : whole + ); } +/** Ids the page templates use themselves; a heading must not take one. */ +const RESERVED_IDS = new Set([ + "attribution-string", + "content", + "copy-attribution", + "copy-status", + "credits-all", + "credits-list", + "every-check", + "facts-title", + "owes-title", + "recent", + "sec-gaps", +]); + export function renderInline(text, resolveLink) { const slots = []; const keep = (html) => { @@ -29,8 +52,9 @@ export function renderInline(text, resolveLink) { s = s.replace(/`([^`]+)`/g, (_, code) => keep(`${esc(code)}`)); // Backslash escapes: any ASCII punctuation, as in CommonMark. s = s.replace(/\\([!-/:-@[-`{-~])/g, (_, ch) => keep(esc(ch))); - // Links. The label keeps bold, italic and code; the href goes through the resolver. - s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, href) => { + // Links. The label keeps bold, italic and code; the href goes through the + // resolver. The href may hold one level of balanced parens (Foo_(bar)). + s = s.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (_, label, href) => { const { href: out, external } = resolveLink(href); const rel = external ? ' rel="noopener noreferrer"' : ""; return keep(`${marks(esc(label))}`); @@ -42,7 +66,6 @@ export function renderInline(text, resolveLink) { // and keeps a closing paren that balances one inside it (Foo_(bar)). s = s.replace(/https?:\/\/[^\s<>"*\u0000]+/g, (url) => { let [, core, tail] = url.match(/^(.*?)([.,;:!?)\]]*)$/); - const count = (str, ch) => str.split(ch).length - 1; while (tail.startsWith(")") && count(core, "(") > count(core, ")")) { core += ")"; tail = tail.slice(1); @@ -74,6 +97,7 @@ const lines = (md) => String(md).replace(/\r\n?/g, "\n").split("\n"); export function renderBlocks(md, { file, resolveLink, firstLine = 1 }) { const html = []; const stack = []; + const ids = new Set(RESERVED_IDS); let para = []; let item = null; let blank = false; @@ -137,7 +161,12 @@ export function renderBlocks(md, { file, resolveLink, firstLine = 1 }) { if (h) { flushPara(); if (h[1] === "#") return; // the page shows the entry name as its h1 - const id = h[2].toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + // Unique on the page: a second "Notes" becomes notes-2, and a heading + // never takes an id the page template uses. + const base = h[2].toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section"; + let id = base; + for (let n = 2; ids.has(id); n += 1) id = `${base}-${n}`; + ids.add(id); html.push(`

${inline(h[2])}

`); return; } diff --git a/site/lib/shared.mjs b/site/lib/shared.mjs index 8046aa8..b3c77ba 100644 --- a/site/lib/shared.mjs +++ b/site/lib/shared.mjs @@ -45,6 +45,27 @@ export function commercialLabel(v) { return "commercial ?"; } +/** + * True for a YYYY-MM-DD string that names a real calendar day. The shape + * alone let 2025-13-01 through, and 2026-02-30 rolled over to 2 March. + */ +export function isRealDate(s) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s)); + if (!m) return false; + const [y, mo, d] = m.slice(1).map(Number); + const t = new Date(Date.UTC(y, mo - 1, d)); + return t.getUTCFullYear() === y && t.getUTCMonth() === mo - 1 && t.getUTCDate() === d; +} + +/** + * The newest date a check may accept as "not in the future": tomorrow in UTC. + * A contributor east of UTC who writes their local date before UTC midnight + * has not written a future date. + */ +export function latestAllowedDate(now = Date.now()) { + return new Date(now + 86400000).toISOString().slice(0, 10); +} + /** Absolute URL of an entry's page. */ export function entryPageUrl(site, id) { return `${String(site.siteUrl).replace(/\/+$/, "")}/entry/${id}/`; diff --git a/site/lib/stacks.mjs b/site/lib/stacks.mjs index b26e0b0..d8b37ab 100644 --- a/site/lib/stacks.mjs +++ b/site/lib/stacks.mjs @@ -5,7 +5,7 @@ */ import fs from "node:fs"; import path from "node:path"; -import { unquoteScalar } from "./shared.mjs"; +import { isRealDate, unquoteScalar } from "./shared.mjs"; export class StackError extends Error {} @@ -13,7 +13,6 @@ export class StackError extends Error {} export const STACK_SECTIONS = ["Art", "Audio", "Fonts", "Tools", "Gaps"]; const REQUIRED = ["id", "title", "task", "walked"]; -const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; // - **Need:** [Entry name](path/to/entry.md). Why this pick. const PICK_RE = /^- \*\*([^*]+?):\*\* \[([^\]]+)\]\(([^)\s]+)\)\.\s+(\S.*)$/; // Names and shorthands matched in any case, as whole words. The exact ids @@ -99,7 +98,7 @@ export function parseStack(text, { file, terms = [] }) { metaLine[key] = i; } for (const key of REQUIRED) if (!meta[key]) fail(0, `frontmatter is missing ${key}`); - if (!DATE_RE.test(meta.walked)) fail(0, "walked is not YYYY-MM-DD"); + if (!isRealDate(meta.walked)) fail(0, "walked is not a real YYYY-MM-DD date"); noLicence(metaLine.title, meta.title, "title"); noLicence(metaLine.task, meta.task, "task"); diff --git a/site/validate.mjs b/site/validate.mjs index 914301a..db18014 100644 --- a/site/validate.mjs +++ b/site/validate.mjs @@ -9,8 +9,10 @@ import { checkActiveIsSettled, checkAttributionConsistency, checkCategoryReadmeRows, + checkCategorySets, checkCountTables, checkDeprecationReason, + checkEntryUrl, checkEvidenceDates, checkLicenseVocabulary, checkPublisherConsistency, @@ -18,7 +20,11 @@ import { checkStacks, checkTaxonomyValues, checkValueSpellings, + evidenceDates, + markdownAnchors, } from "./checks.mjs"; +import { evidenceSection, parseFrontmatter } from "./lib/frontmatter.mjs"; +import { isRealDate, latestAllowedDate } from "./lib/shared.mjs"; import { licenceTerms, listStackFiles } from "./lib/stacks.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -76,13 +82,13 @@ const BINARY_EXT = new Set([ ".bin", ".pak", ]); -const SKIP_WALK = new Set([".git", "node_modules", "dist", "RESEARCH"]); +// Hidden folders (.git, .claude worktrees, editor state) and local scratch +// space are not part of the repo's content. +const SKIP_WALK = new Set(["node_modules", "dist", "RESEARCH", "_scratch"]); /** Generated output and first-party stills (README screenshots, the social card): not third-party packs. */ const ALLOWED_BINARY_PREFIXES = ["site/dist/", "docs/images/readme/"]; const EMOJI_RE = /\p{Extended_Pictographic}/u; const MD_LINK_RE = /!\[[^\]]*\]\(([^)]+)\)|\[[^\]]*\]\(([^)]+)\)/g; -const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; -const EVIDENCE_DATE_RE = /\d{4}-\d{2}-\d{2}/; const COMMERCIAL_VALUES = new Set(["true", "false", "unknown", "varies"]); const STATUS_VALUES = new Set(["active", "needs-review", "deprecated"]); const ATTRIBUTION_REQUIRED_VALUES = new Set(["true", "false", "unknown"]); @@ -92,47 +98,6 @@ function hasEmoji(text) { return EMOJI_RE.test(stripped); } -function parseScalar(raw) { - const v = raw.trim(); - if (v === "true") return true; - if (v === "false") return false; - if (v === "null" || v === "~" || v === "") return null; - if ( - (v.startsWith('"') && v.endsWith('"')) || - (v.startsWith("'") && v.endsWith("'")) - ) { - return v.slice(1, -1); - } - if (v.startsWith("[") && v.endsWith("]")) { - const inner = v.slice(1, -1).trim(); - if (!inner) return []; - return inner.split(",").map((part) => { - const s = part.trim(); - if ( - (s.startsWith('"') && s.endsWith('"')) || - (s.startsWith("'") && s.endsWith("'")) - ) { - return s.slice(1, -1); - } - return s; - }); - } - return v; -} - -function parseFrontmatter(text) { - const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); - if (!match) return null; - const meta = {}; - for (const line of match[1].split(/\r?\n/)) { - if (!line.trim() || line.trim().startsWith("#")) continue; - const idx = line.indexOf(":"); - if (idx === -1) continue; - meta[line.slice(0, idx).trim()] = parseScalar(line.slice(idx + 1)); - } - return { meta, body: match[2] }; -} - function walkFiles(dir, out = [], filter) { if (!fs.existsSync(dir)) return out; for (const name of fs.readdirSync(dir)) { @@ -140,6 +105,7 @@ function walkFiles(dir, out = [], filter) { const full = path.join(dir, name); const stat = fs.statSync(full); if (stat.isDirectory()) { + if (name.startsWith(".")) continue; walkFiles(full, out, filter); continue; } @@ -148,14 +114,6 @@ function walkFiles(dir, out = [], filter) { return out; } -function todayISO() { - const d = new Date(); - const y = d.getUTCFullYear(); - const m = String(d.getUTCMonth() + 1).padStart(2, "0"); - const day = String(d.getUTCDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; -} - function isEmptyField(v) { if (v === undefined || v === null) return true; if (typeof v === "string" && v.trim() === "") return true; @@ -163,14 +121,6 @@ function isEmptyField(v) { return false; } -function evidenceSection(body) { - const idx = body.search(/^## Evidence\s*$/m); - if (idx === -1) return null; - const rest = body.slice(idx); - const next = rest.search(/\n## [^E]/); - return next === -1 ? rest : rest.slice(0, next); -} - function relFromRoot(file) { return path.relative(ROOT, file).split(path.sep).join("/"); } @@ -190,6 +140,13 @@ function enumKey(v) { return String(v); } +/** Heading anchors per Markdown file, read once. */ +const anchorCache = new Map(); +function anchorsOf(file) { + if (!anchorCache.has(file)) anchorCache.set(file, markdownAnchors(fs.readFileSync(file, "utf8"))); + return anchorCache.get(file); +} + function checkRelativeLinks(file, errors) { const text = fs.readFileSync(file, "utf8"); const dir = path.dirname(file); @@ -201,17 +158,46 @@ function checkRelativeLinks(file, errors) { if (target.startsWith("<") && target.endsWith(">")) target = target.slice(1, -1); const href = target.split(/\s+/)[0]; if (!href) continue; - if (/^(https?:|mailto:|ftp:|#)/i.test(href)) continue; + if (/^(https?:|mailto:|ftp:)/i.test(href)) continue; const hash = href.indexOf("#"); const filePart = hash === -1 ? href : href.slice(0, hash); - if (!filePart) continue; - const resolved = path.resolve(dir, filePart); + const fragment = hash === -1 ? "" : href.slice(hash + 1); + const resolved = filePart ? path.resolve(dir, filePart) : file; if (!fs.existsSync(resolved)) { errors.push(`${relFromRoot(file)} broken link: ${href}`); + continue; + } + // A fragment into a Markdown file must name one of its headings. + if (fragment && resolved.endsWith(".md") && fs.statSync(resolved).isFile()) { + let id = fragment; + try { + id = decodeURIComponent(fragment); + } catch { + // A malformed escape stays as written and fails the lookup below. + } + if (!anchorsOf(resolved).has(id.toLowerCase())) { + errors.push(`${relFromRoot(file)} broken anchor: ${href} (no such heading in ${relFromRoot(resolved)})`); + } } } } +/** The options of the `id: category` dropdown in the new-source issue form. */ +function categoryFormOptions(yml) { + const lines = yml.split(/\r?\n/); + const at = lines.findIndex((l) => /^\s*id:\s*category\s*$/.test(l)); + if (at === -1) return []; + const start = lines.findIndex((l, i) => i > at && /^\s*options:\s*$/.test(l)); + if (start === -1) return []; + const out = []; + for (let i = start + 1; i < lines.length; i += 1) { + const m = lines[i].match(/^\s*-\s+(\S+)\s*$/); + if (!m) break; + out.push(m[1]); + } + return out; +} + function coverageReport(entries) { const byCat = new Map(); let spdx = 0; @@ -235,7 +221,8 @@ function coverageReport(entries) { function main() { const errors = []; - const today = todayISO(); + // "In the future" means after tomorrow in UTC, so no time zone's today fails. + const today = latestAllowedDate(); const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); const expectedCount = config.expectedEntryCount; if (!Number.isInteger(expectedCount) || expectedCount < 1) { @@ -266,6 +253,7 @@ function main() { continue; } const { meta, body } = parsed; + for (const e of parsed.errors) errors.push(`${rel} frontmatter ${e}`); const missing = REQUIRED.filter((k) => isEmptyField(meta[k])); if (missing.length) errors.push(`${rel} missing/empty: ${missing.join(", ")}`); @@ -300,6 +288,7 @@ function main() { if (attrKey === "true" && isEmptyField(meta.attribution_string)) { errors.push(`${rel} attribution_required true needs attribution_string`); } + errors.push(...checkEntryUrl(rel, meta)); errors.push(...checkLicenseVocabulary(rel, meta, vocab)); errors.push(...checkSpdxConsistency(rel, meta, vocab, spdxAllowed)); errors.push(...checkAttributionConsistency(rel, meta, body, vocab)); @@ -315,14 +304,14 @@ function main() { if (meta.verified) { const v = String(meta.verified); - if (!DATE_RE.test(v)) errors.push(`${rel} verified is not YYYY-MM-DD`); + if (!isRealDate(v)) errors.push(`${rel} verified "${v}" is not a real YYYY-MM-DD date`); else if (v > today) errors.push(`${rel} verified ${v} is in the future`); } if (meta.status === "active") { const ev = evidenceSection(body); if (!ev) errors.push(`${rel} active entry missing ## Evidence`); - else if (!EVIDENCE_DATE_RE.test(ev)) { + else if (!evidenceDates(ev).length) { errors.push(`${rel} active Evidence section has no YYYY-MM-DD date`); } } @@ -391,6 +380,15 @@ function main() { ); } + const formPath = path.join(ROOT, ".github", "ISSUE_TEMPLATE", "new-source.yml"); + errors.push( + ...checkCategorySets({ + dirs: categories, + configured: Object.keys(config.categories || {}), + formOptions: fs.existsSync(formPath) ? categoryFormOptions(fs.readFileSync(formPath, "utf8")) : null, + }) + ); + for (const item of config.featured || []) { const id = typeof item === "string" ? item : item?.id; if (!id || !ids.has(id)) errors.push(`featured id "${id}" does not resolve`); From 6338ac79d7b3616cf53fad6f5141009ec552c4b0 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:23:46 -0400 Subject: [PATCH 04/31] dev: npm scripts, a new-entry scaffold, and one command for every count - package.json (no dependencies) with test, validate, build, check (all of them in CI order), counts, new-entry and serve. The README's local checks now use it; before, they left out node site/lib/lib.test.mjs, which CI runs. - node site/new-entry.mjs copies the template with the id, category and date filled in, refuses an id used anywhere in the catalog, starts at needs-review, and prints the remaining steps. - node site/sync-counts.mjs rewrites both category count tables, the badge, "Browse N sources", "searches all N entries" and expectedEntryCount from the catalog. Adding an entry took about seven hand edits across three files; it now takes the entry, its README row and this command. - V6 also checks "searches all N entries", which nothing checked. - README layout lists stacks/, site/lib/, the two scripts and package.json; .gitignore ignores node_modules/. Fixes #6 Fixes #29 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- .gitignore | 3 ++ README.md | 16 ++++++++--- package.json | 18 ++++++++++++ site/checks.mjs | 6 ++++ site/checks.test.mjs | 7 +++++ site/new-entry.mjs | 57 +++++++++++++++++++++++++++++++++++++ site/sync-counts.mjs | 67 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 package.json create mode 100644 site/new-entry.mjs create mode 100644 site/sync-counts.mjs diff --git a/.gitignore b/.gitignore index ba54719..5e2d351 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ Desktop.ini .vscode/ .idea/ +# Dependencies (the repo has none, but npx and editors may add them) +node_modules/ + # Logs / caches *.log __pycache__/ diff --git a/README.md b/README.md index aabd15a..2bdb15c 100644 --- a/README.md +++ b/README.md @@ -268,11 +268,14 @@ and follow the [Code of Conduct](CODE_OF_CONDUCT.md).
Run the checks and preview the site locally +Node 22 or newer; there is nothing to install. + ```bash -node site/checks.test.mjs # the checks, against known-bad fixtures -node site/validate.mjs # the catalog, against the checks -node site/build.mjs # builds the site into site/dist -npx --yes serve site/dist # preview at http://localhost:3000 +npm run check # all of the below, in the order CI runs them +npm test # the checks against known-bad fixtures, and the site modules' tests +npm run validate # the catalog, against the checks (node site/validate.mjs) +npm run build # builds the site into site/dist (node site/build.mjs) +npm run serve # preview at http://localhost:3000 ```
@@ -297,14 +300,19 @@ npx --yes serve site/dist # preview at http://localhost:3000 │ ├── godot-budget-stack.md research-index.md │ ├── review-ledger.md every review pass and what it changed │ └── images/readme/ publisher stills used in this README +├── stacks/ starter stacks: one pick per need for a kind of game ├── site/ │ ├── validate.mjs catalog integrity checks │ ├── checks.mjs the checks, as pure functions │ ├── checks.test.mjs fixtures proving each check fails on bad input +│ ├── new-entry.mjs starts an entry from the template +│ ├── sync-counts.mjs rewrites every restated entry count │ ├── license-vocabulary.json the licence values the catalog accepts │ ├── spdx-allowed.json SPDX identifiers in use │ ├── build.mjs config.json static site generator and its settings +│ ├── lib/ page renderers and shared helpers, with lib.test.mjs │ └── public/ page, styles and script +├── package.json npm scripts only; no dependencies └── RESEARCH/README.md research archive index (drafts are gitignored) ``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..9d3ae7a --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "free-game-dev-assets", + "private": true, + "description": "Catalog checks and static site build. No dependencies.", + "license": "CC0-1.0", + "engines": { + "node": ">=22" + }, + "scripts": { + "test": "node site/checks.test.mjs && node site/lib/lib.test.mjs", + "validate": "node site/validate.mjs", + "build": "node site/build.mjs", + "check": "npm test && npm run validate && npm run build", + "counts": "node site/sync-counts.mjs", + "new-entry": "node site/new-entry.mjs", + "serve": "npx --yes serve site/dist" + } +} diff --git a/site/checks.mjs b/site/checks.mjs index b561d06..f6040e3 100644 --- a/site/checks.mjs +++ b/site/checks.mjs @@ -549,6 +549,12 @@ export function checkCountTables(measured, docs) { `README.md "Browse ${browse[1]} sources" but the catalog has ${total}` ); } + // Optional wording; checked wherever it appears. + for (const m of readme.text.matchAll(/searches all (\d+) entries/g)) { + if (Number(m[1]) !== total) { + errors.push(`README.md "searches all ${m[1]} entries" but the catalog has ${total}`); + } + } } return errors; } diff --git a/site/checks.test.mjs b/site/checks.test.mjs index 54e6e58..4046a23 100644 --- a/site/checks.test.mjs +++ b/site/checks.test.mjs @@ -327,6 +327,13 @@ accepts( { name: "README.md", text: goodReadme, pathFragment: "catalog/CAT/" }, ]) ); +rejects( + "V6 rejects a stale 'searches all N entries'", + checkCountTables({ "2d": 2, video: 1 }, [ + { name: "README.md", text: `${goodReadme}\nThe site searches all 2 entries.`, pathFragment: "catalog/CAT/" }, + ]), + '"searches all 2 entries" but the catalog has 3' +); /* V11 ------------------------------------------------------------------- */ const readme = [ diff --git a/site/new-entry.mjs b/site/new-entry.mjs new file mode 100644 index 0000000..b546e35 --- /dev/null +++ b/site/new-entry.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * Starts a catalog entry from catalog/TEMPLATE.md: + * node site/new-entry.mjs + * Writes catalog//.md with the id, category and today's date + * filled in, then prints the steps that still need a person: the licence + * research, the category README row, and the checks. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); +const CATALOG = path.join(ROOT, "catalog"); + +function fail(msg) { + console.error(`new-entry: ${msg}`); + process.exit(1); +} + +const [category, id] = process.argv.slice(2); +if (!category || !id) fail("usage: node site/new-entry.mjs "); + +const config = JSON.parse(fs.readFileSync(path.join(__dirname, "config.json"), "utf8")); +const categories = Object.keys(config.categories || {}); +if (!categories.includes(category)) fail(`"${category}" is not a category. Use one of: ${categories.join(", ")}`); +if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id)) fail(`"${id}" is not a short kebab-case id`); + +// Ids are unique across the whole catalog, not per category. +for (const cat of fs.readdirSync(CATALOG, { withFileTypes: true })) { + if (cat.isDirectory() && fs.existsSync(path.join(CATALOG, cat.name, `${id}.md`))) { + fail(`catalog/${cat.name}/${id}.md already exists`); + } +} + +const today = new Date().toISOString().slice(0, 10); +const text = fs + .readFileSync(path.join(CATALOG, "TEMPLATE.md"), "utf8") + .replace(/^id: .*$/m, `id: ${id}`) + .replace(/^category: .*$/m, `category: ${category}`) + .replace(/^verified: .*$/m, `verified: ${today}`) + // A new entry starts unsettled; set active once the licence is read and quoted. + .replace(/^status: .*$/m, "status: needs-review"); + +const rel = `catalog/${category}/${id}.md`; +fs.writeFileSync(path.join(ROOT, rel), text); + +console.log(`wrote ${rel} + +Next: + 1. Fill every frontmatter field from the live source page (CONTRIBUTING.md, + "Frontmatter rules"), and replace the example body. Quote the licence in + ## Evidence with today's date: (${today}). + 2. Add a row for [${id}.md](${id}.md) to catalog/${category}/README.md. + 3. node site/sync-counts.mjs (README counts, badge, expectedEntryCount) + 4. npm run check (tests, validate, build)`); diff --git a/site/sync-counts.mjs b/site/sync-counts.mjs new file mode 100644 index 0000000..3b1030c --- /dev/null +++ b/site/sync-counts.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * Rewrites every place the repo restates the entry count, from the catalog + * itself: the category tables in README.md and catalog/README.md, the README + * badge, "Browse N sources", "searches all N entries", and + * expectedEntryCount in site/config.json. Run after adding or removing + * entries: node site/sync-counts.mjs. The validator (V6) checks the same + * places, so it passes afterwards. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); +const CATALOG = path.join(ROOT, "catalog"); +const SKIP = new Set(["README.md", "TEMPLATE.md"]); + +function countEntries() { + const counts = {}; + for (const d of fs.readdirSync(CATALOG, { withFileTypes: true })) { + if (!d.isDirectory()) continue; + const walk = (dir) => + fs.readdirSync(dir, { withFileTypes: true }).reduce((n, f) => { + if (f.isDirectory()) return n + walk(path.join(dir, f.name)); + return n + (f.name.endsWith(".md") && !SKIP.has(f.name) ? 1 : 0); + }, 0); + counts[d.name] = walk(path.join(CATALOG, d.name)); + } + return counts; +} + +const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** Replaces the count cell of each category row whose line contains `fragment` (CAT = category). */ +function syncTable(text, counts, fragment) { + let out = text; + for (const [cat, n] of Object.entries(counts)) { + const re = new RegExp(`(\\|[^|\\n]*\\|\\s*)\\d+(\\s*\\|[^\\n]*${escRe(fragment.replace("CAT", cat))})`); + out = out.replace(re, `$1${n}$2`); + } + return out; +} + +function update(file, fn) { + const full = path.join(ROOT, file); + const before = fs.readFileSync(full, "utf8"); + const after = fn(before); + if (after !== before) { + fs.writeFileSync(full, after); + console.log(`updated ${file}`); + } +} + +const counts = countEntries(); +const total = Object.values(counts).reduce((a, b) => a + b, 0); + +update("README.md", (t) => + syncTable(t, counts, "catalog/CAT/") + .replace(/badge\/sources-\d+-/, `badge/sources-${total}-`) + .replace(/Browse \d+ sources/g, `Browse ${total} sources`) + .replace(/searches all \d+ entries/g, `searches all ${total} entries`) +); +update("catalog/README.md", (t) => syncTable(t, counts, "`CAT/`")); +update("site/config.json", (t) => t.replace(/("expectedEntryCount":\s*)\d+/, `$1${total}`)); + +console.log(`${total} entries: ${Object.entries(counts).map(([c, n]) => `${c} ${n}`).join(", ")}`); From 0c0233e2a259baec5fdb57bae646c01b8c96081b Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:23:50 -0400 Subject: [PATCH 05/31] docs: the template, CONTRIBUTING and PR template match the validator - TEMPLATE.md: license_spdx is a real line. Copied as it was, the template failed V3 (CC0 maps to CC0-1.0, license_spdx absent). - CONTRIBUTING: the steps use new-entry and sync-counts, and say what the validator enforces that was undocumented: attribution_string is required when attribution_required is true, verified may not be newer than the newest Evidence date (V8), deprecated entries need a "- Deprecated:" line (V9), url must be http(s), dates must be real, repeated keys and emoji are rejected. - PR template: the category README row, sync-counts, stacks, and npm run check. - The three design specs say implemented, not awaiting review. - SECURITY.md: private vulnerability reporting is enabled; say so. Fixes #27 Fixes #28 Fixes #31 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- .github/PULL_REQUEST_TEMPLATE.md | 11 ++++--- CONTRIBUTING.md | 32 +++++++++---------- SECURITY.md | 2 +- catalog/TEMPLATE.md | 5 +-- .../specs/2026-09-24-entry-pages-design.md | 2 +- .../specs/2026-09-24-starter-stacks-design.md | 2 +- .../2026-09-25-licence-freshness-design.md | 2 +- 7 files changed, 29 insertions(+), 27 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f49547c..15fbb18 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,12 +2,15 @@ - [ ] New catalog entry - [ ] License / metadata correction +- [ ] Starter stack - [ ] Docs or site ## Checklist -- [ ] Copied `catalog/TEMPLATE.md` (new entries) +- [ ] Started from `node site/new-entry.mjs ` or copied `catalog/TEMPLATE.md` (new entries) - [ ] `id` is unique across the whole catalog -- [ ] Live license checked today; `active` entries have dated `## Evidence` -- [ ] Bumped `expectedEntryCount` in `site/config.json` -- [ ] `node site/validate.mjs` passes +- [ ] Live license checked today; `## Evidence` has a dated line quoting it +- [ ] Row added to the category `README.md`, with the same licence as the frontmatter +- [ ] Ran `node site/sync-counts.mjs` after adding or removing entries +- [ ] Stacks: followed `stacks/README.md`; no licence named in a stack's own words +- [ ] `npm run check` passes (tests, `node site/validate.mjs`, `node site/build.mjs`) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 896c666..c3f92e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,20 +21,16 @@ Please follow the [Code of Conduct](CODE_OF_CONDUCT.md). ## Adding an entry -One new markdown file is enough — the website rebuilds from frontmatter on deploy. - -1. Copy [`catalog/TEMPLATE.md`](catalog/TEMPLATE.md). -2. Save it as `catalog//.md` using a short kebab-case `id`. -3. Fill every frontmatter field. Prefer primary URLs over mirror/aggregator pages. -4. `id` must be unique across the **whole** catalog. A mixed kit already listed in another category is a duplicate, not a second entry. -5. Verify the license on the live source page the day you submit. -6. For `status: active`, include an `## Evidence` line with a short quote from that page. -7. Bump `expectedEntryCount` in [`site/config.json`](site/config.json) by the number of files you added (or lowered if you removed some). -8. Run `node site/validate.mjs` — it must exit 0. -9. Run `node site/build.mjs`. It renders your entry's page and fails if the body uses markdown the site does not support (tables, code fences, blockquotes, images, raw HTML, `###` headings, numbered lists) or links to a file that does not exist. -10. Optional 2D/UI fields: `grid_dimensions`, `camera_perspective`, `hardware_tags`, `attribution_string` (see [`TEMPLATE.md`](catalog/TEMPLATE.md)). -11. Add a row to the matching category `README.md`. This is required: the validator fails an entry that is not listed there, and the row's licence cell must match your frontmatter. -12. Optional: add the `id` to `site/config.json` → `featured` to pin it under Safe starting points. +The website rebuilds from frontmatter on deploy. Node 22 or newer is all you need; there +are no dependencies to install. + +1. Run `node site/new-entry.mjs ` (or `npm run new-entry -- `). It copies [`catalog/TEMPLATE.md`](catalog/TEMPLATE.md) to `catalog//.md` with the id, category and today's date filled in, and refuses an id already used anywhere in the catalog. Use a short kebab-case `id`; a mixed kit already listed in another category is a duplicate, not a second entry. +2. Verify the license on the live source page the day you submit, and fill every frontmatter field from it (rules below). Prefer primary URLs over mirror/aggregator pages. +3. Write the body: a one-paragraph summary, `## Notes`, and `## Evidence` with a dated line quoting the source, such as `- Live page (2026-09-25): "Free for commercial use"`. Every Evidence section needs at least one date, and `verified` may not be newer than the newest one. The scaffold starts at `status: needs-review`; set `active` once the licence, the commercial stance and the credit requirement are all settled. +4. Add a row to the matching category `README.md`. This is required: the validator fails an entry that is not listed there, and the row's licence cell must match your frontmatter. +5. Run `node site/sync-counts.mjs` (`npm run counts`). It updates every place the repo restates the entry count: both category count tables, the README badge and "Browse N sources" line, and `expectedEntryCount` in [`site/config.json`](site/config.json). +6. Run `npm run check`: the check tests, `node site/validate.mjs` and `node site/build.mjs`, all of which must pass. The build renders your entry's page and fails if the body uses markdown the site does not support (tables, code fences, blockquotes, images, raw HTML, `###` headings, numbered lists) or links to a file that does not exist. +7. Optional: add the `id` to `site/config.json` → `featured` to pin it under Safe starting points. To add a starter stack (one pick per need for a kind of game), follow [`stacks/README.md`](stacks/README.md). @@ -44,13 +40,15 @@ To add a starter stack (one pick per need for a kind of game), follow [`stacks/R - `license_spdx`: the SPDX identifier the vocabulary maps your `license` to. The validator rejects a missing one when a mapping exists, and an invented one when it does not. - `commercial`: `true` / `false` / `unknown` / `varies`. Use `varies` for aggregators where some files are commercial-ok and others are not. The site still shows these under Commercial OK, labeled **per-file review**, so they are not silently excluded and not silently treated as a blanket grant. - `attribution_required`: `true` / `false` / `unknown`. If the licence normally requires credit (CC-BY) but the publisher waives it, set `false` and add a `- Attribution waived:` line in Notes saying so. -- `attribution_string`: optional copy-paste credit. +- `attribution_string`: the copy-paste credit line. Required when `attribution_required` is `true`; optional otherwise. - `publisher`: optional. Name the **rights-holding publisher**, never the host. Set it whenever that publisher has more than one catalog entry, so the entries group; setting it on a publisher that currently has only one entry is also fine and saves a backfill later. Do not set it to a generic host or distributor (GitHub, Hugging Face, itch.io, OpenGameArt, the Internet Archive, Google Fonts) or to a distributor that does not hold the rights. Distinguish sibling organisations that are genuinely different rights holders: `blender.md` (the application, Blender Foundation) carries no `publisher`, while the asset bundles under `studio.blender.org` carry `Blender Studio`. Kenney, Quaternius, KayKit, LuizMelo, 0x72, Blender Studio, Material Maker, Alif Type, GGBotNet and 3dmodelscc0 are the largest groups in use today; treat that as illustrative, not as the permitted set. - `subcategories`: lower-case kebab-case (`base-meshes`, `field-recordings`). Reuse a value already in the catalog before inventing one, and do not add a variant of an existing value that differs only in plural or spelling. Where both forms were in use, the more common one was kept (`characters`, `environment`, `interior`, `tileset`, `vectors`). - `formats`: what you actually get, in one of four kinds. **File formats**, as commonly written: usually upper case (`PNG`, `FBX`, `JSON`, `VOX`), tool-specific ones as the tool writes them (`gdshader`, `tmx`, `ktx2`). **Engine or language targets**, as the product writes them (`glTF`, `Godot`, `Unity`, `Python`, `React`). **Delivery types** for software with no file format of its own, lower-case kebab-case (`godot-addon`, `blender-extension`, `npm`, `cli`, `library`, `desktop-app`, `mobile-app`, `middleware`, `model`). And `various` for an aggregator whose files come in too many formats to list. Platforms (`windows`, `macos`, `ios`) and descriptions (`heightfield`, `examples`) are `tags`, not formats. Reuse the spelling already in the catalog. +- Frontmatter is one `key: value` per line; lists are `[a, b]` or indented `- item` lines. A key given twice is an error. Entry files may not contain emoji. - `grid_dimensions` / `camera_perspective` / `hardware_tags`: optional metadata for 2D and UI entries. `3d` and `characters` entries leave `camera_perspective` out: it describes a 2D camera, and those entries have none. -- `verified`: ISO date (`YYYY-MM-DD`) of your last license check. -- `status`: `active` | `needs-review` | `deprecated`. `active` means the licence, the commercial stance and the credit requirement are all settled: an `unknown` in `license`, `commercial` or `attribution_required` keeps an entry at `needs-review`. The validator enforces this (V14), and rejects `formats`, `subcategories` or `tags` values that differ only by case, punctuation or a trailing "s" from one already in use (V13). +- `url`: an `https://` address (`http://` only where the source has no https). Other schemes are rejected. +- `verified`: ISO date (`YYYY-MM-DD`) of your last license check. It must be a real date, no later than tomorrow in UTC, and no newer than the newest date in `## Evidence` (V8): a new `verified` date needs a new dated Evidence line from the same check. Dates inside URLs do not count as Evidence dates. +- `status`: `active` | `needs-review` | `deprecated`. A `deprecated` entry needs a `- Deprecated:` line in its body giving the reason (V9). `active` means the licence, the commercial stance and the credit requirement are all settled: an `unknown` in `license`, `commercial` or `attribution_required` keeps an entry at `needs-review`. The validator enforces this (V14), and rejects `formats`, `subcategories` or `tags` values that differ only by case, punctuation or a trailing "s" from one already in use (V13). ## License verification checklist diff --git a/SECURITY.md b/SECURITY.md index 34293d6..9fdc659 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,6 +13,6 @@ and a quote from the live source page. Report XSS or other issues in `site/` through [GitHub private vulnerability reporting](https://github.com/TMHSDigital/Free-Game-Dev-Assets/security/advisories/new) -if it is enabled, otherwise open an issue without a proof-of-concept payload. +(enabled for this repository). If you cannot use it, open an issue without a proof-of-concept payload. Do not open issues for third-party asset files. This repo does not host them. diff --git a/catalog/TEMPLATE.md b/catalog/TEMPLATE.md index f2c6985..6d6be0e 100644 --- a/catalog/TEMPLATE.md +++ b/catalog/TEMPLATE.md @@ -5,6 +5,8 @@ url: https://example.com category: 3d subcategories: [models, props] license: CC0 +# license_spdx is required when site/license-vocabulary.json maps the license to one SPDX id; omit it otherwise +license_spdx: CC0-1.0 commercial: true attribution_required: false formats: [glTF, FBX, PNG] @@ -14,8 +16,7 @@ verified: 2026-07-19 status: active # Optional (omit if N/A): # publisher: Kenney # rights holder when that publisher has more than one entry; not a generic host -# license_spdx: CC0-1.0 -# attribution_string: "Credit line if attribution_required" +# attribution_string: "Credit line" # required when attribution_required is true # commercial: varies # aggregators only; not a blanket commercial grant # grid_dimensions: 16x16 # 2D and UI entries only # camera_perspective: top_down # isometric_3_4 | side_scroller | 2d_flat. 2D and UI entries only; 3D and characters leave it out diff --git a/docs/superpowers/specs/2026-09-24-entry-pages-design.md b/docs/superpowers/specs/2026-09-24-entry-pages-design.md index 81841db..0914a34 100644 --- a/docs/superpowers/specs/2026-09-24-entry-pages-design.md +++ b/docs/superpowers/specs/2026-09-24-entry-pages-design.md @@ -1,6 +1,6 @@ # Design: one page per catalog entry -Date: 2026-09-24. Status: approved in conversation, awaiting spec review. +Date: 2026-09-24. Status: implemented; see the entry for it in `docs/review-ledger.md`. ## Why diff --git a/docs/superpowers/specs/2026-09-24-starter-stacks-design.md b/docs/superpowers/specs/2026-09-24-starter-stacks-design.md index 1eea4b9..f10d4c2 100644 --- a/docs/superpowers/specs/2026-09-24-starter-stacks-design.md +++ b/docs/superpowers/specs/2026-09-24-starter-stacks-design.md @@ -1,6 +1,6 @@ # Design: starter stacks -Date: 2026-09-24. Status: approved in conversation, awaiting spec review. +Date: 2026-09-24. Status: implemented; see the entry for it in `docs/review-ledger.md`. ## Why diff --git a/docs/superpowers/specs/2026-09-25-licence-freshness-design.md b/docs/superpowers/specs/2026-09-25-licence-freshness-design.md index c6a04f3..20d6ce5 100644 --- a/docs/superpowers/specs/2026-09-25-licence-freshness-design.md +++ b/docs/superpowers/specs/2026-09-25-licence-freshness-design.md @@ -1,6 +1,6 @@ # Design: licence freshness -Date: 2026-09-25. Status: approved in conversation, awaiting spec review. +Date: 2026-09-25. Status: implemented; see the entry for it in `docs/review-ledger.md`. ## Why From e3fb1f93308202a16c04ec4923f37ea8824e4396 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:02:00 -0400 Subject: [PATCH 06/31] catalog: remove the internal r04 tag r04 was a review-batch marker that leaked into the published tags of 15 entries. It is not a descriptive tag, so it is dropped. No other field changes. Fixes #35 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- catalog/2d/lucide-icons.md | 2 +- catalog/2d/phosphor-icons.md | 2 +- catalog/3d/magicavoxel.md | 2 +- catalog/3d/texturecan.md | 2 +- catalog/characters/blender-human-base-meshes.md | 2 +- catalog/tools/basis-universal.md | 2 +- catalog/tools/color-oracle.md | 2 +- catalog/tools/ktx-software.md | 2 +- catalog/tools/meshoptimizer.md | 2 +- catalog/tools/polyglot-gamedev.md | 2 +- catalog/tools/proton-scatter.md | 2 +- catalog/tools/scatter2d.md | 2 +- catalog/tools/tiled.md | 2 +- catalog/tools/ubisoft-chroma.md | 2 +- catalog/tools/wwise.md | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/catalog/2d/lucide-icons.md b/catalog/2d/lucide-icons.md index 366462a..95aeffb 100644 --- a/catalog/2d/lucide-icons.md +++ b/catalog/2d/lucide-icons.md @@ -9,7 +9,7 @@ license_spdx: ISC commercial: true attribution_required: false formats: [SVG] -tags: [icons, brand-free, r04] +tags: [icons, brand-free] camera_perspective: 2d_flat verified: 2026-08-24 status: active diff --git a/catalog/2d/phosphor-icons.md b/catalog/2d/phosphor-icons.md index a2e3ef7..087b668 100644 --- a/catalog/2d/phosphor-icons.md +++ b/catalog/2d/phosphor-icons.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [SVG] -tags: [icons, multi-weight, r04] +tags: [icons, multi-weight] grid_dimensions: 16x16 camera_perspective: 2d_flat verified: 2026-08-24 diff --git a/catalog/3d/magicavoxel.md b/catalog/3d/magicavoxel.md index 1f0de6e..9ad652b 100644 --- a/catalog/3d/magicavoxel.md +++ b/catalog/3d/magicavoxel.md @@ -8,7 +8,7 @@ license: custom commercial: true attribution_required: false formats: [VOX, OBJ] -tags: [voxel, freeware, r04] +tags: [voxel, freeware] verified: 2026-08-25 status: active --- diff --git a/catalog/3d/texturecan.md b/catalog/3d/texturecan.md index 739b93f..038b361 100644 --- a/catalog/3d/texturecan.md +++ b/catalog/3d/texturecan.md @@ -9,7 +9,7 @@ license_spdx: CC0-1.0 commercial: true attribution_required: false formats: [PNG, SBSAR] -tags: [pbr, seamless, substance, r04] +tags: [pbr, seamless, substance] verified: 2026-08-24 status: active --- diff --git a/catalog/characters/blender-human-base-meshes.md b/catalog/characters/blender-human-base-meshes.md index 6fa257a..07a0d11 100644 --- a/catalog/characters/blender-human-base-meshes.md +++ b/catalog/characters/blender-human-base-meshes.md @@ -10,7 +10,7 @@ license_spdx: CC0-1.0 commercial: true attribution_required: false formats: [Blend, glTF, FBX] -tags: [blender-studio, topology, r04] +tags: [blender-studio, topology] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/basis-universal.md b/catalog/tools/basis-universal.md index 391a49f..c3b7fcb 100644 --- a/catalog/tools/basis-universal.md +++ b/catalog/tools/basis-universal.md @@ -9,7 +9,7 @@ license_spdx: Apache-2.0 commercial: true attribution_required: false formats: [basis, ktx2] -tags: [gpu-texture, pipeline, r04] +tags: [gpu-texture, pipeline] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/color-oracle.md b/catalog/tools/color-oracle.md index b446a54..47f465e 100644 --- a/catalog/tools/color-oracle.md +++ b/catalog/tools/color-oracle.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [desktop-app] -tags: [a11y, colorblind, r04] +tags: [a11y, colorblind] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/ktx-software.md b/catalog/tools/ktx-software.md index 37cede0..4df2a63 100644 --- a/catalog/tools/ktx-software.md +++ b/catalog/tools/ktx-software.md @@ -9,7 +9,7 @@ license_spdx: Apache-2.0 commercial: true attribution_required: false formats: [ktx, ktx2] -tags: [khronos, pipeline, r04] +tags: [khronos, pipeline] verified: 2026-08-25 status: active --- diff --git a/catalog/tools/meshoptimizer.md b/catalog/tools/meshoptimizer.md index 109dc10..f61905a 100644 --- a/catalog/tools/meshoptimizer.md +++ b/catalog/tools/meshoptimizer.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [cli, library] -tags: [gltf, geometry, pipeline, r04] +tags: [gltf, geometry, pipeline] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/polyglot-gamedev.md b/catalog/tools/polyglot-gamedev.md index 99b66fb..e31719e 100644 --- a/catalog/tools/polyglot-gamedev.md +++ b/catalog/tools/polyglot-gamedev.md @@ -9,7 +9,7 @@ license_spdx: CC0-1.0 commercial: true attribution_required: false formats: [CSV] -tags: [l10n, ui-strings, r04] +tags: [l10n, ui-strings] verified: 2026-08-25 status: active --- diff --git a/catalog/tools/proton-scatter.md b/catalog/tools/proton-scatter.md index a2647df..abf2aae 100644 --- a/catalog/tools/proton-scatter.md +++ b/catalog/tools/proton-scatter.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon] -tags: [godot-4, foliage, r04] +tags: [godot-4, foliage] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/scatter2d.md b/catalog/tools/scatter2d.md index 2a0513b..c2c86b1 100644 --- a/catalog/tools/scatter2d.md +++ b/catalog/tools/scatter2d.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon] -tags: [godot-4, 2d, r04] +tags: [godot-4, 2d] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/tiled.md b/catalog/tools/tiled.md index 0ef76ce..22e755e 100644 --- a/catalog/tools/tiled.md +++ b/catalog/tools/tiled.md @@ -9,7 +9,7 @@ license_spdx: GPL-2.0-or-later commercial: true attribution_required: false formats: [tmx, JSON] -tags: [tilemap, isometric, hex, r04] +tags: [tilemap, isometric, hex] verified: 2026-09-22 status: active --- diff --git a/catalog/tools/ubisoft-chroma.md b/catalog/tools/ubisoft-chroma.md index 520f220..327cfaf 100644 --- a/catalog/tools/ubisoft-chroma.md +++ b/catalog/tools/ubisoft-chroma.md @@ -9,7 +9,7 @@ license_spdx: Apache-2.0 commercial: true attribution_required: false formats: [desktop-app] -tags: [a11y, colorblind, r04, windows] +tags: [a11y, colorblind, windows] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/wwise.md b/catalog/tools/wwise.md index a5756a1..2b560a9 100644 --- a/catalog/tools/wwise.md +++ b/catalog/tools/wwise.md @@ -8,7 +8,7 @@ license: custom commercial: true attribution_required: unknown formats: [middleware] -tags: [audio, freemium, budget-cap, r04] +tags: [audio, freemium, budget-cap] verified: 2026-07-19 status: needs-review --- From 1608bea20af2ecf7c81ec492a402cea814b89091 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:04:13 -0400 Subject: [PATCH 07/31] catalog: merge subcategory synonyms Data half of the subcategory cleanup: - tiles -> tileset (the kept spelling in CONTRIBUTING) - pixel-art -> pixel - ir -> impulse-responses - base-mesh -> base-meshes (the spelling CONTRIBUTING uses as its example) - drop gui where ui is also set - drop the 2d subcategory inside the 2d category - drop public-domain as a subcategory: it is a licence, and each of the four entries already carries descriptive subcategories (archival, stock-footage, planetary, images, video, audio) No README or config referenced the old values. The alias table in the checks is left for a separate change. Refs #34 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- catalog/2d/ansimuz-sunnyland.md | 2 +- catalog/2d/dcss-tiles.md | 2 +- catalog/2d/gameart2d-freebies.md | 2 +- catalog/2d/kenney-pixel-platformer.md | 2 +- catalog/2d/kenney-ui-pack.md | 2 +- catalog/2d/lospec.md | 2 +- catalog/2d/lpc-revised-basics.md | 2 +- catalog/2d/luizmelo-evil-wizard.md | 2 +- catalog/2d/luizmelo-martial-hero.md | 2 +- catalog/2d/luizmelo-monsters-creatures-fantasy.md | 2 +- catalog/2d/ninja-adventure.md | 2 +- catalog/2d/ox72-dungeon-tileset.md | 2 +- catalog/2d/ox72-dungeontileset-ii.md | 2 +- catalog/2d/paleto-vol01.md | 2 +- catalog/2d/pixel-frog.md | 2 +- catalog/2d/playpug-simple-vector-ui.md | 2 +- catalog/2d/screaming-brain-studios.md | 2 +- catalog/2d/sparklin-superpowers.md | 2 +- catalog/audio/adventure-kid-irs.md | 2 +- catalog/audio/echothief.md | 2 +- catalog/audio/voxengo-impulses.md | 2 +- catalog/characters/mpfb.md | 2 +- catalog/characters/quaternius-universal-base-characters.md | 2 +- catalog/environment/nasa-3d-resources.md | 2 +- catalog/environment/nasa-image-video-library.md | 2 +- catalog/tools/libresprite.md | 2 +- catalog/tools/pixelorama.md | 2 +- catalog/video/destockd.md | 2 +- catalog/video/prelinger-archives.md | 2 +- 29 files changed, 29 insertions(+), 29 deletions(-) diff --git a/catalog/2d/ansimuz-sunnyland.md b/catalog/2d/ansimuz-sunnyland.md index d957be2..e7b995f 100644 --- a/catalog/2d/ansimuz-sunnyland.md +++ b/catalog/2d/ansimuz-sunnyland.md @@ -3,7 +3,7 @@ id: ansimuz-sunnyland name: SunnyLand (ansimuz) url: https://ansimuz.itch.io/sunny-land-pixel-game-art category: 2d -subcategories: [sprites, tiles, pixel, platformer, audio] +subcategories: [sprites, tileset, pixel, platformer, audio] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/2d/dcss-tiles.md b/catalog/2d/dcss-tiles.md index c8f11ab..a6522c2 100644 --- a/catalog/2d/dcss-tiles.md +++ b/catalog/2d/dcss-tiles.md @@ -3,7 +3,7 @@ id: dcss-tiles name: Dungeon Crawl Stone Soup Tiles url: https://github.com/crawl/tiles category: 2d -subcategories: [tiles, sprites, monsters, items] +subcategories: [tileset, sprites, monsters, items] license: varies commercial: unknown attribution_required: unknown diff --git a/catalog/2d/gameart2d-freebies.md b/catalog/2d/gameart2d-freebies.md index 18ec9ec..1bd5f0c 100644 --- a/catalog/2d/gameart2d-freebies.md +++ b/catalog/2d/gameart2d-freebies.md @@ -3,7 +3,7 @@ id: gameart2d-freebies name: GameArt2D freebies url: https://www.gameart2d.com/freebies.html category: 2d -subcategories: [sprites, tiles, ui, platformer] +subcategories: [sprites, tileset, ui, platformer] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/2d/kenney-pixel-platformer.md b/catalog/2d/kenney-pixel-platformer.md index a7a87c6..4118a9e 100644 --- a/catalog/2d/kenney-pixel-platformer.md +++ b/catalog/2d/kenney-pixel-platformer.md @@ -4,7 +4,7 @@ name: Kenney Pixel Platformer url: https://kenney.nl/assets/pixel-platformer publisher: Kenney category: 2d -subcategories: [sprites, tiles, platformer] +subcategories: [sprites, tileset, platformer] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/2d/kenney-ui-pack.md b/catalog/2d/kenney-ui-pack.md index f10fa7e..a0b6b1d 100644 --- a/catalog/2d/kenney-ui-pack.md +++ b/catalog/2d/kenney-ui-pack.md @@ -4,7 +4,7 @@ name: Kenney UI Pack url: https://kenney.nl/assets/ui-pack publisher: Kenney category: 2d -subcategories: [ui, gui] +subcategories: [ui] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/2d/lospec.md b/catalog/2d/lospec.md index 156117b..f143c83 100644 --- a/catalog/2d/lospec.md +++ b/catalog/2d/lospec.md @@ -3,7 +3,7 @@ id: lospec name: Lospec url: https://lospec.com category: 2d -subcategories: [palettes, pixel-art] +subcategories: [palettes, pixel] license: varies commercial: unknown attribution_required: unknown diff --git a/catalog/2d/lpc-revised-basics.md b/catalog/2d/lpc-revised-basics.md index 8b285ae..b7899c7 100644 --- a/catalog/2d/lpc-revised-basics.md +++ b/catalog/2d/lpc-revised-basics.md @@ -3,7 +3,7 @@ id: lpc-revised-basics name: LPC Revised Character Basics url: https://opengameart.org/content/lpc-revised-character-basics category: 2d -subcategories: [sprites, pixel-art] +subcategories: [sprites, pixel] license: varies commercial: true attribution_required: true diff --git a/catalog/2d/luizmelo-evil-wizard.md b/catalog/2d/luizmelo-evil-wizard.md index a7ed78b..f32da8a 100644 --- a/catalog/2d/luizmelo-evil-wizard.md +++ b/catalog/2d/luizmelo-evil-wizard.md @@ -4,7 +4,7 @@ name: LuizMelo Evil Wizard url: https://luizmelo.itch.io/evil-wizard publisher: LuizMelo category: 2d -subcategories: [2d, sprites, characters] +subcategories: [sprites, characters] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/2d/luizmelo-martial-hero.md b/catalog/2d/luizmelo-martial-hero.md index 89aeabf..151835d 100644 --- a/catalog/2d/luizmelo-martial-hero.md +++ b/catalog/2d/luizmelo-martial-hero.md @@ -4,7 +4,7 @@ name: LuizMelo Martial Hero url: https://luizmelo.itch.io/martial-hero publisher: LuizMelo category: 2d -subcategories: [2d, sprites, characters] +subcategories: [sprites, characters] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/2d/luizmelo-monsters-creatures-fantasy.md b/catalog/2d/luizmelo-monsters-creatures-fantasy.md index b03fa3e..c261fbd 100644 --- a/catalog/2d/luizmelo-monsters-creatures-fantasy.md +++ b/catalog/2d/luizmelo-monsters-creatures-fantasy.md @@ -4,7 +4,7 @@ name: LuizMelo Monsters Creatures Fantasy url: https://luizmelo.itch.io/monsters-creatures-fantasy publisher: LuizMelo category: 2d -subcategories: [2d, sprites, characters] +subcategories: [sprites, characters] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/2d/ninja-adventure.md b/catalog/2d/ninja-adventure.md index 66af46c..a1c3a41 100644 --- a/catalog/2d/ninja-adventure.md +++ b/catalog/2d/ninja-adventure.md @@ -3,7 +3,7 @@ id: ninja-adventure name: Ninja Adventure url: https://pixel-boy.itch.io/ninja-adventure-asset-pack category: 2d -subcategories: [sprites, tiles, pixel, audio] +subcategories: [sprites, tileset, pixel, audio] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/2d/ox72-dungeon-tileset.md b/catalog/2d/ox72-dungeon-tileset.md index 9471719..d88790a 100644 --- a/catalog/2d/ox72-dungeon-tileset.md +++ b/catalog/2d/ox72-dungeon-tileset.md @@ -4,7 +4,7 @@ name: 0x72 16×16 Dungeon Tileset url: https://0x72.itch.io/16x16-dungeon-tileset publisher: 0x72 category: 2d -subcategories: [sprites, tiles, pixel] +subcategories: [sprites, tileset, pixel] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/2d/ox72-dungeontileset-ii.md b/catalog/2d/ox72-dungeontileset-ii.md index f320c17..f63895c 100644 --- a/catalog/2d/ox72-dungeontileset-ii.md +++ b/catalog/2d/ox72-dungeontileset-ii.md @@ -4,7 +4,7 @@ name: 0x72 Dungeon Tileset II url: https://0x72.itch.io/dungeontileset-ii publisher: 0x72 category: 2d -subcategories: [sprites, tiles, pixel] +subcategories: [sprites, tileset, pixel] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/2d/paleto-vol01.md b/catalog/2d/paleto-vol01.md index 42f6825..bf06a77 100644 --- a/catalog/2d/paleto-vol01.md +++ b/catalog/2d/paleto-vol01.md @@ -3,7 +3,7 @@ id: paleto-vol01 name: Paleto Vol.01 (The Classics) url: https://paleto.itch.io/paleto-vol01 category: 2d -subcategories: [palettes, pixel-art] +subcategories: [palettes, pixel] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/2d/pixel-frog.md b/catalog/2d/pixel-frog.md index 4d6434a..047234e 100644 --- a/catalog/2d/pixel-frog.md +++ b/catalog/2d/pixel-frog.md @@ -3,7 +3,7 @@ id: pixel-frog name: Pixel Frog url: https://pixelfrog-assets.itch.io/ category: 2d -subcategories: [sprites, tiles, pixel, platformer] +subcategories: [sprites, tileset, pixel, platformer] license: varies commercial: varies attribution_required: false diff --git a/catalog/2d/playpug-simple-vector-ui.md b/catalog/2d/playpug-simple-vector-ui.md index 914d91a..a06dcf7 100644 --- a/catalog/2d/playpug-simple-vector-ui.md +++ b/catalog/2d/playpug-simple-vector-ui.md @@ -3,7 +3,7 @@ id: playpug-simple-vector-ui name: PlayPug Simple Vector UI Pack url: https://playpug.itch.io/simple-vector-ui-pack category: 2d -subcategories: [ui, gui] +subcategories: [ui] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/2d/screaming-brain-studios.md b/catalog/2d/screaming-brain-studios.md index fbec86a..e071cc5 100644 --- a/catalog/2d/screaming-brain-studios.md +++ b/catalog/2d/screaming-brain-studios.md @@ -4,7 +4,7 @@ name: Screaming Brain Studios (isometric tiles) url: https://screamingbrainstudios.itch.io publisher: Screaming Brain Studios category: 2d -subcategories: [tiles, isometric] +subcategories: [tileset, isometric] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/2d/sparklin-superpowers.md b/catalog/2d/sparklin-superpowers.md index fb74a22..3b09325 100644 --- a/catalog/2d/sparklin-superpowers.md +++ b/catalog/2d/sparklin-superpowers.md @@ -4,7 +4,7 @@ name: Sparklin Superpowers asset packs url: https://github.com/sparklinlabs/superpowers-asset-packs publisher: Sparklin category: 2d -subcategories: [sprites, tiles, pixel, ui] +subcategories: [sprites, tileset, pixel, ui] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/audio/adventure-kid-irs.md b/catalog/audio/adventure-kid-irs.md index 8d32c6b..e06bca1 100644 --- a/catalog/audio/adventure-kid-irs.md +++ b/catalog/audio/adventure-kid-irs.md @@ -3,7 +3,7 @@ id: adventure-kid-irs name: Adventure Kid Reverb IRs (AKRT) url: https://www.adventurekid.se/akrt/free-reverb-impulse-responses/ category: audio -subcategories: [ir, reverb] +subcategories: [impulse-responses, reverb] license: CC-BY-4.0 license_spdx: CC-BY-4.0 commercial: true diff --git a/catalog/audio/echothief.md b/catalog/audio/echothief.md index c943606..2b87659 100644 --- a/catalog/audio/echothief.md +++ b/catalog/audio/echothief.md @@ -3,7 +3,7 @@ id: echothief name: EchoThief Impulse Response Library url: https://www.echothief.com/ category: audio -subcategories: [ir, reverb] +subcategories: [impulse-responses, reverb] license: unknown commercial: unknown attribution_required: unknown diff --git a/catalog/audio/voxengo-impulses.md b/catalog/audio/voxengo-impulses.md index 1f39685..944f084 100644 --- a/catalog/audio/voxengo-impulses.md +++ b/catalog/audio/voxengo-impulses.md @@ -3,7 +3,7 @@ id: voxengo-impulses name: Voxengo Free Impulse Responses url: https://www.voxengo.com/impulses/ category: audio -subcategories: [ir, reverb] +subcategories: [impulse-responses, reverb] license: custom commercial: true attribution_required: false diff --git a/catalog/characters/mpfb.md b/catalog/characters/mpfb.md index a69301d..4f6370f 100644 --- a/catalog/characters/mpfb.md +++ b/catalog/characters/mpfb.md @@ -4,7 +4,7 @@ name: MPFB (MakeHuman for Blender) url: https://static.makehumancommunity.org/mpfb.html publisher: MakeHuman Community category: characters -subcategories: [generator, base-mesh, blender] +subcategories: [generator, base-meshes, blender] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/characters/quaternius-universal-base-characters.md b/catalog/characters/quaternius-universal-base-characters.md index 5fbac56..5282f68 100644 --- a/catalog/characters/quaternius-universal-base-characters.md +++ b/catalog/characters/quaternius-universal-base-characters.md @@ -4,7 +4,7 @@ name: Quaternius Universal Base Characters url: https://quaternius.com/packs/universalbasecharacters.html publisher: Quaternius category: characters -subcategories: [base-mesh, modular, rigged] +subcategories: [base-meshes, modular, rigged] license: CC0 license_spdx: CC0-1.0 commercial: true diff --git a/catalog/environment/nasa-3d-resources.md b/catalog/environment/nasa-3d-resources.md index 52cab9c..22a5b23 100644 --- a/catalog/environment/nasa-3d-resources.md +++ b/catalog/environment/nasa-3d-resources.md @@ -4,7 +4,7 @@ name: NASA 3D Resources url: https://science.nasa.gov/3d-resources/ publisher: NASA category: environment -subcategories: [models, planetary, public-domain] +subcategories: [models, planetary] license: public-domain commercial: true attribution_required: true diff --git a/catalog/environment/nasa-image-video-library.md b/catalog/environment/nasa-image-video-library.md index 94867c8..028cfe4 100644 --- a/catalog/environment/nasa-image-video-library.md +++ b/catalog/environment/nasa-image-video-library.md @@ -4,7 +4,7 @@ name: NASA Image and Video Library url: https://images.nasa.gov publisher: NASA category: environment -subcategories: [images, video, audio, public-domain] +subcategories: [images, video, audio] license: custom commercial: unknown attribution_required: true diff --git a/catalog/tools/libresprite.md b/catalog/tools/libresprite.md index e592067..62316e0 100644 --- a/catalog/tools/libresprite.md +++ b/catalog/tools/libresprite.md @@ -3,7 +3,7 @@ id: libresprite name: LibreSprite url: https://github.com/LibreSprite/LibreSprite category: tools -subcategories: [pixel-art] +subcategories: [pixel] license: GPL-2.0 commercial: true attribution_required: false diff --git a/catalog/tools/pixelorama.md b/catalog/tools/pixelorama.md index 94df5ea..b05984f 100644 --- a/catalog/tools/pixelorama.md +++ b/catalog/tools/pixelorama.md @@ -3,7 +3,7 @@ id: pixelorama name: Pixelorama url: https://orama-interactive.itch.io/pixelorama category: tools -subcategories: [pixel-art] +subcategories: [pixel] license: MIT license_spdx: MIT commercial: true diff --git a/catalog/video/destockd.md b/catalog/video/destockd.md index 638b897..3b65bb7 100644 --- a/catalog/video/destockd.md +++ b/catalog/video/destockd.md @@ -3,7 +3,7 @@ id: destockd name: Destockd url: https://www.destockd.com/ category: video -subcategories: [stock-footage, archival, public-domain] +subcategories: [stock-footage, archival] license: varies commercial: varies attribution_required: false diff --git a/catalog/video/prelinger-archives.md b/catalog/video/prelinger-archives.md index d770514..da12a14 100644 --- a/catalog/video/prelinger-archives.md +++ b/catalog/video/prelinger-archives.md @@ -4,7 +4,7 @@ name: Prelinger Archives (Internet Archive) url: https://archive.org/details/prelinger publisher: Prelinger Archives category: video -subcategories: [archival, stock-footage, public-domain] +subcategories: [archival, stock-footage] license: varies commercial: varies attribution_required: unknown From 12ee83f0c71a18980081bb571a92ecb146b9a98a Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:06:01 -0400 Subject: [PATCH 08/31] catalog: pick one spelling for duplicate tags - game-ok -> games-ok - ps1 -> psx (psx is already in two entry names) - godot rule: every Godot-specific entry carries godot; an entry that targets Godot 4 also carries godot-4. So the 19 godot-4 entries gain godot, and a godot filter now finds all Godot entries. The nine godot-only entries are unchanged (several are Godot 3 or mixed). - drop cc0 from blenderkit and universal-lpc-generator (licence is varies) and public-domain from openclipart (licence is CC0), since those tags contradicted the licence field. No licence fields changed. The wider licence/publisher tag question in the issue is left open. Refs #36 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- catalog/2d/openclipart.md | 2 +- catalog/2d/universal-lpc-generator.md | 2 +- catalog/3d/blenderkit.md | 2 +- catalog/audio/tallbeard-abstraction-music-loop-bundle.md | 2 +- catalog/characters/barnabe-wild-lowpoly-rigged.md | 2 +- catalog/shaders-vfx/boujie-water.md | 2 +- catalog/shaders-vfx/godot-psx-style-demo.md | 2 +- catalog/shaders-vfx/sky3d.md | 2 +- catalog/shaders-vfx/ultimate-retro-shader-collection.md | 2 +- catalog/tools/beehave.md | 2 +- catalog/tools/dialogic.md | 2 +- catalog/tools/godot-destruction-plugin.md | 2 +- catalog/tools/godot-guide.md | 2 +- catalog/tools/godot-input-helper.md | 2 +- catalog/tools/godot-mod-loader.md | 2 +- catalog/tools/godot-voxel-destruction.md | 2 +- catalog/tools/godotsteam.md | 2 +- catalog/tools/limboai.md | 2 +- catalog/tools/phantom-camera.md | 2 +- catalog/tools/proton-scatter.md | 2 +- catalog/tools/scatter2d.md | 2 +- catalog/tools/smartshape2d.md | 2 +- catalog/tools/terrain3d.md | 2 +- catalog/tools/voronoishatter.md | 2 +- 24 files changed, 24 insertions(+), 24 deletions(-) diff --git a/catalog/2d/openclipart.md b/catalog/2d/openclipart.md index 1afcf43..be12380 100644 --- a/catalog/2d/openclipart.md +++ b/catalog/2d/openclipart.md @@ -9,7 +9,7 @@ license_spdx: CC0-1.0 commercial: true attribution_required: false formats: [SVG, PNG] -tags: [svg, public-domain, clipart, provenance-risk] +tags: [svg, clipart, provenance-risk] camera_perspective: 2d_flat verified: 2026-08-24 status: active diff --git a/catalog/2d/universal-lpc-generator.md b/catalog/2d/universal-lpc-generator.md index ad5954c..684f6d4 100644 --- a/catalog/2d/universal-lpc-generator.md +++ b/catalog/2d/universal-lpc-generator.md @@ -9,7 +9,7 @@ commercial: varies attribution_required: true attribution_string: "Credit all authors listed in the generator export (except CC0 layers)." formats: [PNG] -tags: [lpc, cc-by-sa, cc-by, oga-by, gpl, cc0, copyleft-assets] +tags: [lpc, cc-by-sa, cc-by, oga-by, gpl, copyleft-assets] verified: 2026-07-19 status: active --- diff --git a/catalog/3d/blenderkit.md b/catalog/3d/blenderkit.md index ef69fcf..ba0af20 100644 --- a/catalog/3d/blenderkit.md +++ b/catalog/3d/blenderkit.md @@ -8,7 +8,7 @@ license: varies commercial: varies attribution_required: false formats: [Blend, glTF, HDR] -tags: [blender, marketplace, royalty-free, cc0] +tags: [blender, marketplace, royalty-free] verified: 2026-08-24 status: active --- diff --git a/catalog/audio/tallbeard-abstraction-music-loop-bundle.md b/catalog/audio/tallbeard-abstraction-music-loop-bundle.md index ef62ca9..29ca7db 100644 --- a/catalog/audio/tallbeard-abstraction-music-loop-bundle.md +++ b/catalog/audio/tallbeard-abstraction-music-loop-bundle.md @@ -9,7 +9,7 @@ license_spdx: CC0-1.0 commercial: true attribution_required: false formats: [MP3, OGG] -tags: [cc0, loops, music, itch, game-ok] +tags: [cc0, loops, music, itch, games-ok] verified: 2026-08-29 status: active --- diff --git a/catalog/characters/barnabe-wild-lowpoly-rigged.md b/catalog/characters/barnabe-wild-lowpoly-rigged.md index 72f4141..c551494 100644 --- a/catalog/characters/barnabe-wild-lowpoly-rigged.md +++ b/catalog/characters/barnabe-wild-lowpoly-rigged.md @@ -9,7 +9,7 @@ license_spdx: CC0-1.0 commercial: true attribution_required: false formats: [Blend, FBX] -tags: [low-poly, ps1, cc0, itch] +tags: [low-poly, psx, cc0, itch] verified: 2026-08-24 status: active --- diff --git a/catalog/shaders-vfx/boujie-water.md b/catalog/shaders-vfx/boujie-water.md index 022053c..7546d6e 100644 --- a/catalog/shaders-vfx/boujie-water.md +++ b/catalog/shaders-vfx/boujie-water.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [gdshader, godot-addon] -tags: [godot-4, ocean, gerstner] +tags: [godot, godot-4, ocean, gerstner] verified: 2026-07-19 status: active --- diff --git a/catalog/shaders-vfx/godot-psx-style-demo.md b/catalog/shaders-vfx/godot-psx-style-demo.md index d5ea7ed..c785d21 100644 --- a/catalog/shaders-vfx/godot-psx-style-demo.md +++ b/catalog/shaders-vfx/godot-psx-style-demo.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [gdshader, godot-project] -tags: [godot-4, ps1, affine, dither, vertex-snap] +tags: [godot, godot-4, psx, affine, dither, vertex-snap] verified: 2026-07-27 status: active --- diff --git a/catalog/shaders-vfx/sky3d.md b/catalog/shaders-vfx/sky3d.md index df14399..d2b1af3 100644 --- a/catalog/shaders-vfx/sky3d.md +++ b/catalog/shaders-vfx/sky3d.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [gdshader, godot-addon] -tags: [godot-4, day-night, clouds, atmosphere] +tags: [godot, godot-4, day-night, clouds, atmosphere] verified: 2026-07-19 status: active --- diff --git a/catalog/shaders-vfx/ultimate-retro-shader-collection.md b/catalog/shaders-vfx/ultimate-retro-shader-collection.md index 3406101..b102b0b 100644 --- a/catalog/shaders-vfx/ultimate-retro-shader-collection.md +++ b/catalog/shaders-vfx/ultimate-retro-shader-collection.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [gdshader, godot-addon] -tags: [godot-4, retro, crt, psx, nintendo-64] +tags: [godot, godot-4, retro, crt, psx, nintendo-64] verified: 2026-07-27 status: active --- diff --git a/catalog/tools/beehave.md b/catalog/tools/beehave.md index 4d104f0..5b5e8ff 100644 --- a/catalog/tools/beehave.md +++ b/catalog/tools/beehave.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon] -tags: [godot-4, behavior-tree, ai, enemy-logic] +tags: [godot, godot-4, behavior-tree, ai, enemy-logic] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/dialogic.md b/catalog/tools/dialogic.md index 222dc44..4476cee 100644 --- a/catalog/tools/dialogic.md +++ b/catalog/tools/dialogic.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon] -tags: [godot-4, dialogue, timeline] +tags: [godot, godot-4, dialogue, timeline] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/godot-destruction-plugin.md b/catalog/tools/godot-destruction-plugin.md index e6bb71a..0831876 100644 --- a/catalog/tools/godot-destruction-plugin.md +++ b/catalog/tools/godot-destruction-plugin.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon] -tags: [godot-4, destruction, rigidbody, cell-fracture, gdscript] +tags: [godot, godot-4, destruction, rigidbody, cell-fracture, gdscript] verified: 2026-09-23 status: active --- diff --git a/catalog/tools/godot-guide.md b/catalog/tools/godot-guide.md index 6938693..64764f4 100644 --- a/catalog/tools/godot-guide.md +++ b/catalog/tools/godot-guide.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon] -tags: [godot-4, input, remapping, steam-deck] +tags: [godot, godot-4, input, remapping, steam-deck] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/godot-input-helper.md b/catalog/tools/godot-input-helper.md index 3ac38c9..3a761bc 100644 --- a/catalog/tools/godot-input-helper.md +++ b/catalog/tools/godot-input-helper.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon] -tags: [godot-4, input, gamepad, rumble] +tags: [godot, godot-4, input, gamepad, rumble] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/godot-mod-loader.md b/catalog/tools/godot-mod-loader.md index 42c8953..e286c2f 100644 --- a/catalog/tools/godot-mod-loader.md +++ b/catalog/tools/godot-mod-loader.md @@ -9,7 +9,7 @@ license_spdx: CC0-1.0 commercial: true attribution_required: false formats: [godot-addon] -tags: [godot-4, mods, workshop] +tags: [godot, godot-4, mods, workshop] verified: 2026-09-23 status: active --- diff --git a/catalog/tools/godot-voxel-destruction.md b/catalog/tools/godot-voxel-destruction.md index cd76c3b..ce5f2a9 100644 --- a/catalog/tools/godot-voxel-destruction.md +++ b/catalog/tools/godot-voxel-destruction.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon, VOX] -tags: [godot-4, destruction, voxel, magicavoxel, debris] +tags: [godot, godot-4, destruction, voxel, magicavoxel, debris] verified: 2026-09-23 status: active --- diff --git a/catalog/tools/godotsteam.md b/catalog/tools/godotsteam.md index 60c466d..4369801 100644 --- a/catalog/tools/godotsteam.md +++ b/catalog/tools/godotsteam.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [gdextension] -tags: [godot-4, steamworks, achievements, leaderboards] +tags: [godot, godot-4, steamworks, achievements, leaderboards] verified: 2026-09-23 status: active --- diff --git a/catalog/tools/limboai.md b/catalog/tools/limboai.md index b8d779f..24edf8c 100644 --- a/catalog/tools/limboai.md +++ b/catalog/tools/limboai.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon, gdextension] -tags: [godot-4, behavior-tree, state-machine, ai, gdextension] +tags: [godot, godot-4, behavior-tree, state-machine, ai, gdextension] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/phantom-camera.md b/catalog/tools/phantom-camera.md index 8ce7eb0..33b16ba 100644 --- a/catalog/tools/phantom-camera.md +++ b/catalog/tools/phantom-camera.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon] -tags: [godot-4, camera, shake, cinematic] +tags: [godot, godot-4, camera, shake, cinematic] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/proton-scatter.md b/catalog/tools/proton-scatter.md index abf2aae..bd97a31 100644 --- a/catalog/tools/proton-scatter.md +++ b/catalog/tools/proton-scatter.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon] -tags: [godot-4, foliage] +tags: [godot, godot-4, foliage] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/scatter2d.md b/catalog/tools/scatter2d.md index c2c86b1..7aa1710 100644 --- a/catalog/tools/scatter2d.md +++ b/catalog/tools/scatter2d.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon] -tags: [godot-4, 2d] +tags: [godot, godot-4, 2d] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/smartshape2d.md b/catalog/tools/smartshape2d.md index cf257a3..943167b 100644 --- a/catalog/tools/smartshape2d.md +++ b/catalog/tools/smartshape2d.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon] -tags: [godot-4, 2d, terrain, polygons] +tags: [godot, godot-4, 2d, terrain, polygons] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/terrain3d.md b/catalog/tools/terrain3d.md index b12b745..c6b6e13 100644 --- a/catalog/tools/terrain3d.md +++ b/catalog/tools/terrain3d.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon, gdextension, EXR, R16, PNG] -tags: [godot-4, terrain, heightmap, clipmap, gdextension] +tags: [godot, godot-4, terrain, heightmap, clipmap, gdextension] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/voronoishatter.md b/catalog/tools/voronoishatter.md index ef891b4..9bbdbd9 100644 --- a/catalog/tools/voronoishatter.md +++ b/catalog/tools/voronoishatter.md @@ -9,7 +9,7 @@ license_spdx: MIT commercial: true attribution_required: false formats: [godot-addon] -tags: [godot-4, destruction, fracture, voronoi, debris, rigidbody] +tags: [godot, godot-4, destruction, fracture, voronoi, debris, rigidbody] verified: 2026-09-23 status: active --- From 2d3b34859460a8c6aef9aa8f5f26c1386de2f3b3 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:08:22 -0400 Subject: [PATCH 09/31] catalog: normalize format names - JPEG -> JPG (nasa-image-video-library), matching texture-ninja - godot-project -> Godot, the engine-target spelling CONTRIBUTING uses - glTF KHR_draco -> glTF on google-draco (Draco is a glTF extension; the body already names KHR_draco_mesh_compression) - GPL -> gpl-palette on lospec, so a GIMP palette no longer reads as a licence - add glTF beside GLB where the entry shows GLB is what is offered (quaternius-universal-animation-library 1 and 2, and 3dmodelscc0-melee-weapons, whose listing points at a GLB mirror). GLB is binary glTF. 3dmodelscc0-city and 3dmodelscc0-guns are left as they are: their bodies say the listing does not name a format. - ktx and ktx2 on ktx-software are kept: KTX 1 and KTX 2 are distinct container formats and the tool handles both. Refs #37 (the canonical format list and its check are still open) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- catalog/2d/lospec.md | 2 +- catalog/3d/3dmodelscc0-melee-weapons.md | 2 +- catalog/animation/quaternius-universal-animation-library-2.md | 2 +- catalog/animation/quaternius-universal-animation-library.md | 2 +- catalog/environment/nasa-image-video-library.md | 2 +- catalog/shaders-vfx/godot-psx-style-demo.md | 2 +- catalog/shaders-vfx/godot-volumetric-cloud-demo.md | 2 +- catalog/tools/google-draco.md | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/catalog/2d/lospec.md b/catalog/2d/lospec.md index f143c83..3b641e3 100644 --- a/catalog/2d/lospec.md +++ b/catalog/2d/lospec.md @@ -7,7 +7,7 @@ subcategories: [palettes, pixel] license: varies commercial: unknown attribution_required: unknown -formats: [GPL, PAL, ASE, PNG] +formats: [gpl-palette, PAL, ASE, PNG] tags: [palettes, pixel-art, retro] verified: 2026-07-20 status: needs-review diff --git a/catalog/3d/3dmodelscc0-melee-weapons.md b/catalog/3d/3dmodelscc0-melee-weapons.md index acd60d2..cdc21c7 100644 --- a/catalog/3d/3dmodelscc0-melee-weapons.md +++ b/catalog/3d/3dmodelscc0-melee-weapons.md @@ -9,7 +9,7 @@ license: CC0 license_spdx: CC0-1.0 commercial: true attribution_required: false -formats: [GLB] +formats: [glTF, GLB] tags: [cc0, weapons, melee] verified: 2026-08-29 status: active diff --git a/catalog/animation/quaternius-universal-animation-library-2.md b/catalog/animation/quaternius-universal-animation-library-2.md index 0132627..002ef7c 100644 --- a/catalog/animation/quaternius-universal-animation-library-2.md +++ b/catalog/animation/quaternius-universal-animation-library-2.md @@ -9,7 +9,7 @@ license: CC0 license_spdx: CC0-1.0 commercial: true attribution_required: false -formats: [FBX, GLB, Blend] +formats: [FBX, glTF, GLB, Blend] tags: [cc0, retarget, combat, parkour, zombie] verified: 2026-07-19 status: active diff --git a/catalog/animation/quaternius-universal-animation-library.md b/catalog/animation/quaternius-universal-animation-library.md index c4d8e25..057b227 100644 --- a/catalog/animation/quaternius-universal-animation-library.md +++ b/catalog/animation/quaternius-universal-animation-library.md @@ -9,7 +9,7 @@ license: CC0 license_spdx: CC0-1.0 commercial: true attribution_required: false -formats: [FBX, GLB, Blend] +formats: [FBX, glTF, GLB, Blend] tags: [cc0, retarget, godot, unity, unreal, locomotion] verified: 2026-07-19 status: active diff --git a/catalog/environment/nasa-image-video-library.md b/catalog/environment/nasa-image-video-library.md index 028cfe4..4951f77 100644 --- a/catalog/environment/nasa-image-video-library.md +++ b/catalog/environment/nasa-image-video-library.md @@ -9,7 +9,7 @@ license: custom commercial: unknown attribution_required: true attribution_string: "NASA" -formats: [JPEG, MP4, WAV, TIFF] +formats: [JPG, MP4, WAV, TIFF] tags: [government, space, audio, video, endorsement-trap] verified: 2026-08-24 status: needs-review diff --git a/catalog/shaders-vfx/godot-psx-style-demo.md b/catalog/shaders-vfx/godot-psx-style-demo.md index c785d21..abf507d 100644 --- a/catalog/shaders-vfx/godot-psx-style-demo.md +++ b/catalog/shaders-vfx/godot-psx-style-demo.md @@ -8,7 +8,7 @@ license: MIT license_spdx: MIT commercial: true attribution_required: false -formats: [gdshader, godot-project] +formats: [gdshader, Godot] tags: [godot, godot-4, psx, affine, dither, vertex-snap] verified: 2026-07-27 status: active diff --git a/catalog/shaders-vfx/godot-volumetric-cloud-demo.md b/catalog/shaders-vfx/godot-volumetric-cloud-demo.md index 3b010d0..bc56113 100644 --- a/catalog/shaders-vfx/godot-volumetric-cloud-demo.md +++ b/catalog/shaders-vfx/godot-volumetric-cloud-demo.md @@ -8,7 +8,7 @@ license: MIT license_spdx: MIT commercial: true attribution_required: false -formats: [gdshader, godot-project] +formats: [gdshader, Godot] tags: [godot, volumetric, clouds, sky-shader, clayjohn] verified: 2026-07-27 status: active diff --git a/catalog/tools/google-draco.md b/catalog/tools/google-draco.md index a214561..b88fdce 100644 --- a/catalog/tools/google-draco.md +++ b/catalog/tools/google-draco.md @@ -8,7 +8,7 @@ license: Apache-2.0 license_spdx: Apache-2.0 commercial: true attribution_required: false -formats: [DRC, glTF KHR_draco] +formats: [DRC, glTF] tags: [apache, mesh-compression, gltf] verified: 2026-07-19 status: active From 96e5373129db4e277824094bf851a6bcd2d21a45 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:12:07 -0400 Subject: [PATCH 10/31] catalog: set publisher on three two-entry groups Each pair shares a rights-holding publisher, so the entries now group: - pixabay-audio, pixabay-videos -> Pixabay - sky3d, terrain3d -> TokisanGames - ansimuz-sunnyland, ansimuz-industrial-parallax -> ansimuz None of these publishers was already spelled another way in the catalog. The license_spdx half of the issue is not touched here. Refs #39 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- catalog/2d/ansimuz-industrial-parallax.md | 1 + catalog/2d/ansimuz-sunnyland.md | 1 + catalog/audio/pixabay-audio.md | 1 + catalog/shaders-vfx/sky3d.md | 1 + catalog/tools/terrain3d.md | 1 + catalog/video/pixabay-videos.md | 1 + 6 files changed, 6 insertions(+) diff --git a/catalog/2d/ansimuz-industrial-parallax.md b/catalog/2d/ansimuz-industrial-parallax.md index 7f64dc9..e407e0c 100644 --- a/catalog/2d/ansimuz-industrial-parallax.md +++ b/catalog/2d/ansimuz-industrial-parallax.md @@ -2,6 +2,7 @@ id: ansimuz-industrial-parallax name: Industrial Parallax Background (ansimuz) url: https://opengameart.org/content/industrial-parallax-background +publisher: ansimuz category: 2d subcategories: [backgrounds, pixel] license: CC0 diff --git a/catalog/2d/ansimuz-sunnyland.md b/catalog/2d/ansimuz-sunnyland.md index e7b995f..bd0486b 100644 --- a/catalog/2d/ansimuz-sunnyland.md +++ b/catalog/2d/ansimuz-sunnyland.md @@ -2,6 +2,7 @@ id: ansimuz-sunnyland name: SunnyLand (ansimuz) url: https://ansimuz.itch.io/sunny-land-pixel-game-art +publisher: ansimuz category: 2d subcategories: [sprites, tileset, pixel, platformer, audio] license: CC0 diff --git a/catalog/audio/pixabay-audio.md b/catalog/audio/pixabay-audio.md index b8a78a4..b13162b 100644 --- a/catalog/audio/pixabay-audio.md +++ b/catalog/audio/pixabay-audio.md @@ -2,6 +2,7 @@ id: pixabay-audio name: Pixabay Audio url: https://pixabay.com/sound-effects/ +publisher: Pixabay category: audio subcategories: [sfx, music] license: custom diff --git a/catalog/shaders-vfx/sky3d.md b/catalog/shaders-vfx/sky3d.md index d2b1af3..f89f20d 100644 --- a/catalog/shaders-vfx/sky3d.md +++ b/catalog/shaders-vfx/sky3d.md @@ -2,6 +2,7 @@ id: sky3d name: Sky3D url: https://github.com/TokisanGames/Sky3D +publisher: TokisanGames category: shaders-vfx subcategories: [sky, atmosphere, godot] license: MIT diff --git a/catalog/tools/terrain3d.md b/catalog/tools/terrain3d.md index c6b6e13..d346267 100644 --- a/catalog/tools/terrain3d.md +++ b/catalog/tools/terrain3d.md @@ -2,6 +2,7 @@ id: terrain3d name: Terrain3D url: https://github.com/TokisanGames/Terrain3D +publisher: TokisanGames category: tools subcategories: [godot, terrain] license: MIT diff --git a/catalog/video/pixabay-videos.md b/catalog/video/pixabay-videos.md index d021933..ad00c6a 100644 --- a/catalog/video/pixabay-videos.md +++ b/catalog/video/pixabay-videos.md @@ -2,6 +2,7 @@ id: pixabay-videos name: Pixabay Videos url: https://pixabay.com/videos/ +publisher: Pixabay category: video subcategories: [stock-footage] license: custom From 9bcdb0b81cc439b2ae643fc0348ff6b2d0d12304 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:14:01 -0400 Subject: [PATCH 11/31] catalog: point gdquest-3d-mannequin at its new repo The repo moved from GDQuest to gdquest-demos. Checked 2026-09-25: github.com/GDQuest/godot-3d-mannequin redirects to github.com/gdquest-demos/godot-3d-mannequin, and the GitHub API reports that full name (not archived). Only url changes; the Evidence section does not link the old URL. The versioned zip and http-only URLs in the issue are not touched here. Refs #32 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- catalog/characters/gdquest-3d-mannequin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catalog/characters/gdquest-3d-mannequin.md b/catalog/characters/gdquest-3d-mannequin.md index cba76f1..75432af 100644 --- a/catalog/characters/gdquest-3d-mannequin.md +++ b/catalog/characters/gdquest-3d-mannequin.md @@ -1,7 +1,7 @@ --- id: gdquest-3d-mannequin name: GDQuest Open 3D Mannequin -url: https://github.com/GDQuest/godot-3d-mannequin +url: https://github.com/gdquest-demos/godot-3d-mannequin publisher: GDQuest category: characters subcategories: [mannequin, godot] From 3a0277c29bfbc4c9aed4c271432740d7ebbdbefd Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:38:44 -0400 Subject: [PATCH 12/31] checks: -es and -ies plurals, and retired spellings stay retired - V13 folded only a trailing "s", so base-mesh and base-meshes (two entries each) passed as different values. It now joins an -es or -ies plural to its singular when that singular is in use, and only after a sibilant for -es, so "shades" never meets a "shad". - V19: site/value-aliases.json records the spellings the catalog metadata pass merged (tiles -> tileset, pixel-art -> pixel, ir -> impulse-responses, gui -> ui, base-mesh -> base-meshes, ps1 -> psx, game-ok -> games-ok, JPEG -> JPG, godot-project -> Godot), per field, and forbids public-domain as a subcategory. A tag shaped like an internal review marker (r04) is rejected. - CONTRIBUTING names the kept spellings and the alias file. Fixes #34 Fixes #35 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- CONTRIBUTING.md | 2 +- site/checks.mjs | 37 +++++++++++++++++++++++++++++++++++++ site/checks.test.mjs | 32 +++++++++++++++++++++++++++++++- site/validate.mjs | 4 ++++ site/value-aliases.json | 23 +++++++++++++++++++++++ 5 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 site/value-aliases.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c3f92e6..0cde822 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ To add a starter stack (one pick per need for a kind of game), follow [`stacks/R - `attribution_required`: `true` / `false` / `unknown`. If the licence normally requires credit (CC-BY) but the publisher waives it, set `false` and add a `- Attribution waived:` line in Notes saying so. - `attribution_string`: the copy-paste credit line. Required when `attribution_required` is `true`; optional otherwise. - `publisher`: optional. Name the **rights-holding publisher**, never the host. Set it whenever that publisher has more than one catalog entry, so the entries group; setting it on a publisher that currently has only one entry is also fine and saves a backfill later. Do not set it to a generic host or distributor (GitHub, Hugging Face, itch.io, OpenGameArt, the Internet Archive, Google Fonts) or to a distributor that does not hold the rights. Distinguish sibling organisations that are genuinely different rights holders: `blender.md` (the application, Blender Foundation) carries no `publisher`, while the asset bundles under `studio.blender.org` carry `Blender Studio`. Kenney, Quaternius, KayKit, LuizMelo, 0x72, Blender Studio, Material Maker, Alif Type, GGBotNet and 3dmodelscc0 are the largest groups in use today; treat that as illustrative, not as the permitted set. -- `subcategories`: lower-case kebab-case (`base-meshes`, `field-recordings`). Reuse a value already in the catalog before inventing one, and do not add a variant of an existing value that differs only in plural or spelling. Where both forms were in use, the more common one was kept (`characters`, `environment`, `interior`, `tileset`, `vectors`). +- `subcategories`: lower-case kebab-case (`base-meshes`, `field-recordings`). Reuse a value already in the catalog before inventing one, and do not add a variant of an existing value that differs only in plural or spelling. Where both forms were in use, the more common one was kept (`characters`, `environment`, `interior`, `tileset`, `vectors`, `pixel`, `impulse-responses`, `base-meshes`). Retired spellings are listed in [`site/value-aliases.json`](site/value-aliases.json), which the validator enforces (V19); add a line there when you merge two values. - `formats`: what you actually get, in one of four kinds. **File formats**, as commonly written: usually upper case (`PNG`, `FBX`, `JSON`, `VOX`), tool-specific ones as the tool writes them (`gdshader`, `tmx`, `ktx2`). **Engine or language targets**, as the product writes them (`glTF`, `Godot`, `Unity`, `Python`, `React`). **Delivery types** for software with no file format of its own, lower-case kebab-case (`godot-addon`, `blender-extension`, `npm`, `cli`, `library`, `desktop-app`, `mobile-app`, `middleware`, `model`). And `various` for an aggregator whose files come in too many formats to list. Platforms (`windows`, `macos`, `ios`) and descriptions (`heightfield`, `examples`) are `tags`, not formats. Reuse the spelling already in the catalog. - Frontmatter is one `key: value` per line; lists are `[a, b]` or indented `- item` lines. A key given twice is an error. Entry files may not contain emoji. - `grid_dimensions` / `camera_perspective` / `hardware_tags`: optional metadata for 2D and UI entries. `3d` and `characters` entries leave `camera_perspective` out: it describes a 2D camera, and those entries have none. diff --git a/site/checks.mjs b/site/checks.mjs index f6040e3..1cbe612 100644 --- a/site/checks.mjs +++ b/site/checks.mjs @@ -296,6 +296,18 @@ export function checkValueSpellings(records, fields = ["formats", "subcategories if (!spellings.has(v)) spellings.set(v, rel); } } + // "-es" and "-ies" plurals: after the "s" fold, "base-meshes" keys as + // "basemeshe" and "categories" as "categorie". Join such a key to its + // singular ("basemesh", "category") only when that singular is in use, + // so "shades" is never matched to a "shad". + for (const key of [...byKey.keys()]) { + let singular = null; + if (key.endsWith("ie")) singular = `${key.slice(0, -2)}y`; + else if (key.endsWith("e") && /(ch|sh|x|z|s)$/.test(key.slice(0, -1))) singular = key.slice(0, -1); + if (!singular || !byKey.has(singular)) continue; + for (const [v, rel] of byKey.get(key)) if (!byKey.get(singular).has(v)) byKey.get(singular).set(v, rel); + byKey.delete(key); + } for (const spellings of byKey.values()) { if (spellings.size < 2) continue; const listed = [...spellings.entries()].map(([v, rel]) => `"${v}" (${rel})`).join(", "); @@ -305,6 +317,31 @@ export function checkValueSpellings(records, fields = ["formats", "subcategories return errors; } +/* ----------------------------------------------------------------- V19 */ +/** + * Synonyms V13 cannot see (`tiles` beside `tileset`, `ir` beside + * `impulse-responses`) were merged by hand; site/value-aliases.json records + * each retired spelling so it cannot come back. A tag that looks like an + * internal review marker (`r04`) is rejected too: one leaked onto 15 entries. + */ +export function checkValueAliases(rel, meta, aliases) { + const errors = []; + const retired = aliases.retired || {}; + for (const field of ["formats", "subcategories", "tags"]) { + const values = Array.isArray(meta[field]) ? meta[field].map(String) : []; + for (const v of values) { + const use = aliases[field]?.[v]; + if (use) errors.push(`${rel} ${field} "${v}" is a retired spelling; use "${use}" (site/value-aliases.json)`); + const why = retired[field]?.[v]; + if (why) errors.push(`${rel} ${field} must not carry "${v}": ${why}`); + } + } + for (const t of Array.isArray(meta.tags) ? meta.tags.map(String) : []) { + if (/^r\d+$/.test(t)) errors.push(`${rel} tag "${t}" looks like an internal review marker, not a description`); + } + return errors; +} + /* ----------------------------------------------------------------- V14 */ /** * `active` tells a reader the licence, the commercial stance and the credit diff --git a/site/checks.test.mjs b/site/checks.test.mjs index 4046a23..b16ad94 100644 --- a/site/checks.test.mjs +++ b/site/checks.test.mjs @@ -25,6 +25,7 @@ import { checkSpdxConsistency, checkStacks, checkTaxonomyValues, + checkValueAliases, checkValueSpellings, } from "./checks.mjs"; @@ -638,10 +639,39 @@ eq("V18 skips fenced code", anchors.has("not-a-heading"), false); eq("V18 drops punctuation and link syntax", anchors.has("fonts--ofl-link"), true); eq("V18 reads explicit ids", anchors.has("custom"), true); +/* V13: -es and -ies plurals -------------------------------------------- */ +const rec = (rel, subcategories) => ({ rel, meta: { subcategories } }); +rejects( + "V13 joins an -es plural to its singular", + checkValueSpellings([rec("a.md", ["base-mesh"]), rec("b.md", ["base-meshes"])], ["subcategories"]), + '"base-mesh" (a.md), "base-meshes" (b.md)' +); +rejects( + "V13 joins an -ies plural to its singular", + checkValueSpellings([rec("a.md", ["category"]), rec("b.md", ["categories"])], ["subcategories"]), + "spells one value several ways" +); +accepts( + "V13 does not strip -es from a word that is not a sibilant plural", + checkValueSpellings([rec("a.md", ["shade"]), rec("b.md", ["shades"]), rec("c.md", ["shad"])], ["subcategories"]).filter((e) => e.includes('"shad"')) +); +accepts( + "V13 leaves an -ies word alone when no singular is in use", + checkValueSpellings([rec("a.md", ["movies"]), rec("b.md", ["movie"])], ["subcategories"]).filter((e) => e.includes("movy")) +); + +/* V19: retired spellings ----------------------------------------------- */ +const aliases = JSON.parse(fs.readFileSync(path.join(__dirname, "value-aliases.json"), "utf8")); +rejects("V19 rejects a retired subcategory", checkValueAliases("bad.md", { subcategories: ["tiles"] }, aliases), 'use "tileset"'); +rejects("V19 rejects a retired format", checkValueAliases("bad.md", { formats: ["JPEG"] }, aliases), 'use "JPG"'); +rejects("V19 rejects a licence as a subcategory", checkValueAliases("bad.md", { subcategories: ["public-domain"] }, aliases), "a licence, not a kind of content"); +rejects("V19 rejects an internal review tag", checkValueAliases("bad.md", { tags: ["r04"] }, aliases), "internal review marker"); +accepts("V19 lets a tag use a word retired only as a subcategory", checkValueAliases("ok.md", { tags: ["tiles", "public-domain"] }, aliases)); + /* ----------------------------------------------------------------------- */ if (failures.length) { console.error(`checks.test failed (${failures.length}):`); for (const f of failures) console.error(` - ${f}`); process.exit(1); } -console.log(`checks.test ok: ${passed} assertions across 18 checks`); +console.log(`checks.test ok: ${passed} assertions across 19 checks`); diff --git a/site/validate.mjs b/site/validate.mjs index db18014..67f739a 100644 --- a/site/validate.mjs +++ b/site/validate.mjs @@ -19,6 +19,7 @@ import { checkSpdxConsistency, checkStacks, checkTaxonomyValues, + checkValueAliases, checkValueSpellings, evidenceDates, markdownAnchors, @@ -33,6 +34,7 @@ const CATALOG = path.join(ROOT, "catalog"); const CONFIG_PATH = path.join(__dirname, "config.json"); const SPDX_ALLOWED_PATH = path.join(__dirname, "spdx-allowed.json"); const VOCAB_PATH = path.join(__dirname, "license-vocabulary.json"); +const ALIASES_PATH = path.join(__dirname, "value-aliases.json"); const STACKS = path.join(ROOT, "stacks"); const REQUIRED = [ "id", @@ -232,6 +234,7 @@ function main() { JSON.parse(fs.readFileSync(SPDX_ALLOWED_PATH, "utf8")) ); const vocab = JSON.parse(fs.readFileSync(VOCAB_PATH, "utf8")); + const aliases = JSON.parse(fs.readFileSync(ALIASES_PATH, "utf8")); const categories = catalogCategories(); const entryFiles = walkFiles(CATALOG, [], (f) => { const base = path.basename(f); @@ -295,6 +298,7 @@ function main() { errors.push(...checkEvidenceDates(rel, meta, body, today)); errors.push(...checkDeprecationReason(rel, meta, body, vocab)); errors.push(...checkTaxonomyValues(rel, meta)); + errors.push(...checkValueAliases(rel, meta, aliases)); errors.push(...checkActiveIsSettled(rel, meta)); if (meta.id) { const id = String(meta.id); diff --git a/site/value-aliases.json b/site/value-aliases.json new file mode 100644 index 0000000..3e4a2a5 --- /dev/null +++ b/site/value-aliases.json @@ -0,0 +1,23 @@ +{ + "_comment": "Spellings merged by the 2026-09-25 review, per field: the key is the retired value, the value is the one to use. 'retired' lists values a field must not carry at all, with the reason. checks.mjs V19 enforces both.", + "subcategories": { + "tiles": "tileset", + "pixel-art": "pixel", + "ir": "impulse-responses", + "gui": "ui", + "base-mesh": "base-meshes" + }, + "tags": { + "ps1": "psx", + "game-ok": "games-ok" + }, + "formats": { + "JPEG": "JPG", + "godot-project": "Godot" + }, + "retired": { + "subcategories": { + "public-domain": "a licence, not a kind of content; the license field records it" + } + } +} From 597961d6144f024118e692f29d68ab1bd73a6f8b Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:51:31 -0400 Subject: [PATCH 13/31] ci: weekly link check that opens one correction issue site/check-links.mjs fetches every non-deprecated entry's url (8 at a time, 20 s timeout, one retry) and reports dead links, sources that moved to another domain or GitHub repository (the gdquest mannequin move stayed on github.com and would have been missed otherwise), and entries verified more than a year ago. 401/403/429 answers are listed for a manual look but never raise the issue alone: on 2026-09-25 seven sites refused the checker while serving people fine. links.yml runs it every Monday and opens or updates a single issue titled "Link check: N sources need a look", labelled correction. It edits no entry. Local: npm run links. Today's run: 317 checked, none dead, none moved, none stale. Fixes #4 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- .github/workflows/links.yml | 44 ++++++++++++ package.json | 1 + site/README.md | 19 ++++- site/check-links.mjs | 137 ++++++++++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/links.yml create mode 100644 site/check-links.mjs diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml new file mode 100644 index 0000000..2725753 --- /dev/null +++ b/.github/workflows/links.yml @@ -0,0 +1,44 @@ +name: Check source links + +# Weekly: fetch every entry's url, and open or update one issue listing dead +# links, sources that moved, and entries whose licence check is over a year +# old. It never edits an entry: each one needs its licence re-read first. +on: + schedule: + - cron: "41 7 * * 1" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + links: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + + - name: Check links + id: check + run: node site/check-links.mjs --report link-report.md + + - name: Open or update the report issue + if: steps.check.outputs.problems != '0' + env: + GH_TOKEN: ${{ github.token }} + PROBLEMS: ${{ steps.check.outputs.problems }} + run: | + title="Link check: ${PROBLEMS} sources need a look" + existing=$(gh issue list --state open --label correction --json number,title \ + --jq '[.[] | select(.title | startswith("Link check:"))][0].number // empty') + if [ -n "$existing" ]; then + gh issue edit "$existing" --title "$title" --body-file link-report.md + else + gh issue create --title "$title" --label correction --body-file link-report.md + fi diff --git a/package.json b/package.json index 9d3ae7a..5ea3884 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "check": "npm test && npm run validate && npm run build", "counts": "node site/sync-counts.mjs", "new-entry": "node site/new-entry.mjs", + "links": "node site/check-links.mjs", "serve": "npx --yes serve site/dist" } } diff --git a/site/README.md b/site/README.md index c7546ba..17c18f6 100644 --- a/site/README.md +++ b/site/README.md @@ -65,6 +65,17 @@ not counted. `pages.yml` also runs every Monday at 06:17 UTC so the ages keep mo when nothing is pushed; GitHub pauses scheduled workflows in a public repository after 60 days without activity, so a long quiet spell needs a manual run from the Actions tab. +## Link check + +`.github/workflows/links.yml` runs `site/check-links.mjs` every Monday at 07:41 UTC (and +on demand from the Actions tab). It fetches each non-deprecated entry's `url` and opens, +or updates, one issue labelled `correction` titled "Link check: N sources need a look", +listing dead links, sources that moved to another domain or GitHub repository, and +entries verified more than a year ago. Sites that refuse the checker (401, 403, 429) are +listed for a manual look but never open the issue on their own. It edits nothing: a +moved source needs its licence re-read before its entry changes. Run it locally with +`npm run links`. + ## Social preview `docs/images/readme/og-card.png` is a 1200x630 screenshot of this site's first screen. @@ -74,8 +85,10 @@ Retake it when the hero changes. ## Commands ```bash -node site/build.mjs -npx --yes serve site/dist +npm run check # tests, validate, build: what CI runs +npm run serve # preview site/dist +npm run links # the link check, printed to the terminal ``` -Deploy: `.github/workflows/pages.yml` (source = GitHub Actions). +Deploy: `.github/workflows/pages.yml` (source = GitHub Actions), which first runs +`ci.yml` as a reusable workflow. diff --git a/site/check-links.mjs b/site/check-links.mjs new file mode 100644 index 0000000..3106736 --- /dev/null +++ b/site/check-links.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * Fetches every entry's `url` and reports the ones that need a person: + * node site/check-links.mjs [--report ] [--stale-days 365] + * + * - dead: 404/410, 5xx, DNS failure or timeout + * - moved: the final URL is on another domain, or another GitHub repository + * - stale: `verified` is older than --stale-days (365 by default) + * - blocked: 401/403/429, which is usually a site refusing robots rather + * than a dead link; listed for a manual check, never counted as a problem + * + * Writes a Markdown report (stdout, or --report) and, under GitHub Actions, + * `problems=` to $GITHUB_OUTPUT so the workflow can open or update one + * issue. It never changes an entry: a moved or dead source still needs its + * licence re-read before the entry is edited. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseFrontmatter } from "./lib/frontmatter.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); +const CATALOG = path.join(ROOT, "catalog"); +const REPO = JSON.parse(fs.readFileSync(path.join(__dirname, "config.json"), "utf8")).site.repo; +const CONCURRENCY = 8; +const TIMEOUT_MS = 20000; +const UA = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36 " + + "(+https://github.com/TMHSDigital/Free-Game-Dev-Assets link check)"; + +function arg(name, fallback) { + const i = process.argv.indexOf(name); + return i === -1 ? fallback : process.argv[i + 1]; +} + +function loadEntries() { + const out = []; + const walk = (dir) => { + for (const d of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, d.name); + if (d.isDirectory()) walk(full); + else if (d.name.endsWith(".md") && d.name !== "README.md" && d.name !== "TEMPLATE.md") { + const parsed = parseFrontmatter(fs.readFileSync(full, "utf8")); + if (!parsed?.meta.url) continue; + const { id, url, verified, status } = parsed.meta; + out.push({ id: String(id), url: String(url), verified: verified ? String(verified) : null, status: String(status), rel: path.relative(ROOT, full).split(path.sep).join("/") }); + } + } + }; + walk(CATALOG); + return out.sort((a, b) => a.id.localeCompare(b.id)); +} + +const rootDomain = (host) => host.replace(/^www\./, "").split(".").slice(-2).join("."); +// A transferred GitHub repository redirects within github.com. +const repoOf = (url) => new URL(url).pathname.split("/").slice(1, 3).join("/").toLowerCase(); + +async function check(entry) { + const opts = { redirect: "follow", headers: { "user-agent": UA, accept: "text/html,*/*;q=0.8" } }; + try { + const res = await fetch(entry.url, { ...opts, signal: AbortSignal.timeout(TIMEOUT_MS) }); + res.body?.cancel().catch(() => {}); + const from = rootDomain(new URL(entry.url).hostname); + const to = rootDomain(new URL(res.url).hostname); + if ([401, 403, 429].includes(res.status)) return { kind: "blocked", detail: `HTTP ${res.status}` }; + if (res.status >= 400) return { kind: "dead", detail: `HTTP ${res.status}` }; + if (from !== to) return { kind: "moved", detail: `now ${res.url}` }; + if (from === "github.com" && repoOf(entry.url) !== repoOf(res.url)) return { kind: "moved", detail: `repository now ${res.url}` }; + return { kind: "ok" }; + } catch (err) { + const cause = err.cause?.code || err.name || "error"; + return { kind: "dead", detail: cause === "TimeoutError" ? `no answer in ${TIMEOUT_MS / 1000}s` : cause }; + } +} + +async function run(entries) { + const results = new Array(entries.length); + let next = 0; + await Promise.all( + Array.from({ length: CONCURRENCY }, async () => { + while (next < entries.length) { + const i = next++; + let r = await check(entries[i]); + // One retry for anything that did not answer cleanly. + if (r.kind === "dead") r = await check(entries[i]); + results[i] = r; + } + }) + ); + return results; +} + +const staleDays = Number(arg("--stale-days", "365")); +const entries = loadEntries().filter((e) => e.status !== "deprecated"); +const results = await run(entries); +const cutoff = new Date(Date.now() - staleDays * 86400000).toISOString().slice(0, 10); +const rows = entries.map((e, i) => ({ ...e, ...results[i] })); +const pick = (kind) => rows.filter((r) => r.kind === kind); +const stale = entries.filter((e) => e.verified && e.verified < cutoff); +const line = (r) => `- [ ] [\`${r.id}\`](${REPO}/blob/main/${r.rel}): ${r.url}${r.detail ? ` (${r.detail})` : ""}`; + +const dead = pick("dead"); +const moved = pick("moved"); +const blocked = pick("blocked"); +const problems = dead.length + moved.length + stale.length; +const date = new Date().toISOString().slice(0, 10); +const report = [ + `Link check of ${entries.length} active and needs-review entries, run ${date}.`, + "", + "Re-read the licence before changing an entry: a moved or rebuilt source can come back under different terms.", + "", + `### Dead (${dead.length})`, + "", + ...(dead.length ? dead.map(line) : ["None."]), + "", + `### Moved to another domain or repository (${moved.length})`, + "", + ...(moved.length ? moved.map(line) : ["None."]), + "", + `### Verified more than ${staleDays} days ago (${stale.length})`, + "", + ...(stale.length ? stale.map((e) => `- [ ] [\`${e.id}\`](${REPO}/blob/main/${e.rel}): verified ${e.verified}`) : ["None."]), + "", + `
Refused the checker (${blocked.length}): usually bot blocking, check by hand`, + "", + ...(blocked.length ? blocked.map(line) : ["None."]), + "", + "
", + "", +].join("\n"); + +const reportPath = arg("--report", null); +if (reportPath) fs.writeFileSync(reportPath, report); +else process.stdout.write(report); +if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, `problems=${problems}\n`); +console.error(`${entries.length} checked: ${dead.length} dead, ${moved.length} moved, ${stale.length} stale, ${blocked.length} blocked`); From 0b47c177b84c4278813158fb34129e1e04f3be47 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:54:43 -0400 Subject: [PATCH 14/31] site: link the sitemap from the homepage robots.txt is only read at a host's root, so the Sitemap line in /Free-Game-Dev-Assets/robots.txt is never seen. The homepage now carries , and site/README.md says the step that actually gets it read: submitting it once in Search Console and Bing Webmaster Tools, which only the maintainer can do. Fixes #24 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- site/README.md | 9 +++++++++ site/build.mjs | 3 +++ 2 files changed, 12 insertions(+) diff --git a/site/README.md b/site/README.md index 17c18f6..96279ff 100644 --- a/site/README.md +++ b/site/README.md @@ -65,6 +65,15 @@ not counted. `pages.yml` also runs every Monday at 06:17 UTC so the ages keep mo when nothing is pushed; GitHub pauses scheduled workflows in a public repository after 60 days without activity, so a long quiet spell needs a manual run from the Actions tab. +## Sitemap + +`build.mjs` writes `sitemap.xml` (home, freshness, stacks and every non-deprecated entry) +and links it from the homepage with ``. The `Sitemap:` line in +`robots.txt` is written too, but crawlers only read robots.txt at a host's root, so +under `/Free-Game-Dev-Assets/` it is never seen. Submit +`https://tmhsdigital.github.io/Free-Game-Dev-Assets/sitemap.xml` once in Google Search +Console and Bing Webmaster Tools; that is the step that gets it read. + ## Link check `.github/workflows/links.yml` runs `site/check-links.mjs` every Monday at 07:41 UTC (and diff --git a/site/build.mjs b/site/build.mjs index 7e1cd16..9dc24d4 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -265,6 +265,9 @@ function headMetaHtml(site, stats, generatedAt, hasCard) { : [``]; return [ ``, + // 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. + ``, ``, ``, ``, From 6273387743e8225be6966f2ce395c463aa0381c1 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:58:51 -0400 Subject: [PATCH 15/31] catalog: point the two Blender bundles at the demo-files listing blender-ellie-poses and blender-human-base-meshes linked versioned zips (v2.0.0, v1.4.1) that 404 once Blender replaces them. Both now point at https://www.blender.org/download/demo-files/#assets, whose Asset Bundles section lists both (read 2026-09-25), and which both entries already cite. Only url changes. With the gdquest-3d-mannequin move (earlier commit), this closes the URL fixes. The http-only makehuman and cmu-mocap pass the new url check (V16), and the sites that refuse robots are listed by the weekly link check for a manual look. Fixes #32 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- catalog/animation/blender-ellie-poses.md | 2 +- catalog/characters/blender-human-base-meshes.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/catalog/animation/blender-ellie-poses.md b/catalog/animation/blender-ellie-poses.md index 2d23940..b71b312 100644 --- a/catalog/animation/blender-ellie-poses.md +++ b/catalog/animation/blender-ellie-poses.md @@ -1,7 +1,7 @@ --- id: blender-ellie-poses name: Blender Studio Ellie Pose Library -url: https://www.blender.org/download/demo/asset-bundles/ellie-pose-library/ellie-pose-library-v2.0.0.zip +url: https://www.blender.org/download/demo-files/#assets publisher: Blender Studio category: animation subcategories: [poses, rigging] diff --git a/catalog/characters/blender-human-base-meshes.md b/catalog/characters/blender-human-base-meshes.md index 07a0d11..d895b89 100644 --- a/catalog/characters/blender-human-base-meshes.md +++ b/catalog/characters/blender-human-base-meshes.md @@ -1,7 +1,7 @@ --- id: blender-human-base-meshes name: Blender Studio Human Base Meshes -url: https://www.blender.org/download/demo/asset-bundles/human-base-meshes/human-base-meshes-bundle-v1.4.1.zip +url: https://www.blender.org/download/demo-files/#assets publisher: Blender Studio category: characters subcategories: [base-meshes] From b596aa35c41d11ec54ed35b04629e0a19e85c097 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:03:01 -0400 Subject: [PATCH 16/31] site: search matches word starts, not substrings Each query word now has to start a word in the entry's text, so "ui" no longer finds "build" or "guide" and "art" no longer finds "earth" (?q=ui drops from 106 to 52 entries). The plural fallback ("buttons" finds the "button" tag) is kept. Search text is split into three fields, normalised once at load: name and tags, other metadata, summary. Under the default sort a search ranks name and tag hits above metadata hits above summary-only hits, then orders by name; the sort option now says so. Fixes #16 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- site/public/app.js | 98 ++++++++++++++++++++++++++++++++---------- site/public/index.html | 2 +- 2 files changed, 76 insertions(+), 24 deletions(-) diff --git a/site/public/app.js b/site/public/app.js index cbf29e1..98e61cb 100644 --- a/site/public/app.js +++ b/site/public/app.js @@ -149,28 +149,7 @@ if (state.noAttr && entry.attribution_required !== false) return false; if (state.perspective !== "any" && entry.camera_perspective !== state.perspective) return false; - - const words = normalize(state.q).split(" ").filter(Boolean); - if (!words.length) return true; - const hay = normalize( - [ - entry.name, - entry.license, - entry.category, - entry.summary, - entry.publisher || "", - PERSPECTIVE_SEARCH[entry.camera_perspective] || "", - ...(entry.tags || []), - ...(entry.formats || []), - ...(entry.subcategories || []), - ].join(" ") - ); - // Every word must appear somewhere, in any order, so "top down" and "arms fps" work. - // A plural query word also matches its singular, since tags are stored one way: - // "buttons" finds the "button" tag. - return words.every( - (w) => hay.includes(w) || (w.length > 3 && w.endsWith("s") && hay.includes(w.slice(0, -1))) - ); + return searchScore(entry) > 0; } // Hyphens and underscores read as spaces: "first person" finds the "first-person" tag. @@ -178,6 +157,72 @@ return String(text).toLowerCase().replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim(); } + /* ------------------------------------------------------------- search */ + + // Each entry's searchable text, normalised once at load, in three fields. + // Name and tags are what a reader means by a word, so a hit there ranks + // above one in the other metadata, which ranks above a summary-only hit. + const FIELD_WEIGHTS = [3, 2, 1]; + const searchIndex = new Map( + data.entries.map((e) => [ + e.id, + [ + normalize([e.name, ...(e.tags || [])].join(" ")), + normalize( + [ + e.license, + e.category, + e.publisher || "", + PERSPECTIVE_SEARCH[e.camera_perspective] || "", + ...(e.formats || []), + ...(e.subcategories || []), + ].join(" ") + ), + normalize(e.summary || ""), + ], + ]) + ); + + /** + * One test per query word. A word matches at the start of a word in the + * text, never inside one: "ui" finds "UI kit" but not "build", "art" finds + * "artwork" but not "earth". A plural query word also matches its singular, + * since tags are stored one way: "buttons" finds the "button" tag. + */ + function wordTest(word) { + const forms = [word]; + if (word.length > 3 && word.endsWith("s")) forms.push(word.slice(0, -1)); + const alt = forms.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); + return new RegExp(`(?:^|[^\\p{L}\\p{N}])(?:${alt})`, "u"); + } + + let compiled = { q: null, tests: [] }; + function queryTests() { + if (compiled.q !== state.q) { + const words = normalize(state.q).split(" ").filter(Boolean); + compiled = { q: state.q, tests: words.map(wordTest) }; + } + return compiled.tests; + } + + /** + * 0 when the entry misses any query word. Every word must match somewhere, + * in any order, so "top down" and "arms fps" work. Each word scores by the + * best field it matched in, so name and tag hits rank first. + */ + function searchScore(entry) { + const tests = queryTests(); + if (!tests.length) return 1; + const fields = searchIndex.get(entry.id) || []; + let score = 0; + for (const re of tests) { + const i = fields.findIndex((f) => re.test(f)); + if (i === -1) return 0; + score += FIELD_WEIGHTS[i]; + } + return score; + } + const SORTS = { name: (a, b) => a.name.localeCompare(b.name), license: (a, b) => @@ -330,7 +375,14 @@ } function renderGrid() { - const list = data.entries.filter(matches).sort(SORTS[state.sort] || SORTS.name); + const list = data.entries.filter(matches); + if (state.sort === "name" && queryTests().length) { + // Searching under the default sort: best match first, then by name. + const score = new Map(list.map((e) => [e.id, searchScore(e)])); + list.sort((a, b) => score.get(b.id) - score.get(a.id) || SORTS.name(a, b)); + } else { + list.sort(SORTS[state.sort] || SORTS.name); + } const grid = $("#entry-grid"); const empty = $("#empty-state"); $("#result-count").textContent = `${list.length} / ${data.entries.length}`; diff --git a/site/public/index.html b/site/public/index.html index e3a5d00..bdf1e73 100644 --- a/site/public/index.html +++ b/site/public/index.html @@ -139,7 +139,7 @@

Catalog

-

+

diff --git a/site/public/styles.css b/site/public/styles.css index 9b9958f..ae092c3 100644 --- a/site/public/styles.css +++ b/site/public/styles.css @@ -499,7 +499,8 @@ a:focus-visible, button:focus-visible, select:focus-visible, summary:focus-visible, -input:focus-visible { +input:focus-visible, +.result-count:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } From 59d976359115cc87cc94f4077247f52f50dcd8bd Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:15:05 -0400 Subject: [PATCH 19/31] site: nest card titles under category headings, say what the count means Category headings and entry titles were both h3, so the catalog's outline was flat. Entry titles are now h4 under the h3 category headings, in the prerender (build.mjs, the title tag only) and in app.js alike. A flat list (another sort, or one category) has no category headings, so it gets a visually hidden h3, "Matching entries", and the outline never skips a level. The result count, which is the live region, now reads "52 of 319 entries shown" instead of "52 / 319". The prerendered no-script text still uses the short form; that string lives in build.mjs outside the heading markup and is left for a separate change. Fixes #19 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- site/build.mjs | 2 +- site/public/app.js | 13 ++++++++++--- site/public/styles.css | 6 +++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/site/build.mjs b/site/build.mjs index 9dc24d4..d841d71 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -161,7 +161,7 @@ function entryRowHtml(entry, repo, now) {
-

${esc(entry.name)}

+

${esc(entry.name)}

${flags}

${esc(entry.summary || "")}

diff --git a/site/public/app.js b/site/public/app.js index 43b4c16..ee2be6b 100644 --- a/site/public/app.js +++ b/site/public/app.js @@ -287,7 +287,7 @@
-

${escapeHtml(entry.name)}

+

${escapeHtml(entry.name)}

${flags}

${escapeHtml(entry.summary || "")}

@@ -406,6 +406,7 @@ */ const cardNodes = new Map(); const headingNodes = new Map(); + let flatHeading = null; let placedKey = null; function adoptNodes() { @@ -427,6 +428,11 @@ for (const el of [...grid.children]) { if (!el.matches(".entry-card, .group-heading")) el.remove(); } + // Card titles are h4 under the h3 category headings. A flat list has no + // category headings, so this one keeps the outline from skipping a level. + flatHeading = document.createElement("h3"); + flatHeading.className = "sr-only"; + flatHeading.textContent = "Matching entries"; } /** Puts every node in `order` (visible ones first), moving nothing already in place. */ @@ -441,7 +447,7 @@ const grid = $("#entry-grid"); const empty = $("#empty-state"); const group = shouldGroup(); - $("#result-count").textContent = `${list.length} / ${data.entries.length}`; + $("#result-count").textContent = `${list.length} of ${data.entries.length} entries shown`; empty.hidden = list.length > 0; // Under a plain sort the order of all nodes is fixed, so filtering only @@ -454,7 +460,7 @@ const shown = new Set(list.map((e) => e.id)); const rest = data.entries.filter((e) => !shown.has(e.id)); const all = ranked ? [...list, ...rest] : data.entries.slice().sort(SORTS[state.sort] || SORTS.name); - const order = []; + const order = [flatHeading]; if (group) { for (const cat of Object.keys(categoryLabels)) { order.push(headingNodes.get(cat)); @@ -476,6 +482,7 @@ perCat.set(e.category, (perCat.get(e.category) || 0) + 1); } for (const [id, el] of cardNodes) el.hidden = !shown.has(id); + flatHeading.hidden = group || !list.length; for (const [cat, el] of headingNodes) { const n = group ? perCat.get(cat) || 0 : 0; el.hidden = !n; diff --git a/site/public/styles.css b/site/public/styles.css index ae092c3..ca8d357 100644 --- a/site/public/styles.css +++ b/site/public/styles.css @@ -371,7 +371,7 @@ code { align-items: baseline; } -.entry-top h3 { +.entry-top h4 { margin: 0; font-size: 1.02rem; letter-spacing: -0.015em; @@ -538,12 +538,12 @@ input:focus-visible, box-shadow: inset 3px 0 0 var(--accent); } -.entry-top h3 a { +.entry-top h4 a { color: var(--ink); text-decoration: none; } -.entry-top h3 a:hover { +.entry-top h4 a:hover { color: var(--accent); text-decoration: underline; } From 75aced4321b852fdd3c8fc6fca6c227cc327a6e5 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:17:29 -0400 Subject: [PATCH 20/31] site: chip counts at full contrast in both themes The chip counts were faded with opacity (0.7, and 0.45 for an empty category), which took them to 2.92:1 in light and 4.25:1 in dark. They now use the chip's solid text colour, the muted token: 5.29:1 light, 7.52:1 dark, measured in the browser on every count. The count is set in the mono face so it still reads apart from the label, and an empty category is marked by a dashed outline and its 0 instead of by fading. Fixes #20 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- site/public/styles.css | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/site/public/styles.css b/site/public/styles.css index ca8d357..f23dc47 100644 --- a/site/public/styles.css +++ b/site/public/styles.css @@ -840,15 +840,21 @@ input:focus-visible, font-variant-numeric: tabular-nums; } -/* Live count on each filter chip. */ +/* Live count on each filter chip. Solid colours only: fading the text with + opacity took it below 4.5:1 in both themes. The count is set in the mono + face at the chip's own colour (muted: 5.3:1 light, 7.5:1 dark). */ .chip-count { margin-left: 0.15rem; + font-family: var(--mono); + font-size: 0.92em; font-variant-numeric: tabular-nums; - opacity: 0.7; } -.chip.is-empty { - opacity: 0.45; +/* A category with no matches under the other filters: a dashed outline and + its 0 count say so, at full text contrast. */ +.chip.is-empty:not(.is-active) { + border-style: dashed; + color: var(--muted); } /* "You are here" is deliberately quieter than "this filter is on". */ From a9b4c4e4af22c308b0c2d74659f79ff25c297d10 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:20:07 -0400 Subject: [PATCH 21/31] site: 44px tap targets for in-card links and filters on touch screens Under pointer: coarse, "Open source" and "Entry and evidence" were about 17px tall inside the card-wide title link, so a near miss opened the entry page. They now get 13px of padding cancelled by an equal negative margin: a 45px hit area with no change to the card's layout (checked with hit tests across the full height in a touch-emulated 360px Chromium). Chips, checkboxes, the selects and the legend toggle get the 44px minimum they already had on narrow screens on every touch screen. Fixes #21 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- site/public/styles.css | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/site/public/styles.css b/site/public/styles.css index f23dc47..8783f63 100644 --- a/site/public/styles.css +++ b/site/public/styles.css @@ -555,6 +555,26 @@ input:focus-visible, .topnav a { padding-block: 12px; } + + /* "Open source" and "Entry and evidence" sit inside the card-wide title + link, so a near miss opened the entry page. Padding cancelled by an + equal negative margin grows each to about 44px without moving a thing. */ + .entry-links a { + display: inline-block; + padding: 13px 6px; + margin: -13px -6px; + } + + /* The filter controls get the same 44px on any touch screen, not only + on narrow ones. */ + .chip, + .check, + .sort-field select, + .legend summary { + min-height: 44px; + display: inline-flex; + align-items: center; + } } .entry-links { From 8544b2f270feba35e6e22c71a3ca657c17644910 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:25:12 -0400 Subject: [PATCH 22/31] site: invalid URL params fall back to defaults readUrl() trusted the query string, so ?view=bogus or ?cat=nope showed 0 results with a blank select and ?sort=foo showed a blank sort and an unsorted list. cat, sort and view are now checked against the category keys, SORTS and the perspective list, and the boolean flags accept only 1 or 0. Anything else keeps its default, and the first apply() rewrites the URL without it. Fixes #22 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- site/public/app.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/site/public/app.js b/site/public/app.js index ee2be6b..297f86e 100644 --- a/site/public/app.js +++ b/site/public/app.js @@ -102,14 +102,24 @@ /* -------------------------------------------------------- url <-> state */ + /** + * A hand-edited or stale link can carry values the page has no control + * for. Each one falls back to its default, and apply() then rewrites the + * URL without it. + */ function readUrl() { const p = new URLSearchParams(location.search); - if (p.has("cat")) state.category = p.get("cat"); + const pick = (key, field, allowed) => { + const v = p.get(key); + if (v !== null && allowed.includes(v)) state[field] = v; + }; + pick("cat", "category", ["all", ...Object.keys(categoryLabels)]); + pick("sort", "sort", Object.keys(SORTS)); + pick("view", "perspective", ["any", ...Object.keys(PERSPECTIVE_LABELS)]); if (p.has("q")) state.q = p.get("q"); - if (p.has("sort")) state.sort = p.get("sort"); - if (p.has("view")) state.perspective = p.get("view"); const bool = (key, field) => { - if (p.has(key)) state[field] = p.get(key) === "1"; + const v = p.get(key); + if (v === "1" || v === "0") state[field] = v === "1"; }; bool("active", "active"); bool("review", "review"); From c563120b824c6942ad1f599192f4316f09f782e2 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:29:29 -0400 Subject: [PATCH 23/31] site: defer the homepage scripts data.js and app.js were parser-blocking at the end of the body. Both are now deferred: they still run in order before DOMContentLoaded, and the rows they hydrate are prerendered, so the page is complete before they run. The legacy #entry- redirect and hash jumps still work. Google Fonts already loads with display=swap behind preconnect hints on every page, so nothing changes there. Self-hosting the two families, which the issue also suggests to drop the third-party request, is left for a separate pass. Fixes #23 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- site/public/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/public/index.html b/site/public/index.html index 4c6f770..dba994e 100644 --- a/site/public/index.html +++ b/site/public/index.html @@ -184,7 +184,7 @@

Guides

Search - - + + From 08afe5fa67ff7d14b83f032894fc1d849fa374b3 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:33:40 -0400 Subject: [PATCH 24/31] site: say visibly that "Commercial OK" keeps per-file sources "Commercial OK" deliberately keeps commercial: varies aggregators, but the only on-page explanation was a hover-only title, which touch and keyboard users never see. The title is gone. While the filter is on, a note under the filters says it keeps per-file review sources and asks the reader to check the licence of each file; the checkbox is described by that note (aria-describedby), and its active-filter chip now reads "commercial OK or per-file review". A separate "include per-file sources" toggle was the other option in the issue; the note keeps the filter's documented meaning unchanged. Fixes #25 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- site/public/app.js | 6 +++++- site/public/index.html | 10 ++++++++-- site/public/styles.css | 13 +++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/site/public/app.js b/site/public/app.js index 297f86e..a981020 100644 --- a/site/public/app.js +++ b/site/public/app.js @@ -378,7 +378,11 @@ if (!state.active) chips.push({ key: "active", label: "hiding active" }); if (!state.review) chips.push({ key: "review", label: "hiding needs-review" }); if (state.deprecated) chips.push({ key: "deprecated", label: "showing deprecated" }); - if (state.commercialOnly) chips.push({ key: "commercialOnly", label: "commercial OK only" }); + if (state.commercialOnly) + chips.push({ key: "commercialOnly", label: "commercial OK or per-file review" }); + // The filter keeps aggregators whose files differ, which a reader + // filtering for "safe to ship" must see, not find in a tooltip. + $("#commercial-note").hidden = !state.commercialOnly; if (state.noAttr) chips.push({ key: "noAttr", label: "no attribution only" }); if (!chips.length) { diff --git a/site/public/index.html b/site/public/index.html index dba994e..7ffa72d 100644 --- a/site/public/index.html +++ b/site/public/index.html @@ -118,8 +118,8 @@

Catalog

Deprecated -
+ +
diff --git a/site/public/styles.css b/site/public/styles.css index 8783f63..047cca9 100644 --- a/site/public/styles.css +++ b/site/public/styles.css @@ -688,6 +688,19 @@ input:focus-visible, gap: 0.35rem; } +.filter-note { + margin: 0; + padding: 0.45rem 0.6rem; + border-left: 3px solid var(--warn); + background: var(--surface); + color: var(--ink); + font-size: 0.85rem; +} + +.filter-note[hidden] { + display: none; +} + .active-filters-label { font-family: var(--mono); font-size: 0.72rem; From 31aae2a7e0332a071d87a60bbb55de3d2ecae3f1 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:43:36 -0400 Subject: [PATCH 25/31] site: fold the filter panel behind a disclosure on small screens At 360px the filter panel was about 530px of chips and checkboxes between the search box and the first entry. At 640px and below the category chips and the status, terms, view and sort controls now fold behind a "Filters" button (aria-expanded, aria-controls), which shows how many filters are set, e.g. "Filters (2)". The search box, the result count (the live region), the commercial note and the active-filter chips stay visible. The folded panel is 163px tall at 360px. app.js does the folding, so a page without the script keeps the panel open; wider screens never show the button. The jump link the issue asks for is already in the hero ("Browse catalog"), next to "Catalog" in the top nav and the skip link. Fixes #26 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wc67NX9MM964uaVGn4bpZJ --- site/public/app.js | 19 ++++++++++++++++++- site/public/index.html | 4 +++- site/public/styles.css | 43 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/site/public/app.js b/site/public/app.js index a981020..e3a973e 100644 --- a/site/public/app.js +++ b/site/public/app.js @@ -380,10 +380,14 @@ if (state.deprecated) chips.push({ key: "deprecated", label: "showing deprecated" }); if (state.commercialOnly) chips.push({ key: "commercialOnly", label: "commercial OK or per-file review" }); + if (state.noAttr) chips.push({ key: "noAttr", label: "no attribution only" }); + // The filter keeps aggregators whose files differ, which a reader // filtering for "safe to ship" must see, not find in a tooltip. $("#commercial-note").hidden = !state.commercialOnly; - if (state.noAttr) chips.push({ key: "noAttr", label: "no attribution only" }); + // The small-screen disclosure counts what its hidden panel is doing. + const set = chips.filter((c) => c.key !== "q").length; + $("#filters-toggle").textContent = set ? `Filters (${set})` : "Filters"; if (!chips.length) { host.hidden = true; @@ -713,6 +717,19 @@ $("#result-count").focus(); }); + // On a phone the filter panel is about 530px of chips and checkboxes + // between the search box and the first entry. It folds behind a button + // there (styles.css); on wider screens the button is not shown. Only + // with this script can the panel be folded, so no-script pages keep it. + const controls = $(".controls"); + const toggle = $("#filters-toggle"); + toggle.hidden = false; + controls.classList.add("is-collapsed"); + toggle.addEventListener("click", () => { + const open = controls.classList.toggle("is-collapsed") === false; + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + }); + const top = $("#to-top"); // Returns to the catalog's search and filters, which on small screens have // scrolled away. It used to scroll to the page top and then focus search diff --git a/site/public/index.html b/site/public/index.html index 7ffa72d..6872ed2 100644 --- a/site/public/index.html +++ b/site/public/index.html @@ -103,9 +103,11 @@

Catalog

+ +
-
+