diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 15fbb18..1b9f1ab 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,4 +13,4 @@ - [ ] 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`) +- [ ] `npm test && npm run validate` passes locally (CI runs the full `npm run check`, including the build) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b5c33cc..365c27d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,12 +11,18 @@ Please follow the [Code of Conduct](CODE_OF_CONDUCT.md). - Free engine add-ons / plugins that materially help asset or production pipelines (e.g. Godot Asset Library tools) - Engines only when they ship substantial free asset libraries +## Accepted with caveats + +- A source whose licence is unclear: add it as `status: needs-review` with the open question in + Notes, or open an issue instead. +- A source that is free only for non-commercial or personal use: only if it is clearly marked + `commercial: false`. + ## What does not - Paid-only marketplaces with no free content worth listing -- Broken, abandoned, or license-unclear sources (use `status: needs-review` or open an issue) +- Broken or abandoned sources - Redistributed ZIP/GLB/WAV files of third-party work -- Assets that are free only for non-commercial / personal use (unless clearly tagged `commercial: false`) - GTA V / RDR2 extracts, FiveM MLO leaks, Tebex reuploads, and anonymous `fivem-props` dumps (see [`docs/fivem.md`](docs/fivem.md) and [`docs/high-risk.md`](docs/high-risk.md)) ## Adding an entry @@ -26,10 +32,10 @@ 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. +3. Write the body: a one-paragraph summary, `## Notes`, `## Evidence` with a dated line quoting the source, such as `- Live page (2026-09-25): "Free for commercial use"`, and optionally `## Related` linking sibling entries. Every Evidence section needs at least one date, and `verified` may not be newer than the newest one. The template (and so 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. +6. Run `npm test && npm run validate` before you push; that is quick. CI then runs `npm run check` (the tests, `node site/validate.mjs` and `node site/build.mjs`), and all of it must pass. Run `npm run check` yourself if you changed the site or want to preview your entry's page. 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). diff --git a/README.md b/README.md index 8adc4d0..57989d8 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,15 @@ Each entry records the licence as its source states it and the date someone read active entries quote the source's own words. The catalog indexes sources and never rehosts them: check the source before you ship. +Big lists such as [magictools](https://github.com/ellisonleao/magictools) and +[awesome-gamedev](https://github.com/Calinou/awesome-gamedev) tell you something is free. This one +tells you what the licence says, where it says it, and when it was last read, so you don't have to +re-check forty tabs before a release. (Thanks to magictools: many tool entries started from its list.) + +> [!NOTE] +> This is the catalog's reading of each licence, not legal advice. Licences change: read the +> source's own terms before you ship. +
@@ -40,7 +49,8 @@ them: check the source before you ship.
-> **Recent:** 2026-09-30, 42 more sources, mostly pipeline, pixel-art and chiptune tools. +> **Recent:** 2026-10-04, 15 more video, animation and environment sources. +> 2026-09-30, 42 more sources, mostly pipeline, pixel-art and chiptune tools. > 2026-09-25, starter stacks and a licence freshness page. ## Contents @@ -243,7 +253,7 @@ Read these before you mix packs into a commercial build. | [`docs/high-risk.md`](docs/high-risk.md) | Sources to avoid entirely | | [`docs/ai-assets.md`](docs/ai-assets.md) | AI-generated assets | | [`docs/fivem.md`](docs/fivem.md) | GTA-format and FiveM assets | -| [`docs/trust-score.md`](docs/trust-score.md) | How sources are rated | +| [`docs/trust-score.md`](docs/trust-score.md) | Review rubric used when vetting a source (not shown on entries) |
The short version @@ -304,9 +314,11 @@ When a source does not say plainly, the entry says `unknown` and `needs-review` New sources, licence corrections, dead links and clearer notes are all welcome. -1. Start an entry with `npm run new-entry -- `, or copy [`catalog/TEMPLATE.md`](catalog/TEMPLATE.md). +1. Start an entry with `npm run new-entry -- `. It copies the template, sets the + id and date, and starts the entry as `needs-review`. 2. Read the licence at the source and quote it, with the date, in the entry's Evidence section. -3. Run `npm run check`, then open a pull request. +3. Add a row for it to the category's `README.md`, then run `npm run counts` to update every count. +4. Run `npm test && npm run validate` (quick), then open a pull request. CI also runs the full build. [`CONTRIBUTING.md`](CONTRIBUTING.md) has the full checklist and how to verify a licence. Please follow the [Code of Conduct](CODE_OF_CONDUCT.md). @@ -343,7 +355,9 @@ npm run serve # preview at http://localhost:3000 ├── docs/ │ ├── licenses.md fonts.md geodata.md game-vs-video-licensing.md │ ├── provenance.md high-risk.md ai-assets.md fivem.md trust-score.md -│ ├── godot-budget-stack.md research-index.md +│ ├── godot-budget-stack.md +│ ├── research-index.md maintainer notes on local research drafts +│ ├── design/ design specs behind the entry pages, stacks and freshness page │ ├── 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 diff --git a/ROADMAP.md b/ROADMAP.md index f9da03d..0a3c39c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,8 +5,9 @@ missing entry, so freshness and accuracy come before volume. ## Open to contributors -- **Thin categories:** video (8 entries), animation (12) and environment (13). See - [CONTRIBUTING.md](CONTRIBUTING.md) and the "new source" issue form. +- **Thin categories:** video, animation and environment are still the smallest (counts in the + [category table](catalog/README.md)). See [CONTRIBUTING.md](CONTRIBUTING.md) and the "new + source" issue form. - **Optional fields:** `camera_perspective` and `grid_dimensions` on 2D entries ([#41](https://github.com/TMHSDigital/Free-Game-Dev-Assets/issues/41)). Small, well-scoped, a good first PR. @@ -20,10 +21,14 @@ missing entry, so freshness and accuracy come before volume. - Re-verification in smaller, regular batches, so the July 2026 verifications do not all expire together in mid-2027. - A changelog page generated from [docs/review-ledger.md](docs/review-ledger.md). -- Client-side search over the catalog, not only filters. -- An accessibility pass over the site. +- Full-text search over entry bodies (Notes and Evidence), not only names, tags and summaries. ## Done +- Site search across names, tags and summaries, with licence, format and category filters. +- Accessibility fixes: focus handling, contrast, heading outline and tap targets (Lighthouse + accessibility 100). +- Thin categories topped up: 15 video, animation and environment sources (2026-10-04). + - Machine-readable exports: `data.json`, `catalog.csv` and an Atom feed (`feed.xml`) of recently verified entries. diff --git a/catalog/README.md b/catalog/README.md index b4382a1..a61d6c9 100644 --- a/catalog/README.md +++ b/catalog/README.md @@ -15,7 +15,7 @@ One markdown file per source under a category folder. Copy [`TEMPLATE.md`](TEMPL | Animation | 17 | [`animation/`](animation/) | MoCap, character clips | | Video | 13 | [`video/`](video/) | Stock footage, archival clips | -Guides: [`docs/licenses.md`](../docs/licenses.md) · [`docs/provenance.md`](../docs/provenance.md) · [`docs/high-risk.md`](../docs/high-risk.md) · [`docs/fivem.md`](../docs/fivem.md) · [`docs/ai-assets.md`](../docs/ai-assets.md) · [`docs/trust-score.md`](../docs/trust-score.md) · [`docs/fonts.md`](../docs/fonts.md) · [`docs/geodata.md`](../docs/geodata.md) · [`docs/game-vs-video-licensing.md`](../docs/game-vs-video-licensing.md) · [`docs/godot-budget-stack.md`](../docs/godot-budget-stack.md) · [`docs/research-index.md`](../docs/research-index.md) +Guides: [`docs/licenses.md`](../docs/licenses.md) · [`docs/provenance.md`](../docs/provenance.md) · [`docs/high-risk.md`](../docs/high-risk.md) · [`docs/fivem.md`](../docs/fivem.md) · [`docs/ai-assets.md`](../docs/ai-assets.md) · [`docs/trust-score.md`](../docs/trust-score.md) · [`docs/fonts.md`](../docs/fonts.md) · [`docs/geodata.md`](../docs/geodata.md) · [`docs/game-vs-video-licensing.md`](../docs/game-vs-video-licensing.md) · [`docs/godot-budget-stack.md`](../docs/godot-budget-stack.md) **Which category.** File a source under the kind of content it mainly gives you. Software that exists to make one kind of content lives with that content, next to the diff --git a/catalog/TEMPLATE.md b/catalog/TEMPLATE.md index eabb0c1..9a28b43 100644 --- a/catalog/TEMPLATE.md +++ b/catalog/TEMPLATE.md @@ -12,8 +12,8 @@ attribution_required: false formats: [glTF, FBX, PNG] # subcategories and formats spelling: see CONTRIBUTING.md (formats must be in site/format-vocabulary.json) tags: [low-poly, modular] # describe the content; never the licence or the publisher (V19, V21) -verified: 2026-07-19 -status: active +verified: 2026-07-19 # the day you read the licence at the source +status: needs-review # set active once the licence is read and quoted in ## Evidence # Optional (omit if N/A): # publisher: Kenney # rights holder when that publisher has more than one entry; not a generic host # attribution_string: "Credit line" # required when attribution_required is true diff --git a/catalog/tools/README.md b/catalog/tools/README.md index 8d4c81d..0a008ef 100644 --- a/catalog/tools/README.md +++ b/catalog/tools/README.md @@ -223,4 +223,4 @@ Software only. Not a GTA V asset grant. See [`docs/fivem.md`](../../docs/fivem.m | --- | --- | --- | --- | | [sollumz](sollumz.md) | Sollumz | GPL-3.0-or-later | active | -See: [`docs/godot-budget-stack.md`](../../docs/godot-budget-stack.md) · [`docs/ai-assets.md`](../../docs/ai-assets.md) · [`docs/fivem.md`](../../docs/fivem.md) · [`docs/research-index.md`](../../docs/research-index.md). +See: [`docs/godot-budget-stack.md`](../../docs/godot-budget-stack.md) · [`docs/ai-assets.md`](../../docs/ai-assets.md) · [`docs/fivem.md`](../../docs/fivem.md). diff --git a/docs/superpowers/specs/2026-09-24-entry-pages-design.md b/docs/design/2026-09-24-entry-pages-design.md similarity index 100% rename from docs/superpowers/specs/2026-09-24-entry-pages-design.md rename to docs/design/2026-09-24-entry-pages-design.md diff --git a/docs/superpowers/specs/2026-09-24-starter-stacks-design.md b/docs/design/2026-09-24-starter-stacks-design.md similarity index 100% rename from docs/superpowers/specs/2026-09-24-starter-stacks-design.md rename to docs/design/2026-09-24-starter-stacks-design.md diff --git a/docs/superpowers/specs/2026-09-25-licence-freshness-design.md b/docs/design/2026-09-25-licence-freshness-design.md similarity index 100% rename from docs/superpowers/specs/2026-09-25-licence-freshness-design.md rename to docs/design/2026-09-25-licence-freshness-design.md diff --git a/docs/licenses.md b/docs/licenses.md index d82ddb3..a390f3a 100644 --- a/docs/licenses.md +++ b/docs/licenses.md @@ -1,5 +1,7 @@ # License cheat sheet +> This is the catalog's reading of each licence, not legal advice. Licences change: read the source's own terms before you ship. + Quick reference for assets you might list or use. **Always re-check the source page** — licenses change, packs can be dual-licensed, and marketplace “free” tiers often have extra terms. ## Creative Commons & public domain diff --git a/docs/review-ledger.md b/docs/review-ledger.md index 06d8e0d..092c2a4 100644 --- a/docs/review-ledger.md +++ b/docs/review-ledger.md @@ -4,6 +4,19 @@ 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-10-04 (repo review, #59-#75) + +A whole-repo review (code, docs, live site) filed #59-#75. This run fixed the code and doc items. No entry's licence data changed. + +- **Exports:** feed.xml gets a feed-level `` (#59). catalog.csv puts an apostrophe before cells starting `= + - @`, tab or CR (#62). +- **Build robustness:** `LINK_RE` is now shared by markdown.mjs and llms.mjs, so balanced-paren hrefs read the same in both (#60). renderInline strips NUL before using it as the slot marker. The validator rejects C0 control characters (#61). +- **One entry walker:** `site/lib/entry-files.mjs` is used by build, validate, sync-counts and check-links; hidden and `_scratch` folders are skipped everywhere. The validator enforces kebab-case ids (#64). +- **Link check:** the pure parts moved to `site/lib/link-check.mjs`, with tests. Multi-part suffixes (`co.uk`, `github.io`, ...) are handled, and report URLs are inert code spans. V16 rejects localhost, `.local` and IP-literal URLs (#65). +- **Client:** lookup maps have no prototype. The shortlist export normalises the trailing slash (#66). The shortlist button's accessible name starts with its visible text (#63). +- **Docs:** "not legal advice" in the README, licences guide, every page footer and the CREDITS export (#68). README positioning against magictools and awesome-gamedev, with credit (#71). ROADMAP made count-free (#69). Contributor steps aligned; TEMPLATE starts `needs-review` (#70). Specs moved to `docs/design/`. research-index taken out of the user guides, and trust-score relabelled as a rubric (#73). +- **SEO:** homepage WebSite + Dataset JSON-LD (#74). +- **Not changed, deliberately:** homepage weight (#75) needs a decision about pre-rendering every row versus slimming data.js. The Code of Conduct contact (#72) needs a private address from the maintainer. Client-side JS still has no tests (#67, partly done). + ## 2026-09-30 (magictools import) Candidates were taken from the awesome list ellisonleao/magictools: every non-paid item in every section. Its emoji licence legend was not trusted. Each source was re-read at its own licence page, repo or bundled licence file on 2026-09-30. The catalog went from 334 to 376 entries. @@ -195,7 +208,7 @@ in conversation (no spec). ## 2026-09-25 (licence freshness) Sub-project C from the site brainstorm, built from -`docs/superpowers/specs/2026-09-25-licence-freshness-design.md` in five commits +`docs/design/2026-09-25-licence-freshness-design.md` in five commits (`241af04`..`749860a`). No catalog entry changed and no `verified` moved. - **What shipped.** A line under the homepage hero, "Checked within 180 days: 317 of @@ -253,7 +266,7 @@ from the entry pages and starter stacks reviews, each fix with a test that faile ## 2026-09-25 (starter stacks) Sub-project A from the site brainstorm, built from -`docs/superpowers/specs/2026-09-24-starter-stacks-design.md` in seven commits +`docs/design/2026-09-24-starter-stacks-design.md` in seven commits (`10151a9`..`3724fbb`). No catalog entry changed and no `verified` moved. - **What shipped.** Five stacks in `stacks/`, one per task test: 2D pixel platformer, @@ -292,7 +305,7 @@ Sub-project A from the site brainstorm, built from ## 2026-09-24 (entry pages) Sub-project B from the site brainstorm, built from -`docs/superpowers/specs/2026-09-24-entry-pages-design.md` in eleven commits +`docs/design/2026-09-24-entry-pages-design.md` in eleven commits (`fee59c1`..`a5403a4`). No catalog entry changed and no `verified` moved. - **What shipped.** Every entry has a page at `/entry//` with its full body, a facts diff --git a/site/build.mjs b/site/build.mjs index 9c80fb8..57ae380 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -8,9 +8,10 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { commercialLabel, esc, latestAllowedDate, MAINTENANCE_NOTES, PERSPECTIVE_LABELS, sponsorButtonHtml, verifiedAge } from "./lib/shared.mjs"; import { parseFrontmatter, summaryFromBody } from "./lib/frontmatter.mjs"; -import { entryPageHtml } from "./lib/entry-page.mjs"; +import { listEntryFiles } from "./lib/entry-files.mjs"; +import { entryPageHtml, scriptJson } from "./lib/entry-page.mjs"; import { LinkError, makeLinkResolver } from "./lib/links.mjs"; -import { atomFeed, catalogCsv } from "./lib/exports.mjs"; +import { atomFeed, catalogCsv, homeJsonLd } from "./lib/exports.mjs"; import { llmsFullTxt, llmsTxt } from "./lib/llms.mjs"; import { deprecationReason, MarkdownError, renderBlocks, renderInline, splitEntryBody } from "./lib/markdown.mjs"; import { checkPage } from "./lib/page-checks.mjs"; @@ -37,22 +38,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 walkMarkdown(dir, out = []) { - if (!fs.existsSync(dir)) return out; - for (const name of fs.readdirSync(dir)) { - const full = path.join(dir, name); - const stat = fs.statSync(full); - if (stat.isDirectory()) { - walkMarkdown(full, out); - continue; - } - if (!name.endsWith(".md")) continue; - if (name === "README.md" || name === "TEMPLATE.md") continue; - out.push(full); - } - return out; -} - /** The Licence-filter family a license value belongs to (license-vocabulary.json `families`). */ function licenseFamily(vocab, license) { for (const [key, fam] of Object.entries(vocab.families || {})) { @@ -62,7 +47,7 @@ function licenseFamily(vocab, license) { } function loadEntries(vocab) { - const files = walkMarkdown(CATALOG); + const files = listEntryFiles(CATALOG); const entries = []; const errors = []; const bodies = new Map(); @@ -190,7 +175,7 @@ function entryRowHtml(entry, repo, now) {
Open source Entry and evidence - +
`; @@ -300,6 +285,7 @@ function headMetaHtml(site, stats, generatedAt, hasCard) { ``, ``, ``, + ``, ].join("\n "); } @@ -641,8 +627,9 @@ function main() { process.exit(1); } - // After the page gate: every body link has resolved by now, so the llms - // files cannot hit a link error of their own. + // After the page gate: every body link has resolved by now, and the llms + // files read links with the same LINK_RE, so they cannot hit a link error + // of their own. fs.writeFileSync(path.join(DIST, "llms.txt"), llmsTxt({ entries, site: config.site, categories: config.categories, stacks })); fs.writeFileSync( path.join(DIST, "llms-full.txt"), diff --git a/site/check-links.mjs b/site/check-links.mjs index 16706d2..e4d52e3 100644 --- a/site/check-links.mjs +++ b/site/check-links.mjs @@ -23,6 +23,8 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { maintenanceFromRepo } from "./checks.mjs"; import { parseFrontmatter } from "./lib/frontmatter.mjs"; +import { listEntryFiles } from "./lib/entry-files.mjs"; +import { classify, repoOf, reportUrl } from "./lib/link-check.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, ".."); @@ -41,38 +43,21 @@ function arg(name, fallback) { 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, maintenance } = parsed.meta; - out.push({ id: String(id), url: String(url), verified: verified ? String(verified) : null, status: String(status), maintenance: maintenance ? String(maintenance) : null, rel: path.relative(ROOT, full).split(path.sep).join("/") }); - } - } - }; - walk(CATALOG); + for (const full of listEntryFiles(CATALOG)) { + const parsed = parseFrontmatter(fs.readFileSync(full, "utf8")); + if (!parsed?.meta.url) continue; + const { id, url, verified, status, maintenance } = parsed.meta; + out.push({ id: String(id), url: String(url), verified: verified ? String(verified) : null, status: String(status), maintenance: maintenance ? String(maintenance) : null, rel: path.relative(ROOT, full).split(path.sep).join("/") }); + } 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" }; + return classify(entry.url, res.status, res.url); } catch (err) { const cause = err.cause?.code || err.name || "error"; return { kind: "dead", detail: cause === "TimeoutError" ? `no answer in ${TIMEOUT_MS / 1000}s` : cause }; @@ -143,7 +128,7 @@ const cutoff = new Date(Date.now() - staleDays * 86400000).toISOString().slice(0 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 line = (r) => `- [ ] [\`${r.id}\`](${REPO}/blob/main/${r.rel}): ${reportUrl(r.url)}${r.detail ? ` (${r.detail})` : ""}`; const dead = pick("dead"); const moved = pick("moved"); diff --git a/site/checks.mjs b/site/checks.mjs index f491d51..4bc35e0 100644 --- a/site/checks.mjs +++ b/site/checks.mjs @@ -549,6 +549,13 @@ export function checkCategoryReadmeRows(categoryName, readmeText, entries) { * address. A `javascript:` or `data:` value would render as a live link, and * escaping HTML does nothing about a scheme. */ +/** localhost, *.local / *.internal, and IP literals: never a catalog source. */ +function isPrivateHost(hostname) { + const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (h === "localhost" || /\.(localhost|local|internal|lan)$/.test(h)) return true; + return /^\d{1,3}(\.\d{1,3}){3}$/.test(h) || h.includes(":"); +} + export function checkEntryUrl(rel, meta) { const errors = []; if (empty(meta.url)) return errors; // missing fields are reported elsewhere @@ -563,6 +570,9 @@ export function checkEntryUrl(rel, meta) { 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`); + } else if (isPrivateHost(url.hostname)) { + // The weekly link check fetches every url from CI. + errors.push(`${rel} url must be a public site, not ${url.hostname}`); } return errors; } diff --git a/site/checks.test.mjs b/site/checks.test.mjs index be035a5..2495c10 100644 --- a/site/checks.test.mjs +++ b/site/checks.test.mjs @@ -626,6 +626,11 @@ rejects("V16 rejects javascript:", checkEntryUrl("bad.md", { url: "javascript:al 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" })); +rejects("V16 rejects localhost", checkEntryUrl("bad.md", { url: "http://localhost:8080/x" }), "must be a public site"); +rejects("V16 rejects an IPv4 literal", checkEntryUrl("bad.md", { url: "http://169.254.169.254/latest" }), "must be a public site"); +rejects("V16 rejects an IPv6 literal", checkEntryUrl("bad.md", { url: "http://[::1]/" }), "must be a public site"); +rejects("V16 rejects .local", checkEntryUrl("bad.md", { url: "https://nas.local/files" }), "must be a public site"); +accepts("V16 accepts a digit-led host", checkEntryUrl("ok.md", { url: "https://3dtexel.com/" })); accepts("V16 accepts http where a source has no https", checkEntryUrl("ok.md", { url: "http://www.makehumancommunity.org/" })); /* V17: category sets --------------------------------------------------- */ diff --git a/site/config.json b/site/config.json index f80b538..864693d 100644 --- a/site/config.json +++ b/site/config.json @@ -45,7 +45,7 @@ { "id": "provenance", "title": "Provenance", "path": "docs/provenance.md" }, { "id": "high-risk", "title": "High risk", "path": "docs/high-risk.md" }, { "id": "ai-assets", "title": "AI assets", "path": "docs/ai-assets.md" }, - { "id": "trust-score", "title": "Trust score", "path": "docs/trust-score.md" }, + { "id": "trust-score", "title": "Review rubric (trust score)", "path": "docs/trust-score.md" }, { "id": "game-vs-video", "title": "Games vs video licensing", "path": "docs/game-vs-video-licensing.md" }, { "id": "fonts", "title": "Fonts and embedding", "path": "docs/fonts.md" }, { "id": "geodata", "title": "Geodata attribution", "path": "docs/geodata.md" }, diff --git a/site/lib/entry-files.mjs b/site/lib/entry-files.mjs new file mode 100644 index 0000000..40ebb92 --- /dev/null +++ b/site/lib/entry-files.mjs @@ -0,0 +1,29 @@ +/** + * The one list of catalog entry files. build, validate, sync-counts and + * check-links all read it, so a file is either an entry everywhere or nowhere. + */ +import fs from "node:fs"; +import path from "node:path"; + +/** Not entries: the category index and the copy-me template. */ +export const NON_ENTRY_MD = new Set(["README.md", "TEMPLATE.md"]); +/** Never walked: hidden folders (any name starting ".") and local scratch. */ +const SKIP_DIRS = new Set(["_scratch", "node_modules"]); + +/** Absolute paths of every entry file under `dir`, sorted. */ +export function listEntryFiles(dir) { + const out = []; + const walk = (d) => { + if (!fs.existsSync(d)) return; + for (const ent of fs.readdirSync(d, { withFileTypes: true })) { + const full = path.join(d, ent.name); + if (ent.isDirectory()) { + if (!ent.name.startsWith(".") && !SKIP_DIRS.has(ent.name)) walk(full); + } else if (ent.name.endsWith(".md") && !NON_ENTRY_MD.has(ent.name)) { + out.push(full); + } + } + }; + walk(dir); + return out.sort(); +} diff --git a/site/lib/entry-page.mjs b/site/lib/entry-page.mjs index 6e62238..ed46a4d 100644 --- a/site/lib/entry-page.mjs +++ b/site/lib/entry-page.mjs @@ -167,7 +167,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml, ${pager}