diff --git a/.github/workflows/thumbs.yml b/.github/workflows/thumbs.yml new file mode 100644 index 0000000..b1ca1f7 --- /dev/null +++ b/.github/workflows/thumbs.yml @@ -0,0 +1,67 @@ +name: Monthly thumbnail refresh + +# On the first of each month: re-fetch the publisher previews that are due +# (older than 180 days, licence changed, or new eligible entries), push the +# changed files to a thumbs/ branch and open or update one issue with +# the compare link. The repository does not let Actions open pull requests, +# and a token-made pull request would not run CI, so the maintainer opens it +# from the link. It never edits an entry. Rules: site/lib/thumbs.mjs. +on: + schedule: + - cron: "0 9 1 * *" + workflow_dispatch: + +permissions: + contents: write + issues: write + +jobs: + thumbs: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + + - name: Ensure ffmpeg + run: | + if ! command -v ffmpeg >/dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y -qq ffmpeg + fi + ffmpeg -version | head -1 + + - name: Fetch and resize previews + id: run + run: node site/thumbs.mjs --report thumbs-report.md + + - name: Validate the result + run: node site/validate.mjs + + - name: Push the branch and open or update the issue + if: steps.run.outputs.changed != '0' + env: + GH_TOKEN: ${{ github.token }} + CHANGED: ${{ steps.run.outputs.changed }} + run: | + month=$(date -u +%Y-%m) + branch="thumbs/${month}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git add site/thumbs.json site/public/thumbs + git commit -m "thumbs: monthly refresh ${month} (${CHANGED} changes)" + git push --force origin "$branch" + compare="https://github.com/${GITHUB_REPOSITORY}/compare/main...$(printf '%s' "$branch" | sed 's|/|%2F|g')?expand=1" + { printf 'Open the pull request: %s\n\n' "$compare"; cat thumbs-report.md; } > issue-body.md + title="Thumbnail refresh ${month}: ${CHANGED} changes" + existing=$(gh issue list --state open --label "enhancement" --json number,title \ + --jq '[.[] | select(.title | startswith("Thumbnail refresh "))][0].number // empty') + if [ -n "$existing" ]; then + gh issue edit "$existing" --title "$title" --body-file issue-body.md + else + gh issue create --title "$title" --label "enhancement" --body-file issue-body.md + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28a71cd..7d1547f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,6 +85,12 @@ with no code. Publishers are welcome to list their own work. Say so in the pull request, write the entry as a third party would (facts the source states, no comparisons with other entries, no ranking language), and start it at `needs-review` so a maintainer re-reads the licence before it goes `active`. A free tier of a paid product is listed as the free tier only, with the paid tier named in Notes and not linked as the entry's `url`. A one-time notice or upsell inside a tool is recorded in Notes as a fact. +## Preview images + +The site shows each entry's preview image where it can. The picture is the publisher's own `og:image` from the entry's `url`, fetched by `npm run thumbs`, resized to 640 pixels wide and committed under `site/public/thumbs/` with a record in `site/thumbs.json`. Only `active` entries whose licence is on the allowlist in `site/lib/thumbs.mjs` (CC0, CC-BY, MIT, OFL, GPL and the other open licences that allow a resized copy) get one; `custom`, `varies` and `unknown` never do, and neither do GitHub repositories, whose social card is not a preview. The validator fails when a committed image no longer passes that gate, and the script removes it on its next run. + +A publisher who would rather not have their preview shown can open an issue; the maintainer sets `preview: none` on the entry and the image is removed. A maintainer who has checked that a specific image is covered by the entry's licence can set `preview: https://...` to use that image instead of the page's `og:image`. + ## Automated and AI-assisted pull requests Changes drafted with an agent or a script are judged on the diff like any other. Keep them small, keep tests in `site/checks.test.mjs` or `site/lib/lib.test.mjs` (a test file that `npm test` does not run is dropped), and name the issue the change fixes in the body (`Fixes #N`). A pull request that lists sources or licences the author did not read is closed: the catalog's one rule is that every licence fact was read at the source by the person who recorded it. diff --git a/catalog/TEMPLATE.md b/catalog/TEMPLATE.md index 197f07c..1285e02 100644 --- a/catalog/TEMPLATE.md +++ b/catalog/TEMPLATE.md @@ -21,6 +21,7 @@ verified: 2026-07-19 status: needs-review # Optional (omit if N/A): # publisher: Kenney # rights holder when that publisher has more than one entry; not a generic host +# preview: none # or an https:// image URL a maintainer has checked; see CONTRIBUTING.md "Preview images" # 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 diff --git a/catalog/animation/blender-ellie-poses.md b/catalog/animation/blender-ellie-poses.md index 77952ce..3f70fe2 100644 --- a/catalog/animation/blender-ellie-poses.md +++ b/catalog/animation/blender-ellie-poses.md @@ -1,36 +1,37 @@ ---- -id: blender-ellie-poses -name: Blender Studio Ellie Pose Library -url: https://www.blender.org/download/demo-files/#assets -publisher: Blender Studio -category: animation -subcategories: [poses, rigging] -license: CC-BY -commercial: true -attribution_required: true -attribution_string: "Ellie Pose Library (c) Blender Studio. CC-BY. Suggested format; card does not specify wording." -formats: [Blend] -tags: [poses] +--- +id: blender-ellie-poses +name: Blender Studio Ellie Pose Library +url: https://www.blender.org/download/demo-files/#assets +publisher: Blender Studio +category: animation +subcategories: [poses, rigging] +license: CC-BY +commercial: true +attribution_required: true +attribution_string: "Ellie Pose Library (c) Blender Studio. CC-BY. Suggested format; card does not specify wording." +formats: [Blend] +tags: [poses] +preview: none # the page's og:image is the publisher's general promo, not this pack added: 2026-07-19 -verified: 2026-08-24 -status: active ---- - -# Blender Studio Ellie Pose Library - -Humanoid pose library from Blender Studio. The verified card on the [demo-files](https://www.blender.org/download/demo-files/#assets) listing is **Ellie Pose Library v2.0.0** (Asset Bundles section, 24 MB, CC-BY). That listing mixes CC0 and CC-BY cards on one page; this entry is only the Ellie card, not Cube Diorama or Human Base Meshes. - -## Notes - -- Direct zip (v2.0.0, Blender 4.0+): listed `url`. Version bumps change the filename; re-check the Asset Bundles card if the zip 404s. -- License evidence lives on the demo-files listing, not inside the zip. -- Attribution example: `Ellie Pose Library © Blender Studio — CC-BY` - -## Evidence - -- Live [Demo Files](https://www.blender.org/download/demo-files/) (2026-08-24): "CC-BY" on the Ellie Pose Library card (24 MB, Blender Studio) - -## Related - -- [cmu-mocap](cmu-mocap.md) -- [mixamo](mixamo.md) +verified: 2026-08-24 +status: active +--- + +# Blender Studio Ellie Pose Library + +Humanoid pose library from Blender Studio. The verified card on the [demo-files](https://www.blender.org/download/demo-files/#assets) listing is **Ellie Pose Library v2.0.0** (Asset Bundles section, 24 MB, CC-BY). That listing mixes CC0 and CC-BY cards on one page; this entry is only the Ellie card, not Cube Diorama or Human Base Meshes. + +## Notes + +- Direct zip (v2.0.0, Blender 4.0+): listed `url`. Version bumps change the filename; re-check the Asset Bundles card if the zip 404s. +- License evidence lives on the demo-files listing, not inside the zip. +- Attribution example: `Ellie Pose Library © Blender Studio — CC-BY` + +## Evidence + +- Live [Demo Files](https://www.blender.org/download/demo-files/) (2026-08-24): "CC-BY" on the Ellie Pose Library card (24 MB, Blender Studio) + +## Related + +- [cmu-mocap](cmu-mocap.md) +- [mixamo](mixamo.md) diff --git a/catalog/characters/blender-human-base-meshes.md b/catalog/characters/blender-human-base-meshes.md index c4e8050..645bf04 100644 --- a/catalog/characters/blender-human-base-meshes.md +++ b/catalog/characters/blender-human-base-meshes.md @@ -1,39 +1,40 @@ ---- -id: blender-human-base-meshes -name: Blender Studio Human Base Meshes -url: https://www.blender.org/download/demo-files/#assets -publisher: Blender Studio -category: characters -subcategories: [base-meshes] -license: CC0 -license_spdx: CC0-1.0 -commercial: true -attribution_required: false -formats: [Blend, glTF, FBX] -tags: [topology] +--- +id: blender-human-base-meshes +name: Blender Studio Human Base Meshes +url: https://www.blender.org/download/demo-files/#assets +publisher: Blender Studio +category: characters +subcategories: [base-meshes] +license: CC0 +license_spdx: CC0-1.0 +commercial: true +attribution_required: false +formats: [Blend, glTF, FBX] +tags: [topology] +preview: none # the page's og:image is the publisher's general promo, not this pack added: 2026-07-19 -verified: 2026-08-24 -status: active ---- - -# Blender Studio Human Base Meshes - -Production topology humanoid scaffolds from Blender Studio. The verified card on the [demo-files](https://www.blender.org/download/demo-files/#assets) listing is **Human Base Meshes v1.4.1** (Asset Bundles section, 49 MB, CC0). That listing mixes CC0 and CC-BY cards on one page; Ellie Pose Library on the same section is CC-BY and is a different entry. Export glTF/FBX for engines. Confirm the badge still says CC0 if the version number moves. - -## Notes - -- Direct zip (v1.4.1, Blender 4.2 LTS+): listed `url`. Version bumps change the filename; re-check the Asset Bundles card if the zip 404s. -- Same demo-files listing as [blender-ellie-poses](../animation/blender-ellie-poses.md). Ellie is CC-BY; this bundle is CC0. Do not mix the two grants. - -## Evidence - -- Live Demo Files (2026-08-24): Human Base Meshes v1.4.1 card "49 MB - CC0" - -## Related - -- [../animation/blender-ellie-poses](../animation/blender-ellie-poses.md) -- [blender-studio-rain](blender-studio-rain.md) -- [blender-studio-one](blender-studio-one.md) -- [blender-studio-critter](blender-studio-critter.md) -- [makehuman](makehuman.md) -- [../3d/the-base-mesh](../3d/the-base-mesh.md) +verified: 2026-08-24 +status: active +--- + +# Blender Studio Human Base Meshes + +Production topology humanoid scaffolds from Blender Studio. The verified card on the [demo-files](https://www.blender.org/download/demo-files/#assets) listing is **Human Base Meshes v1.4.1** (Asset Bundles section, 49 MB, CC0). That listing mixes CC0 and CC-BY cards on one page; Ellie Pose Library on the same section is CC-BY and is a different entry. Export glTF/FBX for engines. Confirm the badge still says CC0 if the version number moves. + +## Notes + +- Direct zip (v1.4.1, Blender 4.2 LTS+): listed `url`. Version bumps change the filename; re-check the Asset Bundles card if the zip 404s. +- Same demo-files listing as [blender-ellie-poses](../animation/blender-ellie-poses.md). Ellie is CC-BY; this bundle is CC0. Do not mix the two grants. + +## Evidence + +- Live Demo Files (2026-08-24): Human Base Meshes v1.4.1 card "49 MB - CC0" + +## Related + +- [../animation/blender-ellie-poses](../animation/blender-ellie-poses.md) +- [blender-studio-rain](blender-studio-rain.md) +- [blender-studio-one](blender-studio-one.md) +- [blender-studio-critter](blender-studio-critter.md) +- [makehuman](makehuman.md) +- [../3d/the-base-mesh](../3d/the-base-mesh.md) diff --git a/catalog/characters/kenney-animated-characters.md b/catalog/characters/kenney-animated-characters.md index 187f526..af20a62 100644 --- a/catalog/characters/kenney-animated-characters.md +++ b/catalog/characters/kenney-animated-characters.md @@ -1,39 +1,40 @@ ---- -id: kenney-animated-characters -name: Kenney Animated Characters -url: https://kenney.nl/assets?series=Animated+Characters -publisher: Kenney -category: characters -subcategories: [rigged, animated, humanoid] -license: CC0 -license_spdx: CC0-1.0 -commercial: true -attribution_required: false -formats: [FBX, PNG, SVG] -tags: [low-poly, survivors, zombie, skins] +--- +id: kenney-animated-characters +name: Kenney Animated Characters +url: https://kenney.nl/assets?series=Animated+Characters +publisher: Kenney +category: characters +subcategories: [rigged, animated, humanoid] +license: CC0 +license_spdx: CC0-1.0 +commercial: true +attribution_required: false +formats: [FBX, PNG, SVG] +tags: [low-poly, survivors, zombie, skins] +preview: none # the page's og:image is the publisher's general promo, not this pack added: 2026-09-26 -verified: 2026-09-26 -status: active ---- - -# Kenney Animated Characters - -Kenney's "Animated Characters" series: three small packs of rigged, animated low-poly humanoids with swappable PNG skins. [Protagonists](https://kenney.nl/assets/animated-characters-protagonists) (2020; skater, cyborg, criminal), [Retro](https://kenney.nl/assets/animated-characters-retro) (2022) and [Survivors](https://kenney.nl/assets/animated-characters-survivors) (2019; zombie and survivor). All three are CC0. - -## Notes - -- One entry for the series, since each pack is the same rig and format with different skins -- Contents read from each pack's download ZIP (2026-09-26): 4 FBX files plus PNG skins in each, and SVG files in Protagonists and Survivors -- The bundled `License.txt` says credit is welcome but "not a requirement" -- Attribution example (optional): `Characters by Kenney.nl (CC0)` - -## Evidence - -- Live pack pages for Protagonists, Retro and Survivors (2026-09-26): each shows Category "3D", series "Animated Characters", 8 files, License "Creative Commons CC0" -- `License.txt` in the Protagonists download (2026-09-26): "License: (Creative Commons Zero, CC0)", "You can use this content for personal, educational, and commercial purposes. Support by crediting 'Kenney' or 'www.kenney.nl' (this is not a requirement)" - -## Related - -- [kaykit-adventurers](kaykit-adventurers.md) -- [quaternius-universal-base-characters](quaternius-universal-base-characters.md) -- [kenney](../3d/kenney.md) +verified: 2026-09-26 +status: active +--- + +# Kenney Animated Characters + +Kenney's "Animated Characters" series: three small packs of rigged, animated low-poly humanoids with swappable PNG skins. [Protagonists](https://kenney.nl/assets/animated-characters-protagonists) (2020; skater, cyborg, criminal), [Retro](https://kenney.nl/assets/animated-characters-retro) (2022) and [Survivors](https://kenney.nl/assets/animated-characters-survivors) (2019; zombie and survivor). All three are CC0. + +## Notes + +- One entry for the series, since each pack is the same rig and format with different skins +- Contents read from each pack's download ZIP (2026-09-26): 4 FBX files plus PNG skins in each, and SVG files in Protagonists and Survivors +- The bundled `License.txt` says credit is welcome but "not a requirement" +- Attribution example (optional): `Characters by Kenney.nl (CC0)` + +## Evidence + +- Live pack pages for Protagonists, Retro and Survivors (2026-09-26): each shows Category "3D", series "Animated Characters", 8 files, License "Creative Commons CC0" +- `License.txt` in the Protagonists download (2026-09-26): "License: (Creative Commons Zero, CC0)", "You can use this content for personal, educational, and commercial purposes. Support by crediting 'Kenney' or 'www.kenney.nl' (this is not a requirement)" + +## Related + +- [kaykit-adventurers](kaykit-adventurers.md) +- [quaternius-universal-base-characters](quaternius-universal-base-characters.md) +- [kenney](../3d/kenney.md) diff --git a/docs/images/readme/og-card.png b/docs/images/readme/og-card.png index c3661a4..eee2002 100644 Binary files a/docs/images/readme/og-card.png and b/docs/images/readme/og-card.png differ diff --git a/docs/superpowers/plans/2026-10-23-site-refresh.md b/docs/superpowers/plans/2026-10-23-site-refresh.md new file mode 100644 index 0000000..10e10b8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-site-refresh.md @@ -0,0 +1,515 @@ +# Site Refresh Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the catalog site show the assets it lists (licence-gated publisher thumbnails on cards, rows, entry and stack pages), cut the homepage to per-category previews with static category pages behind them, self-host the fonts, and fix the audit's layout items, without changing the site's identity. + +**Architecture:** A new `site/thumbs.mjs` script fetches each eligible entry's `og:image`, resizes it with ffmpeg, and commits `site/public/thumbs/.webp` plus a manifest `site/thumbs.json`; the validator refuses a thumbnail the gate no longer allows. The build reads the manifest, attaches `thumb` to each entry, and renders every entry through one browser-safe template module `site/lib/cards.mjs` that the homepage, category pages, stack pages and `app.js` all share; Cards vs List is a `data-view` attribute flipped by CSS, not a second template. Fonts become WOFF2 files under `site/public/fonts/` with `@font-face` rules, and every page's `` comes from the shared helper. + +**Tech Stack:** Node 22+ ESM, no npm dependencies; plain CSS and ES modules in the browser; ffmpeg (optional, for the thumbnail script only); GitHub Actions with the already-pinned checkout and setup-node. + +**Spec:** `docs/superpowers/specs/2026-10-23-site-refresh-design.md` + +## Global Constraints + +- No npm dependencies; `package.json` keeps `"description": "Catalog checks and static site build. No dependencies."` true. +- `npm run check` (tests, validate, build) is the gate for every task; `site/checks.test.mjs` ends with `checks.test ok: N assertions across M checks` and `site/lib/lib.test.mjs` with `lib.test ok: N assertions`. +- Every page must be complete without JavaScript; the build's `checkPages` must pass on every generated page. +- Refinement preserves: existing tokens in `site/public/styles.css` (`--bg`, `--ink`, `--muted`, `--line`, `--surface`, `--accent`, `--warn`, `--danger`, `--font`, `--mono`, `--max`), the copy voice, and the "every licence read at the source" promise. No new colours outside the token set. +- Thumbnails only for entries with `status: active` and a licence in the allowlist; never for `custom`, `varies`, `unknown`, `github.com` or `fonts.google.com` sources without an explicit `preview:` URL. +- Before any UI edit (Tasks 4 to 7 and 9), read `C:\Users\16mat\.claude\skills\impeccable\reference\craft-floor.md`. After Task 9's build, run the detector once: `sh C:/Users/16mat/.claude/skills/impeccable/scripts/impeccable detect --json site/public/styles.css site/public/index.html site/lib/cards.mjs site/lib/entry-page.mjs site/lib/stack-page.mjs site/lib/category-page.mjs`. +- Commit after each task on branch `site-refresh`; commit messages end with `Co-Authored-By: Claude Fable 5.1 `. + +## Review Focus + +1. **An entry's licence changes from CC0 to custom after its thumbnail was committed.** The next `npm run validate` must fail naming that entry; the next `node site/thumbs.mjs` run must delete the file and record. (Task 2, `checkThumbManifest` test "rejects a thumbnail whose entry is no longer eligible".) +2. **A publisher's `og:image` is their logo, shared by every pack page.** Three or more entries with the same image hash must all lose the image and get the tile. (Task 1, `sharedImageIds` test.) +3. **A page with no `og:image`, a 404, a timeout, or an image over 15 MB.** The script records the failure in its report and moves on; exit code stays 0. (Task 2, `--dry-run` fixture run and the "no converter" path.) +4. **JavaScript off, or `data.js` fails to load.** Homepage shows 6 or 8 entries per category and a working "Show all" link to a category page that lists everything; the view toggle is simply absent. (Task 6 page check; Task 5 no-JS screenshot in Task 9.) +5. **Category with zero thumbnails (Tools, Fonts) in Cards view, and a visitor who switched it to Cards.** Tiles must render with real names, not blank boxes; the grid must not be taller than the list it replaced by more than the image height. (Task 4 `tileHtml` tests; Task 9 screenshot of Tools in Cards.) + +--- + +### Task 1: Thumbnail rules library (pure) + +**Files:** +- Create: `site/lib/thumbs.mjs` +- Test: `site/lib/lib.test.mjs` (append a section `// Site refresh: thumbnail rules.` before the report block) + +**Interfaces:** +- Consumes: nothing from the repo except `hostname` parsing via `URL`. +- Produces (all exported from `site/lib/thumbs.mjs`): + - `PREVIEW_LICENSES: Set` = `CC0, Unlicense, public-domain, CC-BY, CC-BY-3.0, CC-BY-4.0, CC-BY-SA, CC-BY-SA-3.0, CC-BY-SA-4.0, MIT, ISC, BSD, BSD-2-Clause, BSD-3-Clause, BSD-3-Clause-Clear, Apache-2.0, MPL-2.0, SIL OFL, GPL-2.0, GPL-2.0-only, GPL-2.0-or-later, GPL-3.0, GPL-3.0-only, GPL-3.0-or-later, LGPL-3.0, CDLA-Permissive-2.0` + - `SKIP_HOSTS: Set` = `github.com, www.github.com, fonts.google.com` + - `previewEligibility(entry) -> { ok: boolean, reason: string | null }` where `entry` has `license, status, url, preview?`. Reasons, exact strings: `"status is needs-review"` / `"status is deprecated"`, `"licence CUSTOM is not in the preview allowlist"` (with the real value in place of CUSTOM), `"preview: none"`, `"host github.com is skipped"` (host in place). A `preview` that is an `https://` URL overrides only the host rule. Order of checks: preview none, status, licence, host. + - `imageUrlFromHtml(html, pageUrl) -> string | null`: `og:image` first (`property` before or after `content`), then `twitter:image`; relative values resolved against `pageUrl`; `&` in the value decoded; returns null when absent or not `http(s)`. + - `sharedImageIds(records: Record) -> Set`: ids whose `hash` appears on 3 or more records; `pinned` records (from a `preview:` URL) never count and are never returned. + - `needsFetch(record | undefined, { license, today, force = false, maxAgeDays = 180 }) -> boolean`: true when no record, `force`, `record.license !== license`, or `record.fetched` older than `maxAgeDays` days before `today` (ISO dates). + - `webpSize(buf: Buffer) -> { width, height } | null` for VP8, VP8L and VP8X containers; null when not `RIFF....WEBP`. + - `publisherKey(entry) -> string`: `entry.publisher` if set, else the hostname of `entry.url` without `www.`. + - `previewCredit(entry) -> string`: `Preview image from Kenney, shown under CC0` or `Preview image from the source page, shown under CC-BY-4.0`. + - `filmstripPick(entries, { count = 9, cardCategories }) -> entry[]`: entries with `status === "active"`, `thumb` set, `category` in `cardCategories`; sorted by `verified` descending then `name`; at most one per `publisherKey`; first `count`. + - `thumbRelPath(id) -> string` = `thumbs/.webp`. + +- [ ] **Step 1: Write the failing tests** in `site/lib/lib.test.mjs` using the file's `eq`/`has` helpers: + +```js +import { filmstripPick, imageUrlFromHtml, needsFetch, previewCredit, previewEligibility, publisherKey, sharedImageIds, webpSize } from "./thumbs.mjs"; +eq("thumbs: CC0 active itch page is eligible", previewEligibility({ license: "CC0", status: "active", url: "https://kaylousberg.itch.io/block-bits" }).ok, true); +eq("thumbs: custom licence refused", previewEligibility({ license: "custom", status: "active", url: "https://x.itch.io/a" }).reason, "licence custom is not in the preview allowlist"); +eq("thumbs: needs-review refused before licence", previewEligibility({ license: "custom", status: "needs-review", url: "https://x.itch.io/a" }).reason, "status is needs-review"); +eq("thumbs: github host skipped", previewEligibility({ license: "MIT", status: "active", url: "https://github.com/a/b" }).reason, "host github.com is skipped"); +eq("thumbs: preview url overrides host only", previewEligibility({ license: "MIT", status: "active", url: "https://github.com/a/b", preview: "https://a.b/c.png" }).ok, true); +eq("thumbs: preview url cannot override licence", previewEligibility({ license: "custom", status: "active", url: "https://a.b/", preview: "https://a.b/c.png" }).ok, false); +eq("thumbs: preview none", previewEligibility({ license: "CC0", status: "active", url: "https://a.b/", preview: "none" }).reason, "preview: none"); +eq("thumbs: og:image property-first", imageUrlFromHtml('', "https://x/"), "https://i.x/a.png?w=1&h=2"); +eq("thumbs: og:image content-first", imageUrlFromHtml('', "https://x.y/p/"), "https://x.y/img/a.jpg"); +eq("thumbs: twitter fallback", imageUrlFromHtml('', "https://x/"), "https://i/t.png"); +eq("thumbs: no image", imageUrlFromHtml("", "https://x/"), null); +eq("thumbs: shared hash dropped at three", [...sharedImageIds({ a: { hash: "h1" }, b: { hash: "h1" }, c: { hash: "h1" }, d: { hash: "h2" }, e: { hash: "h2" } })].sort().join(), "a,b,c"); +eq("thumbs: pinned never dropped", [...sharedImageIds({ a: { hash: "h1", pinned: true }, b: { hash: "h1" }, c: { hash: "h1" }, d: { hash: "h1" } })].sort().join(), "b,c,d"); +eq("thumbs: needsFetch no record", needsFetch(undefined, { license: "CC0", today: "2026-10-23" }), true); +eq("thumbs: needsFetch fresh same licence", needsFetch({ fetched: "2026-09-01", license: "CC0" }, { license: "CC0", today: "2026-10-23" }), false); +eq("thumbs: needsFetch licence changed", needsFetch({ fetched: "2026-09-01", license: "CC0" }, { license: "CC-BY-4.0", today: "2026-10-23" }), true); +eq("thumbs: needsFetch stale", needsFetch({ fetched: "2026-01-01", license: "CC0" }, { license: "CC0", today: "2026-10-23" }), true); +eq("thumbs: webp VP8X size", webpSize(Buffer.from("52494646 24000000 57454250 56503858 0a000000 10000000 7f020000 8f010000".replaceAll(" ", ""), "hex")).width, 640); +eq("thumbs: not webp", webpSize(Buffer.from("89504e47", "hex")), null); +eq("thumbs: publisherKey falls back to host", publisherKey({ url: "https://www.kenney.nl/assets/x" }), "kenney.nl"); +eq("thumbs: credit with publisher", previewCredit({ publisher: "Kenney", license: "CC0" }), "Preview image from Kenney, shown under CC0"); +eq("thumbs: credit without publisher", previewCredit({ license: "CC-BY-4.0" }), "Preview image from the source page, shown under CC-BY-4.0"); +{ + const t = { path: "thumbs/x.webp", width: 640, height: 400 }; + const pool = [ + { id: "a", name: "A", status: "active", category: "3d", verified: "2026-10-01", publisher: "P1", url: "https://p1/", thumb: t }, + { id: "b", name: "B", status: "active", category: "3d", verified: "2026-10-02", publisher: "P1", url: "https://p1/", thumb: t }, + { id: "c", name: "C", status: "active", category: "tools", verified: "2026-10-03", url: "https://p2/", thumb: t }, + { id: "d", name: "D", status: "needs-review", category: "2d", verified: "2026-10-04", url: "https://p3/", thumb: t }, + { id: "e", name: "E", status: "active", category: "2d", verified: "2026-10-05", url: "https://p4/", thumb: null }, + { id: "f", name: "F", status: "active", category: "2d", verified: "2026-09-01", url: "https://p5/", thumb: t }, + ]; + eq("thumbs: filmstrip picks newest, one per publisher, card categories, active, with thumb", filmstripPick(pool, { count: 9, cardCategories: ["3d", "2d"] }).map((e) => e.id).join(), "b,f"); +} +``` + +For `webpSize`: the VP8X test bytes are a RIFF header, `WEBP`, a `VP8X` chunk of size 10 whose canvas is 24 bits `width-1` = 639 (`7f0200`) and `height-1` = 399 (`8f0100`); add a VP8L case (`VP8L` chunk, signature byte `0x2f`, 14 bits `width-1` then 14 bits `height-1`) and a lossy VP8 case (frame tag, `9d 01 2a`, then 16-bit width and height with the top two bits masked) in the same style. + +- [ ] **Step 2: Run the tests to see them fail** + +Run: `node site/lib/lib.test.mjs` +Expected: fails to import `./thumbs.mjs`. + +- [ ] **Step 3: Implement `site/lib/thumbs.mjs`** with the exports above. `webpSize` reads: VP8X at offset 24 (three little-endian bytes each for width-1 and height-1); VP8L at offset 21 (bits 0 to 13 width-1, bits 14 to 27 height-1 across the 4 bytes after the `0x2f` signature); VP8 at offset 26 (`uint16le & 0x3fff` each). No `node:` imports in this file; it is copied into dist later. + +- [ ] **Step 4: Run the tests to see them pass** + +Run: `node site/lib/lib.test.mjs` +Expected: `lib.test ok: ` with a count above 553. + +- [ ] **Step 5: Commit** + +```bash +git add site/lib/thumbs.mjs site/lib/lib.test.mjs +git commit -m "site: thumbnail eligibility, image-url and filmstrip rules" +``` + +--- + +### Task 2: Thumbnail script, manifest, validator rules, first batch + +**Files:** +- Create: `site/thumbs.mjs` (CLI), `site/thumbs.json` (manifest, written by the CLI), `site/public/thumbs/*.webp` (first batch) +- Modify: `site/validate.mjs:104` (`ALLOWED_BINARY_PREFIXES`), `site/validate.mjs` entry loop (the `preview` field), `site/checks.mjs` (new `checkThumbManifest`), `site/checks.test.mjs`, `package.json` scripts, `CONTRIBUTING.md`, `catalog/TEMPLATE.md` (one commented `preview:` line) +- Test: `site/checks.test.mjs` + +**Interfaces:** +- Consumes: Task 1 exports. +- Produces: + - `site/thumbs.json` shape: `{ "generated": "YYYY-MM-DD", "thumbs": { "": { "source": "", "image": "", "fetched": "YYYY-MM-DD", "hash": "<16 hex of sha256 of the downloaded bytes>", "width": N, "height": N, "license": "", "pinned": true? } } }` sorted by id. + - `checkThumbManifest(manifest, entriesById: Map, files: string[]) -> string[]` in `site/checks.mjs`; `files` are the basenames under `site/public/thumbs/`. + - npm script `"thumbs": "node site/thumbs.mjs"`. + - CLI flags: `--force`, `--only `, `--limit `, `--dry-run`, `--report `; environment `GITHUB_OUTPUT` gets `changed=` when set. + +- [ ] **Step 1: Write the failing validator tests** in `site/checks.test.mjs`: + +```js +import { checkThumbManifest } from "./checks.mjs"; +const okEntry = { id: "a", license: "CC0", status: "active", url: "https://a.itch.io/x" }; +const manifest = { thumbs: { a: { fetched: "2026-10-01", license: "CC0", hash: "h", width: 640, height: 400, source: "https://a.itch.io/x", image: "https://i/a.png" } } }; +accepts("thumbs manifest: file and record agree", checkThumbManifest(manifest, new Map([["a", okEntry]]), ["a.webp"])); +rejects("thumbs manifest: file without record", checkThumbManifest({ thumbs: {} }, new Map([["a", okEntry]]), ["a.webp"]), "site/public/thumbs/a.webp has no record in site/thumbs.json"); +rejects("thumbs manifest: record without file", checkThumbManifest(manifest, new Map([["a", okEntry]]), []), 'site/thumbs.json has "a" but site/public/thumbs/a.webp is missing'); +rejects("thumbs manifest: record for unknown entry", checkThumbManifest(manifest, new Map(), ["a.webp"]), 'site/thumbs.json has "a" but no catalog entry has that id'); +rejects("thumbs manifest: rejects a thumbnail whose entry is no longer eligible", checkThumbManifest(manifest, new Map([["a", { ...okEntry, license: "custom" }]]), ["a.webp"]), 'thumbnail for "a" must be removed: licence custom is not in the preview allowlist (run node site/thumbs.mjs)'); +rejects("thumbs manifest: licence drift", checkThumbManifest(manifest, new Map([["a", { ...okEntry, license: "MIT" }]]), ["a.webp"]), 'thumbnail for "a" was fetched under CC0 but the entry now says MIT (run node site/thumbs.mjs)'); +``` + +Also, in the same file, a fixture test for the `preview` field through whatever helper `validate.mjs` exposes for per-entry field checks; if the field checks live inline in `validate.mjs main()`, add `checkPreviewField(file, meta) -> string[]` to `site/checks.mjs` and test: accepts absent, accepts `none`, accepts `https://a/b.png`, rejects `http://a/b.png` with `preview must be "none" or an https:// image URL`, rejects `yes`. + +- [ ] **Step 2: Run the tests to see them fail** + +Run: `node site/checks.test.mjs` +Expected: import of `checkThumbManifest` is undefined / throws. + +- [ ] **Step 3: Implement `checkThumbManifest` and `checkPreviewField` in `site/checks.mjs`**, using `previewEligibility` from `site/lib/thumbs.mjs`. Wire both into `site/validate.mjs`: read `site/thumbs.json` if present (an absent manifest with an empty `site/public/thumbs/` is fine), list `site/public/thumbs/`, and push the errors; add `"site/public/thumbs/"` and `"site/public/fonts/"` to `ALLOWED_BINARY_PREFIXES`. Update the count in the `checks.test ok` line's `across N checks` text if the file prints a fixed number. + +- [ ] **Step 4: Run the tests and validate** + +Run: `node site/checks.test.mjs && node site/validate.mjs` +Expected: both pass (no manifest yet). + +- [ ] **Step 5: Implement `site/thumbs.mjs`.** Reads entries with `listEntryFiles` + `parseFrontmatter` (fields: id, url, license, status, publisher, preview). Converter detection: `ffmpeg -version` via `execFileSync`, else `magick -version`, else none (then every fetch is reported as `skipped: no converter on PATH`). For each entry: `previewEligibility`; `needsFetch` against the current manifest; fetch the page (20 s `AbortSignal.timeout`, `User-Agent: Mozilla/5.0 (compatible; FreeGameDevAssets-thumbs; +https://github.com/TMHSDigital/Free-Game-Dev-Assets)`), `imageUrlFromHtml` (or the `preview` URL directly, which sets `pinned: true`); download (cap 15 MB, else fail); hash; convert to a temp file with + +``` +ffmpeg -y -loglevel error -i -frames:v 1 -vf "scale='min(640,iw)':-2" -c:v libwebp -quality 78 .webp +``` + +(ImageMagick: `magick [0] -resize 640x> -quality 78 .webp`); `webpSize` on the result; write `site/public/thumbs/.webp`. After the loop: remove records and files for entries that are now ineligible or gone; `sharedImageIds` over the manifest, delete those files and records, and list them in the report under "Dropped as shared images". Write the manifest sorted by id. Print a summary (`fetched N, kept N, skipped N, failed N, dropped N`), write the markdown report when `--report`, and append `changed=` to `$GITHUB_OUTPUT` when set. Exit 0 unless the script itself throws. `--dry-run` does everything but write. + +- [ ] **Step 6: Smoke-test the script on two entries** + +Run: `node site/thumbs.mjs --only kenney-car-kit,kaykit-block-bits --report "C:\Users\16mat\AppData\Local\Temp\claude\E--Free-Game-Dev-Assets\926d9f61-c4d8-441c-ab73-6503b49d0158\scratchpad\thumbs-smoke.md"` +Expected: two files under `site/public/thumbs/`, each under 60 KB, manifest with two records with width 640, `node site/validate.mjs` passes. + +- [ ] **Step 7: Run the first full batch** + +Run: `node site/thumbs.mjs --report "C:\Users\16mat\AppData\Local\Temp\claude\E--Free-Game-Dev-Assets\926d9f61-c4d8-441c-ab73-6503b49d0158\scratchpad\thumbs-first-batch.md"` (allow 10 minutes; run in the background and wait for the notification). +Expected: roughly 150 to 250 files, total under 8 MB (`du -sh site/public/thumbs`), validate passes. Note the failures list from the report in the commit body. + +- [ ] **Step 8: Add `"thumbs": "node site/thumbs.mjs"` to `package.json`, a commented `# preview: none` line to `catalog/TEMPLATE.md`, and a CONTRIBUTING section "Preview images"** stating: previews are the publisher's own `og:image`, fetched only for active entries whose licence is in the allowlist, resized and committed; a publisher can ask for removal by issue, which is done by setting `preview: none`; a maintainer may set `preview: ` to a specific image they have checked is covered by the entry's licence. + +- [ ] **Step 9: Run the full check and commit** + +Run: `npm run check` +Expected: all green. + +```bash +git add site/thumbs.mjs site/thumbs.json site/public/thumbs site/validate.mjs site/checks.mjs site/checks.test.mjs package.json CONTRIBUTING.md catalog/TEMPLATE.md +git commit -m "site: licence-gated thumbnail pipeline and first batch" +``` + +--- + +### Task 3: Self-hosted fonts and one shared head + +**Files:** +- Create: `site/public/fonts/public-sans-400.woff2`, `-400-italic`, `-500`, `-600`, `-700`, `site/public/fonts/ibm-plex-mono-400.woff2`, `-500`, `site/public/fonts/OFL.txt`, `site/public/fonts/README.md` +- Modify: `site/public/styles.css` (top: `@font-face` rules), `site/lib/shared.mjs:196` (`baseHeadHtml` gains `root` and emits the stylesheet, icon and font preloads), `site/public/index.html:12-19`, `site/lib/entry-page.mjs:115-119`, `site/lib/stack-page.mjs:121-125`, `site/lib/freshness.mjs:110-113`, `site/build.mjs:324-351` (`notFoundHtml`), `site/lib/page-checks.mjs` +- Test: `site/lib/lib.test.mjs` + +**Interfaces:** +- Produces: `baseHeadHtml({ site, title, description, url, type, hasCard, indent, root })` where `root` is `"./"`, `"../"` or `"../../"` and the output now also contains ``, ``, the same for `public-sans-600`, and ``. Callers remove their own icon, font and stylesheet lines. +- `checkPage` adds the error `${file}: references Google Fonts` when the html contains `fonts.googleapis.com` or `fonts.gstatic.com`. + +- [ ] **Step 1: Write the failing tests** in `site/lib/lib.test.mjs`: + +```js +import { checkPage } from "./page-checks.mjs"; +has("head: preloads body font", baseHeadHtml({ site: { siteUrl: "https://s/", title: "T", tagline: "x" }, title: "T", description: "d", url: "https://s/", root: "../../" }), 'x

x

', { file: "p.html", kindOf: () => "file" }).join(), "p.html: references Google Fonts"); +``` + +- [ ] **Step 2: Run the tests to see them fail** + +Run: `node site/lib/lib.test.mjs` +Expected: the three new assertions fail. + +- [ ] **Step 3: Download the fonts.** Write a throwaway script `C:\Users\16mat\AppData\Local\Temp\claude\E--Free-Game-Dev-Assets\926d9f61-c4d8-441c-ab73-6503b49d0158\scratchpad\fetch-fonts.mjs` (run with `node`, not pasted into Bash) that requests `https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Public+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap` with a Chrome User-Agent, parses each `@font-face` block whose `unicode-range` is the latin block (starts `U+0000-00FF`), downloads its `url(...)`, and saves to the names above. Write `site/public/fonts/README.md` (source: Google Fonts CSS API, families and weights, date, licence OFL 1.1) and `OFL.txt` (the SIL Open Font License 1.1 text from `https://openfontlicense.org/open-font-license-official-text/`, fetched by the same script). +Expected: seven `.woff2` files, each 10 to 40 KB. + +- [ ] **Step 4: Add `@font-face` rules** at the top of `site/public/styles.css`, one per file: `font-family: "Public Sans"` / `"IBM Plex Mono"`, matching `font-weight`/`font-style`, `font-display: swap`, `src: url("fonts/.woff2") format("woff2")`, `unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD`. Stylesheet URLs resolve against `styles.css`, so `fonts/` works from every page depth. + +- [ ] **Step 5: Implement the `root` parameter in `baseHeadHtml`** and remove the icon, preconnect, Google Fonts and stylesheet lines from `index.html`, `entry-page.mjs`, `stack-page.mjs`, `freshness.mjs` and `notFoundHtml` (that one builds its own head from `base`; give it the same preload and stylesheet lines with absolute `${base}/` paths). The homepage passes `root: "./"` through `headMetaHtml`. Add the Google Fonts check to `checkPage`. + +- [ ] **Step 6: Build and check** + +Run: `npm run check && grep -rl "fonts.googleapis" site/dist | head` +Expected: check green; grep prints nothing. + +- [ ] **Step 7: Commit** + +```bash +git add site/public/fonts site/public/styles.css site/lib/shared.mjs site/public/index.html site/lib/entry-page.mjs site/lib/stack-page.mjs site/lib/freshness.mjs site/lib/page-checks.mjs site/build.mjs site/lib/lib.test.mjs +git commit -m "site: self-host Public Sans and IBM Plex Mono, one shared head + +Closes #87" +``` + +--- + +### Task 4: Shared entry templates with thumbnails, cards and list views + +**Files:** +- Create: `site/lib/cards.mjs` (browser-safe; imports only `./shared.mjs`) +- Modify: `site/config.json` (each category gains `"view": "cards" | "list"`), `site/build.mjs:168-240` (`entryRowHtml`, `groupedRowsHtml` replaced by calls into cards.mjs; `loadEntries` attaches `thumb`), `site/build.mjs` main (copy `cards.mjs` and `shared.mjs` into dist with their `.mjs` names), `site/public/app.js:54-110, 222-266, 381-389` (delete the duplicated helpers and `cardHtml`/`headingHtml`; import from `./cards.mjs`), `site/public/styles.css` (`.entry-list` becomes a grid; new `.entry-thumb`, `.entry-tile`, `[data-view]` rules) +- Test: `site/lib/lib.test.mjs` + +**Interfaces:** +- Consumes: `thumbRelPath`, `previewCredit` from Task 1; `thumbs.json` from Task 2. +- Produces (exports of `site/lib/cards.mjs`): + - `CARD_LIMIT = 6`, `LIST_LIMIT = 8`. + - `thumbHtml(entry, root) -> string`: with `entry.thumb` → ``; otherwise `tileHtml(entry)`. + - `tileHtml(entry) -> string`: ``; the motif is CSS by `data-cat`. + - `entryCardHtml(entry, { root, repo, now, view, lead = "", trail = "" }) -> string`: the one `
` markup with `
` first, then the existing `.entry-body` (heading link `href="entry//"`, flags, summary, meta-line, entry-links), `lead` HTML inserted before the heading and `trail` after the meta-line. Keeps `id`, `data-id`, `data-status`, `style="--edge:..."`, `.entry-edge`. + - `groupHeadingHtml({ cat, label, count, view, root }) -> string`: `

LABEL, COUNT entries

` (the toggle is injected into `.group-tools` by app.js, Task 5). + - `groupMoreHtml({ cat, label, count, root }) -> string`: `Show all COUNT in LABEL`. + - `groupedEntriesHtml(entries, categories, { root, repo, now, limit = true }) -> string`: per category in config order: heading, then the first `CARD_LIMIT`/`LIST_LIMIT` entries (by the category's `view`) when `limit`, else all, then `groupMoreHtml` when `limit` and the category has more than the limit. + - Re-exports `escapeHtml` (alias of `esc`), `commercialLabel`, `verifiedAge`, `edgeVar`, `PERSPECTIVE_LABELS`, `MAINTENANCE_NOTES`, `ENGINE_LOCK_NOTES`, `ENGINE_LOCK_LABELS` so `app.js` drops its copies. + - `data.js` entries gain `thumb: { path, width, height } | null` (build attaches it from the manifest in `loadEntries`). + +- [ ] **Step 1: Write the failing tests** in `site/lib/lib.test.mjs`: + +```js +import { entryCardHtml, groupHeadingHtml, groupMoreHtml, groupedEntriesHtml, thumbHtml, tileHtml } from "./cards.mjs"; +const base = { id: "x", name: "X Pack", url: "https://x.itch.io/p", category: "3d", license: "CC0", commercial: true, status: "active", formats: ["GLB"], verified: "2026-10-01", path: "catalog/3d/x.md", summary: "S." }; +has("cards: img with dimensions and lazy", thumbHtml({ ...base, thumb: { path: "thumbs/x.webp", width: 640, height: 400 } }, "../../"), ''); +has("cards: tile when no thumb", thumbHtml(base, ""), 'class="entry-tile" data-cat="3d"'); +has("cards: tile carries name", tileHtml({ ...base, name: "A " }), "A <b>"); +has("cards: card view attribute", entryCardHtml(base, { root: "", repo: "https://r", now: Date.parse("2026-10-23"), view: "cards" }), '
Need

", trail: "

Why

" }), '

Need

'); +has("cards: heading count readable", groupHeadingHtml({ cat: "3d", label: "3D", count: 77, view: "cards", root: "" }), ', 77 entries'); +has("cards: show-all link", groupMoreHtml({ cat: "3d", label: "3D", count: 77, root: "" }), 'Show all 77 in 3D'); +{ + const many = Array.from({ length: 9 }, (_, i) => ({ ...base, id: `e${i}`, name: `E${i}` })); + const html = groupedEntriesHtml(many, { "3d": { label: "3D", view: "cards" } }, { root: "", repo: "https://r", now: 0 }); + eq("cards: limit six cards", (html.match(/
h3.sr-only { grid-column: 1 / -1; }`; `.entry-card[data-view="cards"]` stacks `.entry-thumb` (aspect-ratio 16 / 10, `object-fit: cover`, background `var(--line)`) above `.entry-body`; `.entry-card[data-view="list"]` is `display: grid; grid-template-columns: 56px 1fr; gap: 12px` with the thumb square (`aspect-ratio: 1`, border-radius 4px) and the summary paragraph hidden on narrow screens only; `.entry-tile` uses `repeating-linear-gradient(135deg, var(--surface) 0 6px, var(--bg) 6px 12px)`, name in 600 weight, note in `var(--mono)` 11px `var(--muted)`; `[data-cat="audio"]` tile uses a 90deg stripe, `[data-cat="fonts"]` prefixes the name with "Aa " via `::before`, `[data-cat="tools"]` uses a 0deg 8px rule pattern. Dark mode needs no new rules because only tokens are used. Keep the existing `.entry-card` hover, focus and `:target` rules. + +- [ ] **Step 7: Build, test, and look** + +Run: `npm run check` +Expected: green. Then open `site/dist/index.html` via `npx --yes serve site/dist` in the background and take one desktop screenshot of the 3D section with Playwright (save under `E:\Free-Game-Dev-Assets\.playwright-mcp\t4-cards.jpeg`); confirm images render and tiles show names. + +- [ ] **Step 8: Commit** + +```bash +git add site/lib/cards.mjs site/lib/shared.mjs site/build.mjs site/public/app.js site/public/styles.css site/config.json site/lib/lib.test.mjs +git commit -m "site: one entry template for build and app.js, thumbnails, cards and list views" +``` + +--- + +### Task 5: Homepage: hero, filmstrip, per-category cut-off, view toggle, mobile header + +**Files:** +- Modify: `site/public/index.html:36-49` (hero), `:195` (grid), `:230-232` (to-top), `site/build.mjs` main (substitutions `HERO_COUNT`, `FILMSTRIP`, hero copy), `site/lib/freshness.mjs:36-48` (`freshnessLineHtml` shortened), `site/public/app.js` (cut-off, `.group-more` click, view toggle, list form while searching), `site/public/styles.css` (hero, filmstrip, toggle, topbar at ≤640px, to-top, side-tab rule at line ~700) +- Test: `site/lib/lib.test.mjs` + +**Interfaces:** +- Consumes: `filmstripPick` (Task 1), `groupedEntriesHtml`, `CARD_LIMIT`, `LIST_LIMIT` (Task 4). +- Produces: `filmstripHtml(entries, { root }) -> string` exported from `site/lib/cards.mjs`: `
- -
`; -} - -/** - * The catalog grouped under category headings. 300 rows in one flat list is - * not navigable; headings give the page structure a reader can scan and an - * anchor they can link to. app.js reproduces this exact shape on hydrate. - * - * Grouping is only correct under the default sort. Sorting by verified date - * or license across category buckets would be meaningless, so app.js falls - * back to a flat list in those cases; the prerender is always the default. - */ -function groupedRowsHtml(entries, categories, repo, now) { - const order = Object.keys(categories); - const seen = new Set(entries.map((e) => e.category)); - const out = []; - for (const cat of order) { - if (!seen.has(cat)) continue; - const group = entries.filter((e) => e.category === cat); - if (!group.length) continue; - const label = categories[cat]?.label || cat; - out.push( - `

` + - `${esc(label)}` + - `${group.length}` + - `

` - ); - out.push(...group.map((e) => entryRowHtml(e, repo, now))); - } - return out.join("\n"); -} - /** * Without JavaScript the chips cannot filter, so the prerender emits them as * jump links to the group headings instead. app.js replaces them with real @@ -278,7 +199,7 @@ function guideRowsHtml(guides, repo) { function headMetaHtml(site, stats, generatedAt, hasCard) { return [ - baseHeadHtml({ site, title: site.title, description: site.tagline, url: site.siteUrl, hasCard }), + baseHeadHtml({ site, title: site.title, description: site.tagline, url: site.siteUrl, hasCard, root: "./" }), ``, ``, ``, @@ -330,8 +251,7 @@ function notFoundHtml(site) { Not found - ${esc(site.title)} - - + ${headAssetsHtml(`${base}/`).join("\n ")}
@@ -533,6 +453,21 @@ function writeEntryPages({ entries, bodies, config, stats, stamp, hasCard, now, return { errors, written }; } +/** Writes dist/category//index.html for every configured category with a visible entry. */ +function writeCategoryPages({ entries, config, stats, stamp, hasCard, now }) { + const written = []; + for (const [cat, meta] of Object.entries(config.categories)) { + const group = entries.filter((e) => e.category === cat); + if (!group.length) continue; + writeOut( + path.join("category", cat, "index.html"), + categoryPageHtml({ cat, meta, entries: group, site: config.site, stamp, total: stats.total, hasCard, now }) + ); + written.push(`category/${cat}/index.html`); + } + return { written }; +} + /** Runs the page checks over dist-relative files. */ function checkPages(files) { // Link targets resolve against what this build produced, not what is on @@ -630,6 +565,10 @@ function main() { copyOut(path.join(__dirname, "lib", "owes.mjs"), "owes.js"); // The catalog search and the 404 page's guesses, tested in lib.test.mjs. copyOut(path.join(__dirname, "lib", "search.mjs"), "search.js"); + // The entry template and its helpers, served as modules to app.js and the + // category pages. Neither may import from node:. + copyOut(path.join(__dirname, "lib", "cards.mjs"), "cards.mjs"); + copyOut(path.join(__dirname, "lib", "shared.mjs"), "shared.mjs"); const hasCard = fs.existsSync(OG_CARD_SRC); if (hasCard) copyOut(OG_CARD_SRC, OG_CARD_NAME); // data.json is the public machine-readable catalog (see site/README.md); @@ -646,13 +585,20 @@ function main() { const stamp = payload.generatedAt.slice(0, 10); const visible = entries.filter((e) => e.status !== "deprecated"); const fresh = freshnessStats(entries, now); + // The hero filmstrip draws from the categories that default to cards. + const cardCategories = Object.entries(config.categories) + .filter(([, meta]) => meta.view === "cards") + .map(([cat]) => cat); const substitutions = { FRESHNESS_LINE: freshnessLineHtml(fresh, stamp, "freshness/"), NEW_LINE: newEntriesLineHtml(visible, stamp), HEAD_META: headMetaHtml(config.site, stats, payload.generatedAt, hasCard), STACK_ROWS: stackRowsHtml(stacks), STARTER_ROWS: starterRowsHtml(featured), - ENTRY_ROWS: groupedRowsHtml(visible, config.categories, repoUrl, now), + // Each category shows its first few entries; the category pages hold the rest. + ENTRY_ROWS: groupedEntriesHtml(visible, config.categories, { root: "", repo: repoUrl, now }), + HERO_COUNT: String(stats.total), + FILMSTRIP: filmstripHtml(filmstripPick(visible, { count: 9, cardCategories }), { root: "" }), 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`, @@ -672,18 +618,22 @@ function main() { } writeOut("index.html", html); - writeOut("sitemap.xml", sitemapXml({ site: config.site, entries: visible, stacks, stamp, modified: lastCommitDates() })); + const categoriesInSitemap = Object.keys(config.categories) + .filter((cat) => visible.some((e) => e.category === cat)) + .map((id) => ({ id, lastmod: stamp })); + writeOut("sitemap.xml", sitemapXml({ site: config.site, entries: visible, stacks, stamp, modified: lastCommitDates(), categories: categoriesInSitemap })); writeOut("robots.txt", robotsTxt(config.site)); writeOut("404.html", notFoundHtml(config.site)); writeOut(path.join("freshness", "index.html"), freshnessPageHtml({ stats: fresh, site: config.site, stamp, total: stats.total, hasCard })); const pages = writeEntryPages({ entries, bodies, config, stats, stamp, hasCard, now, usedIn }); const stackPages = writeStackPages({ stacks, entries, config, stats, stamp, hasCard, now }); + const categoryPages = writeCategoryPages({ entries: visible, config, stats, stamp, hasCard, now }); const pageErrors = [ ...stackErrors, ...pages.errors, ...stackPages.errors, - ...checkPages(["index.html", "freshness/index.html", ...pages.written, ...stackPages.written]), + ...checkPages(["index.html", "freshness/index.html", ...pages.written, ...stackPages.written, ...categoryPages.written]), ]; if (pageErrors.length) { console.error(`Page build failed (${pageErrors.length}):`); @@ -715,7 +665,7 @@ function main() { `Built ${entries.length} entries → site/dist (${stats.active} active, ${stats.commercialOk} commercial-ok, ${stats.commercialVaries} per-file)` ); 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` + `Prerendered ${visible.length} entry rows into index.html; wrote ${pages.written.length} entry pages, ${stackPages.written.length} stack pages, ${categoryPages.written.length} category pages, sitemap.xml, robots.txt, 404.html, freshness/index.html` ); console.log(`${writes} of ${produced.size} output files changed${clean ? " (clean build)" : ""}; ${((Date.now() - started) / 1000).toFixed(1)}s`); } diff --git a/site/checks.mjs b/site/checks.mjs index 255b630..622e112 100644 --- a/site/checks.mjs +++ b/site/checks.mjs @@ -9,6 +9,7 @@ import path from "node:path"; import { evidenceSection } from "./lib/frontmatter.mjs"; import { isRealDate } from "./lib/shared.mjs"; +import { previewEligibility } from "./lib/thumbs.mjs"; import { parseStack, pickPath, StackError } from "./lib/stacks.mjs"; export { evidenceSection }; @@ -778,3 +779,50 @@ export function checkCountTables(measured, docs) { } return errors; } + +/** + * `preview` is optional: "none" opts an entry out of a thumbnail, an https + * URL names the image to use instead of the page's og:image. + */ +export function checkPreviewField(rel, meta) { + if (meta.preview === undefined || meta.preview === null || meta.preview === "") return []; + const v = String(meta.preview); + if (v === "none" || /^https:\/\/\S+$/.test(v)) return []; + return [`${rel} preview must be "none" or an https:// image URL`]; +} + +/** + * The committed thumbnails, their manifest and the licence gate must agree: + * a file needs a record, a record needs a file and a catalog entry, and the + * entry must still be allowed to show a preview. A licence that changed to + * custom after the image was fetched fails here until the script removes it; + * `rec` is otherwise not compared with the entry. + */ +export function checkThumbManifest(manifest, entriesById, files) { + const errors = []; + const thumbs = (manifest && manifest.thumbs) || {}; + const fileIds = new Set(); + for (const name of files) { + const m = name.match(/^([a-z0-9]+(?:-[a-z0-9]+)*)\.webp$/); + if (!m) { + errors.push(`site/public/thumbs/${name} is not a .webp thumbnail`); + continue; + } + fileIds.add(m[1]); + if (!thumbs[m[1]]) errors.push(`site/public/thumbs/${name} has no record in site/thumbs.json`); + } + for (const [id, rec] of Object.entries(thumbs)) { + if (!fileIds.has(id)) errors.push(`site/thumbs.json has "${id}" but site/public/thumbs/${id}.webp is missing`); + const entry = entriesById.get(id); + if (!entry) { + errors.push(`site/thumbs.json has "${id}" but no catalog entry has that id`); + continue; + } + // The gate is what matters. A licence that moved between two allowed + // values (CC-BY to CC-BY-4.0) is not an error: the credit line reads the + // entry's live licence, and the next script run refreshes the record. + const gate = previewEligibility(entry); + if (!gate.ok) errors.push(`thumbnail for "${id}" must be removed: ${gate.reason} (run node site/thumbs.mjs)`); + } + return errors; +} diff --git a/site/checks.test.mjs b/site/checks.test.mjs index 77a8461..8062a7c 100644 --- a/site/checks.test.mjs +++ b/site/checks.test.mjs @@ -773,10 +773,30 @@ rejects("V25 rejects a body with no heading", checkTitleMatchesName("bad.md", { accepts("V25 accepts a matching heading", checkTitleMatchesName("ok.md", { name: "KayKit (Kay Lousberg)" }, "\n# KayKit (Kay Lousberg)\n\n## Notes")); accepts("V25 ignores a missing name", checkTitleMatchesName("ok.md", {}, "# Anything")); +/* Thumbnails: the manifest, the files and the gate must agree ------------- */ +import { checkPreviewField, checkThumbManifest } from "./checks.mjs"; +{ + const okEntry = { id: "a", license: "CC0", status: "active", url: "https://a.itch.io/x" }; + const manifest = { thumbs: { a: { fetched: "2026-10-01", license: "CC0", hash: "h", width: 640, height: 400, source: "https://a.itch.io/x", image: "https://i/a.png" } } }; + accepts("thumbs manifest: file and record agree", checkThumbManifest(manifest, new Map([["a", okEntry]]), ["a.webp"])); + accepts("thumbs manifest: nothing at all", checkThumbManifest(null, new Map([["a", okEntry]]), [])); + rejects("thumbs manifest: file without record", checkThumbManifest({ thumbs: {} }, new Map([["a", okEntry]]), ["a.webp"]), "site/public/thumbs/a.webp has no record in site/thumbs.json"); + rejects("thumbs manifest: record without file", checkThumbManifest(manifest, new Map([["a", okEntry]]), []), 'site/thumbs.json has "a" but site/public/thumbs/a.webp is missing'); + rejects("thumbs manifest: record for unknown entry", checkThumbManifest(manifest, new Map(), ["a.webp"]), 'site/thumbs.json has "a" but no catalog entry has that id'); + rejects("thumbs manifest: rejects a thumbnail whose entry is no longer eligible", checkThumbManifest(manifest, new Map([["a", { ...okEntry, license: "custom" }]]), ["a.webp"]), 'thumbnail for "a" must be removed: licence custom is not in the preview allowlist (run node site/thumbs.mjs)'); + accepts("thumbs manifest: licence drift between two allowed licences is not an error", checkThumbManifest(manifest, new Map([["a", { ...okEntry, license: "MIT" }]]), ["a.webp"])); + rejects("thumbs manifest: stray file", checkThumbManifest(manifest, new Map([["a", okEntry]]), ["a.webp", "notes.txt"]), "site/public/thumbs/notes.txt is not a .webp thumbnail"); +} +accepts("preview field: absent", checkPreviewField("ok.md", {})); +accepts("preview field: none", checkPreviewField("ok.md", { preview: "none" })); +accepts("preview field: https url", checkPreviewField("ok.md", { preview: "https://a/b.png" })); +rejects("preview field: http refused", checkPreviewField("bad.md", { preview: "http://a/b.png" }), 'bad.md preview must be "none" or an https:// image URL'); +rejects("preview field: word refused", checkPreviewField("bad.md", { preview: "yes" }), 'bad.md preview must be "none" or an https:// image URL'); + /* ----------------------------------------------------------------------- */ 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 25 checks`); +console.log(`checks.test ok: ${passed} assertions across 27 checks`); diff --git a/site/config.json b/site/config.json index e7edbec..36960da 100644 --- a/site/config.json +++ b/site/config.json @@ -8,16 +8,16 @@ }, "expectedEntryCount": 416, "categories": { - "3d": { "label": "3D", "blurb": "Models, scans, PBR textures" }, - "environment": { "label": "Environment", "blurb": "HDRI, terrain, geodata" }, - "2d": { "label": "2D", "blurb": "Sprites, UI, icons, palettes" }, - "characters": { "label": "Characters", "blurb": "Generators & modular humanoids" }, - "audio": { "label": "Audio", "blurb": "SFX, music, foley, IRs" }, - "animation": { "label": "Animation", "blurb": "MoCap & character clips" }, - "video": { "label": "Video", "blurb": "Stock footage, archival clips" }, - "shaders-vfx": { "label": "Shaders & VFX", "blurb": "Shaders, particles, FX" }, - "fonts": { "label": "Fonts", "blurb": "OFL / commercial-ok type" }, - "tools": { "label": "Tools", "blurb": "Editors, pipeline, TTS, add-ons" } + "3d": { "label": "3D", "blurb": "Models, scans, PBR textures", "view": "cards" }, + "environment": { "label": "Environment", "blurb": "HDRI, terrain, geodata", "view": "cards" }, + "2d": { "label": "2D", "blurb": "Sprites, UI, icons, palettes", "view": "cards" }, + "characters": { "label": "Characters", "blurb": "Generators & modular humanoids", "view": "cards" }, + "audio": { "label": "Audio", "blurb": "SFX, music, foley, IRs", "view": "list" }, + "animation": { "label": "Animation", "blurb": "MoCap & character clips", "view": "cards" }, + "video": { "label": "Video", "blurb": "Stock footage, archival clips", "view": "list" }, + "shaders-vfx": { "label": "Shaders & VFX", "blurb": "Shaders, particles, FX", "view": "cards" }, + "fonts": { "label": "Fonts", "blurb": "OFL / commercial-ok type", "view": "list" }, + "tools": { "label": "Tools", "blurb": "Editors, pipeline, TTS, add-ons", "view": "list" } }, "featured": [ { "id": "kenney", "need": "Modular low-poly 3D / UI" }, diff --git a/site/lib/cards.mjs b/site/lib/cards.mjs new file mode 100644 index 0000000..0a89bf9 --- /dev/null +++ b/site/lib/cards.mjs @@ -0,0 +1,146 @@ +/** + * The one entry template: the homepage, the category and stack pages and + * app.js all render an entry through entryCardHtml, so the prerender and the + * hydrated page can never drift apart. Cards and List are the same markup + * under a `data-view` attribute; CSS does the rest. + * + * Browser-safe: this file and shared.mjs are copied into dist as modules, so + * neither may import anything from node:. + */ +import { commercialLabel, edgeVar, ENGINE_LOCK_LABELS, ENGINE_LOCK_NOTES, esc, MAINTENANCE_NOTES, PERSPECTIVE_LABELS, verifiedAge } from "./shared.mjs"; + +export { commercialLabel, edgeVar, ENGINE_LOCK_LABELS, ENGINE_LOCK_NOTES, MAINTENANCE_NOTES, PERSPECTIVE_LABELS, verifiedAge }; +export const escapeHtml = esc; + +/** How many entries a category shows on the homepage before "Show all". */ +export const CARD_LIMIT = 6; +export const LIST_LIMIT = 8; + +/** The typographic tile for an entry with no permitted image: name and a short note on an editor-style checker. */ +export function tileHtml(entry, label = entry.category) { + return ( + `` + ); +} + +/** The entry's thumbnail image, or its tile. `root` is the page's path to the site root. */ +export function thumbHtml(entry, root = "", label) { + const t = entry.thumb; + if (t && t.path) { + return ``; + } + return tileHtml(entry, label); +} + +function maintenanceFlagHtml(entry) { + if (!entry.maintenance) return ""; + return ` ${esc(entry.maintenance)}`; +} + +function engineLockFlagHtml(entry) { + if (!entry.engine_lock) return ""; + return ` ${esc(ENGINE_LOCK_LABELS[entry.engine_lock] || entry.engine_lock)}`; +} + +/** + * One entry. The card is an
with a real heading link (a permalink + * that keeps heading navigation working), never a + + +
`; +} + +/** + * A category heading. The count reads as ", 77 entries" to assistive tech + * instead of running into the label. `level` keeps the outline honest: h3 + * under the homepage's h2, h2 on a category page whose h1 is the category. + */ +export function groupHeadingHtml({ cat, label, count, view = "list", level = 3 }) { + return ( + `` + + `${esc(label)}` + + `, ${Number(count) || 0} entries` + + `` + + `` + ); +} + +/** The "Show all" link under a category's homepage preview; it leads to the static category page. */ +export function groupMoreHtml({ cat, label, count, root = "" }) { + return `Show all ${Number(count) || 0} in ${esc(label)}`; +} + +/** + * Entries grouped under category headings in config order. With `limit` + * (the homepage) each category shows its first CARD_LIMIT or LIST_LIMIT + * entries and a "Show all" link; without it (category pages) everything. + */ +export function groupedEntriesHtml(entries, categories, { root = "", repo, now, limit = true, headingLevel = 3, titleLevel = 4 } = {}) { + const out = []; + for (const [cat, meta] of Object.entries(categories)) { + const group = entries.filter((e) => e.category === cat); + if (!group.length) continue; + const view = meta.view === "cards" ? "cards" : "list"; + const label = meta.label || cat; + const max = view === "cards" ? CARD_LIMIT : LIST_LIMIT; + const shown = limit ? group.slice(0, max) : group; + out.push(groupHeadingHtml({ cat, label, count: group.length, view, level: headingLevel })); + out.push(...shown.map((e) => entryCardHtml(e, { root, repo, now, view, label, titleLevel }))); + if (limit && group.length > max) out.push(groupMoreHtml({ cat, label, count: group.length, root })); + } + return out.join("\n"); +} + +/** + * The hero filmstrip: a row of square thumbnails linking to their entries, + * so the first screen already shows what the catalog holds. The first three + * load eagerly (they are above the fold); the rest lazily. + */ +export function filmstripHtml(entries, { root = "" } = {}) { + if (!entries.length) return ""; + const items = entries + .map((e, i) => { + const t = e.thumb; + return ( + `
  • ` + + `${esc(e.name)}` + + `
  • ` + ); + }) + .join(""); + return `
      ${items}
    `; +} diff --git a/site/lib/category-page.mjs b/site/lib/category-page.mjs new file mode 100644 index 0000000..ad5a2f3 --- /dev/null +++ b/site/lib/category-page.mjs @@ -0,0 +1,66 @@ +/** One category's page: every entry in it, both views, no JavaScript needed to see everything. Pure. */ +import { groupedEntriesHtml } from "./cards.mjs"; +import { baseHeadHtml, clipText, esc, PREVIEW_CREDIT_LINE, SEO_DESCRIPTION_MAX, SEO_TITLE_MAX, sponsorButtonHtml } from "./shared.mjs"; + +export function categoryPageUrl(site, cat) { + return `${String(site.siteUrl).replace(/\/+$/, "")}/category/${cat}/`; +} + +export function categoryPageHtml({ cat, meta, entries, site, stamp, total, hasCard, now }) { + const label = meta.label || cat; + const blurb = meta.blurb || ""; + const url = categoryPageUrl(site, cat); + const title = `${label} assets | ${site.title}`; + const n = entries.length; + const sources = `${n} ${n === 1 ? "source" : "sources"}`; + const description = clipText(`${sources} of free ${label} assets: ${blurb}. Every licence read at the source.`, SEO_DESCRIPTION_MAX); + const repo = site.repo; + // The category is the h1, so its group heading is an h2 and the card titles h3. + const grid = groupedEntriesHtml(entries, { [cat]: meta }, { root: "../../", repo, now, limit: false, headingLevel: 2, titleLevel: 3 }); + return ` + + + + + ${esc(clipText(title, SEO_TITLE_MAX))} + + ${baseHeadHtml({ site, title, description, url, hasCard, root: "../../" })} + + + +
    + Free Game Dev Assets + +
    +
    + +

    ${esc(label)}

    +

    ${esc(sources)} · ${esc(blurb)}

    + +
    +${grid} +
    +
    +
    +

    Catalog metadata is CC0. Linked assets keep their own licences. This is the catalog's reading of each licence, not legal advice: re-check the live source before shipping.

    + +

    Licence freshness

    + + +
    + + + + +`; +} diff --git a/site/lib/entry-page.mjs b/site/lib/entry-page.mjs index 2400133..6923692 100644 --- a/site/lib/entry-page.mjs +++ b/site/lib/entry-page.mjs @@ -12,7 +12,10 @@ import { STATUS_NOTES, verifiedAge, sponsorButtonHtml, + PREVIEW_CREDIT_LINE, } from "./shared.mjs"; +import { thumbHtml } from "./cards.mjs"; +import { previewCredit } from "./thumbs.mjs"; export function jsonLd(entry, site) { const work = { "@type": "CreativeWork", name: entry.name, url: entry.url }; @@ -100,6 +103,10 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml, ${next ? `` : ""} ` : ""; + // The publisher's preview, when the licence gate allowed one, with its credit. + const strip = entry.thumb + ? `
    ${thumbHtml(entry, "../../")}
    ${esc(previewCredit(entry))}
    ` + : ""; const usedIn = stacks.length ? `

    Used in: ${stacks.map((s) => `${esc(s.title)}`).join(", ")}

    ` : ""; @@ -111,12 +118,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml, ${esc(seoTitle(entry.name, entry.license))} ${deprecated ? '' : ""} - ${baseHeadHtml({ site, title, description: desc, url, type: "article", hasCard })} - - - - - + ${baseHeadHtml({ site, title, description: desc, url, type: "article", hasCard, root: "../../" })} @@ -135,11 +137,12 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,
    ${banner}

    ${esc(entry.name)}

    + ${strip}
    ${leadHtml}

    At a glance

    @@ -148,11 +151,11 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml, ${c.block}
    -

    - Go to source - View the file on GitHub - Report a problem with this entry - +

    Go to source

    + ${usedIn}
    ${restHtml}
    @@ -160,6 +163,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,