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 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/.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/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/.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/.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/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 896c666..0cde822 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`). +- `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. -- `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/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/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/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 d957be2..bd0486b 100644 --- a/catalog/2d/ansimuz-sunnyland.md +++ b/catalog/2d/ansimuz-sunnyland.md @@ -2,8 +2,9 @@ id: ansimuz-sunnyland name: SunnyLand (ansimuz) url: https://ansimuz.itch.io/sunny-land-pixel-game-art +publisher: ansimuz 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/bondoki-rotating-gems.md b/catalog/2d/bondoki-rotating-gems.md index 10e8a99..257903c 100644 --- a/catalog/2d/bondoki-rotating-gems.md +++ b/catalog/2d/bondoki-rotating-gems.md @@ -10,6 +10,7 @@ commercial: true attribution_required: false formats: [GIF, PNG] tags: [cc0, match-3, gems, jewels, animated, puzzle, casual, mobile] +grid_dimensions: 52x52 verified: 2026-09-23 status: active --- 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..3b641e3 100644 --- a/catalog/2d/lospec.md +++ b/catalog/2d/lospec.md @@ -3,11 +3,11 @@ id: lospec name: Lospec url: https://lospec.com category: 2d -subcategories: [palettes, pixel-art] +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/2d/lpc-revised-basics.md b/catalog/2d/lpc-revised-basics.md index 8b285ae..712350e 100644 --- a/catalog/2d/lpc-revised-basics.md +++ b/catalog/2d/lpc-revised-basics.md @@ -3,13 +3,14 @@ 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 attribution_string: "LPC Revised Character Basics by Eliza Wyatt, Stephen Challener (Redshrike), Durrani, BenCreating, Johannes Sjölund (Wulax), Thecilekli and BlueCarrot16 (OGA-BY 3.0). See the Credits files for per-item authors." formats: [PNG] tags: [lpc, opengameart, cc-by-sa, gpl, oga-by, copyleft-assets] +camera_perspective: isometric_3_4 verified: 2026-09-24 status: active --- @@ -22,6 +23,7 @@ Modernised LPC character bases on OpenGameArt: bodies, heads, hair and clothing - **Take OGA-BY.** Measured from the download on 2026-09-24: the three `Credits.txt` files (Body, Clothing, Hair) list 39 items, and all 39 say "LICENSE: OGA-by". OGA-BY 3.0 is CC-BY without the anti-DRM clause, which suits console and store builds. CC-BY-SA and GPL would add share-alike obligations you do not need - **Credit per item.** The page names seven authors; the `Credits.txt` files say which author made each head, garment and hairstyle. Credit the ones you ship +- View: the author's [LPC repository](https://github.com/ElizaWy/LPC), which the submission names as the home of the whole LPC Revised collection, describes it (read 2026-09-26) as "a 32-pixel style, with a three-quarters (top-down) perspective" - The download is large: about 80 MB and 14,700 files, most of them palette and layer variants - `license: varies` because the page offers three licences and the catalog vocabulary has no OGA-BY value. `commercial: true` because all three permit commercial use - Pair with [universal-lpc-generator](universal-lpc-generator.md); see [`docs/high-risk.md`](../../docs/high-risk.md) for the share-alike trap on other LPC material 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/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/material-symbols.md b/catalog/2d/material-symbols.md index d9cd1ab..2f5b524 100644 --- a/catalog/2d/material-symbols.md +++ b/catalog/2d/material-symbols.md @@ -8,18 +8,17 @@ subcategories: [icons, ui] license: Apache-2.0 license_spdx: Apache-2.0 commercial: true -attribution_required: true +attribution_required: false formats: [SVG, TTF, WOFF2] tags: [icons, google, variable-font] -attribution_string: "Material Symbols - Apache License 2.0 (Google)" camera_perspective: 2d_flat -verified: 2026-08-24 +verified: 2026-09-26 status: active --- # Material Symbols -Google's variable icon font (outlined / rounded / sharp) plus SVG, the system-style HUD set this catalog was missing next to Lucide/Tabler/Phosphor. The fonts.google.com/icons page is a JavaScript app and yielded no license body on 2026-08-24. The grant we can quote is `LICENSE` in [google/material-design-icons](https://github.com/google/material-design-icons): Apache License 2.0. Apache permits commercial use. Keep the copyright/NOTICE when you redistribute the font files. +Google's variable icon font (outlined / rounded / sharp) plus SVG, the system-style HUD set this catalog was missing next to Lucide/Tabler/Phosphor. The fonts.google.com/icons page is a JavaScript app and yielded no license body on 2026-08-24. The grant we can quote is `LICENSE` in [google/material-design-icons](https://github.com/google/material-design-icons): Apache License 2.0. Apache permits commercial use. Keep the licence text when you redistribute the font files. ## Notes @@ -27,6 +26,7 @@ Google's variable icon font (outlined / rounded / sharp) plus SVG, the system-st - Variable font is the practical game path (one file, weight axis). SVG export is for atlases - GitHub README (2016): "License change to Apache 2.0". That is historical; current `LICENSE` is still Apache 2.0 - Prefer [lucide-icons](lucide-icons.md) if you want a smaller brand-free SVG set with a simpler ISC grant +- Keep the Apache-2.0 licence text with redistributed copies of the font or SVG files (section 4(a)); the repository ships no NOTICE file, and no on-screen credit is required. Google's README says an about-screen credit is welcome but optional ## Evidence @@ -34,6 +34,8 @@ Google's variable icon font (outlined / rounded / sharp) plus SVG, the system-st - Same file, section 2 (2026-08-24): "perpetual, worldwide, non-exclusive, no-charge, royalty-free" - fonts.google.com/icons (2026-08-24): JS shell, no license text extracted. License evidence is the GitHub file, not the icons UI - Live GitHub README, google/material-design-icons (2026-09-24): "These are two different official icon sets from Google, using the same underlying designs." +- Live GitHub README, google/material-design-icons, License section (2026-09-26): "We'd love attribution in your app's *about* screen, but it's not required." +- Live GitHub `LICENSE`, section 4(a) (2026-09-26): "You must give any other recipients of the Work or Derivative Works a copy of this License" ## Related 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/characters/blender-human-base-meshes.md b/catalog/characters/blender-human-base-meshes.md index 6fa257a..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] @@ -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/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] diff --git a/catalog/characters/microsoft-rocketbox.md b/catalog/characters/microsoft-rocketbox.md index f2d6818..5ce5f38 100644 --- a/catalog/characters/microsoft-rocketbox.md +++ b/catalog/characters/microsoft-rocketbox.md @@ -8,11 +8,10 @@ subcategories: [humanoid, rigged, library] license: MIT license_spdx: MIT commercial: true -attribution_required: true -attribution_string: "Microsoft Rocketbox Copyright (c) 2020 Microsoft. Keep LICENSE.md with redistributed FBX." +attribution_required: false formats: [FBX] tags: [rigged, vr, research-origin, diverse] -verified: 2026-08-24 +verified: 2026-09-26 status: active --- @@ -27,11 +26,14 @@ status: active - Paper citation requested for *research* use; MIT still requires copyright notice in distributions. - What the repository ships beyond the meshes, per its README read 2026-09-23: **four poly levels per avatar** (`hipoly`, `midpoly`, `lowpoly`, `ultralowpoly`), **417 animations** (added 4/2022), and **facial blendshapes** (15 visemes, 48 FACS, 30 for the Vive facial tracker, plus ARKit-compatible sets from 6/2022). No other character source in this catalog has facial blendshapes or built-in LODs - Tooling is Unity-first: the import script fixes 3ds Max materials and reorganises bones for Unity's humanoid rig. In Godot or Unreal, plan on doing that step yourself +- Keep the MIT copyright and permission notice (`LICENSE.md`, "Copyright (c) 2020 Microsoft") with redistributed copies of the avatars; no on-screen credit is required. The README's citation request covers research use only ## Evidence - `LICENSE.md` (2026-08-24): MIT License, Copyright (c) 2020 Microsoft — rights include use, modify, sell. - README changelog: “12/2020: Updated license to MIT.” +- Live [LICENSE.md](https://github.com/microsoft/Microsoft-Rocketbox/blob/master/LICENSE.md) (2026-09-26): "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software." +- Live README (2026-09-26): "If you are using this library for research you should consider citing it." and "The library of avatars is now released under MIT License." ## Related 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/ms-building-footprints.md b/catalog/environment/ms-building-footprints.md index 257012b..83a1f9e 100644 --- a/catalog/environment/ms-building-footprints.md +++ b/catalog/environment/ms-building-footprints.md @@ -8,11 +8,10 @@ subcategories: [geodata, buildings] license: CDLA-Permissive-2.0 license_spdx: CDLA-Permissive-2.0 commercial: true -attribution_required: true -attribution_string: "Microsoft Global ML Building Footprints (CDLA Permissive 2.0). Suggested; include the CDLA text when redistributing the Data files." +attribution_required: false formats: [GeoJSON, CSV] tags: [buildings, footprints, height-estimates] -verified: 2026-08-24 +verified: 2026-09-26 status: active --- @@ -24,12 +23,16 @@ status: active - Redistributing the **Data** requires including CDLA Permissive 2.0 text. Derived “Results” (e.g. analysis outputs) are unrestricted under the license. - Imagery sources vary by region; treat footprints as approximate. +- Keep the CDLA Permissive 2.0 text with any copy of the Data you share (section 2.1); no on-screen credit is required, and neither the licence nor the repository README asks for one. ## Evidence - Repo `LICENSE` (2026-08-24): “licensed by Microsoft under CLDA Permissive 2.0” (CDLA Permissive 2.0). - README (2026-08-24): “freely available for download and use under CDLA Permissive 2.0.” - Live CDLA Permissive 2.0: use/modify/share Data; no restriction on Results. +- Live [CDLA Permissive 2.0](https://cdla.dev/permissive-2-0/) (2026-09-26): "A Data Recipient may share Data, with or without modifications, so long as the Data Recipient makes available the text of this agreement with the shared Data." +- Same page, section 3.1 (2026-09-26): "This agreement does not impose any restriction or obligations with respect to the use, modification, or sharing of Results." +- Live README, License section (2026-09-26): "This data is licensed by Microsoft under the CDLA Permissive 2.0." No credit or citation request elsewhere in the README ## Related 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..4951f77 100644 --- a/catalog/environment/nasa-image-video-library.md +++ b/catalog/environment/nasa-image-video-library.md @@ -4,12 +4,12 @@ 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 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/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..abf507d 100644 --- a/catalog/shaders-vfx/godot-psx-style-demo.md +++ b/catalog/shaders-vfx/godot-psx-style-demo.md @@ -8,8 +8,8 @@ license: MIT license_spdx: MIT commercial: true attribution_required: false -formats: [gdshader, godot-project] -tags: [godot-4, ps1, affine, dither, vertex-snap] +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/shaders-vfx/sky3d.md b/catalog/shaders-vfx/sky3d.md index df14399..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 @@ -9,7 +10,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/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/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/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/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/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 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/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/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/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/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/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/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..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, r04] +tags: [godot, godot-4, foliage] verified: 2026-08-24 status: active --- diff --git a/catalog/tools/scatter2d.md b/catalog/tools/scatter2d.md index 2a0513b..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, r04] +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..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 @@ -9,7 +10,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/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/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 --- 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 --- 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/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 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 diff --git a/docs/review-ledger.md b/docs/review-ledger.md index e840e66..bc20d3f 100644 --- a/docs/review-ledger.md +++ b/docs/review-ledger.md @@ -4,6 +4,52 @@ Newest section at the top. One section per review run. This is the record of wha run measured, what it changed, what it deliberately did not change, and what the next run should not spend time re-deciding. +## 2026-09-26 (repo review and issue fixes) + +A full review of code, site, catalog data and docs filed issues #1 to #47; this run fixed +most of them on the `review-fixes` branch, one commit per issue or per interlocked group. + +- **CI:** no push path filters (stacks-only pushes were neither validated nor deployed), + Node 24 from `.nvmrc`, current action majors, Dependabot for actions, Pages calls + `ci.yml` instead of repeating it, and a weekly link check (`links.yml`, + `site/check-links.mjs`) that opens one `correction` issue. First run: 317 checked, none + dead, moved or stale; 7 to 8 sites refuse robots. +- **Build and checks:** one frontmatter parser (`site/lib/frontmatter.mjs`) for build + and validate, which had drifted; summaries keep literal `_` and `#` (five changed); the + build fails on an unreadable entry instead of dropping it. New checks: V16 url scheme, + V17 category sets (folders, config, issue form), V18 heading anchors, V19 retired + spellings (`site/value-aliases.json`); real calendar dates (the shared `isRealDate` is + from a contributor's PR, #48, merged first); Evidence dates inside URLs + no longer count; a day of time-zone slack. Renderer: parens in link hrefs, misnested + emphasis, unique heading ids. +- **Tooling:** `package.json` scripts (`npm run check`), `site/new-entry.mjs`, + `site/sync-counts.mjs` (every restated count in one command). +- **Site:** word-start search with name and tag hits first, rows toggled rather than + rebuilt, focus kept after removing a filter, `h4` card titles, contrast and touch + targets, URL params validated, deferred scripts, a visible per-file note on Commercial + OK, a folding filter panel on phones, back-to-results breadcrumb, 404 suggestions, + sitemap link. +- **Catalog (metadata only):** removed the leaked `r04` tag (15 entries); merged + subcategory synonyms (`tiles`, `pixel-art`, `ir`, `gui`, `base-mesh`, `public-domain` + as a subcategory); one spelling for duplicate tags and formats; publisher on six + entries; three URLs moved to durable pages. +- **Catalog (licence facts, re-read live 2026-09-26):** material-symbols, + ms-building-footprints and microsoft-rocketbox no longer ask for on-screen credit; + their licences (Apache-2.0, CDLA-Permissive-2.0, MIT) need the licence text kept, and + none of the publishers asks for more. Two 2D fields set where the page says so + (bondoki-rotating-gems 52x52, lpc-revised-basics 3/4 view). +- **Deliberately not changed:** `charge-materials` and `blender-ellie-poses` keep a bare + `CC-BY`: Blender's card says only "CC-BY", and guessing a version would be a licence + claim. Fonts are not self-hosted: that would commit third-party binaries. No check yet + for notice-only licences marked credit-required: `nasa-3d-resources` would need its + credit wording re-read first. +- **Still open for a person:** #3 (branch protection), #36 (licence and publisher tags), + #37 (a canonical format list), #38 (category moves), #40 (re-verify needs-review + entries), #41 (the rest of the 2D fields: the sources do not state them), #42, #43, + #45, #47. +- Next highest-value action: submit `sitemap.xml` in Search Console and Bing Webmaster + Tools, then the third full review on or after 2026-10-17. + ## 2026-09-25 (outreach) Sub-project E, the items that reach outside the repo, each approved by the maintainer 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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..5ea3884 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "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", + "links": "node site/check-links.mjs", + "serve": "npx --yes serve site/dist" + } +} diff --git a/site/README.md b/site/README.md index c7546ba..96279ff 100644 --- a/site/README.md +++ b/site/README.md @@ -65,6 +65,26 @@ 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 +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 +94,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/build.mjs b/site/build.mjs index 40729fe..716f1aa 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) { @@ -215,7 +161,7 @@ function entryRowHtml(entry, repo, now) {
-

${esc(entry.name)}

+

${esc(entry.name)}

${flags}

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

@@ -319,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. + ``, ``, ``, ``, @@ -370,10 +319,13 @@ function notFoundHtml(site) {

Not found

That page is not part of this catalog.

Looking for an entry? Search the catalog.

+

Back to the catalog

+ + `; @@ -407,7 +359,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 +494,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; @@ -607,17 +562,18 @@ function main() { CATEGORY_CHIPS: categoryChipsHtml(visible, config.categories), GUIDE_ROWS: guideRowsHtml(config.guides, repoUrl), CATALOG_BLURB: `${stats.total} sources · ${stats.active} active · ${stats.commercialOk} commercial-ok · ${stats.commercialVaries} per-file · ${stats.deprecated} deprecated`, - RESULT_COUNT: `${visible.length} / ${stats.total}`, + RESULT_COUNT: `${visible.length} of ${stats.total} entries shown`, 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 +621,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/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`); diff --git a/site/checks.mjs b/site/checks.mjs index 7faa51a..f2a580e 100644 --- a/site/checks.mjs +++ b/site/checks.mjs @@ -7,10 +7,27 @@ */ import path from "node:path"; -import { parseStack, pickPath, StackError } from "./lib/stacks.mjs"; +import { evidenceSection } from "./lib/frontmatter.mjs"; import { isRealDate } from "./lib/shared.mjs"; +import { parseStack, pickPath, StackError } from "./lib/stacks.mjs"; + +export { evidenceSection }; -const DATE_ANY_RE = /\d{4}-\d{2}-\d{2}/g; +// 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,7 +159,7 @@ 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; @@ -291,6 +299,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(", "); @@ -300,6 +320,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 @@ -425,6 +470,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 @@ -469,6 +589,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 30246b0..a7100e2 100644 --- a/site/checks.test.mjs +++ b/site/checks.test.mjs @@ -13,14 +13,19 @@ import { checkActiveIsSettled, checkAttributionConsistency, checkCategoryReadmeRows, + checkCategorySets, checkCountTables, checkDeprecationReason, + checkEntryUrl, checkEvidenceDates, + evidenceDates, + markdownAnchors, checkLicenseVocabulary, checkPublisherConsistency, checkSpdxConsistency, checkStacks, checkTaxonomyValues, + checkValueAliases, checkValueSpellings, } from "./checks.mjs"; @@ -49,6 +54,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(" | ")}`); @@ -334,6 +344,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 = [ @@ -574,10 +591,103 @@ 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 YYYY-MM-DD 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); + +/* 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 15 checks`); +console.log(`checks.test ok: ${passed} assertions across 19 checks`); diff --git a/site/lib/entry-page.mjs b/site/lib/entry-page.mjs index c27f870..b222456 100644 --- a/site/lib/entry-page.mjs +++ b/site/lib/entry-page.mjs @@ -35,7 +35,6 @@ function credit(entry) { if (entry.attribution_required === true && entry.attribution_string) { return { value: "Required", - script: true, block: `

Credit line

${esc(entry.attribution_string)}

@@ -47,12 +46,11 @@ function credit(entry) { if (entry.attribution_required === true) { return { value: "Required", - script: false, block: `

Attribution is required and no canned credit line is recorded. Read the Notes for what the source asks for.

`, }; } - if (entry.attribution_required === false) return { value: "Not required", script: false, block: "" }; - return { value: "Unclear: read the Notes", script: false, block: "" }; + if (entry.attribution_required === false) return { value: "Not required", block: "" }; + return { value: "Unclear: read the Notes", block: "" }; } export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml, prev, next, site, categoryLabel, stamp, total, hasCard, now, stacks = [] }) { @@ -140,7 +138,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,
@@ -168,7 +166,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,

Licence freshness

- ${c.script ? '' : ""} + `; 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 2c18fb8..30197e3 100644 --- a/site/lib/lib.test.mjs +++ b/site/lib/lib.test.mjs @@ -128,8 +128,10 @@ has("title is escaped", p, "A "quoted" <Name> (CC-BY-4.0) has("canonical", p, '<link rel="canonical" href="https://x.test/s/entry/e1/" />'); eq("one h1", (p.match(/<h1[\s>]/g) || []).length, 1); has("credit line escaped", p, "Art by "X" </script>"); -has("copy script with a credit line", p, '<script src="../../entry.js" defer></script>'); -lacks("no copy script without one", page({ attribution_string: undefined }), "entry.js"); +has("entry script with a credit line", p, '<script src="../../entry.js" defer></script>'); +has("entry script without one, for the back link", page({ attribution_string: undefined }), '<script src="../../entry.js" defer></script>'); +lacks("no copy button without a credit line", page({ attribution_string: undefined }), "copy-attribution"); +has("breadcrumb catalog link can return to the results", p, '<a href="../../#catalog" data-back-to-results>Catalog</a>'); has("note when credit is required but not canned", page({ attribution_string: undefined }), "no canned credit line"); has("breadcrumb category link", p, "../../?cat=2d#catalog"); has("next link", p, 'href="../e2/" rel="next"'); @@ -439,6 +441,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 { 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\\* & <b>"), "A *literal* & <b>"); +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), '<a href="https://en.wikipedia.org/wiki/Foo_(bar)" rel="noopener noreferrer">Foo</a> next'); +eq("misnested emphasis stays literal", renderInline("**a *b** c*", passLink), "<strong>a *b</strong> c*"); +eq("italic around bold still works", renderInline("*a **b** c*", passLink), "<em>a <strong>b</strong> c</em>"); +const dupIds = rb("## Notes\n\nx\n\n## Notes\n\ny\n\n## Content\n\nz"); +has("first heading keeps its id", dupIds, '<h2 id="notes">'); +has("repeated heading gets a suffix", dupIds, '<h2 id="notes-2">'); +has("heading avoids a template id", dupIds, '<h2 id="content-2">'); + +/* 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, "<strong>$1</strong>") - .replace(/(^|[^*\w])\*(?=\S)([^*]+?)(?<=\S)\*(?![*\w])/g, "$1<em>$2</em>"); + .replace(/(^|[^*\w])\*(?=\S)([^*]+?)(?<=\S)\*(?![*\w])/g, (whole, pre, inner) => + count(inner, "<strong>") === count(inner, "</strong>") ? `${pre}<em>${inner}</em>` : 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(`<code>${esc(code)}</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(`<a href="${esc(out)}"${rel}>${marks(esc(label))}</a>`); @@ -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(`<h2 id="${esc(id)}">${inline(h[2])}</h2>`); return; } diff --git a/site/lib/shared.mjs b/site/lib/shared.mjs index 1fb1793..21d05e9 100644 --- a/site/lib/shared.mjs +++ b/site/lib/shared.mjs @@ -55,6 +55,15 @@ export function commercialLabel(v) { return "commercial ?"; } +/** + * 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/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 <category> <id> + * Writes catalog/<category>/<id>.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 <category> <id>"); + +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/public/app.js b/site/public/app.js index cbf29e1..6920a40 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"); @@ -118,6 +128,8 @@ bool("noattr", "noAttr"); } + const RESULTS_KEY = "fgda:results"; + function writeUrl() { const p = new URLSearchParams(); if (state.category !== DEFAULTS.category) p.set("cat", state.category); @@ -135,12 +147,19 @@ const qs = p.toString(); const next = `${location.pathname}${qs ? `?${qs}` : ""}${location.hash}`; history.replaceState(null, "", next); + // Entry pages link back to these results (entry.js reads this). + try { + if (qs) sessionStorage.setItem(RESULTS_KEY, qs); + else sessionStorage.removeItem(RESULTS_KEY); + } catch { + // Storage can be unavailable; the entry pages then link to the plain catalog. + } } /* ---------------------------------------------------------- filtering */ - function matches(entry) { - if (state.category !== "all" && entry.category !== state.category) return false; + /** Every filter except the category and the search words. */ + function passesFilters(entry) { if (entry.status === "active" && !state.active) return false; if (entry.status === "needs-review" && !state.review) return false; if (entry.status === "deprecated" && !state.deprecated) return false; @@ -149,28 +168,37 @@ if (state.noAttr && entry.attribution_required !== false) return false; if (state.perspective !== "any" && entry.camera_perspective !== state.perspective) return false; + return true; + } - 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))) - ); + /** + * One pass over the catalog per change. The pool is what passes every + * filter but the category: the chip counts are taken from it, because + * counting with the category applied would make every chip read 0 except + * the selected one, which tells the reader nothing. + */ + function computeResults() { + const score = new Map(); + const pool = []; + for (const e of data.entries) { + if (!passesFilters(e)) continue; + const s = searchScore(e); + if (!s) continue; + score.set(e.id, s); + pool.push(e); + } + const counts = new Map(); + for (const e of pool) counts.set(e.category, (counts.get(e.category) || 0) + 1); + const list = + state.category === "all" ? pool.slice() : pool.filter((e) => e.category === state.category); + const ranked = state.sort === "name" && queryTests().length > 0; + if (ranked) { + // Searching under the default sort: best match first, then by name. + list.sort((a, b) => score.get(b.id) - score.get(a.id) || SORTS.name(a, b)); + } else { + list.sort(SORTS[state.sort] || SORTS.name); + } + return { list, counts, total: pool.length, ranked }; } // Hyphens and underscores read as spaces: "first person" finds the "first-person" tag. @@ -178,6 +206,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) => @@ -212,7 +306,7 @@ <span class="entry-edge" aria-hidden="true"></span> <div class="entry-body"> <div class="entry-top"> - <h3><a class="entry-link" href="entry/${escapeHtml(entry.id)}/" data-id="${escapeHtml(entry.id)}">${escapeHtml(entry.name)}</a></h3> + <h4><a class="entry-link" href="entry/${escapeHtml(entry.id)}/" data-id="${escapeHtml(entry.id)}">${escapeHtml(entry.name)}</a></h4> <span class="entry-flags">${flags}</span> </div> <p>${escapeHtml(entry.summary || "")}</p> @@ -238,21 +332,6 @@ return state.sort === "name" && state.category === "all"; } - /** - * How many entries each category would yield under the *other* active - * filters. Counting with the category filter applied would make every chip - * read 0 except the selected one, which tells the reader nothing. - */ - function facetCounts() { - const saved = state.category; - state.category = "all"; - const pool = data.entries.filter(matches); - state.category = saved; - const counts = new Map(); - for (const e of pool) counts.set(e.category, (counts.get(e.category) || 0) + 1); - return { counts, total: pool.length }; - } - function renderCategoryFilters() { const host = $("#category-filters"); const buttons = [ @@ -276,8 +355,7 @@ }); } - function syncCategoryChips() { - const { counts, total } = facetCounts(); + function syncCategoryChips({ counts, total }) { $$("#category-filters .chip").forEach((chip) => { const cat = chip.getAttribute("data-cat"); const on = cat === state.category; @@ -309,9 +387,17 @@ 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" }); 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; + // 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; host.innerHTML = ""; @@ -329,42 +415,109 @@ <button type="button" class="chip chip-clear" data-clear="all">Clear all</button>`; } - function renderGrid() { - const list = data.entries.filter(matches).sort(SORTS[state.sort] || SORTS.name); - const grid = $("#entry-grid"); - const empty = $("#empty-state"); - $("#result-count").textContent = `${list.length} / ${data.entries.length}`; + function headingHtml(cat, count) { + const label = categoryLabels[cat]?.label || cat; + return ( + `<h3 class="group-heading" id="group-${escapeHtml(cat)}" data-cat="${escapeHtml(cat)}">` + + `<span class="group-name">${escapeHtml(label)}</span>` + + `<span class="group-count">${count}</span>` + + `</h3>` + ); + } - if (!list.length) { - grid.innerHTML = ""; - empty.hidden = false; - groupHeadings = []; - return; + /* + * The grid keeps one node per entry and per category heading for the life + * of the page. The prerendered rows are adopted as they are; only entries + * the build leaves out (deprecated ones) are rendered here, once. Filtering + * then toggles `hidden`, and nodes move only when the order changes. + */ + const cardNodes = new Map(); + const headingNodes = new Map(); + let flatHeading = null; + let placedKey = null; + + function adoptNodes() { + const grid = $("#entry-grid"); + for (const el of $$(".entry-card", grid)) cardNodes.set(el.getAttribute("data-id"), el); + for (const el of $$(".group-heading", grid)) headingNodes.set(el.getAttribute("data-cat"), el); + const tpl = document.createElement("template"); + tpl.innerHTML = [ + ...data.entries.filter((e) => !cardNodes.has(e.id)).map(cardHtml), + ...Object.keys(categoryLabels) + .filter((cat) => !headingNodes.has(cat)) + .map((cat) => headingHtml(cat, 0)), + ].join(""); + for (const el of [...tpl.content.children]) { + if (el.matches(".entry-card")) cardNodes.set(el.getAttribute("data-id"), el); + else headingNodes.set(el.getAttribute("data-cat"), el); } - empty.hidden = true; + // Anything else the grid held (a stale prerender) goes. + 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"; + } - if (!shouldGroup()) { - grid.innerHTML = list.map(cardHtml).join(""); - groupHeadings = []; - syncSpy(); - return; + /** Puts every node in `order` (visible ones first), moving nothing already in place. */ + function placeNodes(order) { + const grid = $("#entry-grid"); + const current = grid.children; + if (order.length === current.length && order.every((n, i) => current[i] === n)) return; + grid.append(...order); + } + + function renderGrid({ list, ranked }) { + const grid = $("#entry-grid"); + const empty = $("#empty-state"); + const group = shouldGroup(); + $("#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 + // toggles visibility. A ranked search reorders on every change. + const key = ranked + ? `rank:${group}:${list.map((e) => e.id).join(",")}` + : `${group ? "group" : "flat"}:${state.sort}`; + if (key !== placedKey) { + placedKey = key; + 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 = [flatHeading]; + if (group) { + for (const cat of Object.keys(categoryLabels)) { + order.push(headingNodes.get(cat)); + for (const e of all) if (e.category === cat) order.push(cardNodes.get(e.id)); + } + // Entries outside the configured categories never show grouped. + for (const e of all) if (!categoryLabels[e.category]) order.push(cardNodes.get(e.id)); + } else { + order.push(...headingNodes.values(), ...all.map((e) => cardNodes.get(e.id))); + } + placeNodes(order); } - const out = []; - for (const cat of Object.keys(categoryLabels)) { - const group = list.filter((e) => e.category === cat); - if (!group.length) continue; - const label = categoryLabels[cat]?.label || cat; - out.push( - `<h3 class="group-heading" id="group-${escapeHtml(cat)}" data-cat="${escapeHtml(cat)}">` + - `<span class="group-name">${escapeHtml(label)}</span>` + - `<span class="group-count">${group.length}</span>` + - `</h3>` - ); - out.push(...group.map(cardHtml)); + const shown = new Set(); + const perCat = new Map(); + for (const e of list) { + if (group && !categoryLabels[e.category]) continue; + shown.add(e.id); + perCat.set(e.category, (perCat.get(e.category) || 0) + 1); } - grid.innerHTML = out.join(""); - groupHeadings = $$("#entry-grid .group-heading"); + 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; + if (n) el.querySelector(".group-count").textContent = String(n); + } + groupHeadings = [...grid.children].filter((el) => el.matches(".group-heading") && !el.hidden); + // The first visible heading sits flush with the top of the list. + for (const el of headingNodes.values()) el.classList.toggle("is-lead", el === groupHeadings[0]); syncSpy(); } @@ -417,10 +570,16 @@ }); } + // Typing waits for a pause before filtering; every other control is immediate. + const SEARCH_DELAY = 120; + let searchTimer = 0; + function apply() { - syncCategoryChips(); + clearTimeout(searchTimer); + const results = computeResults(); + syncCategoryChips(results); renderActiveFilters(); - renderGrid(); + renderGrid(results); writeUrl(); } @@ -496,7 +655,8 @@ search.value = state.q; search.addEventListener("input", (e) => { state.q = e.target.value; - apply(); + clearTimeout(searchTimer); + searchTimer = setTimeout(apply, SEARCH_DELAY); }); const toggles = [ @@ -542,20 +702,41 @@ if (e.target.closest("a")) rememberScroll(); }); + // The button pressed is gone after the re-render. Focus goes to the chip + // that took its place, or to the result count, never back to the top of + // the document. $("#active-filters").addEventListener("click", (e) => { const btn = e.target.closest("[data-clear]"); if (!btn) return; const key = btn.getAttribute("data-clear"); + const index = $$("#active-filters [data-clear]").indexOf(btn); if (key === "all") Object.assign(state, DEFAULTS); else state[key] = DEFAULTS[key]; syncControls(); apply(); + const left = $$("#active-filters [data-clear]"); + const next = key === "all" ? null : left[Math.min(index, left.length - 1)]; + (next || $("#result-count")).focus(); }); $("#empty-reset").addEventListener("click", () => { Object.assign(state, DEFAULTS); syncControls(); apply(); + $("#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"); @@ -612,6 +793,7 @@ /* --------------------------------------------------------------- init */ readUrl(); + adoptNodes(); renderCategoryFilters(); renderStarters(); renderGuides(); diff --git a/site/public/entry.js b/site/public/entry.js index 5351373..dda596d 100644 --- a/site/public/entry.js +++ b/site/public/entry.js @@ -1,4 +1,23 @@ -/** Copy button for an entry page's credit line. The page works without it. */ +/** Entry page helpers. The page works without them. */ + +// "Catalog" in the breadcrumb returns to the search and filters the reader +// left, which the homepage keeps in sessionStorage. Without them it goes to +// the plain catalog, as built. +(() => { + const link = document.querySelector("[data-back-to-results]"); + if (!link) return; + let qs = null; + try { + qs = sessionStorage.getItem("fgda:results"); + } catch { + return; + } + // Only a query string the homepage wrote: URL-encoded key=value pairs. + if (!qs || !/^[\w.~%+=&-]+$/.test(qs)) return; + link.setAttribute("href", `../../?${qs}#catalog`); +})(); + +/** Copy button for an entry page's credit line. */ (() => { const btn = document.getElementById("copy-attribution"); if (!btn) return; diff --git a/site/public/index.html b/site/public/index.html index e3a5d00..6872ed2 100644 --- a/site/public/index.html +++ b/site/public/index.html @@ -103,9 +103,11 @@ <h2 id="catalog-title">Catalog</h2> <kbd class="search-key" aria-hidden="true">/</kbd> </label> + <button type="button" class="filters-toggle" id="filters-toggle" aria-expanded="false" aria-controls="category-filters meta-filters" hidden>Filters</button> + <div class="filter-row" id="category-filters" role="group" aria-label="Filter by category"><!--CATEGORY_CHIPS--></div> - <div class="filter-row meta-filters" role="group" aria-label="Filter by status and terms"> + <div class="filter-row meta-filters" id="meta-filters" role="group" aria-label="Filter by status and terms"> <label class="check"> <input type="checkbox" id="filter-active" checked /> Active @@ -118,8 +120,8 @@ <h2 id="catalog-title">Catalog</h2> <input type="checkbox" id="filter-deprecated" /> Deprecated </label> - <label class="check" title="Includes aggregators that need per-file review"> - <input type="checkbox" id="filter-commercial" /> + <label class="check"> + <input type="checkbox" id="filter-commercial" aria-describedby="commercial-note" /> Commercial OK </label> <label class="check"> @@ -139,14 +141,20 @@ <h2 id="catalog-title">Catalog</h2> <label class="check sort-field"> <span>Sort</span> <select id="sort"> - <option value="name">Name</option> + <option value="name">Name, best match first</option> <option value="license">License, least owed first</option> <option value="verified">Recently verified</option> </select> </label> - <p class="result-count" id="result-count" aria-live="polite"><!--RESULT_COUNT--></p> + <p class="result-count" id="result-count" aria-live="polite" tabindex="-1"><!--RESULT_COUNT--></p> </div> + <p class="filter-note" id="commercial-note" hidden> + Commercial OK also keeps per-file review sources: aggregators where + some files allow commercial use and some do not. Check the licence + of each file you take. + </p> + <div class="filter-row active-filters" id="active-filters" hidden></div> </div> @@ -184,7 +192,7 @@ <h2 id="guides-title">Guides</h2> <span aria-hidden="true">↑</span> Search </button> - <script src="./data.js"></script> - <script src="./app.js"></script> + <script src="./data.js" defer></script> + <script src="./app.js" defer></script> </body> </html> diff --git a/site/public/not-found.js b/site/public/not-found.js new file mode 100644 index 0000000..398ecd4 --- /dev/null +++ b/site/public/not-found.js @@ -0,0 +1,92 @@ +/** + * The 404 page reads the words in the address that missed (a mistyped + * /entry/kenny-ui/, say), suggests the closest entries and offers a catalog + * search for the same words. The page works without it. + */ +(() => { + const data = window.__CATALOG__; + const host = document.getElementById("suggestions"); + if (!data || !host || !document.currentScript) return; + + // Scripts load from the site root, so this script's own address gives + // the root however deep the missing path was. + const root = new URL("./", document.currentScript.src); + let rest = location.pathname; + try { + rest = decodeURIComponent(rest); + } catch { + // A malformed escape: match on the raw path. + } + const rootPath = root.pathname; + if (rest.startsWith(rootPath)) rest = rest.slice(rootPath.length); + const slug = rest + .replace(/^(entry|stack)\//, "") + .replace(/(index)?\.html?$/, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!slug) return; + + /** Letter pairs, so a dropped or swapped letter still leaves most in common. */ + function pairs(text) { + const out = new Map(); + for (let i = 0; i < text.length - 1; i++) { + const p = text.slice(i, i + 2); + out.set(p, (out.get(p) || 0) + 1); + } + return out; + } + /** Dice coefficient over letter pairs: 1 is identical, 0 shares nothing. */ + function similarity(a, b) { + const pa = pairs(a); + const pb = pairs(b); + let shared = 0; + let total = 0; + for (const n of pa.values()) total += n; + for (const [p, n] of pb) { + total += n; + shared += Math.min(n, pa.get(p) || 0); + } + return total ? (2 * shared) / total : 0; + } + const slugOf = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + + const MIN_SCORE = 0.4; + const matches = data.entries + .map((e) => ({ + entry: e, + // Deprecated entries still have pages, but a live one is the better guess. + score: + Math.max(similarity(slug, e.id), similarity(slug, slugOf(e.name))) - + (e.status === "deprecated" ? 0.1 : 0), + })) + .filter((m) => m.score >= MIN_SCORE) + .sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name)) + .slice(0, 5); + + const words = slug.split("-").join(" "); + if (matches.length) { + const heading = document.createElement("h2"); + heading.textContent = "Did you mean"; + const list = document.createElement("ul"); + list.className = "stack-list"; + for (const { entry } of matches) { + const li = document.createElement("li"); + const a = document.createElement("a"); + a.href = new URL(`entry/${encodeURIComponent(entry.id)}/`, root).href; + a.textContent = entry.name; + const note = document.createElement("span"); + note.textContent = `${entry.license} · ${entry.category}${entry.status === "deprecated" ? " · deprecated" : ""}`; + a.append(note); + li.append(a); + list.append(li); + } + host.append(heading, list); + } + const search = document.createElement("p"); + const link = document.createElement("a"); + link.href = new URL(`?q=${encodeURIComponent(words)}#catalog`, root).href; + link.textContent = `Search the catalog for "${words}"`; + search.append(link); + host.append(search); +})(); diff --git a/site/public/styles.css b/site/public/styles.css index 16863ba..1fbb373 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; @@ -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; } @@ -537,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; } @@ -554,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 { @@ -667,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; @@ -825,23 +859,35 @@ input:focus-visible { color: var(--muted); } -.group-heading:not(:first-child) { +.group-heading:not(:first-child):not(.is-lead) { margin-top: 1.5rem; } +/* app.js filters by hiding rows it keeps, and a row's own display would + otherwise win over the hidden attribute. */ +.entry-list > [hidden] { + display: none; +} + .group-count { 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". */ @@ -910,6 +956,49 @@ input:focus-visible { } } +/* The filter disclosure exists for small screens only. app.js unhides it + and folds the panel; everything but the result count (the live region) + hides while folded. */ +.filters-toggle { + display: none; +} + +@media (max-width: 640px) { + .filters-toggle:not([hidden]) { + display: inline-flex; + align-items: center; + justify-self: start; + min-height: 44px; + padding: 0.4rem 0.8rem; + border: 1px solid var(--line); + background: transparent; + color: var(--ink); + font: inherit; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + } + + .filters-toggle::after { + content: ""; + width: 0.4rem; + height: 0.4rem; + margin-left: 0.55rem; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: translateY(-2px) rotate(45deg); + } + + .filters-toggle[aria-expanded="true"]::after { + transform: translateY(2px) rotate(-135deg); + } + + .controls.is-collapsed #category-filters, + .controls.is-collapsed #meta-filters > :not(.result-count) { + display: none; + } +} + /* On a phone the wrapped filter panel is about 530px tall, two thirds of the screen, and pinning it left a strip for the entries. Small or short screens scroll it away with the page instead; the floating Search button brings it back. */ 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(", ")}`); diff --git a/site/validate.mjs b/site/validate.mjs index 2c0616f..67f739a 100644 --- a/site/validate.mjs +++ b/site/validate.mjs @@ -9,18 +9,24 @@ import { checkActiveIsSettled, checkAttributionConsistency, checkCategoryReadmeRows, + checkCategorySets, checkCountTables, checkDeprecationReason, + checkEntryUrl, checkEvidenceDates, checkLicenseVocabulary, checkPublisherConsistency, checkSpdxConsistency, checkStacks, checkTaxonomyValues, + checkValueAliases, 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"; -import { isRealDate } from "./lib/shared.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, ".."); @@ -28,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", @@ -77,12 +84,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 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 +100,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 +107,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 +116,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 +123,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 +142,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 +160,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 +223,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) { @@ -245,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); @@ -266,6 +256,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,12 +291,14 @@ 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)); 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); @@ -315,14 +308,14 @@ function main() { if (meta.verified) { const v = String(meta.verified); - if (!isRealDate(v)) errors.push(`${rel} verified is not a real YYYY-MM-DD date`); + 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 +384,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`); 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" + } + } +}