From 424b84504fd8aed849241788d8bdf0712dc93a61 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 26 Oct 2026 20:59:12 +0100 Subject: [PATCH 01/18] docs(games): add spec 0032 and record the IGDB terms check Co-Authored-By: Claude Opus 5.5 --- docs/scope/games.md | 17 ++- docs/specs/0032-games-launch/index.md | 152 ++++++++++++++++++++++ docs/specs/0032-games-launch/rationale.md | 104 +++++++++++++++ 3 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 docs/specs/0032-games-launch/index.md create mode 100644 docs/specs/0032-games-launch/rationale.md diff --git a/docs/scope/games.md b/docs/scope/games.md index 9dd3ce7..4ae866a 100644 --- a/docs/scope/games.md +++ b/docs/scope/games.md @@ -33,7 +33,7 @@ _This is the second scope for BeStats. The movie and TV scope lives in [scope.md | G7 | Game search and filters | Slice G4 | done | | G8 | Game cards everywhere | Slice G4 | done | | G9 | Richer game page | Slice G5 | done | -| G10 | Games launch | Slice G6 | planned | +| G10 | Games launch | Slice G6 | in-progress | ## Foundations @@ -178,10 +178,21 @@ spec [0031](../specs/0031-richer-game-page/index.md) · code in `components/game ## Slice G6: Launch -### G10. Games launch · needs a decision · GA +### G10. Games launch · in-progress · GA Turn games on for search engines and production, with the credits IGDB asks for. **Done when:** game pages and list pages are in the sitemap with `VideoGame` JSON-LD and share cards; an `IGDB_ATTRIBUTION` sits next to `TMDB_ATTRIBUTION` in the footer and on `/terms` with `LEGAL_LAST_UPDATED` bumped; IGDB's current terms are checked again and the result recorded; the `IGDB_*` variables are set in Vercel and the change is deployed through [docs/deploy.md](../deploy.md). -- [ ] Design it (spec): `/architect games launch` +- [x] Design it (spec): `/architect games launch` +- [ ] Build it: `/develop games launch` + - [ ] Terms check in a browser, recorded in the spec's rationale, plus the logo search, AC-10, AC-11 + - [ ] Thin thread: an indexable game page (canonical, share card, `VideoGame` JSON-LD) and its ids in the sitemap with failure isolation, AC-1 to AC-3, AC-6, AC-7, AC-14 + - [ ] `/games` and the five list pages indexed and in the sitemap, AC-4 to AC-6 + - [ ] IGDB credit in the footer and on `/terms`, `LEGAL_LAST_UPDATED` bumped, AC-8 to AC-10 + - [ ] Flag removed, Preview credentials and the deploy docs, then the production launch checks, AC-12, AC-13, AC-15 +- [ ] Verify it: `/check verify games launch` +- [ ] Test it: `/test games launch` +- [ ] Review it (fresh model): `/check review games launch` +- [ ] Document it: `/document games launch` +spec [0032](../specs/0032-games-launch/index.md) · code in `app/games/`, `app/sitemap.ts`, `lib/seo/`, `lib/legal/`, `components/layout/site-footer.tsx`, `app/terms/` ## Deferred Out of scope for the current build pass, kept so the plan stays honest. diff --git a/docs/specs/0032-games-launch/index.md b/docs/specs/0032-games-launch/index.md new file mode 100644 index 0000000..4efd5a2 --- /dev/null +++ b/docs/specs/0032-games-launch/index.md @@ -0,0 +1,152 @@ +# 0032. Games launch + +**Date**: 2026-10-26 +**Status**: In Progress + +Scope feature: [G10. Games launch](../../scope/games.md#g10-games-launch--in-progress--ga) · GA tier + +## Summary + +This spec opens games to everyone: search engines, link previews and production visitors. Game pages, the `/games` landing and its five list pages lose their `noindex`, get canonicals and share cards, and join the sitemap. Game pages also get `VideoGame` structured data (JSON-LD, the machine readable block search engines read). The footer and `/terms` credit IGDB next to TMDB. The `NEXT_PUBLIC_GAMES_LIBRARY` flag is deleted, so games are simply always on. Before the deploy, you read IGDB's current terms yourself, because the automated fetch was blocked. Nothing is stored and no schema changes. + +## Requirements + +**User stories**: +- As a visitor searching the web for a game, I want BeStats game pages to show up with a proper title, description and preview image, so I can open the one I want. +- As a visitor on production, I want the GAMES tab and the games library tabs, so I can browse and track games like shows and movies. +- As the operator, I want IGDB credited the way its terms ask, recorded with the date I checked, so the launch stays within IGDB's free, non commercial terms. + +**Acceptance criteria** (the contract): + +- **AC-1**: A found, visible game's page (`/games/{id}`) has no `robots` noindex and builds its metadata through `catalogMetadata()`: the existing title (`{name} ({year})` or `{name}`) and description (summary cut at 160 characters by `truncateAtWord`, left out without a summary), the canonical `absoluteUrl("/games/{id}")`, `og:type` `website`, and a share image chosen in this order: `artUrl`, then `screenshotUrls[0]`, then `coverUrl`, then the site card (`shareMetadata`'s fallback). Without a site URL, the canonical, `og:url` and the site card fallback are left out, never relative (`lib/seo/AGENTS.md`). +- **AC-2**: A malformed id, an unknown game, a `hidden` game (the soft 404 of spec 0025, AC-7) and a failed IGDB read keep their current metadata: `noindex`, no canonical, no Open Graph or X fields, and no JSON-LD. +- **AC-3**: The found branch of a game page, and only it, renders ``. `gameJsonLd()` lives in `lib/seo/json-ld.ts`, takes the structural `GameJsonLdInput` (`id`, `name`, `coverUrl`, `releaseDate`, `summary`, `genres: { name }[]`, `platforms: { name }[]`, `developers: string[]`, `publishers: string[]`), drops any blank company name, and returns a schema.org `VideoGame` with `name`, `url` (`absoluteUrl("/games/{id}")`), `image` (`coverUrl`), `datePublished` (`releaseDate`), `description` (`summary`), `genre` (genre names), `gamePlatform` (platform names), `author` (each of `developers` as `{ "@type": "Organization", name }`) and `publisher` (each of `publishers`, the same shape). `withoutEmpty` drops any null, empty or zero field. No rating of any kind is emitted, community or personal. It is serialized only through `jsonLdScript()`. +- **AC-4**: `/games` has the canonical `absoluteUrl("/games")` and the site card, and no `noindex`. `gamesMetadata({ title, description })` keeps its signature, now returns `landingMetadata({ title, description, path: "/games" })`, and is used only by `app/games/page.tsx`. Its title and description stay as they are. `gameListMetadata` and its tests are deleted. +- **AC-5**: Each of the five games list pages (`trending`, `popular`, `recently-released`, `coming-soon`, `top-rated`) replaces its static `metadata` with `generateMetadata` calling `catalogListMetadata(list, searchParams)`, widened to take any `CatalogList` and reading the same `readTaggedCatalogGridPage(list, page)` call the page body makes (one shared cache entry, no extra IGDB request). `landingPageMetadata`'s `discover` callback changes from `{ totalPages }` to `{ hasResults: boolean; lastPage: number | null }`. Page N is indexed with the canonical `listPageHref(list, N)` when `hasResults` is true and either `lastPage` is null or N is at most `lastPage`. For a TMDB list `lastPage` is always its `totalPages` and `hasResults` is `page <= totalPages`, so TMDB lists behave exactly as today (their tests stay green unchanged). A games discover list with no IGDB count is therefore indexed on any page that shows games, up to IGDB's page 50 (past it the read answers empty). An invalid `?page`, an empty page, a page past the last, or an `IgdbError` or `TmdbError` is `noindex` with no canonical. Any other error, `IgdbValidationError` included, is rethrown, the existing TMDB rule. `app/catalog-lists.test.ts` expects `catalogListMetadata(` on the game list pages too. +- **AC-6**: On an indexable deployment, `sitemap.xml` lists, in this order: `/movies`, `/shows`, `/games`; the movie list pages, the show list pages, then the five games list pages, each at page 1 in registry row order; `/privacy`, `/terms`; then every movie, every show, then every game. `sitemapEntries` gains a fifth argument, `gameIds: number[]`. The games are the first `GAME_SITEMAP_LIMIT` (200, exported from `lib/seo/sitemap.ts`) ids of `getPopularGames(POPULAR_GAME_LISTS.popular)`, the exact call `/games/popular` makes, deduplicated in rank order. That read returns visible games only, so no hidden game is listed. It joins the same `Promise.allSettled` as the 20 TMDB reads inside the existing cached scope, so it adds no wait. Every entry is absolute. A deployment that is not indexable still answers an empty `urlset` and asks neither TMDB nor IGDB anything. `app/sitemap.test.ts` replaces "lists no games URL before G10" with the new order, and `lib/seo/sitemap.test.ts` covers the new argument. +- **AC-7**: When the Popular read throws an `IgdbError` (any kind, including `unauthorized` for missing credentials) while the sitemap is built, only the game ids drop: the landings, every list page (games included), the legal pages, the movies and the shows stay, and the file's `cacheLife` is `minutes` instead of `days`, the same rule a TMDB failure follows. `getPopularGames` rethrows a rebuilt `IgdbError` outside its own cached scope, so `instanceof` holds inside the sitemap's scope. A rejection that is neither an `IgdbError` nor a `TmdbError`, an `IgdbValidationError` included, is rethrown and fails the build on purpose. +- **AC-8**: `IGDB_ATTRIBUTION` is `"Game data and images from IGDB.com"` (or the wording AC-11 records, if IGDB asks for different text). It is defined once, in `lib/legal/operator.ts` next to `IGDB_HOME_URL`. The footer shows it on every route beside TMDB's logo and notice, linking to `IGDB_HOME_URL` in a new tab (`noopener noreferrer`), with the footer's 44px mobile and 36px desktop tap areas. `lib/legal/boundary.test.ts` fails if the literal appears in any other file. +- **AC-9**: `/terms` gains IGDB in its catalog section. The section title becomes `Catalog data, TMDB and IGDB`, and after the TMDB paragraphs it adds, in this order: "Game details and images come from IGDB. We can't guarantee that they are accurate or complete."; a paragraph rendering `IGDB_ATTRIBUTION`; "IGDB community ratings belong to IGDB. Your own ratings are yours." `LEGAL_LAST_UPDATED` is set to the date of the PR's final commit (the merge day can't be known in advance). `/privacy` keeps its existing IGDB image paragraph unchanged, since no processor changes. +- **AC-10**: When the terms check (AC-11) finds an official IGDB logo file, it is saved byte for byte as `public/igdb-logo.svg` (or `.png` if that is the only official format), hash checked by a test like `tmdb-logo.test.ts`, and shown in the footer as the link, sized no taller than TMDB's logo, with `alt="IGDB"` and the attribution text beside it. With no official file, the footer shows the text credit alone as the link, and the result is recorded. +- **AC-11**: Before the production deploy, you read IGDB's current API terms, its FAQ and the Twitch Developer Services Agreement in a browser and record, in `rationale.md` under *IGDB terms check*: each document's URL, its last updated date if shown, the date you read it, verbatim quotes on attribution, non commercial use, caching and logo use, and whether BeStats complies. The `IGDB_ATTRIBUTION` doc comment cites that date, the way `TMDB_ATTRIBUTION` cites its terms. The deploy waits for this record. What the read can change: different wording or placement changes only `IGDB_ATTRIBUTION` and the footer; if the 24 hour caching clause applies to IGDB data, `getGameGenres` and `getGamePlatforms` drop from `max` to `hours` (every other IGDB read already lives `hours` or less); anything else unclear or forbidding (commercial terms, a required partnership, a hotlinking ban) stops the launch and goes back to `/architect`. +- **AC-12**: `NEXT_PUBLIC_GAMES_LIBRARY` is gone, so the GAMES tab shows on every page, `?type=game` is always valid on `/watchlist`, `/upcoming`, `/watched`, `/search` and `/api/search`, and the brand link and media tabs always know `game`. Removed: `getGamesLibrary`, `gamesLibraryEnabled` and their tests in `lib/env.ts`; `lib/search/games-flag.ts` and its test; the `gamesEnabled` argument of `parseLibraryTypeParam` and `libraryTypeForLocation` (`lib/catalog/media-type.ts`), the `gamesEnabled` option of `parseSearchParams` (`ParseOptions` and its `{ gamesEnabled?: false }` overload, `lib/search/params.ts`) and of `lib/search/quick.ts`, so `game` is always accepted; every caller (`page-media-type.tsx`, `media-type-tabs.tsx`, `brand-link.tsx`, `library-section.tsx`, `search-filters.tsx`, `search-results.tsx`, `app/api/search/route.ts`). Tests updated: `lib/env.test.ts`, `params.test.ts`, `quick.test.ts`, `brand-link.test.tsx`, `media-type-tabs.test.tsx`, `library-nav.test.tsx`, `navbar.test.tsx`, `game-library-tab.test.tsx`, `search-results.test.tsx`, `app/api/search/route.test.ts`, and `library-error-boundary.test.tsx` (its invalid flag case is deleted; the boundary stays for other thrown errors). The variable leaves `.env.example`, `docs/deploy.md` and Vercel Preview. `security-boundary.test.ts` gains a case that fails if any git tracked file outside `docs/` (and other than itself) names `NEXT_PUBLIC_GAMES_LIBRARY`. +- **AC-13**: `IGDB_CLIENT_ID` and `IGDB_CLIENT_SECRET` are set in Vercel Preview (the same Twitch app as Production, sensitive), so a preview renders games. `docs/deploy.md` section 4 lists both rows for Production and Preview, with the rule that neither ever takes a `NEXT_PUBLIC_` prefix. +- **AC-14**: No SEO surface reads cookies, headers, the session or the visitor's region: game page metadata reads only `params` and `getGame`, list metadata only `searchParams` and the list read, and `gameJsonLd` only the normalized `Game`. Nothing user specific (status, score, timestamps) reaches metadata, JSON-LD or the sitemap. `robots.txt`, `sitemap.xml` and `opengraph-image` stay prerendered, and the game page, `/games` and the five list pages keep their prerendered shells, checked by `request-scope.test.ts` coverage of the new code and by `pnpm build` listing those routes as partially prerendered. +- **AC-15**: The change ships through `docs/deploy.md`: the PR preview is checked with `vercel curl`, then production after merge: `/sitemap.xml` lists games, a game page has its canonical, `og:image` and `VideoGame` JSON-LD with no `noindex`, `/games` and one list page have canonicals, the GAMES tab shows, and one live game URL passes the Schema Markup Validator (validator.schema.org) with a `VideoGame` item and zero errors. Google has no `VideoGame` rich result, so its Rich Results Test would only say "no items detected". A failed launch is undone with Vercel Instant Rollback (section 8). + +## Decision + +**Chosen option**: Option 1: One launch release on the existing SEO and legal modules. + +Turn games on in one release that reuses `catalogMetadata`, `jsonLdScript`, the sitemap and the legal constants exactly as movies and shows use them, and delete the games flag rather than flip it. + +**Implementation skills**: `next-dev-loop` (`vercel/next.js`, `.agents/skills/next-dev-loop/`) for checking the metadata and JSON-LD in the running app · `next-cache-components-optimizer` (`vercel/next.js`, `.agents/skills/next-cache-components-optimizer/`) only if the game page or a list page loses its static shell. + +## Feature design + +**Data model sketch**: none. No table, column or migration changes. Catalog data stays unstored (no catalog cache, spec 0008). + +**State transitions**: none. + +**API surface** (pages and modules, no new endpoint): +| Surface | Change | Inputs | Outputs | Auth | Errors | +|---|---|---|---|---|---| +| `app/games/[id]/page.tsx` `generateMetadata` | found branch indexable | `params.id` | title, description, canonical, OG and X | public | malformed, not found, hidden, failed: noindex (AC-2) | +| `app/games/[id]/page.tsx` found branch | renders `JsonLd` | `Game` | `VideoGame` script | public | none rendered outside the found branch | +| `lib/seo/json-ld.ts` `gameJsonLd()` | new, pure | `GameJsonLdInput` (structural) | JSON-LD object | n/a | none | +| `lib/landing-metadata.ts` | `gamesMetadata` takes the `/games` path; `gameListMetadata` removed; `catalogListMetadata` takes any `CatalogList` | `searchParams` | metadata | public | `IgdbError` or `TmdbError`: noindex | +| `app/games/{slug}/page.tsx` (five) | `generateMetadata` instead of a static `metadata` | `searchParams` | metadata | public | as above | +| `app/sitemap.ts`, `lib/seo/sitemap.ts` | games landing, list paths, game ids | none | entries | public | `IgdbError` drops game ids (AC-7) | +| `lib/legal/operator.ts` | `IGDB_ATTRIBUTION` | none | string | n/a | none | +| `components/layout/site-footer.tsx`, `app/terms/page.tsx` | IGDB credit | none | markup | public | none | +| `lib/env.ts` and the flag's call sites | flag removed | none | none | n/a | none | + +**Value sourcing**: +| Action | Value produced / displayed | Source | +|---|---|---| +| Game metadata | canonical, `og:url` | `absoluteUrl("/games/{id}")` from the parsed id, never the request URL | +| Game metadata | share image | `Game.artUrl` (t_1080p), else `Game.screenshotUrls[0]` (t_1080p), else `Game.coverUrl`, else `siteCardUrl()` | +| Game metadata | title, description | unchanged, from `Game.name`, `Game.releaseYear`, `Game.summary` (spec 0025, AC-10) | +| `gameJsonLd` | `image` | `Game.coverUrl` (the portrait cover, as `movieJsonLd` uses the poster) | +| `gameJsonLd` | `datePublished` | `Game.releaseDate`, the UTC day of `first_release_date` | +| `gameJsonLd` | `genre`, `gamePlatform` | `Game.genres[].name`, `Game.platforms[].name` | +| `gameJsonLd` | `author`, `publisher` | `Game.developers`, `Game.publishers` (spec 0031, AC-5 order and dedupe) | +| List metadata | indexable or not, page count | `readTaggedCatalogGridPage(list, page)` results and `lastListPage()` | +| List metadata | today (discover windows) | `requestTodayUtc()` after `connection()`, inside the existing `readTaggedGamePage` | +| Sitemap | game ids | `getPopularGames(POPULAR_GAME_LISTS.popular)`, first 200 (`GAME_SITEMAP_LIMIT`) | +| Sitemap | games list paths | `catalogListsFor("game").map(listPath)` | +| Footer, `/terms` | credit text, link | `IGDB_ATTRIBUTION`, `IGDB_HOME_URL` in `lib/legal/operator.ts` | +| `/terms` | last updated | `LEGAL_LAST_UPDATED`, bumped by hand | +| Terms record | quotes, dates, compliance | you, from the AC-11 browser read | + +**Key invariants**: +- Canonicals are built from ids and registry paths, never the request URL. Only a list page's `?page=N` (N > 1) may carry a query. +- Every non found branch stays `noindex`, so an IGDB outage or a hidden game never gets indexed. +- No rating of any kind in metadata or JSON-LD (`lib/seo/AGENTS.md`). +- `lib/seo/` stays pure: `gameJsonLd` takes a structural input type, with no runtime import of `lib/igdb`. +- Every sitemap game is linked from a public page (the Popular list), as every sitemap movie and show is. +- `IGDB_ATTRIBUTION`, like `TMDB_ATTRIBUTION`, has one home. + +**Security model**: everything here is public catalog data. No SEO surface imports `lib/supabase/` or reads request state (AC-14). JSON-LD text from IGDB is third party input, escaped by `jsonLdScript()`. The IGDB credentials stay server only; adding them to Preview gives them no `NEXT_PUBLIC_` prefix, and `security-boundary.test.ts` still holds. No processor changes: IGDB receives no user data, so `PROCESSORS` and `/privacy` stay as they are. + +**Configuration required**: +- `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET`: added to Vercel Preview (already in Production since 2026-10-24). +- `NEXT_PUBLIC_GAMES_LIBRARY`: removed from Vercel Preview, `.env.example` and `.env.local`. + +**Critical test scenarios**: +- Happy path: a found game with art gets a canonical, `og:image` set to its art, no `robots`, and a `VideoGame` script with authors and publishers, verifies **AC-1**, **AC-3** +- Image fallback: art missing uses the first screenshot; both missing uses the cover; all missing uses the site card, verifies **AC-1** +- Not found, hidden and failed games stay noindex with no JSON-LD, verifies **AC-2** +- `gameJsonLd` drops empty genres, platforms, companies and a null date, and never emits `aggregateRating`, verifies **AC-3** +- List metadata: page 1 with games indexed; page 3 of a list with no count but results indexed; an empty page and an `IgdbError` noindex, verifies **AC-5** +- Sitemap order with games, 200 game cap and dedupe; an `IgdbError` keeps everything but game ids and caches for minutes; a non indexable build is empty, verifies **AC-6**, **AC-7** +- Footer and `/terms` render the IGDB credit; the boundary test fails on a pasted literal, verifies **AC-8**, **AC-9** +- With no flag set, the GAMES tab shows and `?type=game` works on every library page and search; the no flag name test passes, verifies **AC-12** +- Request scope tests cover the new metadata and JSON-LD, verifies **AC-14** + +## Build plan + +Tracer Bullet: the terms read comes first because its result can change the credit text; then one thin indexable thread (a game page, end to end into the sitemap), then the strands around it. + +1. **Terms check (you, in a browser)**: read IGDB's API docs, its FAQ and the Twitch Developer Services Agreement; record the quotes, dates and compliance verdict in `rationale.md`, *IGDB terms check*; look for an official logo file, satisfies **AC-11**, **AC-10** +2. **Thin thread, one game page**: `gameJsonLd` and its tests, the found branch metadata and `JsonLd` on `/games/[id]`, the not found and failed branches unchanged, the sitemap's game ids from the Popular read with failure isolation, the request scope coverage, satisfies **AC-1**, **AC-2**, **AC-3**, **AC-6**, **AC-7**, **AC-14** +3. **Landing and list pages**: `/games` canonical, `catalogListMetadata` widened with the results rule, `generateMetadata` on the five list pages, `/games` and the five list paths in the sitemap, satisfies **AC-4**, **AC-5**, **AC-6** +4. **Credits**: `IGDB_ATTRIBUTION` with its dated doc comment, the footer credit (and logo if found, with its hash test), the `/terms` section, the `LEGAL_LAST_UPDATED` bump, the boundary test, satisfies **AC-8**, **AC-9**, **AC-10** +5. **Remove the flag**: delete the two readers, `games-flag.ts` and every branch, update their tests and the comments that cite the flag, the no flag name test, `.env.example`, satisfies **AC-12** +6. **Deploy docs and Preview**: `docs/deploy.md` section 4 (IGDB rows, flag row and paragraph removed), the IGDB credentials into Preview and the flag out of it (you, with `vercel env`), satisfies **AC-13**, **AC-12** +7. **Launch**: typecheck, lint, test, build with `VERCEL_ENV=production` and a site URL to see the indexable sitemap locally, PR preview check, merge, production checks and the Schema Markup Validator, satisfies **AC-15** + +## Migration plan + +**Strategy**: no data migration. One release turns indexing on and removes the flag together. +**Phases**: +1. Preview: credentials added, flag removed, the PR's preview checked. +2. Production: merge deploys; run the AC-15 checks. +**Rollback**: Vercel Instant Rollback to the last production deployment (`docs/deploy.md`, section 8), then fix forward. Nothing in the database to undo. +**Risks**: a crawl of 200 game pages and the list pages meets IGDB's 4 requests a second per instance. `getGame` is cached for hours and the list reads share one entry, and a 429 surfaces as the failed branch, which is `noindex`, so a retry panel is never indexed. The accepted cost: a crawler that meets that branch sees `noindex` and may drop that game from the index until its next crawl; a 429 failure caches for `seconds`, so the next crawl normally gets the page. After a rollback, search engines may still hold game URLs for a while; they keep resolving, because the pages exist without the launch. + +## Consequences + +**Positive**: +- Games reach search and link previews with the same quality bar as movies and shows, through the same modules. +- One fewer flag: no dead branch to keep tested, and Preview matches Production. +- IGDB credit lives in one constant with a dated terms record behind it. + +**Negative / tradeoffs**: +- The launch depends on a manual browser read; the automated fetch could not confirm the terms. +- No kill switch: hiding games again needs a rollback or a code change. +- List page metadata now makes the page's IGDB read during metadata, so a slow IGDB delays the head (shared cache entry, so no extra request). +- Removing the flag touches many files and tests at once. + +**Neutral**: +- `AGENTS.md` lines on the flag, "game pages reach the sitemap only in G10", and the `lib/seo/AGENTS.md` sitemap description go stale; `/sync` updates them. +- `components/library/library-error-boundary.tsx` stays (it guards any thrown library error) but its comment no longer cites the flag. + +## Follow-up + +- [ ] `/sync` after merge: root `AGENTS.md` (Games section, the `NEXT_PUBLIC_GAMES_LIBRARY` and `game` bullets), `lib/seo/AGENTS.md` (sitemap and JSON-LD), `docs/scope/games.md` G10. +- [ ] Specs 0027 and 0028 mention the flag staying until G10; this spec settles their follow-up. + +## Rationale + +Reasoning and options: see [rationale.md](rationale.md). diff --git a/docs/specs/0032-games-launch/rationale.md b/docs/specs/0032-games-launch/rationale.md new file mode 100644 index 0000000..3f2910c --- /dev/null +++ b/docs/specs/0032-games-launch/rationale.md @@ -0,0 +1,104 @@ +# 0032. Games launch: rationale + +## Context + +Games G1 to G9 are built and live in code, but production hides them. Every games page sets `robots: { index: false }` with no canonical or share card (spec 0025, AC-10; spec 0028, AC-13), the sitemap lists only movies and shows (`catalogListsFor` keeps game lists out by type), and the GAMES tab and the games library tabs sit behind `NEXT_PUBLIC_GAMES_LIBRARY`, unset in Production (spec 0027). The footer and `/terms` credit TMDB only. + +Launching means three things at once: search engines and link previews must see game pages at the quality movies and shows already have (spec 0016), IGDB must be credited as its terms ask (the games scope's settled decision of 2026-10-17 says the credit goes in the footer and on `/terms`, not in the processor list, and that IGDB's terms are checked again before launch), and production must expose the games navigation. + +The forces: `lib/seo/` must stay pure and request free, so the prerendered sitemap and static shells survive; no rating of any kind may appear in metadata; every listed URL must be linked from a public page; IGDB allows 4 requests a second per server instance, and a crawl lands on uncached pages; the legal pages must describe only what the code does (spec 0017). The IGDB terms could not be read automatically: `api-docs.igdb.com` and the IGDB FAQ answered 403 to the research fetch, so only search snippets were available. + +Not deciding leaves nine finished features invisible in production and no record that BeStats meets IGDB's terms. + +## Options considered + +### Option 1: One launch release on the existing SEO and legal modules + +Extend `catalogMetadata`, `jsonLdScript`, the sitemap and `lib/legal/operator.ts` for games, exactly as movies and shows use them, and delete the flag in the same release. + +**Pros**: +- No new pattern; every rule in `lib/seo/AGENTS.md` already covers the new code. +- A visitor from search lands on a site that shows the GAMES tab. +- One fewer flag and one fewer environment difference between Preview and Production. + +**Cons**: +- A larger single PR: the flag removal touches many files and tests. +- No kill switch after launch; only a rollback hides games. + +### Option 2: Flag first, indexing later + +Set `NEXT_PUBLIC_GAMES_LIBRARY=on` in Production now, then open indexing in a second release after watching production. + +**Pros**: +- Smaller first change and a chance to watch real traffic before crawlers arrive. + +**Cons**: +- Two releases for one launch, and the flag still has to go later. +- A `NEXT_PUBLIC_` change needs a redeploy anyway, so it is no faster than a rollback. + +### Option 3: Indexing only, flag stays off + +Index games while the navbar keeps hiding them. + +**Pros**: +- Smallest change to production navigation. + +**Cons**: +- Visitors from search land on pages they can't reach from the menu, and the games library pages answer "That page doesn't exist". + +## Rationale + +Option 1 wins because every piece of machinery already exists and is tested: canonicals, share cards, escaped JSON-LD and failure isolated sitemap reads are rules in `lib/seo/AGENTS.md`, and games only need their inputs wired in. Splitting the launch (Option 2) buys nothing real, since the flag is a build time `NEXT_PUBLIC_` value and Vercel Instant Rollback undoes a bad release just as fast. Option 3 contradicts itself by inviting search traffic into hidden navigation. + +Removing the flag rather than setting it settles what specs 0027 and 0028 planned, and makes Preview and Production render the same app. The share image prefers 16:9 art (share cards are 1200 by 630, and a portrait cover crops badly), mirroring movies' backdrop first rule, while JSON-LD `image` keeps the cover, mirroring movies' poster. `author` and `publisher` come from the companies already shown on the page, so nothing is claimed that the page does not show. The trailer is left out of JSON-LD because Google's `VideoObject` expects a thumbnail and an upload date BeStats doesn't have. + +The sitemap uses the first 200 Popular games because that is the exact cached call `/games/popular` makes, so it costs no extra IGDB request and every listed game is linked from a public page, the same rule movies and shows follow. List pages follow the TMDB list rule (index a page that shows results), with one change: a games discover list may have no count, so "has results" decides instead of `totalPages`. + +`IGDB_ATTRIBUTION` lives in `lib/legal/operator.ts`, not `lib/igdb/`, because `lib/igdb/index.ts` has a locked export surface (`surface.test.ts`) and components may not import `lib/igdb/constants.ts`; `IGDB_HOME_URL` already sits there for the same reason. Runner up: export it from `lib/igdb/index.ts` and widen the surface test, which pulls a server only module into the footer for a string. + +The terms read is manual and gates the deploy because the automated fetch was blocked; shipping on snippets would not meet the scope's "checked again before launch". + +## IGDB terms check + +Read on 2026-10-26 in a real browser (Playwright driving Chromium, from the agent shell during `/develop`), which the IGDB pages served normally; the earlier 403 was the research fetch only. Worth a quick human pass before the deploy, since the reading was done by the agent. + +**IGDB API docs, Getting Started and FAQ** (one page): , FAQ at . No last updated date shown. + +- Non commercial use: "The IGDB.com API is free for non-commercial usage under the terms of the Twitch Developer Service Agreement." and "We offer commercial partnership for users with a commercial need in their projects. For more details on that process please reach out to partner@igdb.com" +- Price: "The API is free for both non-commercial and commercial projects." +- Caching: "3. Am I allowed to store/cache the data locally? Yes. In fact, we prefer if you store and serve the data to your end users. You remain in control over your user experience, while alleviating pressure on the API itself." +- Attribution (asked of commercial partners): "we ask for user facing attribution to IGDB.com from products integrating the IGDB API." and "4. Regarding user facing attribution (relating to the commercial partnership), any specific guidelines? Not really. We expect fair attribution, i.e. attribution that is visible to your users and located in a static location (e.g. not in a change log)." +- Images: "Images that are removed or replaced from IGDB.com exist for 30 days before they are removed. Keep that in mind when designing cache logic." + +**Twitch Developer Services Agreement**: (linked from the docs as ). Last modified 11/01/2023, as the page shows it. + +- What it covers: "Program Materials: software, software development kits, libraries, application programming interfaces (“APIs”), services, documentation, specifications, sample code, data, metadata, ..." So IGDB data counts as Program Materials. +- Caching: "Do not store copies of Twitch Content or Program Materials, unless you: (a) obtain prior written authorization from Twitch (through these terms or otherwise); (b) control the rights associated with such content; or (c) cache such information for only a twenty-four hour time period without further sharing it with third parties. Re-syndication and re-distribution of Program Materials or data as available from a Twitch API is prohibited. You must use commercially reasonable efforts to cause Your Services to update cached results ..." +- Attribution: "You must ensure that there is a clear path to the source from displays of Program Materials in Your Services. Appropriately attribute uses of Program Materials or Twitch Content." +- Marks: "Use and display Twitch Marks in accordance with the Twitch Trademark Guidelines solely to attribute Twitch’s offerings as the source of the Program Materials as set forth in this Agreement." and "You will not remove, modify, or obscure any copyright, patent, trademark, or other proprietary or attribution notices". + +**IGDB Press Kit**: . Offers "IGDB logo (SVG)", "IGDB logo White (PNG)" and "IGDB logo Black (PNG)". No usage guidelines are stated beside them. The SVG (white on transparent, 114 × 55) is saved unmodified as `public/igdb-logo.svg`, SHA-256 `5269511c541a125f7dd8c71990e0c86ff4a6881be29ad8bd81e1ae4fe83527df`. + +**Verdict: BeStats complies, and the launch goes ahead.** + +- Non commercial and free: BeStats has no ads, fees or paid tier, so no partnership is needed. +- Attribution: a footer credit on every route is visible and in a static location, and its link to IGDB.com is the "clear path to the source". The press kit logo is shown unmodified, only as that credit. +- Caching: the IGDB FAQ is Twitch's own written permission to store and cache, which meets exception (a). Still, to stay inside the 24 hour clause without leaning on that reading, `getGameGenres` and `getGamePlatforms` drop from `max` to `hours`, the one change AC-11 allows. Every other IGDB read already lives `hours` or less, and nothing from IGDB is stored in Postgres. +- No re-syndication: game data is only displayed on pages; no API, feed or export hands it on. +- Final wording: `IGDB_ATTRIBUTION` stays `"Game data and images from IGDB.com"`. Nothing asks for different text. + +## References + +**Project sources**: +- `AGENTS.md`, the Games section (credit in footer and `/terms`, terms checked before launch) and the SEO and legal bullets +- `lib/seo/AGENTS.md`, every rule the new metadata, JSON-LD and sitemap code follows +- Spec 0016 (SEO metadata and sitemap), spec 0017 (legal pages, TMDB attribution), spec 0018 (deploy runbook) +- Spec 0025 (game page, soft 404), spec 0027 (games flag), spec 0028 (games landing and lists, the G10 follow up), spec 0031 (developers and publishers) +- `docs/scope/games.md`, settled decisions of 2026-10-17 + +**Practices & standards**: +- schema.org `VideoGame` for structured data +- Open Graph share images at a wide aspect ratio +- Single source constants guarded by boundary tests + +**Links**: the IGDB API docs and FAQ, the Twitch Developer Services Agreement and the IGDB Press Kit, read on 2026-10-26 and listed under *IGDB terms check*. From 126dfc5400fb41a7b65dda9f0a1bb997b3239ddf Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 26 Oct 2026 20:59:12 +0100 Subject: [PATCH 02/18] fix(igdb): cache the genre and platform lists for hours, inside Twitch's 24 hour clause Co-Authored-By: Claude Opus 5.5 --- lib/igdb/AGENTS.md | 7 ++++--- lib/igdb/reads.test.ts | 4 ++-- lib/igdb/reads.ts | 21 +++++++++++++-------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/lib/igdb/AGENTS.md b/lib/igdb/AGENTS.md index b4af8e0..3015a73 100644 --- a/lib/igdb/AGENTS.md +++ b/lib/igdb/AGENTS.md @@ -55,9 +55,10 @@ scope feature G1. It mirrors `lib/tmdb/` in shape but shares no code with it. - **A failure crosses the cache boundary as a plain `IgdbResult`**, and the wrapper rebuilds the `IgdbError`, so `kind` and `isIgdbNotFound` survive. Never throw an `IgdbError` out of a cached scope. -- **Lifetimes**: `hours` for `getGame`, `getGamesByIds` and - `getPopularGames`, `minutes` for search and discover, `max` for the - reference lists. A failure takes +- **Lifetimes**: `hours` for `getGame`, `getGamesByIds`, + `getPopularGames` and the reference lists, `minutes` for search and + discover. Nothing lives longer, because Twitch's terms cap a cached copy at + 24 hours (spec 0032, AC-11). A failure takes `failureProfile(kind)`: `seconds` for `timeout`, `rate_limited` and `upstream`, `minutes` for the rest. - **The token and the limiter live in process memory**, never in a cache entry, diff --git a/lib/igdb/reads.test.ts b/lib/igdb/reads.test.ts index 88678c3..af3c811 100644 --- a/lib/igdb/reads.test.ts +++ b/lib/igdb/reads.test.ts @@ -86,12 +86,12 @@ const READS = [ inner.fetchPopularGames, "hours", ], - ["getGameGenres", () => getGameGenres(), inner.fetchGameGenres, "max"], + ["getGameGenres", () => getGameGenres(), inner.fetchGameGenres, "hours"], [ "getGamePlatforms", () => getGamePlatforms(), inner.fetchGamePlatforms, - "max", + "hours", ], ] as const; diff --git a/lib/igdb/reads.ts b/lib/igdb/reads.ts index a97a408..a9bf48c 100644 --- a/lib/igdb/reads.ts +++ b/lib/igdb/reads.ts @@ -45,7 +45,9 @@ import { * Lifetimes follow how fast each answer changes: `hours` for a game, a * batch and a popularity list, so a new release date unlocks Playing within * hours and the heaviest read in the app runs a few times a day; `minutes` for - * search and discover; `max` for the genre and platform lists. A failure takes + * search and discover; `hours` too for the genre and platform lists, which + * barely change but stay inside the Twitch Developer Services Agreement's 24 + * hour caching clause (spec 0032, AC-11). A failure takes * `failureProfile(kind)`. * * No cached scope reads cookies, headers, search params or any user value. A @@ -53,14 +55,13 @@ import { * never in a cache entry. */ -type Lifetime = "seconds" | "minutes" | "hours" | "max"; +type Lifetime = "seconds" | "minutes" | "hours"; /** One literal call per profile, since `cacheLife` is typed per literal. */ function setLifetime(profile: Lifetime): void { if (profile === "seconds") cacheLife("seconds"); else if (profile === "minutes") cacheLife("minutes"); - else if (profile === "hours") cacheLife("hours"); - else cacheLife("max"); + else cacheLife("hours"); } /** @@ -188,20 +189,24 @@ export async function getPopularGames( async function getGameGenresCached(): Promise> { "use cache"; - return settle(() => fetchGameGenres(), "max"); + return settle(() => fetchGameGenres(), "hours"); } -/** IGDB's genres by name, cached for `max`: the list barely ever changes. */ +/** + * IGDB's genres by name, cached for `hours` rather than longer: the list + * barely changes, but Twitch's terms cap a cached copy at 24 hours + * (spec 0032, AC-11). + */ export async function getGameGenres(): Promise { return unwrap(await getGameGenresCached()); } async function getGamePlatformsCached(): Promise> { "use cache"; - return settle(() => fetchGamePlatforms(), "max"); + return settle(() => fetchGamePlatforms(), "hours"); } -/** IGDB's platforms by name, cached for `max` like the genres. */ +/** IGDB's platforms by name, cached for `hours` like the genres. */ export async function getGamePlatforms(): Promise { return unwrap(await getGamePlatformsCached()); } From 0619329dd26fb051c0e6cd05dfc999a8e18ef955 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 26 Oct 2026 21:02:27 +0100 Subject: [PATCH 03/18] feat(seo): index a found game page with its canonical, share card and VideoGame JSON-LD Co-Authored-By: Claude Opus 5.5 --- app/games/[id]/page.test.ts | 132 ++++++++++++++++++++++++++++------ app/games/[id]/page.tsx | 34 ++++++--- lib/seo/json-ld.test.ts | 81 ++++++++++++++++++++- lib/seo/json-ld.ts | 51 +++++++++++++ lib/seo/request-scope.test.ts | 9 ++- 5 files changed, 271 insertions(+), 36 deletions(-) diff --git a/app/games/[id]/page.test.ts b/app/games/[id]/page.test.ts index 935c895..cd68928 100644 --- a/app/games/[id]/page.test.ts +++ b/app/games/[id]/page.test.ts @@ -1,7 +1,8 @@ import { createElement, Fragment, type ReactElement, Suspense } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { JsonLd } from "@/components/catalog/json-ld"; import { GameDetailSkeleton } from "@/components/game/game-detail-skeleton"; import { GameFacts } from "@/components/game/game-facts"; import { GameHero } from "@/components/game/game-hero"; @@ -9,13 +10,15 @@ import { GameMoreLikeThisSection } from "@/components/game/game-more-like-this"; import { GameOverview } from "@/components/game/game-overview"; import { ScreenshotGallery } from "@/components/game/screenshot-gallery"; import { GameTrackingSlot } from "@/components/tracking/game-tracking-slot"; +import { gameJsonLd } from "@/lib/seo/json-ld"; /** * covers: spec 0025, AC-1, AC-6, AC-7, AC-8, AC-9, AC-10; spec 0031, AC-1, - * AC-11 + * AC-11; spec 0032, AC-1, AC-2, AC-3 * - * Until G10 every game page outcome is `noindex`, with no canonical or share - * card. `loadGame` is replaced, so the metadata and the streamed body branch + * A found game is indexable with a canonical, a share card and `VideoGame` + * JSON-LD; every other outcome stays `noindex` with none of them. `loadGame` + * is replaced, so the metadata and the streamed body branch * on the same fake result, and `notFound` throws a marker the tests catch. */ const loadGame = vi.fn(); @@ -34,10 +37,20 @@ vi.mock("next/navigation", () => ({ const { default: GamePage, generateMetadata } = await import("./page"); +const ORIGIN = "https://bestats.example"; + +const ART = "https://images.igdb.com/igdb/image/upload/t_1080p/ar1.jpg"; +const SHOT = "https://images.igdb.com/igdb/image/upload/t_1080p/sc1.jpg"; +const COVER = + "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/co1wyy.jpg"; + const GAME = { name: "The Witcher 3: Wild Hunt", releaseYear: 2015, summary: "RPG and sequel to The Witcher 2 (2011).", + artUrl: ART, + screenshotUrls: [SHOT], + coverUrl: COVER, }; function metadataFor(id: string) { @@ -61,20 +74,74 @@ async function bodyFor(id: string) { return render(detail.props); } -afterEach(() => loadGame.mockReset()); +beforeEach(() => vi.stubEnv("NEXT_PUBLIC_SITE_URL", ORIGIN)); -describe("a found game's metadata (AC-10)", () => { - it("is the name and year, the summary, and noindex with nothing else", async () => { +afterEach(() => { + loadGame.mockReset(); + vi.unstubAllEnvs(); +}); + +describe("a found game's metadata (spec 0025, AC-10; spec 0032, AC-1)", () => { + it("is indexable, with the name and year, the summary, its canonical and its art", async () => { loadGame.mockResolvedValue({ kind: "found", game: GAME }); - expect(await metadataFor("1942")).toEqual({ - title: "The Witcher 3: Wild Hunt (2015)", - description: "RPG and sequel to The Witcher 2 (2011).", - robots: { index: false }, + const metadata = await metadataFor("1942"); + + expect(metadata.title).toBe("The Witcher 3: Wild Hunt (2015)"); + expect(metadata.description).toBe( + "RPG and sequel to The Witcher 2 (2011).", + ); + expect(metadata.robots).toBeUndefined(); + expect(metadata.alternates).toEqual({ canonical: `${ORIGIN}/games/1942` }); + expect(metadata.openGraph).toMatchObject({ + url: `${ORIGIN}/games/1942`, + type: "website", + siteName: "BeStats", + images: [ART], + }); + expect(metadata.twitter).toMatchObject({ + card: "summary_large_image", + images: [ART], }); expect(loadGame).toHaveBeenCalledWith(1942); }); + it.each([ + ["the first screenshot without art", { artUrl: null }, SHOT], + [ + "the cover without art or screenshots", + { artUrl: null, screenshotUrls: [] }, + COVER, + ], + [ + "the site card with no image at all", + { artUrl: null, screenshotUrls: [], coverUrl: null }, + `${ORIGIN}/opengraph-image`, + ], + ])("shares %s", async (_, override, image) => { + loadGame.mockResolvedValue({ + kind: "found", + game: { ...GAME, ...override }, + }); + + expect((await metadataFor("1942")).openGraph?.images).toEqual([image]); + }); + + it("builds the canonical from the id alone, never relative without a site URL", async () => { + vi.stubEnv("NEXT_PUBLIC_SITE_URL", undefined); + loadGame.mockResolvedValue({ + kind: "found", + game: { ...GAME, artUrl: null, screenshotUrls: [], coverUrl: null }, + }); + + const metadata = await metadataFor("1942"); + + expect(metadata.alternates).toBeUndefined(); + expect(metadata.openGraph?.url).toBeUndefined(); + expect(metadata.openGraph?.images).toBeUndefined(); + expect(metadata.robots).toBeUndefined(); + }); + it("leaves the year and description out when IGDB has none", async () => { loadGame.mockResolvedValue({ kind: "found", @@ -99,18 +166,21 @@ describe("a found game's metadata (AC-10)", () => { }); }); -describe("a game page with nothing to show (AC-7, AC-8)", () => { +describe("a game page with nothing to show (AC-7, AC-8; spec 0032, AC-2)", () => { it.each([ ["not found", { kind: "not_found" }, "Game not found"], ["a failed IGDB read", { kind: "failed" }, "Game"], - ])("is noindex for %s", async (_, result, title) => { - loadGame.mockResolvedValue(result); - - expect(await metadataFor("1942")).toEqual({ - title, - robots: { index: false }, - }); - }); + ])( + "is noindex with no canonical or share card for %s", + async (_, result, title) => { + loadGame.mockResolvedValue(result); + + expect(await metadataFor("1942")).toEqual({ + title, + robots: { index: false }, + }); + }, + ); it("is noindex for a malformed id without asking IGDB (AC-6)", async () => { expect(await metadataFor("abc")).toEqual({ @@ -160,8 +230,25 @@ describe("the streamed body", () => { platformReleases: [], }; - /** The article's children: the hero first, then the sections in order. */ - const sectionsOf = (article: Element) => article.props.children as Element[]; + /** + * The article's children after its JSON-LD: the hero first, then the + * sections in order. + */ + const sectionsOf = (article: Element) => + (article.props.children as Element[]).slice(1); + + it("opens with the game's VideoGame JSON-LD, only in the found branch (spec 0032, AC-3)", async () => { + loadGame.mockResolvedValue({ kind: "found", game: FOUND }); + + const [script] = (await bodyFor("1942")).props.children as Element[]; + + expect(script.type).toBe(JsonLd); + expect(script.props.data).toEqual(gameJsonLd(FOUND)); + expect(script.props.data).toMatchObject({ + "@type": "VideoGame", + url: `${ORIGIN}/games/1942`, + }); + }); it("hands the found game's facts to the hero and nothing it does not show (AC-1)", async () => { loadGame.mockResolvedValue({ kind: "found", game: FOUND }); @@ -253,6 +340,7 @@ describe("the streamed body", () => { const html = renderToStaticMarkup(await bodyFor("1942")); + expect(html).not.toContain("application/ld+json"); expect(html).toContain("Couldn't reach IGDB"); expect(html).toContain("IGDB didn't respond. Try again in a moment."); expect(html).toMatch(/]*>Try again<\/a>/); diff --git a/app/games/[id]/page.tsx b/app/games/[id]/page.tsx index 8ec5b78..530037c 100644 --- a/app/games/[id]/page.tsx +++ b/app/games/[id]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { Suspense } from "react"; +import { JsonLd } from "@/components/catalog/json-ld"; import { GameDetailSkeleton } from "@/components/game/game-detail-skeleton"; import { GameFacts } from "@/components/game/game-facts"; import { GameHero } from "@/components/game/game-hero"; @@ -14,6 +15,8 @@ import { GameTrackingSlot } from "@/components/tracking/game-tracking-slot"; import { parseGameId } from "@/lib/catalog/ids"; import { gameHref } from "@/lib/catalog/media-type"; import { truncateAtWord } from "@/lib/format"; +import { gameJsonLd } from "@/lib/seo/json-ld"; +import { catalogMetadata } from "@/lib/seo/metadata"; import { loadGame } from "./load-game"; @@ -28,13 +31,17 @@ const NOT_FOUND_METADATA: Metadata = { /** * The tab title and description, from the same `loadGame` the body uses - * (spec 0025, AC-10). + * (spec 0025, AC-10), plus the canonical and share card of spec 0032, AC-1. * - * Every branch is `noindex` until G10 turns indexing on for games, so no - * canonical, share card or JSON-LD yet. The not found branch sets it itself - * because the status is already 200 when the body decides (a soft 404, - * AC-7), and the failed branch has it so an IGDB outage never gets a retry - * panel indexed (AC-8). + * Only a found, visible game is indexable. The not found branch sets + * `noindex` itself because the status is already 200 when the body decides + * (a soft 404, spec 0025, AC-7), and the failed branch has it so an IGDB + * outage never gets a retry panel indexed (AC-8; spec 0032, AC-2). The + * canonical is built from the id alone, so a query string never reaches it. + * + * The share image prefers the wide art, then a screenshot, then the cover, + * because a share card is 1200 by 630 and a portrait cover crops badly; the + * movie page's backdrop first rule (spec 0032, rationale). */ export async function generateMetadata({ params, @@ -48,13 +55,16 @@ export async function generateMetadata({ return { title: "Game", robots: { index: false } }; } - const { name, releaseYear, summary } = result.game; - return { + const { name, releaseYear, summary, artUrl, screenshotUrls, coverUrl } = + result.game; + return catalogMetadata({ title: releaseYear === null ? name : `${name} (${releaseYear})`, description: summary === null ? undefined : truncateAtWord(summary, DESCRIPTION_LIMIT), - robots: { index: false }, - }; + path: gameHref(id), + image: artUrl ?? screenshotUrls[0] ?? coverUrl, + ogType: "website", + }); } /** @@ -69,6 +79,9 @@ export async function generateMetadata({ * (AC-9). The status control reads the session in its own Suspense boundary * through the hero's `tracking` slot (spec 0026, AC-1). * + * The found branch, and only it, carries the `VideoGame` JSON-LD (spec 0032, + * AC-3), so a missing, hidden or failed game never describes itself. + * * Spec 0031 adds the backdrop and Trailer button to the hero, then Overview, * Screenshots, More like this and Facts, in that order (AC-1). All but More * like this come from the one `getGame` read; that section streams its own @@ -112,6 +125,7 @@ async function GameDetail({ return (
+ { }); }); +const GAME: GameJsonLdInput = { + id: 1942, + name: "The Witcher 3: Wild Hunt", + coverUrl: + "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/co1wyy.jpg", + releaseDate: "2015-05-19", + summary: "RPG and sequel to The Witcher 2 (2011).", + genres: [{ name: "Role-playing (RPG)" }, { name: "Adventure" }], + platforms: [{ name: "PC (Microsoft Windows)" }, { name: "PlayStation 4" }], + developers: ["CD Projekt RED"], + publishers: ["CD Projekt", "Bandai Namco Entertainment"], +}; + +describe("gameJsonLd (spec 0032, AC-3)", () => { + it("maps every field from the game", () => { + vi.stubEnv("NEXT_PUBLIC_SITE_URL", ORIGIN); + + expect(gameJsonLd(GAME)).toEqual({ + "@context": "https://schema.org", + "@type": "VideoGame", + name: "The Witcher 3: Wild Hunt", + url: `${ORIGIN}/games/1942`, + image: GAME.coverUrl, + datePublished: "2015-05-19", + description: "RPG and sequel to The Witcher 2 (2011).", + genre: ["Role-playing (RPG)", "Adventure"], + gamePlatform: ["PC (Microsoft Windows)", "PlayStation 4"], + author: [{ "@type": "Organization", name: "CD Projekt RED" }], + publisher: [ + { "@type": "Organization", name: "CD Projekt" }, + { "@type": "Organization", name: "Bandai Namco Entertainment" }, + ], + }); + }); + + it("drops a blank company name", () => { + const { author, publisher } = gameJsonLd({ + ...GAME, + developers: [" ", "CD Projekt RED"], + publishers: [""], + }); + + expect(author).toEqual([ + { "@type": "Organization", name: "CD Projekt RED" }, + ]); + expect(publisher).toBeUndefined(); + }); + + it("leaves out every field whose source is missing, and the url without a site URL", () => { + vi.stubEnv("NEXT_PUBLIC_SITE_URL", undefined); + + expect( + gameJsonLd({ + ...GAME, + coverUrl: null, + releaseDate: null, + summary: null, + genres: [], + platforms: [], + developers: [], + publishers: [], + }), + ).toEqual({ + "@context": "https://schema.org", + "@type": "VideoGame", + name: "The Witcher 3: Wild Hunt", + }); + }); + + it("never carries a rating, even when handed a whole Game", () => { + vi.stubEnv("NEXT_PUBLIC_SITE_URL", ORIGIN); + const game = { ...GAME, communityRating: 9.3, communityRatingCount: 5501 }; + + expect(JSON.stringify(gameJsonLd(game))).not.toMatch(/rating|review/i); + }); +}); + describe("jsonLdScript (AC-23)", () => { const hostile = { description: "\u2028\u2029", diff --git a/lib/seo/json-ld.ts b/lib/seo/json-ld.ts index 5546c99..98bc4fc 100644 --- a/lib/seo/json-ld.ts +++ b/lib/seo/json-ld.ts @@ -35,6 +35,22 @@ export type ShowJsonLdInput = { numberOfEpisodes: number; }; +/** + * As `MovieJsonLdInput`, for a game's normalized `Game` (spec 0032, AC-3). + * Structural for the same reason: `lib/seo/` imports nothing from `lib/igdb`. + */ +export type GameJsonLdInput = { + id: number; + name: string; + coverUrl: string | null; + releaseDate: string | null; + summary: string | null; + genres: { name: string }[]; + platforms: { name: string }[]; + developers: string[]; + publishers: string[]; +}; + type JsonLd = Record; /** @@ -87,6 +103,41 @@ export function showJsonLd(show: ShowJsonLdInput): JsonLd { }); } +/** + * A schema.org `VideoGame` for a found game page (spec 0032, AC-3), under the + * same omission rule as `movieJsonLd`. + * + * `image` is the portrait cover, as a movie's is its poster. `author` and + * `publisher` are the companies the page itself shows (spec 0031, AC-5), so + * nothing is claimed that a visitor cannot see. IGDB's community rating is + * left out like every other rating (`lib/seo/AGENTS.md`, Rules), and so is + * the trailer, since a `VideoObject` needs a thumbnail and upload date IGDB + * does not give. + */ +export function gameJsonLd(game: GameJsonLdInput): JsonLd { + return withoutEmpty({ + "@context": "https://schema.org", + "@type": "VideoGame", + name: game.name, + url: absoluteUrl(`/games/${game.id}`), + image: game.coverUrl, + datePublished: game.releaseDate, + description: game.summary, + genre: game.genres.map(({ name }) => name), + gamePlatform: game.platforms.map(({ name }) => name), + author: organizations(game.developers), + publisher: organizations(game.publishers), + }); +} + +/** Company names as schema.org organizations, blank names dropped. */ +function organizations(names: string[]): JsonLd[] { + return names + .map((name) => name.trim()) + .filter((name) => name !== "") + .map((name) => ({ "@type": "Organization", name })); +} + /** * Serializes JSON-LD for a `