diff --git a/.env.example b/.env.example
index 5f074e3c..ca1ca6f6 100644
--- a/.env.example
+++ b/.env.example
@@ -59,14 +59,6 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000
# it on only together with enable_confirmations = true and an SMTP provider.
NEXT_PUBLIC_AUTH_EMAIL_DELIVERY=off
-# Optional. on or off; unset or empty means off. on adds a GAMES tab to
-# /watchlist, /upcoming and /watched and accepts ?type=game there (spec 0027).
-# Production leaves it unset until people have a way to find games; set it on
-# in .env.local and in Vercel Preview. Public on purpose: it only decides
-# whether the tab renders, never who may read what. Inlined at build time, so a
-# change needs a redeploy.
-NEXT_PUBLIC_GAMES_LIBRARY=on
-
# ---------------------------------------------------------------------------
# Deployment (set by the host, not by you)
# ---------------------------------------------------------------------------
diff --git a/AGENTS.md b/AGENTS.md
index 32b11eaf..11ed915a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -284,7 +284,7 @@ Games are a third media type, planned in [docs/scope/games.md](docs/scope/games.
- **Ratings:** a personal score from 1 to 10, only on Playing or Finished. Moving back to Want to play clears it, with Undo restoring status, score and timestamps; Playing to Finished and back keeps it. IGDB's community rating shows out of 10 and is always labelled as the community rating.
- **Timestamps:** added, started (the first time a game becomes Playing) and finished (every time it becomes Finished). Want to play sorts by added, Playing by started, Finished by finished.
- **Not tracked:** platform played on, hours played, 100% completion.
-- **Navigation:** SHOWS | MOVIES | GAMES with `?type=game`; for games `/watchlist` is Want to play, `/upcoming` Playing, `/watched` Finished. Since G6 the GAMES tab shows on every page while `NEXT_PUBLIC_GAMES_LIBRARY` is `on` (spec 0028), and `/games` is the games landing with five paged list pages (`/games/{slug}`); game pages reach the sitemap only in G10.
+- **Navigation:** SHOWS | MOVIES | GAMES with `?type=game`; for games `/watchlist` is Want to play, `/upcoming` Playing, `/watched` Finished. The GAMES tab shows on every page (spec 0028; the flag that gated it was removed in G10, spec 0032), and `/games` is the games landing with five paged list pages (`/games/{slug}`). Since G10 game pages, `/games` and its list pages are indexable and in the sitemap (spec 0032).
## Build approach
@@ -316,11 +316,10 @@ Tracer Bullet: prove the whole pipe works with one thin real thread, then thicke
- Images render through `next/image` and the one `loaderFile`, `lib/images/loader.ts`, which sends a TMDB URL to `lib/tmdb/image-loader.ts` and an IGDB cover to `lib/images/igdb-loader.ts` (spec 0025). The optimizer is closed: no `remotePatterns` and an empty `localPatterns`, so `/_next/image` fetches only the static import folders Next always appends (`/_next/static/media/**`), which hold no images (prompts/close-image-optimizer.md). Never add a remote pattern, and never import an image file into a component (that would open the optimizer to it); give a new host its own loader.
- The visitor's country (Where to watch, the age rating chip) comes only from `getVisitorRegion()` in `lib/region.ts`, which reads Vercel's `x-vercel-ip-country` header with a `US` fallback (spec 0022). Call it only inside a streamed Suspense boundary, never in a page shell, a cached scope or an SEO surface, and never store, log or send it to TMDB.
- The navbar is transparent (no background, border or blur) and not sticky, so it scrolls away with the page; every control in it is 40px tall at every width, with the `hit-area` utilities keeping a 44px tap target. The footer is borderless. See [components/AGENTS.md](components/AGENTS.md).
-- Legal facts (operator handle and GitHub URL, contact email, processors, `LEGAL_LAST_UPDATED`) live only in `lib/legal/operator.ts`, and TMDB's notice only in `TMDB_ATTRIBUTION`; `lib/legal/boundary.test.ts` fails if either is pasted elsewhere. `/privacy` and `/terms` describe only what the code does, so a change that adds a processor, email provider or analytics updates `PROCESSORS`, the policy text and `LEGAL_LAST_UPDATED` in the same PR (spec 0017). `public/tmdb-logo.svg` is TMDB's official file, hash checked; never edit it.
+- Legal facts (operator handle and GitHub URL, contact email, processors, `LEGAL_LAST_UPDATED`) live only in `lib/legal/operator.ts`, TMDB's notice only in `TMDB_ATTRIBUTION` and IGDB's credit only in `IGDB_ATTRIBUTION`; `lib/legal/boundary.test.ts` fails if any is pasted elsewhere. `/privacy` and `/terms` describe only what the code does, so a change that adds a processor, email provider or analytics updates `PROCESSORS`, the policy text and `LEGAL_LAST_UPDATED` in the same PR (spec 0017). `public/tmdb-logo.svg` and `public/igdb-logo.svg` are TMDB's and IGDB's official files, each hash checked by its own test; never edit either.
- Production runs from [docs/deploy.md](docs/deploy.md), the runbook of spec 0018: push a migration with `supabase db push` before merging the PR that adds it, and auth settings reach the cloud only through `supabase config push` (never the dashboard). Run both pushes yourself; from an agent shell the CLI applies them without stopping at the prompt. `vercel.json` pins functions to `fra1`, next to the Supabase region `eu-central-1`. The `/api/search` rate limit is a Vercel Firewall rule, recorded only in `docs/deploy.md`.
- `NEXT_PUBLIC_AUTH_EMAIL_DELIVERY` (`on` or `off`, unset meaning `off`) is read only through `getAuthEmailDelivery()` in `lib/env.ts`, separate from `getPublicEnv()` so previews with no Supabase variables still build. Production is `off`: no auth email is sent or promised. Being a `NEXT_PUBLIC_` value, a change takes a redeploy.
-- `NEXT_PUBLIC_GAMES_LIBRARY` (`on` or `off`, unset meaning `off`) gates the games tabs (spec 0027). It is read only in `lib/env.ts`, through `getGamesLibrary()` (throws on an invalid value; `LibrarySection` calls it, and `/search` and `/api/search` call it only for a raw `type=game`, so a bad value never breaks a show or movie search, spec 0029) and `gamesLibraryEnabled()` (an invalid value reads as off; the navbar uses it). Production leaves it unset, Preview and `.env.local` set `on` (`docs/deploy.md`).
-- `game` is a `LibraryType` (`MediaType | "game"`), never a `MediaType`: `parseLibraryTypeParam` and `typedHref` accept it on `/watchlist`, `/upcoming`, `/watched` and, since G7, `/search` (spec 0029); every TMDB read still takes `MediaType`, so `game` is narrowed away before one, and the title pages and the proxy never receive it. G6 widened `CATALOG_PATHS`, `CATALOG_SOURCES`, the list registry and the proxy's list slug rule to `game` (spec 0028). A games tab reads its stored status, not a classification: one counted `user_game_state` page of 24 plus one `getGamesByIds` batch for those ids (`lib/tracking/game-library.ts`), with no 500 title limit.
+- `game` is a `LibraryType` (`MediaType | "game"`), never a `MediaType`: `parseLibraryTypeParam` and `typedHref` always accept it on `/watchlist`, `/upcoming`, `/watched` and, since G7, `/search` (spec 0029; no flag since spec 0032); every TMDB read still takes `MediaType`, so `game` is narrowed away before one, and the title pages and the proxy never receive it. G6 widened `CATALOG_PATHS`, `CATALOG_SOURCES`, the list registry and the proxy's list slug rule to `game` (spec 0028). A games tab reads its stored status, not a classification: one counted `user_game_state` page of 24 plus one `getGamesByIds` batch for those ids (`lib/tracking/game-library.ts`), with no 500 title limit.
## Code conventions
diff --git a/app/api/search/route.test.ts b/app/api/search/route.test.ts
index ab77ddf2..7e1dc79e 100644
--- a/app/api/search/route.test.ts
+++ b/app/api/search/route.test.ts
@@ -4,11 +4,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { IgdbError, IgdbValidationError } from "@/lib/igdb/errors";
import { TmdbError } from "@/lib/tmdb/errors";
-/** covers: spec 0010, AC-20; spec 0029, AC-17 */
+/** covers: spec 0010, AC-20; spec 0029, AC-17; spec 0032, AC-12 */
const readSearchPage = vi.hoisted(() => vi.fn());
const searchGames = vi.hoisted(() => vi.fn());
-const games = vi.hoisted(() => ({ library: "on" as "on" | "off" }));
vi.mock("@/lib/search/read", async (importOriginal) => {
const actual = await importOriginal();
@@ -18,9 +17,6 @@ vi.mock("@/lib/igdb", async () => {
const errors = await import("@/lib/igdb/errors");
return { ...errors, searchGames };
});
-vi.mock("@/lib/env", () => ({
- getGamesLibrary: vi.fn(() => games.library),
-}));
const { GET } = await import("./route");
@@ -46,7 +42,6 @@ const PAGE = {
afterEach(() => {
readSearchPage.mockReset();
searchGames.mockReset();
- games.library = "on";
});
describe("GET /api/search", () => {
@@ -176,13 +171,6 @@ describe("GET /api/search for games (spec 0029, AC-17)", () => {
});
});
- it("answers 400 with the games library off, without asking IGDB", async () => {
- games.library = "off";
- const response = await GET(request("type=game&q=zelda"));
- expect(response.status).toBe(400);
- expect(searchGames).not.toHaveBeenCalled();
- });
-
it("answers 400, never 500, for a query IGDB's cleaning refuses", async () => {
searchGames.mockRejectedValue(
new IgdbValidationError("query", "The search query is empty."),
diff --git a/app/api/search/route.ts b/app/api/search/route.ts
index dab9c08c..5c900c1d 100644
--- a/app/api/search/route.ts
+++ b/app/api/search/route.ts
@@ -2,7 +2,6 @@ import { type NextRequest, NextResponse } from "next/server";
import { IgdbError, IgdbValidationError, searchGames } from "@/lib/igdb";
import { toGameQuickSearchResponse } from "@/lib/search/game-read";
-import { gameSearchEnabled } from "@/lib/search/games-flag";
import { quickSearchRequestSchema } from "@/lib/search/quick";
import { readSearchPage, toQuickSearchResponse } from "@/lib/search/read";
import { TmdbError } from "@/lib/tmdb";
@@ -26,17 +25,16 @@ const SUCCESS_CACHE = "public, s-maxage=300, stale-while-revalidate=600";
* cached module read, so a popular query costs one TMDB request per cache
* lifetime. Errors are never cached.
*
- * `type=game` is accepted only while the games library is on, and the flag is
- * read only for that raw value; it reads the cached IGDB search page 1 at the
- * default 20 and returns its first five games (spec 0029, AC-17). A query
+ * `type=game` reads the cached IGDB search page 1 at the default 20 and
+ * returns its first five games (spec 0029, AC-17). A query
* IGDB's cleaning refuses answers 400, never 500.
*/
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
- const rawType = searchParams.get("type");
- const parsed = quickSearchRequestSchema({
- gamesEnabled: gameSearchEnabled(rawType),
- }).safeParse({ type: rawType, q: searchParams.get("q") });
+ const parsed = quickSearchRequestSchema.safeParse({
+ type: searchParams.get("type"),
+ q: searchParams.get("q"),
+ });
if (!parsed.success) return invalid();
const { type, q } = parsed.data;
diff --git a/app/catalog-lists.test.ts b/app/catalog-lists.test.ts
index 455fb81c..89bae6dd 100644
--- a/app/catalog-lists.test.ts
+++ b/app/catalog-lists.test.ts
@@ -36,9 +36,7 @@ describe.each([
const source = readFileSync(join(dir, slug, "page.tsx"), "utf8");
expect(source).toContain(`catalogList("${mediaType}", "${slug}")`);
expect(source).toContain(" ({
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 8ec5b784..530037c8 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 (
+
): Promise {
+ return catalogListMetadata(LIST, searchParams);
+}
/** One games list, paged by 24 (spec 0028, AC-9). */
export default function ComingSoonGamesPage({
diff --git a/app/games/page.tsx b/app/games/page.tsx
index fd6d1a39..66694e54 100644
--- a/app/games/page.tsx
+++ b/app/games/page.tsx
@@ -4,8 +4,8 @@ import { CatalogLanding } from "@/components/catalog/catalog-landing";
import { gamesMetadata } from "@/lib/landing-metadata";
/**
- * The games landing's metadata (spec 0028, AC-13): `noindex`, with no
- * canonical or share card of its own, until G10 opens games to search.
+ * The games landing's metadata (spec 0032, AC-4): its title and description,
+ * the canonical `/games` and the site card, like `/movies` and `/shows`.
*/
export const metadata: Metadata = gamesMetadata({
title: "Games",
diff --git a/app/games/popular/page.tsx b/app/games/popular/page.tsx
index b857afd4..5c2fe081 100644
--- a/app/games/popular/page.tsx
+++ b/app/games/popular/page.tsx
@@ -2,12 +2,16 @@ import type { Metadata } from "next";
import { CatalogListPage } from "@/components/catalog/catalog-list-page";
import { catalogList } from "@/lib/catalog/lists";
-import { gameListMetadata } from "@/lib/landing-metadata";
+import { catalogListMetadata } from "@/lib/landing-metadata";
const LIST = catalogList("game", "popular");
-/** The list's title and note, `noindex` until G10 (spec 0028, AC-13). */
-export const metadata: Metadata = gameListMetadata(LIST);
+/** The list's title, note and canonical (spec 0032, AC-5). */
+export async function generateMetadata({
+ searchParams,
+}: PageProps<"/games/popular">): Promise {
+ return catalogListMetadata(LIST, searchParams);
+}
/** One games list, paged by 24 (spec 0028, AC-9). */
export default function PopularGamesPage({
diff --git a/app/games/recently-released/page.tsx b/app/games/recently-released/page.tsx
index d0d8a44a..d1c34b0e 100644
--- a/app/games/recently-released/page.tsx
+++ b/app/games/recently-released/page.tsx
@@ -2,12 +2,16 @@ import type { Metadata } from "next";
import { CatalogListPage } from "@/components/catalog/catalog-list-page";
import { catalogList } from "@/lib/catalog/lists";
-import { gameListMetadata } from "@/lib/landing-metadata";
+import { catalogListMetadata } from "@/lib/landing-metadata";
const LIST = catalogList("game", "recently-released");
-/** The list's title and note, `noindex` until G10 (spec 0028, AC-13). */
-export const metadata: Metadata = gameListMetadata(LIST);
+/** The list's title, note and canonical (spec 0032, AC-5). */
+export async function generateMetadata({
+ searchParams,
+}: PageProps<"/games/recently-released">): Promise {
+ return catalogListMetadata(LIST, searchParams);
+}
/** One games list, paged by 24 (spec 0028, AC-9). */
export default function RecentlyReleasedGamesPage({
diff --git a/app/games/request-scope.test.ts b/app/games/request-scope.test.ts
index 79e3b622..9dd83abe 100644
--- a/app/games/request-scope.test.ts
+++ b/app/games/request-scope.test.ts
@@ -5,13 +5,15 @@ import { describe, expect, it } from "vitest";
import { GAME_LIST_SLUGS } from "@/lib/catalog/lists";
/**
- * covers: spec 0028, AC-18, AC-19
+ * covers: spec 0028, AC-18, AC-19; spec 0032, AC-14
*
* `/games` and its list pages are public pages served from a prerendered
* shell. The only session read on them is the hero's `GameTrackingSlot`,
* inside its own Suspense boundary in `components/`, so no route file may
* reach for a request API or a Supabase client, and none may opt out of the
* static shell. `app/games/actions.ts` is a POST handler, not render code.
+ * The visitor's region is request state too, and since spec 0032 these pages
+ * feed search engines, so it stays out of them as well.
*/
const FORBIDDEN = [
"cookies(",
@@ -19,6 +21,7 @@ const FORBIDDEN = [
"draftMode(",
"@/lib/supabase",
"@/lib/auth/user",
+ "getVisitorRegion",
];
const ACTIONS = join("app/games/actions.ts");
diff --git a/app/games/top-rated/page.tsx b/app/games/top-rated/page.tsx
index 5d97701e..e7f003b0 100644
--- a/app/games/top-rated/page.tsx
+++ b/app/games/top-rated/page.tsx
@@ -2,12 +2,16 @@ import type { Metadata } from "next";
import { CatalogListPage } from "@/components/catalog/catalog-list-page";
import { catalogList } from "@/lib/catalog/lists";
-import { gameListMetadata } from "@/lib/landing-metadata";
+import { catalogListMetadata } from "@/lib/landing-metadata";
const LIST = catalogList("game", "top-rated");
-/** The list's title and note, `noindex` until G10 (spec 0028, AC-13). */
-export const metadata: Metadata = gameListMetadata(LIST);
+/** The list's title, note and canonical (spec 0032, AC-5). */
+export async function generateMetadata({
+ searchParams,
+}: PageProps<"/games/top-rated">): Promise {
+ return catalogListMetadata(LIST, searchParams);
+}
/** One games list, paged by 24 (spec 0028, AC-9). */
export default function TopRatedGamesPage({
diff --git a/app/games/trending/page.tsx b/app/games/trending/page.tsx
index 0075d642..51d7a9ed 100644
--- a/app/games/trending/page.tsx
+++ b/app/games/trending/page.tsx
@@ -2,12 +2,16 @@ import type { Metadata } from "next";
import { CatalogListPage } from "@/components/catalog/catalog-list-page";
import { catalogList } from "@/lib/catalog/lists";
-import { gameListMetadata } from "@/lib/landing-metadata";
+import { catalogListMetadata } from "@/lib/landing-metadata";
const LIST = catalogList("game", "trending");
-/** The list's title and note, `noindex` until G10 (spec 0028, AC-13). */
-export const metadata: Metadata = gameListMetadata(LIST);
+/** The list's title, note and canonical (spec 0032, AC-5). */
+export async function generateMetadata({
+ searchParams,
+}: PageProps<"/games/trending">): Promise {
+ return catalogListMetadata(LIST, searchParams);
+}
/** One games list, paged by 24 (spec 0028, AC-9). */
export default function TrendingGamesPage({
diff --git a/app/sitemap.test.ts b/app/sitemap.test.ts
index f66c7244..0622d571 100644
--- a/app/sitemap.test.ts
+++ b/app/sitemap.test.ts
@@ -1,18 +1,21 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { IgdbError, IgdbValidationError } from "@/lib/igdb/errors";
import { TmdbError } from "@/lib/tmdb/errors";
/**
- * covers: spec 0016, AC-6, AC-7, AC-8, AC-9; spec 0017, AC-13; spec 0021, AC-16
+ * covers: spec 0016, AC-6, AC-7, AC-8, AC-9; spec 0017, AC-13; spec 0021,
+ * AC-16; spec 0032, AC-6, AC-7
*
- * The sitemap is the one SEO surface that reads TMDB, so its order, dedupe,
- * independent failure and lifetime choice are pinned against mocked discover
- * reads. `cacheLife` only means something inside Next, so it is recorded
+ * The sitemap is the one SEO surface that reads TMDB and IGDB, so its order,
+ * dedupe, independent failure and lifetime choice are pinned against mocked
+ * discover and Popular reads. `cacheLife` only means something inside Next, so it is recorded
* rather than run; outside Next the `use cache` directive is an inert string.
*/
const cacheLife = vi.fn();
const discoverMovies = vi.fn();
const discoverTvShows = vi.fn();
+const getPopularGames = vi.fn();
vi.mock("next/cache", () => ({
cacheLife: (profile: string) => cacheLife(profile),
@@ -26,11 +29,22 @@ vi.mock("@/lib/tmdb", async () => {
};
});
+vi.mock("@/lib/igdb", async () => {
+ const errors = await import("@/lib/igdb/errors");
+ return {
+ getPopularGames: (kind: unknown) => getPopularGames(kind),
+ IgdbError: errors.IgdbError,
+ };
+});
+
const { default: sitemap } = await import("./sitemap");
const ORIGIN = "https://bestats.example";
-/** The ten list pages at page 1, movies first, in row order (spec 0021, AC-16). */
+/**
+ * The fifteen list pages at page 1, movies, then shows, then games, in row
+ * order (spec 0021, AC-16; spec 0032, AC-6).
+ */
const LISTS = [
"/movies/trending",
"/movies/popular",
@@ -42,12 +56,18 @@ const LISTS = [
"/shows/airing-today",
"/shows/on-the-air",
"/shows/top-rated",
+ "/games/trending",
+ "/games/popular",
+ "/games/recently-released",
+ "/games/coming-soon",
+ "/games/top-rated",
].map((path) => `${ORIGIN}${path}`);
/** Everything listed before the titles. */
const FIXED = [
`${ORIGIN}/movies`,
`${ORIGIN}/shows`,
+ `${ORIGIN}/games`,
...LISTS,
`${ORIGIN}/privacy`,
`${ORIGIN}/terms`,
@@ -63,8 +83,15 @@ function page(ids: number[]) {
};
}
-/** Page N of movies holds ids N*100+1 and N*100+2; shows the same plus 5000. */
+/** Games 9001 and 9002, in IGDB's Popular rank order. */
+const GAMES = [{ id: 9001 }, { id: 9002 }];
+
+/**
+ * Page N of movies holds ids N*100+1 and N*100+2; shows the same plus 5000;
+ * the Popular games list holds `GAMES`.
+ */
function happyReads() {
+ getPopularGames.mockResolvedValue(GAMES);
discoverMovies.mockImplementation(async ({ page: n }: { page: number }) =>
page([n * 100 + 1, n * 100 + 2]),
);
@@ -87,23 +114,51 @@ afterEach(() => {
cacheLife.mockReset();
discoverMovies.mockReset();
discoverTvShows.mockReset();
+ getPopularGames.mockReset();
});
describe("the sitemap on an indexable deployment", () => {
- it("lists the landings, the list pages, the legal pages, then movies, then shows, in page order (AC-6; spec 0017, AC-13; spec 0021, AC-16)", async () => {
+ it("lists the landings, the list pages, the legal pages, then movies, shows and games, in page order (AC-6; spec 0017, AC-13; spec 0021, AC-16; spec 0032, AC-6)", async () => {
happyReads();
const listed = urls(await sitemap());
- expect(listed.slice(0, 16)).toEqual([
+ expect(listed.slice(0, 22)).toEqual([
...FIXED,
`${ORIGIN}/movies/101`,
`${ORIGIN}/movies/102`,
]);
- expect(listed).toHaveLength(14 + 20 + 20);
- expect(listed[33]).toBe(`${ORIGIN}/movies/1002`);
- expect(listed[34]).toBe(`${ORIGIN}/shows/5101`);
- expect(listed.at(-1)).toBe(`${ORIGIN}/shows/6002`);
+ expect(listed).toHaveLength(20 + 20 + 20 + 2);
+ expect(listed[39]).toBe(`${ORIGIN}/movies/1002`);
+ expect(listed[40]).toBe(`${ORIGIN}/shows/5101`);
+ expect(listed[59]).toBe(`${ORIGIN}/shows/6002`);
+ expect(listed.slice(60)).toEqual([
+ `${ORIGIN}/games/9001`,
+ `${ORIGIN}/games/9002`,
+ ]);
+ });
+
+ it("reads the games with the exact Popular call /games/popular makes (spec 0032, AC-6)", async () => {
+ happyReads();
+
+ await sitemap();
+
+ expect(getPopularGames.mock.calls).toEqual([["popular"]]);
+ });
+
+ it("lists at most 200 games, deduplicated in rank order (spec 0032, AC-6)", async () => {
+ happyReads();
+ const ranked = [7, 7, ...Array.from({ length: 300 }, (_, i) => 10_000 + i)];
+ getPopularGames.mockResolvedValue(ranked.map((id) => ({ id })));
+
+ const games = urls(await sitemap()).filter(
+ (url) => url.startsWith(`${ORIGIN}/games/`) && /\/games\/\d+$/.test(url),
+ );
+
+ expect(games).toHaveLength(200);
+ expect(games[0]).toBe(`${ORIGIN}/games/7`);
+ expect(games[1]).toBe(`${ORIGIN}/games/10000`);
+ expect(games.at(-1)).toBe(`${ORIGIN}/games/10198`);
});
it("reads pages 1 to 10 of each landing's discover call (AC-6)", async () => {
@@ -128,6 +183,7 @@ describe("the sitemap on an indexable deployment", () => {
});
it("dedupes ids within a media type, keeping the first, but not across types (AC-6)", async () => {
+ getPopularGames.mockResolvedValue([{ id: 2 }]);
discoverMovies.mockImplementation(async ({ page: n }: { page: number }) =>
n === 1 ? page([1, 2]) : n === 2 ? page([2, 3]) : page([]),
);
@@ -141,6 +197,7 @@ describe("the sitemap on an indexable deployment", () => {
`${ORIGIN}/movies/2`,
`${ORIGIN}/movies/3`,
`${ORIGIN}/shows/2`,
+ `${ORIGIN}/games/2`,
]);
});
@@ -157,14 +214,6 @@ describe("the sitemap on an indexable deployment", () => {
}
});
- it("lists no games URL before G10 (spec 0028, AC-13)", async () => {
- happyReads();
-
- for (const url of urls(await sitemap())) {
- expect(url.slice(ORIGIN.length)).not.toMatch(/^\/games(\/|$)/);
- }
- });
-
it("keeps a complete list for days (AC-8)", async () => {
happyReads();
@@ -190,14 +239,43 @@ describe("the sitemap on an indexable deployment", () => {
const listed = urls(await sitemap());
- expect(listed).toHaveLength(14 + 18 + 18);
+ expect(listed).toHaveLength(20 + 18 + 18 + 2);
expect(listed).not.toContain(`${ORIGIN}/movies/201`);
expect(listed).not.toContain(`${ORIGIN}/shows/6001`);
expect(listed).toContain(`${ORIGIN}/movies/301`);
expect(cacheLife.mock.calls).toEqual([["minutes"]]);
});
+ it.each([
+ ["unauthorized, as with missing credentials", "unauthorized"],
+ ["rate limited", "rate_limited"],
+ ] as const)(
+ "drops only the game ids when IGDB is %s, and keeps it for minutes (spec 0032, AC-7)",
+ async (_, kind) => {
+ happyReads();
+ getPopularGames.mockRejectedValue(new IgdbError(kind, "games", "failed"));
+
+ const listed = urls(await sitemap());
+
+ expect(listed.slice(0, 20)).toEqual(FIXED);
+ expect(listed).toHaveLength(20 + 20 + 20);
+ expect(listed.at(-1)).toBe(`${ORIGIN}/shows/6002`);
+ expect(listed.filter((url) => /\/games\/\d+$/.test(url))).toEqual([]);
+ expect(cacheLife.mock.calls).toEqual([["minutes"]]);
+ },
+ );
+
+ it("rethrows an IGDB validation error, a bug rather than an outage (spec 0032, AC-7)", async () => {
+ happyReads();
+ getPopularGames.mockRejectedValue(new IgdbValidationError("kind", "bad"));
+
+ await expect(sitemap()).rejects.toBeInstanceOf(IgdbValidationError);
+ });
+
it("still lists the landings, list pages and legal pages when every read fails (AC-8)", async () => {
+ getPopularGames.mockRejectedValue(
+ new IgdbError("upstream", "games", "failed"),
+ );
discoverMovies.mockRejectedValue(
new TmdbError("rate_limited", "/discover", "failed"),
);
@@ -225,12 +303,13 @@ describe("the sitemap on a deployment that is not indexable (AC-9)", () => {
"production with no site URL",
{ VERCEL_ENV: "production", NEXT_PUBLIC_SITE_URL: undefined },
],
- ])("is empty on %s and asks TMDB nothing", async (_, env) => {
+ ])("is empty on %s and asks TMDB and IGDB nothing", async (_, env) => {
for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value);
happyReads();
expect(await sitemap()).toEqual([]);
expect(discoverMovies).not.toHaveBeenCalled();
expect(discoverTvShows).not.toHaveBeenCalled();
+ expect(getPopularGames).not.toHaveBeenCalled();
});
});
diff --git a/app/sitemap.ts b/app/sitemap.ts
index a1b3db91..ecc2c720 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -1,36 +1,42 @@
import type { MetadataRoute } from "next";
import { cacheLife } from "next/cache";
+import { POPULAR_GAME_LISTS } from "@/lib/catalog/game-lists";
import { catalogListsFor, listPath } from "@/lib/catalog/lists";
+import { getPopularGames, IgdbError } from "@/lib/igdb";
import { isIndexableDeployment, siteUrl } from "@/lib/seo/site";
import { DISCOVER_PAGES_PER_TYPE, sitemapEntries } from "@/lib/seo/sitemap";
import { discoverMovies, discoverTvShows, TmdbError } from "@/lib/tmdb";
/**
- * `/sitemap.xml` (spec 0016, AC-6 to AC-9): the two landings, the ten list
- * pages (spec 0021, AC-16), the two legal pages (spec 0017, AC-13), then the 200 most popular movies and 200 most
- * popular shows, all pages a signed out visitor can open from a landing.
+ * `/sitemap.xml` (spec 0016, AC-6 to AC-9): the three landings, every list
+ * page (spec 0021, AC-16; spec 0032, AC-6), the two legal pages (spec 0017,
+ * AC-13), then the 200 most popular movies, shows and games, all pages a
+ * signed out visitor can open from a landing.
*
* A deployment that is not indexable answers an empty `urlset` and asks TMDB
- * nothing. Prerendered, so the build decides which branch it is, and it reads
- * no request state, so nothing user specific can reach it (`lib/seo/AGENTS.md`,
- * the prerendered routes and request state rules).
+ * and IGDB nothing. Prerendered, so the build decides which branch it is, and
+ * it reads no request state, so nothing user specific can reach it
+ * (`lib/seo/AGENTS.md`, the prerendered routes and request state rules).
*/
export default async function sitemap(): Promise {
const origin = siteUrl();
if (!isIndexableDeployment() || origin === null) return [];
- const { moviePages, showPages } = await popularTitleIds();
- return sitemapEntries(origin, LIST_PATHS, moviePages, showPages);
+ const { moviePages, showPages, gameIds } = await popularTitleIds();
+ return sitemapEntries(origin, LIST_PATHS, moviePages, showPages, gameIds);
}
/**
- * Every list page at page 1, movie lists first, each in row order (spec 0021,
- * AC-16). From the registry, so a new list is listed with no change here.
+ * Every list page at page 1, movie lists first, then show lists, then games
+ * lists, each in row order (spec 0021, AC-16; spec 0032, AC-6). From the
+ * registry, so a new list is listed with no change here.
*/
-const LIST_PATHS = [...catalogListsFor("movie"), ...catalogListsFor("tv")].map(
- listPath,
-);
+const LIST_PATHS = [
+ ...catalogListsFor("movie"),
+ ...catalogListsFor("tv"),
+ ...catalogListsFor("game"),
+].map(listPath);
/**
* The ids on discover pages 1 to 10 for each media type, read with exactly the
@@ -38,23 +44,33 @@ const LIST_PATHS = [...catalogListsFor("movie"), ...catalogListsFor("tv")].map(
* entries and every listed title is linked from a public page (spec 0016,
* AC-6, AC-8; spec 0021, AC-3).
*
- * The 20 reads settle independently: a `TmdbError` drops only its own page,
- * so a TMDB hiccup shrinks the list instead of failing the file. Any other
- * rejection is a bug and is rethrown. A complete list lives for days; a
- * partial one for minutes, so the full list returns soon after TMDB recovers
- * (`lib/seo/AGENTS.md`, the sitemap cache rule).
- * Only plain id arrays leave this scope (`AGENTS.md`, the cached scope rule).
+ * The games are IGDB's Popular list, cut to its first 200 by `sitemapEntries`,
+ * the exact read `/games/popular` makes (spec 0032, AC-6), so every listed
+ * game is linked from a public page too, and that read returns visible games
+ * only.
+ *
+ * The 21 reads settle independently: a `TmdbError` drops only its own page
+ * and an `IgdbError` only the game ids, so an upstream hiccup shrinks the
+ * list instead of failing the file (spec 0032, AC-7). `getPopularGames`
+ * rebuilds its error outside its own cached scope, so `instanceof` holds
+ * here. Any other rejection, a validation error included, is a bug and is
+ * rethrown. A complete list lives for days; a partial one for minutes, so the
+ * full list returns soon after TMDB or IGDB recovers (`lib/seo/AGENTS.md`, the
+ * sitemap cache rule). Only plain id arrays leave this scope (`AGENTS.md`, the
+ * cached scope rule).
*/
async function popularTitleIds(): Promise<{
moviePages: number[][];
showPages: number[][];
+ gameIds: number[];
}> {
"use cache";
const pages = Array.from(
{ length: DISCOVER_PAGES_PER_TYPE },
(_, index) => index + 1,
);
- const settled = await Promise.allSettled([
+ const [games, ...settled] = await Promise.allSettled([
+ getPopularGames(POPULAR_GAME_LISTS.popular),
...pages.map((page) => discoverMovies({ page })),
...pages.map((page) => discoverTvShows({ page })),
]);
@@ -72,6 +88,15 @@ async function popularTitleIds(): Promise<{
}
}
+ let gameIds: number[] = [];
+ if (games.status === "fulfilled") {
+ gameIds = games.value.map((game) => game.id);
+ } else if (games.reason instanceof IgdbError) {
+ failed = true;
+ } else {
+ throw games.reason;
+ }
+
if (failed) {
cacheLife("minutes");
} else {
@@ -81,5 +106,6 @@ async function popularTitleIds(): Promise<{
return {
moviePages: idPages.slice(0, DISCOVER_PAGES_PER_TYPE),
showPages: idPages.slice(DISCOVER_PAGES_PER_TYPE),
+ gameIds,
};
}
diff --git a/app/terms/page.test.tsx b/app/terms/page.test.tsx
index ea188553..21c6b805 100644
--- a/app/terms/page.test.tsx
+++ b/app/terms/page.test.tsx
@@ -7,6 +7,7 @@ import {
COMPLAINT_RESPONSE_DAYS,
CONTACT_EMAIL,
DELETION_DAYS,
+ IGDB_ATTRIBUTION,
LEGAL_LAST_UPDATED,
MINIMUM_AGE,
OPERATOR_COUNTRY,
@@ -24,14 +25,14 @@ vi.mock("@/lib/tmdb", async () => {
const { default: TermsPage, metadata } = await import("./page");
/**
- * covers: spec 0017, AC-3, AC-7, AC-9, AC-10, AC-12
+ * covers: spec 0017, AC-3, AC-7, AC-9, AC-10, AC-12; spec 0032, AC-9
*/
const SECTIONS = [
"About BeStats",
"Your account",
"Technical requirements",
"Acceptable use",
- "Catalog data and TMDB",
+ "Catalog data, TMDB and IGDB",
"Availability and changes to the service",
"Ending your account",
"Liability",
@@ -75,6 +76,25 @@ describe("/terms", () => {
expect(screen.getByText(TMDB_ATTRIBUTION)).toBeInTheDocument();
});
+ it("credits IGDB after TMDB in the catalog section, in the spec's order (spec 0032, AC-9)", () => {
+ render( );
+
+ const section = screen
+ .getByRole("heading", { level: 2, name: "Catalog data, TMDB and IGDB" })
+ .closest("section");
+ const paragraphs = [...(section?.querySelectorAll("p") ?? [])].map(
+ (p) => p.textContent,
+ );
+ expect(paragraphs.slice(-3)).toEqual([
+ "Game details and images come from IGDB. We can’t guarantee that they are accurate or complete.",
+ IGDB_ATTRIBUTION,
+ "IGDB community ratings belong to IGDB. Your own ratings are yours.",
+ ]);
+ expect(paragraphs.indexOf(TMDB_ATTRIBUTION)).toBeLessThan(
+ paragraphs.indexOf(IGDB_ATTRIBUTION),
+ );
+ });
+
it("states the operator facts from the one module (AC-10)", () => {
const { container } = render( );
const text = container.textContent ?? "";
diff --git a/app/terms/page.tsx b/app/terms/page.tsx
index fbc7e608..861db991 100644
--- a/app/terms/page.tsx
+++ b/app/terms/page.tsx
@@ -12,6 +12,7 @@ import {
import {
COMPLAINT_RESPONSE_DAYS,
DELETION_DAYS,
+ IGDB_ATTRIBUTION,
MINIMUM_AGE,
OPERATOR_COUNTRY,
} from "@/lib/legal/operator";
@@ -31,8 +32,9 @@ export const metadata: Metadata = catalogMetadata({
* `/terms` (spec 0017, AC-6, AC-7, AC-9 to AC-12).
*
* Fully static, like `/privacy`. Operator facts come from
- * `lib/legal/operator.ts` and the TMDB notice from `TMDB_ATTRIBUTION`, so
- * neither is restated here. The text follows the spec's Terms of Service facts
+ * `lib/legal/operator.ts`, the TMDB notice from `TMDB_ATTRIBUTION` and the
+ * IGDB credit from `IGDB_ATTRIBUTION` (spec 0032, AC-9), so none is restated
+ * here. The text follows the spec's Terms of Service facts
* table and promises nothing the app does not do.
*/
export default function TermsPage() {
@@ -87,7 +89,7 @@ export default function TermsPage() {
-
+
Titles, images and details come from TMDB. We can’t guarantee
that they are accurate or complete.
@@ -96,6 +98,14 @@ export default function TermsPage() {
TMDB community ratings belong to TMDB. Your own ratings are yours.
+
+ Game details and images come from IGDB. We can’t guarantee that
+ they are accurate or complete.
+
+ {IGDB_ATTRIBUTION}
+
+ IGDB community ratings belong to IGDB. Your own ratings are yours.
+
diff --git a/components/layout/brand-link.test.tsx b/components/layout/brand-link.test.tsx
index 865c56c8..2fd0ad25 100644
--- a/components/layout/brand-link.test.tsx
+++ b/components/layout/brand-link.test.tsx
@@ -89,7 +89,6 @@ describe("BrandLink", () => {
["/games/1942", ""],
["/watched", "type=game"],
])("links %s?%s to /games and remembers game", (path, query) => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
pathname.value = path;
search.value = query;
render( );
@@ -98,25 +97,15 @@ describe("BrandLink", () => {
expect(window.localStorage.getItem(LAST_MEDIA_TYPE_KEY)).toBe("game");
});
- it("sends a remembered game to /games only while the flag is on", () => {
+ it("sends a remembered game to /games", () => {
window.localStorage.setItem(LAST_MEDIA_TYPE_KEY, "game");
pathname.value = "/account";
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
render( ).unmount();
expect(window.localStorage.getItem(LAST_MEDIA_TYPE_KEY)).toBe("game");
render( );
expect(brandHref()).toBe("/games");
});
-
- it("reads a stale remembered game as nothing with the flag off", () => {
- window.localStorage.setItem(LAST_MEDIA_TYPE_KEY, "game");
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "off");
- pathname.value = "/account";
- render( );
-
- expect(brandHref()).toBe("/shows");
- });
});
it("survives storage that throws", () => {
diff --git a/components/layout/brand-link.tsx b/components/layout/brand-link.tsx
index a2e8f5ce..f74ea3e2 100644
--- a/components/layout/brand-link.tsx
+++ b/components/layout/brand-link.tsx
@@ -8,7 +8,6 @@ import {
isMediaType,
type LibraryType,
} from "@/lib/catalog/media-type";
-import { gamesLibraryEnabled } from "@/lib/env";
import { WithPageMediaType } from "./page-media-type";
@@ -72,15 +71,11 @@ function subscribe(onChange: () => void) {
return () => window.removeEventListener("storage", onChange);
}
-/**
- * The stored type, parsed as a `LibraryType`. A remembered `game` counts only
- * while the games library is on, so a stale value never leads to a hidden
- * tab (spec 0028, AC-16).
- */
+/** The stored type, parsed as a `LibraryType` (spec 0028, AC-16). */
function readRemembered(): LibraryType | null {
try {
const value = window.localStorage.getItem(LAST_MEDIA_TYPE_KEY);
- if (value === "game") return gamesLibraryEnabled() ? "game" : null;
+ if (value === "game") return "game";
return isMediaType(value) ? value : null;
} catch {
return null;
diff --git a/components/layout/igdb-logo.test.ts b/components/layout/igdb-logo.test.ts
new file mode 100644
index 00000000..dd0dc1c6
--- /dev/null
+++ b/components/layout/igdb-logo.test.ts
@@ -0,0 +1,25 @@
+import { createHash } from "node:crypto";
+import { existsSync, readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+/**
+ * covers: spec 0032, AC-10
+ *
+ * The footer credits IGDB with its own logo, the *IGDB logo (SVG)* file from
+ * IGDB's press kit (https://www.igdb.com/press), downloaded unmodified on
+ * 2026-10-26. The Twitch Developer Services Agreement forbids altering
+ * attribution notices, so this hash, recorded at download, fails an edit of
+ * one byte, a recolour or a reformat by a tool before it ships, as
+ * `tmdb-logo.test.ts` does for TMDB's file.
+ */
+const LOGO = "public/igdb-logo.svg";
+const OFFICIAL_SHA256 =
+ "5269511c541a125f7dd8c71990e0c86ff4a6881be29ad8bd81e1ae4fe83527df";
+
+describe("the IGDB logo", () => {
+ it("is the official file, byte for byte", () => {
+ expect(existsSync(LOGO)).toBe(true);
+ const hash = createHash("sha256").update(readFileSync(LOGO)).digest("hex");
+ expect(hash).toBe(OFFICIAL_SHA256);
+ });
+});
diff --git a/components/layout/library-nav.test.tsx b/components/layout/library-nav.test.tsx
index 1efce9b8..c3cdb9c5 100644
--- a/components/layout/library-nav.test.tsx
+++ b/components/layout/library-nav.test.tsx
@@ -114,8 +114,7 @@ describe("LibraryNav", () => {
);
// covers: spec 0027, AC-4
- it("carries type=game on a games tab while the games library is on", () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
+ it("carries type=game on a games tab", () => {
pathname = "/upcoming";
search = "type=game";
render( );
@@ -127,12 +126,10 @@ describe("LibraryNav", () => {
"/upcoming?type=game",
"/watched?type=game",
]);
- vi.unstubAllEnvs();
});
// covers: spec 0028, AC-14
- it("carries type=game on /games while the games library is on", () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
+ it("carries type=game on /games", () => {
pathname = "/games/top-rated";
search = "";
render( );
@@ -144,6 +141,5 @@ describe("LibraryNav", () => {
"/upcoming?type=game",
"/watched?type=game",
]);
- vi.unstubAllEnvs();
});
});
diff --git a/components/layout/media-type-tabs.test.tsx b/components/layout/media-type-tabs.test.tsx
index 6ca256e8..1bffb8bc 100644
--- a/components/layout/media-type-tabs.test.tsx
+++ b/components/layout/media-type-tabs.test.tsx
@@ -144,15 +144,8 @@ describe("MediaTypeTabs", () => {
},
);
- // covers: spec 0027, AC-3
- describe("with the games library on", () => {
- beforeEach(() => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
- });
- afterEach(() => {
- vi.unstubAllEnvs();
- });
-
+ // covers: spec 0027, AC-3; spec 0032, AC-12
+ describe("the GAMES tab, always shown", () => {
it.each(["/watchlist", "/upcoming", "/watched"])(
"adds GAMES on %s and lights it on a games tab",
(path) => {
@@ -237,56 +230,13 @@ describe("MediaTypeTabs", () => {
);
});
- it("shows no tabs on /games with the games library off", () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "off");
- pathname.value = "/games";
- search.value = "";
- render( );
-
- expect(screen.queryByRole("link")).not.toBeInTheDocument();
- vi.unstubAllEnvs();
- });
-
- it("never renders GAMES with the games library off", () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "off");
- pathname.value = "/watchlist";
- search.value = "type=game";
- render( );
-
- expect(
- screen.queryByRole("link", { name: "GAMES" }),
- ).not.toBeInTheDocument();
- expect(screen.getByRole("link", { name: "SHOWS" })).toHaveAttribute(
- "aria-current",
- "page",
- );
- vi.unstubAllEnvs();
- });
-
- /** covers: spec 0027, AC-1, AC-3 */
+ /** covers: spec 0027, AC-3 */
describe("the games tab edges", () => {
afterEach(() => {
- vi.unstubAllEnvs();
suspendSearch.value = false;
});
- it("reads an invalid flag as off, still rendering the two tabs", () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "maybe");
- pathname.value = "/watched";
- search.value = "type=game";
- render( );
-
- expect(
- screen.queryByRole("link", { name: "GAMES" }),
- ).not.toBeInTheDocument();
- expect(screen.getByRole("link", { name: "SHOWS" })).toHaveAttribute(
- "aria-current",
- "page",
- );
- });
-
it("renders all three tabs with none lit before ?type is known", async () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
suspendSearch.value = true;
pathname.value = "/upcoming";
await act(async () => {
@@ -307,7 +257,6 @@ describe("MediaTypeTabs", () => {
});
it("gives the /search fallback a GAMES link that stays on /search (spec 0029, AC-14)", async () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
suspendSearch.value = true;
pathname.value = "/search";
await act(async () => {
diff --git a/components/layout/media-type-tabs.tsx b/components/layout/media-type-tabs.tsx
index 02145d47..734d8fd6 100644
--- a/components/layout/media-type-tabs.tsx
+++ b/components/layout/media-type-tabs.tsx
@@ -6,18 +6,16 @@ import Link from "next/link";
import {
CATALOG_PATHS,
type LibraryType,
- type MediaType,
type TypedPath,
typedHref,
} from "@/lib/catalog/media-type";
-import { gamesLibraryEnabled } from "@/lib/env";
import { WithLibraryTypedPage } from "./page-media-type";
/**
* The SHOWS and MOVIES control in the navbar (spec 0004, AC-14; feature 22),
- * with GAMES beside them on every page while the games library is on (spec
- * 0027, AC-3; spec 0028, AC-14).
+ * with GAMES beside them on every page (spec 0027, AC-3; spec 0028, AC-14;
+ * spec 0032, AC-12).
*
* These are links, not toggles. Selection comes from the URL, so a deep link,
* a browser back button and a server render all agree on which tab is lit;
@@ -70,30 +68,29 @@ function MediaTypeTabs({ className }: { className?: string }) {
}
/**
- * Each tab's link on a typed page: the same page with its own `type`, GAMES
- * only while the games library is on (spec 0028, AC-14; spec 0029, AC-14).
- * On `/search` each keeps the query, year and rating `typedHref` allows.
+ * Each tab's link on a typed page: the same page with its own `type`
+ * (spec 0028, AC-14; spec 0029, AC-14). On `/search` each keeps the query,
+ * year and rating `typedHref` allows.
*/
function typedHrefs(pathname: TypedPath, current?: URLSearchParams): TabHrefs {
- const hrefs = {
+ return {
tv: typedHref(pathname, "tv", { current }),
movie: typedHref(pathname, "movie", { current }),
+ game: typedHref(pathname, "game", { current }),
};
- if (!gamesLibraryEnabled()) return hrefs;
- return { ...hrefs, game: typedHref(pathname, "game", { current }) };
}
-/**
- * Each tab's link off the typed pages: the three catalog landings, GAMES
- * only while the games library is on (spec 0028, AC-14).
- */
+/** Each tab's link off the typed pages, the three catalog landings (spec 0028, AC-14). */
function catalogHrefs(): TabHrefs {
- const hrefs = { tv: CATALOG_PATHS.tv, movie: CATALOG_PATHS.movie };
- return gamesLibraryEnabled() ? { ...hrefs, game: CATALOG_PATHS.game } : hrefs;
+ return {
+ tv: CATALOG_PATHS.tv,
+ movie: CATALOG_PATHS.movie,
+ game: CATALOG_PATHS.game,
+ };
}
-/** Where each tab leads; a GAMES tab renders only when it has a link. */
-type TabHrefs = Record & { game?: string };
+/** Where each tab leads. */
+type TabHrefs = Record;
const TABS = [
{ type: "tv", label: "SHOWS" },
@@ -106,9 +103,8 @@ const TABS = [
* which only the prerendered fallback uses: a page about neither catalog
* renders no tabs at all.
*
- * @param hrefs Where each tab leads, `/shows`, `/movies` and, with the games
- * library on, `/games` by default. The GAMES tab renders only when `hrefs`
- * gives it a link.
+ * @param hrefs Where each tab leads, `/shows`, `/movies` and `/games` by
+ * default.
*/
function MediaTypeTabsView({
selected,
@@ -119,10 +115,7 @@ function MediaTypeTabsView({
hrefs?: TabHrefs;
className?: string;
}) {
- const tabs = TABS.flatMap((tab) => {
- const href = hrefs[tab.type];
- return href === undefined ? [] : [{ ...tab, href }];
- });
+ const tabs = TABS.map((tab) => ({ ...tab, href: hrefs[tab.type] }));
return (
{
// covers: spec 0027, AC-3; spec 0029, AC-15. A games tab has the GAMES tab
// lit and searches games.
it("lights GAMES and searches games on a games tab (spec 0029, AC-15)", () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
window.localStorage.removeItem("bestats:last-media-type");
pathname.current = "/watched";
search.current = "type=game";
@@ -190,7 +189,6 @@ describe("Navbar", () => {
);
expect(window.localStorage.getItem("bestats:last-media-type")).toBe("game");
- vi.unstubAllEnvs();
window.localStorage.removeItem("bestats:last-media-type");
pathname.current = "/shows";
search.current = "type=movie";
@@ -200,7 +198,6 @@ describe("Navbar", () => {
it.each(["/games", "/games/coming-soon"])(
"lights GAMES on %s, searching games, with the logo on /games",
(path) => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
pathname.current = path;
search.current = "";
render(
@@ -223,7 +220,6 @@ describe("Navbar", () => {
"/games",
);
- vi.unstubAllEnvs();
window.localStorage.removeItem("bestats:last-media-type");
pathname.current = "/shows";
search.current = "type=movie";
@@ -232,7 +228,6 @@ describe("Navbar", () => {
// spec 0029, AC-15, AC-18
it("searches games on /search?type=game, with the logo on /games", () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
pathname.current = "/search";
search.current = "type=game&q=zelda";
render(
@@ -253,30 +248,6 @@ describe("Navbar", () => {
"href",
"/games",
);
- vi.unstubAllEnvs();
- window.localStorage.removeItem("bestats:last-media-type");
- pathname.current = "/shows";
- search.current = "type=movie";
- });
-
- it("lights SHOWS and searches shows on /search?type=game with the flag off", () => {
- pathname.current = "/search";
- search.current = "type=game";
- render(
- }
- desktopAccountSlot={ }
- />,
- );
- expect(screen.getByRole("combobox")).toHaveAttribute(
- "placeholder",
- "Search shows",
- );
- expect(screen.queryByRole("link", { name: "GAMES" })).toBeNull();
- expect(screen.getByRole("link", { name: "BeStats" })).toHaveAttribute(
- "href",
- "/shows",
- );
window.localStorage.removeItem("bestats:last-media-type");
pathname.current = "/shows";
search.current = "type=movie";
diff --git a/components/layout/page-media-type.tsx b/components/layout/page-media-type.tsx
index 4a83c32e..75bad78f 100644
--- a/components/layout/page-media-type.tsx
+++ b/components/layout/page-media-type.tsx
@@ -9,7 +9,6 @@ import {
mediaTypeForLocation,
type TypedPath,
} from "@/lib/catalog/media-type";
-import { gamesLibraryEnabled } from "@/lib/env";
/**
* Where the visitor is, as the navbar's type aware controls need it: the
@@ -73,7 +72,7 @@ function WithPageMediaType({
return children({
pathname,
type: mediaTypeForLocation(pathname, null),
- libraryType: libraryTypeForLocation(pathname, null, gamesLibraryEnabled()),
+ libraryType: libraryTypeForLocation(pathname, null),
params: null,
});
}
@@ -119,11 +118,7 @@ function TypedPageMediaType({
}) {
const params = useSearchParams();
const typeParam = params.get("type");
- const libraryType = libraryTypeForLocation(
- pathname,
- typeParam,
- gamesLibraryEnabled(),
- );
+ const libraryType = libraryTypeForLocation(pathname, typeParam);
return children({
pathname,
type:
diff --git a/components/layout/site-footer.test.tsx b/components/layout/site-footer.test.tsx
index 29280e6f..24aa7673 100644
--- a/components/layout/site-footer.test.tsx
+++ b/components/layout/site-footer.test.tsx
@@ -1,7 +1,12 @@
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
-import { OPERATOR_GITHUB_URL, OPERATOR_HANDLE } from "@/lib/legal/operator";
+import {
+ IGDB_ATTRIBUTION,
+ IGDB_HOME_URL,
+ OPERATOR_GITHUB_URL,
+ OPERATOR_HANDLE,
+} from "@/lib/legal/operator";
import { TMDB_ATTRIBUTION } from "@/lib/tmdb/constants";
// The real entry point pulls `server-only` and the cached reads into jsdom.
@@ -15,7 +20,7 @@ vi.mock("@/lib/tmdb", async () => {
const { SiteFooter } = await import("./site-footer");
/**
- * covers: spec 0017, AC-2, AC-3, AC-4, AC-5
+ * covers: spec 0017, AC-2, AC-3, AC-4, AC-5; spec 0032, AC-8, AC-10
*
* The footer is TMDB's attribution on every route, so the exact notice, the
* logo link and its attributes are pinned here. The row and stack swap at `md`
@@ -51,6 +56,32 @@ describe("SiteFooter", () => {
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
+ it("credits IGDB with its logo and the one constant, linked in a new tab at touch height (spec 0032, AC-8, AC-10)", () => {
+ render( );
+
+ const logo = screen.getByRole("img", { name: "IGDB" });
+ expect(logo).toHaveAttribute("src", "/igdb-logo.svg");
+ // No taller than TMDB's logo, which is h-3 and md:h-3.5.
+ expect(logo).toHaveClass("h-3", "md:h-3.5", "w-auto");
+
+ const link = logo.closest("a");
+ expect(link).toHaveTextContent(IGDB_ATTRIBUTION);
+ expect(link).toHaveAttribute("href", IGDB_HOME_URL);
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ expect(link).toHaveClass("min-h-11", "md:min-h-9");
+ });
+
+ it("puts IGDB's credit after TMDB's notice (spec 0032, AC-8)", () => {
+ render( );
+
+ const tmdb = screen.getByText(TMDB_ATTRIBUTION);
+ const igdb = screen.getByText(IGDB_ATTRIBUTION);
+ expect(
+ tmdb.compareDocumentPosition(igdb) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
it("links both legal pages at touch height (AC-4, AC-5)", () => {
render( );
const nav = screen.getByRole("navigation", { name: "Legal" });
diff --git a/components/layout/site-footer.tsx b/components/layout/site-footer.tsx
index e09c6807..ea0d2809 100644
--- a/components/layout/site-footer.tsx
+++ b/components/layout/site-footer.tsx
@@ -3,6 +3,8 @@ import Image from "next/image";
import Link from "next/link";
import type * as React from "react";
import {
+ IGDB_ATTRIBUTION,
+ IGDB_HOME_URL,
OPERATOR_GITHUB_URL,
OPERATOR_HANDLE,
TMDB_HOME_URL,
@@ -14,7 +16,8 @@ import { IconKey } from "./icon-key";
/**
* The quiet footer under every route: the icon key to the card marks
* (`IconKey`), then TMDB's logo and the notice its API terms require, then
- * the two legal pages and the credit line (spec 0017, AC-1 to AC-5). The
+ * IGDB's logo and credit (spec 0032, AC-8, AC-10), then the two legal pages
+ * and the credit line (spec 0017, AC-1 to AC-5). The
* credit names the operator by handle, linked to their GitHub profile, the
* same way the legal pages do.
*
@@ -61,6 +64,28 @@ export function SiteFooter() {
{TMDB_ATTRIBUTION}
+
+ {/*
+ * IGDB's press kit SVG, byte for byte (`./igdb-logo.test.ts`),
+ * white on transparent for the one dark palette. Sized like
+ * TMDB's logo, never taller; the size attributes are its viewBox
+ * (114 × 55).
+ */}
+
+ {IGDB_ATTRIBUTION}
+
{/*
diff --git a/components/library/game-library-tab.test.tsx b/components/library/game-library-tab.test.tsx
index c28e266c..498d9ded 100644
--- a/components/library/game-library-tab.test.tsx
+++ b/components/library/game-library-tab.test.tsx
@@ -7,8 +7,8 @@ import type { LibraryGameItem, LibraryList } from "./types";
* covers: spec 0027, AC-5 to AC-11, AC-14; spec 0030, AC-11
*
* The games tabs through `LibrarySection`, as the page renders them. The
- * session, the flag, the Postgres page and the IGDB batch are the
- * boundaries and are replaced; `libraryLastPage`, the item mapping and the
+ * session, the Postgres page and the IGDB batch are the boundaries and are
+ * replaced; `libraryLastPage`, the item mapping and the
* date formatting stay real. The client grid has its own suite, so here it
* is a stub that shows what it was handed. `redirect()` throws, as in Next.
*/
@@ -126,7 +126,6 @@ function page(rows: unknown[], total = rows.length) {
}
beforeEach(() => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
requireUser.mockResolvedValue({ id: "user-a", email: "a@example.test" });
getGameLibraryPage.mockResolvedValue(page([]));
getLibraryGames.mockResolvedValue({
@@ -145,28 +144,9 @@ beforeEach(() => {
afterEach(() => {
vi.clearAllMocks();
- vi.unstubAllEnvs();
});
-describe("the flag and the parameters (AC-5)", () => {
- it("answers ?type=game with the doesn't exist panel while the flag is off", async () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "off");
- await renderSection("watchlist", { type: "game" });
-
- expect(screen.getByText("That page doesn't exist")).toBeInTheDocument();
- expect(
- screen.getByRole("link", { name: "Back to Watchlist" }),
- ).toHaveAttribute("href", "/watchlist?type=tv");
- expect(getGameLibraryPage).not.toHaveBeenCalled();
- });
-
- it("throws on an invalid flag, so the error boundary shows", async () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "maybe");
- await expect(renderSection("watchlist", { type: "game" })).rejects.toThrow(
- "NEXT_PUBLIC_GAMES_LIBRARY",
- );
- });
-
+describe("the parameters (AC-5)", () => {
it("keeps type=game in a malformed page's Back to page 1 link", async () => {
await renderSection("watched", { type: "game", page: "x" });
diff --git a/components/library/library-error-boundary.test.tsx b/components/library/library-error-boundary.test.tsx
index 66e8ac82..ae4fd413 100644
--- a/components/library/library-error-boundary.test.tsx
+++ b/components/library/library-error-boundary.test.tsx
@@ -4,14 +4,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LibraryErrorBoundary } from "./library-error-boundary";
/**
- * covers: spec 0027, AC-5 (an invalid flag shows the library error boundary)
+ * covers: spec 0027, AC-5
*
* The section is replaced by a child that throws, the way `LibrarySection`
- * does on a bad `NEXT_PUBLIC_GAMES_LIBRARY`. React logs every caught error,
- * so the console is silenced for the throwing cases.
+ * does on a read the tabs do not settle. React logs every caught error, so
+ * the console is silenced for the throwing cases.
*/
function Throws(): never {
- throw new Error("NEXT_PUBLIC_GAMES_LIBRARY must be on or off");
+ throw new Error("library read exploded");
}
describe("LibraryErrorBoundary", () => {
@@ -56,8 +56,6 @@ describe("LibraryErrorBoundary", () => {
,
);
- expect(
- screen.queryByText(/NEXT_PUBLIC_GAMES_LIBRARY/),
- ).not.toBeInTheDocument();
+ expect(screen.queryByText(/library read exploded/)).not.toBeInTheDocument();
});
});
diff --git a/components/library/library-error-boundary.tsx b/components/library/library-error-boundary.tsx
index 02529405..0d6b3562 100644
--- a/components/library/library-error-boundary.tsx
+++ b/components/library/library-error-boundary.tsx
@@ -10,8 +10,8 @@ import type { LibraryList } from "./types";
/**
* The panel a library page shows when its section throws instead of
- * returning a panel of its own: an invalid `NEXT_PUBLIC_GAMES_LIBRARY` value
- * (spec 0027, AC-5), or any read that fails in a way the tabs do not settle.
+ * returning a panel of its own: any read that fails in a way the tabs do not
+ * settle (spec 0027, AC-5).
*
* It wraps only the streamed section, so the heading stays in the static
* shell and keeps the focus target removals rely on (spec 0008, AC-16). It
diff --git a/components/library/library-section.tsx b/components/library/library-section.tsx
index 4eb47d3a..9fbbb25f 100644
--- a/components/library/library-section.tsx
+++ b/components/library/library-section.tsx
@@ -1,7 +1,6 @@
import { requireUser } from "@/lib/auth/user";
import { parseLibraryTypeParam } from "@/lib/catalog/media-type";
import { parsePageParam } from "@/lib/catalog/pages";
-import { getGamesLibrary } from "@/lib/env";
import { requestTodayUtc } from "@/lib/tracking/episode-state";
import { gameLibraryTab } from "./game-library-tab";
@@ -15,9 +14,7 @@ import type { LibraryList } from "./types";
* spec 0008 for the shared states). It streams behind the page's Suspense
* boundary, because it reads the session, the search params and the user's
* rows. It lists one media type, the navbar tab's `type` parameter
- * (feature 22), or games while `NEXT_PUBLIC_GAMES_LIBRARY` is on (spec 0027,
- * AC-5). An invalid flag value throws here, so a misconfigured deploy shows
- * the library error boundary instead of guessing.
+ * (feature 22), or games (spec 0027, AC-5).
*
* The order is deliberate. `requireUser()` first, so a request that got past
* the proxy still receives no list data. Then the type and page parameters,
@@ -41,7 +38,7 @@ async function LibrarySection({
const user = await requireUser();
const raw = await searchParams;
- const type = parseLibraryTypeParam(raw.type, getGamesLibrary() === "on");
+ const type = parseLibraryTypeParam(raw.type);
const page = parsePageParam(raw.page);
if (type === null) return ;
if (page === null) return ;
diff --git a/components/search/navbar-search.tsx b/components/search/navbar-search.tsx
index 213c138d..5d45775d 100644
--- a/components/search/navbar-search.tsx
+++ b/components/search/navbar-search.tsx
@@ -17,7 +17,7 @@ type Layout = "desktop" | "mobile";
* AC-6).
*
* The type follows the page (feature 22): movies under `/movies`, shows
- * under `/shows`, games under `/games` while the games library is on, and the
+ * under `/shows`, games under `/games`, and the
* `type` parameter on `/watchlist`, `/upcoming`, `/watched` and `/search`,
* read through `WithLibraryTypedPage`, the field the tabs follow (spec 0029,
* AC-15). A page about neither catalog renders no search at all
diff --git a/components/search/search-filters.tsx b/components/search/search-filters.tsx
index 9f665258..3a691f49 100644
--- a/components/search/search-filters.tsx
+++ b/components/search/search-filters.tsx
@@ -1,11 +1,10 @@
import { Skeleton } from "@/components/skeleton";
import { parseLibraryTypeParam } from "@/lib/catalog/media-type";
-import { gameSearchEnabled } from "@/lib/search/games-flag";
import { readGameLists, settleList } from "@/lib/search/lists";
import {
emptySearchParams,
parseGameSearchParams,
- parseSearchParams,
+ parseTmdbSearchParams,
} from "@/lib/search/params";
import { getMovieGenres, getTvGenres } from "@/lib/tmdb";
@@ -22,13 +21,12 @@ type RawParams = Record;
* An invalid URL fills the bar with the valid type and nothing else; the
* results panel below says what was wrong.
*
- * A games search reads IGDB's genres and platforms instead of TMDB's genres,
- * and the games flag is read only for a raw `type=game` (spec 0029, AC-1).
+ * A games search reads IGDB's genres and platforms instead of TMDB's genres
+ * (spec 0029, AC-1).
*/
async function SearchFilters({ raw }: { raw: RawParams }) {
const currentYear = new Date().getUTCFullYear();
- const gamesEnabled = gameSearchEnabled(raw.type);
- const type = parseLibraryTypeParam(raw.type, gamesEnabled) ?? "tv";
+ const type = parseLibraryTypeParam(raw.type) ?? "tv";
if (type === "game") {
const lists = await readGameLists();
@@ -47,7 +45,7 @@ async function SearchFilters({ raw }: { raw: RawParams }) {
settleList(getTvGenres),
settleList(getMovieGenres),
]);
- const parsed = parseSearchParams(
+ const parsed = parseTmdbSearchParams(
raw,
{ genres: (type === "movie" ? movie : tv) ?? [] },
currentYear,
diff --git a/components/search/search-results.test.tsx b/components/search/search-results.test.tsx
index e55bfe9d..ce85a373 100644
--- a/components/search/search-results.test.tsx
+++ b/components/search/search-results.test.tsx
@@ -7,18 +7,12 @@ import type { GameSummary, Paged } from "@/lib/igdb";
* covers: spec 0029, AC-1, AC-2, AC-4, AC-5, AC-8, AC-11, AC-12, AC-13;
* spec 0030, AC-1
*
- * The games branch of `SearchResults`, as `/search` renders it. The flag,
- * IGDB's reference lists and the one results read are the boundaries and are
+ * The games branch of `SearchResults`, as `/search` renders it. IGDB's
+ * reference lists and the one results read are the boundaries and are
* replaced; the URL parser, the mode, the paging rules and every panel stay
* real, so each test checks what the visitor would see for one URL. The TMDB
* half and the session slots have their own suites and are stubbed out.
*/
-const getGamesLibrary = vi.fn();
-vi.mock("@/lib/env", async (importOriginal) => ({
- ...(await importOriginal()),
- getGamesLibrary: () => getGamesLibrary(),
-}));
-
class IgdbError extends Error {}
const getGameGenres = vi.fn();
const getGamePlatforms = vi.fn();
@@ -107,7 +101,6 @@ async function renderResults(
}
beforeEach(() => {
- getGamesLibrary.mockReturnValue("on");
getGameGenres.mockResolvedValue(GENRES);
getGamePlatforms.mockResolvedValue(PLATFORMS);
});
@@ -366,33 +359,3 @@ describe("SearchResults for games", () => {
expect(screen.queryByRole("link", { name: "Clear filters" })).toBeNull();
});
});
-
-describe("SearchResults and the games flag (AC-1)", () => {
- it("refuses type=game while the games library is off, before any IGDB request", async () => {
- getGamesLibrary.mockReturnValue("off");
-
- await renderResults({ type: "game", q: "zelda" });
-
- expect(screen.getByText("That filter isn't valid")).toBeInTheDocument();
- expect(screen.getByText(/“type”/)).toBeInTheDocument();
- expect(getGameGenres).not.toHaveBeenCalled();
- expect(readGameSearchPage).not.toHaveBeenCalled();
- });
-
- it("never reads the flag for a movie search, so an invalid flag cannot break it", async () => {
- getGamesLibrary.mockImplementation(() => {
- throw new Error("invalid NEXT_PUBLIC_GAMES_LIBRARY");
- });
- getMovieGenres.mockResolvedValue([]);
- readSearchGridPage.mockResolvedValue({
- results: [],
- totalResults: 0,
- totalPages: 0,
- });
-
- await renderResults({ type: "movie", q: "dune" });
-
- expect(getGamesLibrary).not.toHaveBeenCalled();
- expect(screen.getByText("No movies match “dune”")).toBeInTheDocument();
- });
-});
diff --git a/components/search/search-results.tsx b/components/search/search-results.tsx
index 240ec8f4..fc552e3c 100644
--- a/components/search/search-results.tsx
+++ b/components/search/search-results.tsx
@@ -39,12 +39,11 @@ import {
import { SEARCH_CATALOGS } from "@/lib/search/catalogs";
import { formatRange, mediaNoun } from "@/lib/search/count";
import { readGameSearchPage } from "@/lib/search/game-read";
-import { gameSearchEnabled } from "@/lib/search/games-flag";
import { readGameLists } from "@/lib/search/lists";
import { searchMode } from "@/lib/search/mode";
import {
parseGameSearchParams,
- parseSearchParams,
+ parseTmdbSearchParams,
type SearchParamName,
type SearchParams,
type SearchType,
@@ -79,13 +78,10 @@ type RawParams = Record;
* except by each card's bookmark in its own boundary (AC-16, AC-22; spec
* 0013, AC-18).
*
- * `type=game` counts only while the games library is on, and the flag is read
- * only for that raw value, so an invalid flag can never take a show or movie
- * search down (spec 0029, AC-1). A games search has its own branch.
+ * A games search has its own branch (spec 0029, AC-1).
*/
async function SearchResults({ raw }: { raw: RawParams }) {
- const gamesEnabled = gameSearchEnabled(raw.type);
- const type = parseLibraryTypeParam(raw.type, gamesEnabled);
+ const type = parseLibraryTypeParam(raw.type);
if (type === null) return ;
const retryHref = rawHref(raw);
@@ -107,7 +103,7 @@ async function SearchResults({ raw }: { raw: RawParams }) {
// Read at request time, after the URL, so the year range is never baked
// into a prerendered shell.
- const parsed = parseSearchParams(
+ const parsed = parseTmdbSearchParams(
raw,
{ genres },
new Date().getUTCFullYear(),
diff --git a/docs/deploy.md b/docs/deploy.md
index 3dc5ac24..797a13eb 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -70,11 +70,12 @@ Set them in the Vercel project, under Settings, Environment Variables. `.env.exa
| `TMDB_READ_ACCESS_TOKEN` | your TMDB read token (sensitive) | the same token |
| `NEXT_PUBLIC_SITE_URL` | `https://bestats-sable.vercel.app`, no trailing slash | not set |
| `NEXT_PUBLIC_AUTH_EMAIL_DELIVERY` | `off` | `off` |
-| `NEXT_PUBLIC_GAMES_LIBRARY` | not set (off) | `on` |
+| `IGDB_CLIENT_ID` | your Twitch app's client id (sensitive) | the same id |
+| `IGDB_CLIENT_SECRET` | your Twitch app's client secret (sensitive) | the same secret |
-Never add a Supabase service role key, a secret key or a database password to any environment, and never give the TMDB token a `NEXT_PUBLIC_` prefix.
+Never add a Supabase service role key, a secret key or a database password to any environment, and never give the TMDB token or either IGDB variable a `NEXT_PUBLIC_` prefix (`security-boundary.test.ts` fails if one does). Without the IGDB pair a deployment still builds, but every games read answers "Couldn't reach IGDB" and the sitemap lists no games (spec 0032, AC-7).
-`NEXT_PUBLIC_` values are inlined at build time, so changing one (the email flag, for example) takes a redeploy, not just an edit. `NEXT_PUBLIC_GAMES_LIBRARY` stays unset in Production until a games browse page or game search (G6 or G7) gives people a way to find games; with it off, `?type=game` on the library pages shows "That page doesn't exist" (spec 0027, AC-1). You can check the list with `vercel env ls`.
+`NEXT_PUBLIC_` values are inlined at build time, so changing one (the email flag, for example) takes a redeploy, not just an edit. Games have no flag since spec 0032: the GAMES tab shows everywhere, so remove any leftover `NEXT_PUBLIC_GAMES_LIBRARY` from Preview. You can check the list with `vercel env ls`.
**Previews** have no Supabase variables on purpose. They show the catalog, a signed out navbar, `Sign in isn't available on this deployment.` on `/sign-in` and `/sign-up`, and a `robots.txt` that disallows everything. Vercel's deployment protection stays on, so only you (signed in to Vercel) or someone holding the protection bypass token can open one. `vercel curl --deployment ` opens a preview with the bypass for a quick check. No preview origin ever goes into the Supabase redirect list.
@@ -143,8 +144,8 @@ There are no automatic backups on Free. If losing data would hurt, revisit the p
## 9. Before and after a release
-1. Before: run `pnpm tmdb:live` to confirm TMDB answers with your token.
-2. After the deploy: open `https://bestats-sable.vercel.app/sitemap.xml`. It should list `/movies`, `/shows`, `/privacy`, `/terms` and a few hundred title pages. A sitemap with only the static routes means TMDB failed at build time: redeploy from Vercel.
+1. Before: run `pnpm tmdb:live` and `pnpm igdb:live` to confirm TMDB and IGDB answer with your credentials.
+2. After the deploy: open `https://bestats-sable.vercel.app/sitemap.xml`. It should list `/movies`, `/shows`, `/games`, the fifteen list pages, `/privacy`, `/terms`, then a few hundred movie and show pages and up to 200 game pages. A sitemap with only the static routes means TMDB failed at build time, and one with no `/games/{id}` entries means IGDB did: redeploy from Vercel.
3. Check `https://bestats-sable.vercel.app/robots.txt` allows crawling and names the sitemap.
4. Once, submit the sitemap in Google Search Console (add the origin as a URL prefix property, verify it, then Sitemaps, `sitemap.xml`).
diff --git a/docs/scope/games.md b/docs/scope/games.md
index 9dd3ce71..ad973fce 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`
+ - [x] Terms check in a browser, recorded in the spec's rationale, plus the logo search, AC-10, AC-11
+ - [x] 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
+ - [x] `/games` and the five list pages indexed and in the sitemap, AC-4 to AC-6
+ - [x] 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`
+- [x] Test it: `/test games launch`
+- [ ] Review it (fresh model): `/check review games launch`
+- [x] 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 00000000..e14adb03
--- /dev/null
+++ b/docs/specs/0032-games-launch/index.md
@@ -0,0 +1,153 @@
+# 0032. Games launch
+
+**Date**: 2026-10-26
+**Status**: In Progress
+**Amended**: 2026-10-26. AC-2 no longer says "no Open Graph or X fields". These pages keep the root layout's default site card (its `og:title`, `og:image` and X fields), as `/movies/{id}` does; what they never carry is anything game specific. Found by `/check verify`.
+
+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 `og:url`, and no JSON-LD. They may keep the root layout's default site card, but never a game's title, description, cover or URL in any Open Graph or X field.
+- **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 00000000..3f2910cf
--- /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*.
diff --git a/docs/specs/0032-games-launch/verify.md b/docs/specs/0032-games-launch/verify.md
new file mode 100644
index 00000000..bb4e291d
--- /dev/null
+++ b/docs/specs/0032-games-launch/verify.md
@@ -0,0 +1,29 @@
+# Verify: Games launch · spec 0032 · updated 2026-10-26
+_Steps derived from spec 0032 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._
+
+Build locally with `VERCEL_ENV=production NEXT_PUBLIC_SITE_URL=https://bestats.example pnpm build`, then `PORT=3123 pnpm start` with the same two variables, so the indexable branch is what you check.
+
+## UI / manual
+- [x] Open `/games/1942` → ` `, no ` `, `og:image` is the game's `t_1080p` art, title `The Witcher 3: Wild Hunt (2015) · BeStats` → AC-1
+- [x] Open a game with no art but screenshots, then one with only a cover → `og:image` is the first screenshot, then the cover → AC-1 (Value sourcing: share image)
+- [x] Open `/games/999999999` and a hidden game (a DLC id) → `noindex`, no canonical, no `og:url`, no `application/ld+json`, and any `og:*` or `twitter:*` field is the site default, never the game's → AC-2
+- [x] With IGDB credentials removed, open `/games/1942` → retry panel, `noindex`, no JSON-LD → AC-2
+- [x] View source of `/games/1942` → one `VideoGame` script with `name`, `url`, `image` (the cover), `datePublished`, `description`, `genre`, `gamePlatform`, `author` and `publisher` as `Organization`s, no `rating` anywhere → AC-3 (Value sourcing: `image`, `datePublished`, `genre`, `gamePlatform`, `author`, `publisher`)
+- [x] Open `/games` → canonical `…/games`, site card `og:image`, no `noindex` → AC-4
+- [x] Open `/games/popular`, `/games/recently-released?page=3` → each has its own canonical; `/games/top-rated?page=51` and `?page=abc` are `noindex` with no canonical → AC-5 (Value sourcing: list indexability)
+- [x] Open `/sitemap.xml` → `/movies`, `/shows`, `/games`, five movie, five show, five games list pages, `/privacy`, `/terms`, movies, shows, then up to 200 `/games/{id}`; all absolute → AC-6 (Value sourcing: game ids, games list paths)
+- [x] Look at the footer on `/shows` at 1440px and 390px → TMDB logo and notice, then the IGDB logo and "Game data and images from IGDB.com" as one link to igdb.com in a new tab, logo no taller than TMDB's → AC-8, AC-10
+- [x] Open `/terms` → section "Catalog data, TMDB and IGDB" ends with the three IGDB paragraphs in order; Last updated 26 October 2026 → AC-9
+- [ ] With no flag set anywhere: GAMES tab on `/shows`, `/watchlist?type=game` (signed in) shows Want to play, `/search?type=game&q=zelda` returns games, `GET /api/search?type=game&q=zelda` answers 200 → AC-12
+- [x] Open a Preview deployment with `vercel curl /games/1942 --deployment ` → the game renders (credentials present in Preview) → AC-13
+- [ ] After merge, on production: `/sitemap.xml` lists games; `/games/1942` has canonical, `og:image`, `VideoGame` JSON-LD and no `noindex`; `/games` and `/games/popular` have canonicals; GAMES tab shows; validator.schema.org on `https://bestats-sable.vercel.app/games/1942` finds a `VideoGame` with zero errors → AC-15
+
+## Commands
+- [x] `pnpm test` → all green, including `app/sitemap.test.ts`, `lib/seo/sitemap.test.ts`, `lib/seo/json-ld.test.ts`, `lib/landing-metadata.test.ts`, `components/layout/igdb-logo.test.ts`, `security-boundary.test.ts` → AC-1 to AC-12, AC-14
+- [x] Mock `getPopularGames` to reject with `IgdbError("unauthorized")` → sitemap keeps every other entry and picks `cacheLife("minutes")`; an `IgdbValidationError` fails the build → AC-7 (in `app/sitemap.test.ts`)
+- [x] `pnpm build` → `/games`, `/games/[id]` and the five list pages are `◐` (partial prerender), `/sitemap.xml`, `/robots.txt`, `/opengraph-image` are `○` → AC-14
+- [x] `git grep NEXT_PUBLIC_GAMES_LIBRARY -- ':!docs'` → only `security-boundary.test.ts` builds the name from fragments, so no match → AC-12
+- [x] `vercel env ls` → `IGDB_CLIENT_ID` and `IGDB_CLIENT_SECRET` in Production and Preview, no `NEXT_PUBLIC_GAMES_LIBRARY` → AC-12, AC-13
+
+## Acceptance-criteria coverage
+- AC-1 to AC-3: game page steps · AC-4, AC-5: landing and list steps · AC-6, AC-7: sitemap step and the IGDB failure command · AC-8 to AC-10: footer and `/terms` steps · AC-11: the record in `rationale.md`, *IGDB terms check* · AC-12: no flag steps and the grep · AC-13: Preview step and `vercel env ls` · AC-14: build command and request scope tests · AC-15: production step
diff --git a/lib/catalog/media-type.test.ts b/lib/catalog/media-type.test.ts
index 250957e2..30204daa 100644
--- a/lib/catalog/media-type.test.ts
+++ b/lib/catalog/media-type.test.ts
@@ -97,21 +97,18 @@ describe("titleHref", () => {
});
});
-/** covers: spec 0027, AC-2 to AC-4 */
+/** covers: spec 0027, AC-2 to AC-4; spec 0032, AC-12 */
describe("parseLibraryTypeParam", () => {
- it("accepts game only while the games library is on", () => {
- expect(parseLibraryTypeParam("game", true)).toBe("game");
- expect(parseLibraryTypeParam("game", false)).toBeNull();
+ it("always accepts game", () => {
+ expect(parseLibraryTypeParam("game")).toBe("game");
});
it("reads everything else as parseMediaTypeParam does", () => {
- for (const enabled of [true, false]) {
- expect(parseLibraryTypeParam(undefined, enabled)).toBe("tv");
- expect(parseLibraryTypeParam("movie", enabled)).toBe("movie");
- expect(parseLibraryTypeParam("", enabled)).toBeNull();
- expect(parseLibraryTypeParam(["game", "game"], enabled)).toBeNull();
- expect(parseLibraryTypeParam("GAME", enabled)).toBeNull();
- }
+ expect(parseLibraryTypeParam(undefined)).toBe("tv");
+ expect(parseLibraryTypeParam("movie")).toBe("movie");
+ expect(parseLibraryTypeParam("")).toBeNull();
+ expect(parseLibraryTypeParam(["game", "game"])).toBeNull();
+ expect(parseLibraryTypeParam("GAME")).toBeNull();
});
it("never lets game into the catalog parser", () => {
@@ -120,34 +117,31 @@ describe("parseLibraryTypeParam", () => {
});
describe("libraryTypeForLocation", () => {
- it("is game on a library page asking for it while the flag is on", () => {
+ it("is game on a library page asking for it", () => {
for (const path of ["/watchlist", "/upcoming", "/watched"]) {
- expect(libraryTypeForLocation(path, "game", true)).toBe("game");
+ expect(libraryTypeForLocation(path, "game")).toBe("game");
}
});
- it("is game on /search asking for it while the flag is on (spec 0029, AC-18)", () => {
- expect(libraryTypeForLocation("/search", "game", true)).toBe("game");
+ it("is game on /search asking for it (spec 0029, AC-18)", () => {
+ expect(libraryTypeForLocation("/search", "game")).toBe("game");
});
- it("is never game on a catalog page or with the flag off", () => {
- expect(libraryTypeForLocation("/movies", "game", true)).toBe("movie");
- expect(libraryTypeForLocation("/watchlist", "game", false)).toBe("tv");
- expect(libraryTypeForLocation("/search", "game", false)).toBe("tv");
+ it("is never game on a catalog page", () => {
+ expect(libraryTypeForLocation("/movies", "game")).toBe("movie");
});
// spec 0028, AC-14, AC-16
- it("is game on /games and below it while the flag is on, and no type when off", () => {
+ it("is game on /games and below it", () => {
for (const path of ["/games", "/games/trending", "/games/1942"]) {
- expect(libraryTypeForLocation(path, null, true)).toBe("game");
- expect(libraryTypeForLocation(path, null, false)).toBeNull();
+ expect(libraryTypeForLocation(path, null)).toBe("game");
}
- expect(libraryTypeForLocation("/gamesx", null, true)).toBeNull();
+ expect(libraryTypeForLocation("/gamesx", null)).toBeNull();
});
it("matches mediaTypeForLocation for the catalogs", () => {
- expect(libraryTypeForLocation("/upcoming", "movie", true)).toBe("movie");
- expect(libraryTypeForLocation("/account", null, true)).toBeNull();
+ expect(libraryTypeForLocation("/upcoming", "movie")).toBe("movie");
+ expect(libraryTypeForLocation("/account", null)).toBeNull();
});
});
diff --git a/lib/catalog/media-type.ts b/lib/catalog/media-type.ts
index eb2e2cda..d1c54f50 100644
--- a/lib/catalog/media-type.ts
+++ b/lib/catalog/media-type.ts
@@ -110,19 +110,14 @@ export function parseMediaTypeParam(
}
/**
- * The library pages' `type` parameter (spec 0027, AC-2): `parseMediaTypeParam`
- * plus `game`, which only counts while the games library is switched on. With
- * it off, `?type=game` is null like any unknown value, so the page shows its
- * "doesn't exist" panel.
- *
- * @param gamesEnabled `getGamesLibrary() === "on"` on the server,
- * `gamesLibraryEnabled()` in the navbar.
+ * The library pages' and `/search`'s `type` parameter (spec 0027, AC-2):
+ * `parseMediaTypeParam` plus `game`, always accepted since games launched
+ * (spec 0032, AC-12).
*/
export function parseLibraryTypeParam(
value: string | string[] | null | undefined,
- gamesEnabled: boolean,
): LibraryType | null {
- if (value === "game") return gamesEnabled ? "game" : null;
+ if (value === "game") return "game";
return parseMediaTypeParam(value);
}
@@ -182,20 +177,14 @@ export function isGamesPath(pathname: string | null): boolean {
* tabs, the Library links, the search and the logo (spec 0027, AC-3, AC-4;
* spec 0028, AC-14, AC-16; spec 0029, AC-14, AC-15, AC-18). It is `game` on
* `/games` and its list and game pages, and on a library page or `/search`
- * asking for it, while the games library is on, and otherwise exactly
- * `mediaTypeForLocation`. With the library off a games page has no
- * type, like a page about neither catalog.
+ * asking for it, and otherwise exactly `mediaTypeForLocation`.
*/
export function libraryTypeForLocation(
pathname: string | null,
typeParam: string | null,
- gamesEnabled: boolean,
): LibraryType | null {
- if (isGamesPath(pathname)) return gamesEnabled ? "game" : null;
- if (
- isTypedPath(pathname) &&
- parseLibraryTypeParam(typeParam, gamesEnabled) === "game"
- ) {
+ if (isGamesPath(pathname)) return "game";
+ if (isTypedPath(pathname) && parseLibraryTypeParam(typeParam) === "game") {
return "game";
}
return mediaTypeForLocation(pathname, typeParam);
diff --git a/lib/env.test.ts b/lib/env.test.ts
index b578c787..4b984d36 100644
--- a/lib/env.test.ts
+++ b/lib/env.test.ts
@@ -1,9 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
- gamesLibraryEnabled,
getAuthEmailDelivery,
- getGamesLibrary,
getPublicEnv,
publicEnvProblems,
} from "@/lib/env";
@@ -96,39 +94,3 @@ describe("getAuthEmailDelivery", () => {
expect(() => getAuthEmailDelivery()).not.toThrow("yes-please");
});
});
-
-/**
- * covers: spec 0027, AC-1
- *
- * The games library flag: unset means off, the library throws on a typo,
- * and the navbar reads a typo as off so no page outside the library breaks.
- */
-describe("getGamesLibrary and gamesLibraryEnabled", () => {
- afterEach(() => {
- vi.unstubAllEnvs();
- });
-
- it.each([undefined, ""])("reads %j as off", (value) => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", value);
- expect(getGamesLibrary()).toBe("off");
- expect(gamesLibraryEnabled()).toBe(false);
- });
-
- it.each(["on", "off"] as const)("returns %s as given", (value) => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", value);
- expect(getGamesLibrary()).toBe(value);
- expect(gamesLibraryEnabled()).toBe(value === "on");
- });
-
- it("throws on anything else in the library, naming only the variable", () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "yes-please");
-
- expect(() => getGamesLibrary()).toThrow("NEXT_PUBLIC_GAMES_LIBRARY");
- expect(() => getGamesLibrary()).not.toThrow("yes-please");
- });
-
- it("reads anything else as off in the navbar, without throwing", () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "yes-please");
- expect(gamesLibraryEnabled()).toBe(false);
- });
-});
diff --git a/lib/env.ts b/lib/env.ts
index 7238b83d..0d0fb931 100644
--- a/lib/env.ts
+++ b/lib/env.ts
@@ -152,52 +152,3 @@ export function getAuthEmailDelivery(): AuthEmailDelivery {
"Invalid public environment configuration. NEXT_PUBLIC_AUTH_EMAIL_DELIVERY must be on or off.",
);
}
-
-/** Whether the games library tab is switched on for this deployment. */
-export type GamesLibrary = "on" | "off";
-
-/** The raw flag, or null for a value that is neither `on` nor `off`. */
-function readGamesLibrary(): GamesLibrary | null {
- const value = process.env.NEXT_PUBLIC_GAMES_LIBRARY;
-
- if (value === undefined || value === "" || value === "off") return "off";
- if (value === "on") return "on";
- return null;
-}
-
-/**
- * Reads `NEXT_PUBLIC_GAMES_LIBRARY`, the switch for the GAMES tab on the
- * library pages (spec 0027, AC-1). Production leaves it unset until a games
- * browse page or game search gives people a way to find games, so unset or
- * empty means `off`.
- *
- * `LibrarySection` calls this, so a misconfigured deploy shows the library
- * error boundary there instead of guessing (AC-5). `/search` and
- * `/api/search` call it too, but only for a raw `type=game`, so a bad value
- * can never take a show or movie search down (spec 0029, AC-1, AC-17). Written like
- * `getAuthEmailDelivery()`, apart from `getPublicEnv()`, so a build with no
- * Supabase values still works. Read as a literal `process.env` access, so
- * Next.js inlines it at build time and a change needs a redeploy.
- *
- * @returns `on` or `off`.
- * @throws If the variable holds anything else. The message names the variable
- * and never its value.
- */
-export function getGamesLibrary(): GamesLibrary {
- const value = readGamesLibrary();
- if (value !== null) return value;
-
- throw new Error(
- "Invalid public environment configuration. NEXT_PUBLIC_GAMES_LIBRARY must be on or off.",
- );
-}
-
-/**
- * The same flag for the navbar, which renders on every page (spec 0027,
- * AC-1, AC-3). An invalid value counts as off and never throws, so a bad
- * value cannot take a page outside the library down: the `publicEnvProblems()`
- * and `getPublicEnv()` split.
- */
-export function gamesLibraryEnabled(): boolean {
- return readGamesLibrary() === "on";
-}
diff --git a/lib/igdb/AGENTS.md b/lib/igdb/AGENTS.md
index b4af8e0b..3015a73c 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 88678c3f..af3c811a 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 a97a408e..a9bf48cd 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());
}
diff --git a/lib/landing-metadata.test.ts b/lib/landing-metadata.test.ts
index bfe9960f..9e73109b 100644
--- a/lib/landing-metadata.test.ts
+++ b/lib/landing-metadata.test.ts
@@ -1,55 +1,185 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { catalogList, GAME_LIST_SLUGS } from "@/lib/catalog/lists";
-import { gameListMetadata, gamesMetadata } from "./landing-metadata";
+import { IgdbError, IgdbValidationError } from "@/lib/igdb/errors";
+import { TmdbError } from "@/lib/tmdb/errors";
/**
- * covers: spec 0028, AC-13
+ * covers: spec 0032, AC-4, AC-5
*
- * Games stay out of search engines until G10: `/games` and every games list
- * page is `noindex`, with no canonical and no share card of its own. The
- * metadata is static, so it holds for every `?page` and costs no IGDB read.
+ * Games open to search engines like movies and shows: `/games` carries its
+ * canonical and the site card, and a games list page is indexed when its page
+ * shows games and is not past a known last page. The grid read is replaced;
+ * the error classes are the real ones, so the catch branch runs as is.
*/
-describe("gamesMetadata", () => {
- it("keeps its title and description and marks the page noindex", () => {
+const readTaggedCatalogGridPage = vi.fn();
+
+vi.mock("@/lib/catalog-list-read", async (importOriginal) => ({
+ ...(await importOriginal()),
+ readTaggedCatalogGridPage: (list: unknown, page: number) =>
+ readTaggedCatalogGridPage(list, page),
+}));
+vi.mock("@/lib/igdb", async () => {
+ const errors = await import("@/lib/igdb/errors");
+ return { IgdbError: errors.IgdbError };
+});
+vi.mock("@/lib/tmdb", async () => {
+ const errors = await import("@/lib/tmdb/errors");
+ return { TmdbError: errors.TmdbError };
+});
+
+const { catalogListMetadata, gamesMetadata } = await import(
+ "./landing-metadata"
+);
+
+const ORIGIN = "https://bestats.example";
+
+beforeEach(() => vi.stubEnv("NEXT_PUBLIC_SITE_URL", ORIGIN));
+
+afterEach(() => {
+ readTaggedCatalogGridPage.mockReset();
+ vi.unstubAllEnvs();
+});
+
+/** A games page holding `count` games, with IGDB's count or none. */
+function gamePage(count: number, totalResults: number | null) {
+ return {
+ mediaType: "game",
+ paged: {
+ page: 1,
+ results: Array.from({ length: count }, (_, id) => ({ id })),
+ hasNextPage: false,
+ totalResults,
+ },
+ };
+}
+
+function metadataFor(
+ list: Parameters[0],
+ page?: string,
+) {
+ return catalogListMetadata(
+ list,
+ Promise.resolve(page === undefined ? {} : { page }),
+ );
+}
+
+describe("gamesMetadata (AC-4)", () => {
+ it("keeps its title and description, with the /games canonical and the site card", () => {
const metadata = gamesMetadata({
title: "Games",
description: "Browse games.",
});
- expect(metadata).toEqual({
- title: "Games",
- description: "Browse games.",
- robots: { index: false },
+ expect(metadata.title).toBe("Games");
+ expect(metadata.description).toBe("Browse games.");
+ expect(metadata.robots).toBeUndefined();
+ expect(metadata.alternates).toEqual({ canonical: `${ORIGIN}/games` });
+ expect(metadata.openGraph).toMatchObject({
+ url: `${ORIGIN}/games`,
+ type: "website",
+ images: [`${ORIGIN}/opengraph-image`],
});
});
- it("names no canonical and no share card", () => {
+ it("leaves the canonical out without a site URL, never relative", () => {
+ vi.stubEnv("NEXT_PUBLIC_SITE_URL", undefined);
+
const metadata = gamesMetadata({ title: "Games", description: "x" });
expect(metadata.alternates).toBeUndefined();
- expect(metadata.openGraph).toBeUndefined();
- expect(metadata.twitter).toBeUndefined();
+ expect(metadata.openGraph?.images).toBeUndefined();
});
});
-describe("gameListMetadata", () => {
+describe("a games list page's metadata (AC-5)", () => {
it.each(GAME_LIST_SLUGS)(
- "titles %s with its h1 and note, noindex",
- (slug) => {
+ "indexes page 1 of %s with its h1, note and own canonical",
+ async (slug) => {
const list = catalogList("game", slug);
+ readTaggedCatalogGridPage.mockResolvedValue(gamePage(24, 500));
- expect(gameListMetadata(list)).toEqual({
- title: list.heading,
- description: list.note,
- robots: { index: false },
+ const metadata = await metadataFor(list);
+
+ expect(readTaggedCatalogGridPage).toHaveBeenCalledWith(list, 1);
+ expect(metadata.title).toBe(list.heading);
+ expect(metadata.description).toBe(list.note);
+ expect(metadata.robots).toBeUndefined();
+ expect(metadata.alternates).toEqual({
+ canonical: `${ORIGIN}/games/${slug}`,
});
},
);
- it("returns metadata directly, not a promise, so it never reads IGDB", () => {
- const metadata = gameListMetadata(catalogList("game", "top-rated"));
+ it("indexes page 3 of a list with no IGDB count while it shows games", async () => {
+ readTaggedCatalogGridPage.mockResolvedValue(gamePage(24, null));
+
+ const metadata = await metadataFor(
+ catalogList("game", "recently-released"),
+ "3",
+ );
+
+ expect(metadata.alternates).toEqual({
+ canonical: `${ORIGIN}/games/recently-released?page=3`,
+ });
+ });
+
+ it.each([
+ ["an empty page", gamePage(0, null)],
+ ["a page past the counted last one", gamePage(5, 24)],
+ ])("is noindex with no canonical for %s", async (_, result) => {
+ readTaggedCatalogGridPage.mockResolvedValue(result);
+
+ const metadata = await metadataFor(catalogList("game", "top-rated"), "2");
+
+ expect(metadata).toEqual({
+ title: catalogList("game", "top-rated").heading,
+ description: catalogList("game", "top-rated").note,
+ robots: { index: false },
+ });
+ });
+
+ it("is noindex without asking IGDB for an invalid page", async () => {
+ const metadata = await metadataFor(catalogList("game", "popular"), "abc");
+
+ expect(metadata.robots).toEqual({ index: false });
+ expect(readTaggedCatalogGridPage).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["an IGDB failure", new IgdbError("rate_limited", "games", "failed")],
+ ["a TMDB failure", new TmdbError("timeout", "/discover", "failed")],
+ ])("is noindex for %s", async (_, error) => {
+ readTaggedCatalogGridPage.mockRejectedValue(error);
+
+ const metadata = await metadataFor(catalogList("game", "trending"));
+
+ expect(metadata.robots).toEqual({ index: false });
+ expect(metadata.alternates).toBeUndefined();
+ });
+
+ it("rethrows an IGDB validation error, a bug rather than an outage", async () => {
+ readTaggedCatalogGridPage.mockRejectedValue(
+ new IgdbValidationError("page", "bad"),
+ );
+
+ await expect(
+ metadataFor(catalogList("game", "trending")),
+ ).rejects.toBeInstanceOf(IgdbValidationError);
+ });
+});
+
+describe("a TMDB list page keeps the rule it had (AC-5)", () => {
+ const tmdbPage = (totalPages: number) => ({
+ mediaType: "movie",
+ paged: { page: 1, results: [], totalPages, totalResults: 0 },
+ });
+
+ it("indexes its last page and not the one after", async () => {
+ const list = catalogList("movie", "top-rated");
+ readTaggedCatalogGridPage.mockResolvedValue(tmdbPage(6));
- expect(metadata).not.toBeInstanceOf(Promise);
+ expect((await metadataFor(list, "6")).robots).toBeUndefined();
+ expect((await metadataFor(list, "7")).robots).toEqual({ index: false });
});
});
diff --git a/lib/landing-metadata.ts b/lib/landing-metadata.ts
index 36ff9e2d..38721a1c 100644
--- a/lib/landing-metadata.ts
+++ b/lib/landing-metadata.ts
@@ -1,14 +1,11 @@
import type { Metadata } from "next";
-import {
- type GameCatalogList,
- listPageHref,
- type TmdbCatalogList,
-} from "@/lib/catalog/lists";
+import { type CatalogList, listPageHref } from "@/lib/catalog/lists";
import { parsePageParam } from "@/lib/catalog/pages";
import {
lastListPage,
readTaggedCatalogGridPage,
} from "@/lib/catalog-list-read";
+import { IgdbError } from "@/lib/igdb";
import { landingMetadata } from "@/lib/seo/metadata";
import { TmdbError } from "@/lib/tmdb";
@@ -18,16 +15,20 @@ import { TmdbError } from "@/lib/tmdb";
* below): parse `page`, read the same cached call the page body makes, and
* index the page only when it shows results.
*
- * It lives outside `lib/seo/` because it touches TMDB at runtime (it catches
- * `TmdbError`), and `lib/seo/AGENTS.md` keeps that folder free of TMDB
- * imports; `lib/catalog/` stays pure for the proxy for the same reason. The
- * caller passes its own discover read, so the request is the very call the
- * body makes and shares its cache entry: metadata costs no extra TMDB request.
+ * It lives outside `lib/seo/` because it touches TMDB and IGDB at runtime (it
+ * catches `TmdbError` and `IgdbError`), and `lib/seo/AGENTS.md` keeps that
+ * folder free of both; `lib/catalog/` stays pure for the proxy for the same
+ * reason. The caller passes its own discover read, so the request is the very
+ * call the body makes and shares its cache entry: metadata costs no extra
+ * request.
*
- * An invalid page, a page past the read's own `totalPages` (already capped by
- * the 24 title grid, spec 0022, AC-21), or a failed TMDB read is `noindex`
- * with no canonical. Only a `TmdbError` counts as a failed read; anything
- * else is a bug and is rethrown.
+ * The page is indexed when the read shows results and the page is not past
+ * a known last page (spec 0032, AC-5). A games discover list may have no
+ * count, so `lastPage` is null there and "has results" alone decides. An
+ * invalid page, an empty page, a page past the last, or a failed read is
+ * `noindex` with no canonical. Only a `TmdbError` or an `IgdbError` counts as
+ * a failed read; anything else, `IgdbValidationError` included, is a bug and
+ * is rethrown.
*/
export async function landingPageMetadata({
pageParam,
@@ -37,7 +38,9 @@ export async function landingPageMetadata({
pageHref,
}: {
pageParam: string | string[] | undefined;
- discover: (input: { page: number }) => Promise<{ totalPages: number }>;
+ discover: (input: {
+ page: number;
+ }) => Promise<{ hasResults: boolean; lastPage: number | null }>;
title: string;
description: string;
pageHref: (page: number) => string;
@@ -48,32 +51,47 @@ export async function landingPageMetadata({
if (page === null) return noindex();
try {
- const { totalPages } = await discover({ page });
- if (page > totalPages) return noindex();
+ const { hasResults, lastPage } = await discover({ page });
+ if (!hasResults || (lastPage !== null && page > lastPage)) {
+ return noindex();
+ }
return landingMetadata({ title, description, path: pageHref(page) });
} catch (error) {
- if (!(error instanceof TmdbError)) throw error;
- return noindex();
+ if (error instanceof TmdbError || error instanceof IgdbError) {
+ return noindex();
+ }
+ throw error;
}
}
/**
- * A list page's metadata (spec 0021, AC-15): its h1 as the title, its note as
- * the description, and page N's own canonical. The `landingPageMetadata` flow
- * with the See all grid's own read as the read, the very call the page body makes,
- * so metadata costs no extra TMDB request and follows the 24 title page count
- * (spec 0022, AC-21).
+ * A list page's metadata (spec 0021, AC-15; games since spec 0032, AC-5): its
+ * h1 as the title, its note as the description, and page N's own canonical.
+ * The `landingPageMetadata` flow with the See all grid's own read as the read,
+ * the very call the page body makes, so metadata costs no extra TMDB or IGDB
+ * request and follows the 24 title page count (spec 0022, AC-21).
+ *
+ * A TMDB list always knows its last page, so "has results" is just "not past
+ * it", exactly the rule before games. A games list has results when its page
+ * holds a game; with no IGDB count its last page is unknown.
*/
export async function catalogListMetadata(
- list: TmdbCatalogList,
+ list: CatalogList,
searchParams: Promise>,
): Promise {
return landingPageMetadata({
pageParam: (await searchParams).page,
- discover: async ({ page }) => ({
- totalPages:
- lastListPage(await readTaggedCatalogGridPage(list, page)) ?? 0,
- }),
+ discover: async ({ page }) => {
+ const result = await readTaggedCatalogGridPage(list, page);
+ const lastPage = lastListPage(result);
+ return {
+ hasResults:
+ result.mediaType === "game"
+ ? result.paged.results.length > 0
+ : page <= (lastPage ?? 0),
+ lastPage,
+ };
+ },
title: list.heading,
description: list.note,
pageHref: (page) => listPageHref(list, page),
@@ -81,10 +99,9 @@ export async function catalogListMetadata(
}
/**
- * A games page's metadata (spec 0028, AC-13): its title and description, and
- * `noindex` with no canonical and no share card of its own, until G10 opens
- * games to search engines. Static: it reads neither `?page` nor IGDB, so it
- * costs no request and holds for every page of a list.
+ * `/games` metadata (spec 0032, AC-4): its title and description, the
+ * canonical `/games` and the site card, like the `/movies` and `/shows`
+ * landings. The landing is not paged, so it reads neither `?page` nor IGDB.
*/
export function gamesMetadata({
title,
@@ -93,10 +110,5 @@ export function gamesMetadata({
title: string;
description: string;
}): Metadata {
- return landingMetadata({ title, description, path: null });
-}
-
-/** `gamesMetadata` for one games list page: its h1 and its note. */
-export function gameListMetadata(list: GameCatalogList): Metadata {
- return gamesMetadata({ title: list.heading, description: list.note });
+ return landingMetadata({ title, description, path: "/games" });
}
diff --git a/lib/legal/boundary.test.ts b/lib/legal/boundary.test.ts
index e21a194a..08af6710 100644
--- a/lib/legal/boundary.test.ts
+++ b/lib/legal/boundary.test.ts
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import {
CONTACT_EMAIL,
+ IGDB_ATTRIBUTION,
IGDB_HOME_URL,
JUSTWATCH_ATTRIBUTION,
OPERATOR_COUNTRY,
@@ -14,7 +15,7 @@ import {
} from "./operator";
/**
- * covers: spec 0017, AC-3, AC-6, AC-10
+ * covers: spec 0017, AC-3, AC-6, AC-10; spec 0032, AC-8
*
* The legal facts and TMDB's notice each have one home, so a later edit that
* pastes a copy somewhere else would publish two versions that drift apart.
@@ -57,6 +58,12 @@ describe("one home for each legal fact", () => {
]);
});
+ it("writes IGDB's credit only in lib/legal/operator.ts (spec 0032, AC-8)", () => {
+ expect(filesContaining(IGDB_ATTRIBUTION)).toEqual([
+ "lib/legal/operator.ts",
+ ]);
+ });
+
it("writes IGDB's site URL only in lib/legal/operator.ts", () => {
expect(filesContaining(IGDB_HOME_URL)).toEqual(["lib/legal/operator.ts"]);
});
diff --git a/lib/legal/operator.ts b/lib/legal/operator.ts
index aa89599b..c062c4e2 100644
--- a/lib/legal/operator.ts
+++ b/lib/legal/operator.ts
@@ -42,7 +42,7 @@ export const MINIMUM_AGE = 16;
* whenever the text changes materially. Pages format it in UTC and never read
* the clock (spec 0017, AC-7).
*/
-export const LEGAL_LAST_UPDATED = "2026-10-23";
+export const LEGAL_LAST_UPDATED = "2026-10-27";
/**
* Where the production Supabase project stores your data, read from
@@ -74,11 +74,24 @@ export const TMDB_HOME_URL = "https://www.themoviedb.org";
/**
* IGDB's own site, linked where `/privacy` says game covers load from IGDB's
- * image service (spec 0025). One home, like `TMDB_HOME_URL`. IGDB is not a
- * processor: the server sends it no user data, only the browser loads covers.
+ * image service (spec 0025) and from the footer's IGDB credit (spec 0032,
+ * AC-8). One home, like `TMDB_HOME_URL`. IGDB is not a processor: the server
+ * sends it no user data, only the browser loads covers.
*/
export const IGDB_HOME_URL = "https://www.igdb.com";
+/**
+ * The credit IGDB's terms ask for, shown in the footer on every route and on
+ * `/terms` (spec 0032, AC-8, AC-9). IGDB's FAQ asks for "fair attribution,
+ * i.e. attribution that is visible to your users and located in a static
+ * location", and the Twitch Developer Services Agreement (last modified
+ * 2023-11-01) for "a clear path to the source", which the footer link is.
+ * Both read on 2026-10-26 (spec 0032, rationale, *IGDB terms check*). It
+ * lives here, not in `lib/igdb`, because that entry point is server only and
+ * its export surface is locked.
+ */
+export const IGDB_ATTRIBUTION = "Game data and images from IGDB.com";
+
/** Days within which an account deletion request is completed. */
export const DELETION_DAYS = 30;
diff --git a/lib/search/games-flag.test.ts b/lib/search/games-flag.test.ts
deleted file mode 100644
index 21523db6..00000000
--- a/lib/search/games-flag.test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
-
-import { gameSearchEnabled } from "./games-flag";
-
-/**
- * covers: spec 0029, AC-1, AC-17
- *
- * Only a raw `type=game` reads the flag, so a bad flag value never breaks a
- * show or movie search.
- */
-describe("gameSearchEnabled", () => {
- afterEach(() => {
- vi.unstubAllEnvs();
- });
-
- it("accepts type=game while the games library is on", () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
- expect(gameSearchEnabled("game")).toBe(true);
- });
-
- it("refuses type=game while the games library is off", () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "off");
- expect(gameSearchEnabled("game")).toBe(false);
- });
-
- it("never reads an invalid flag for another type", () => {
- vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "yes-please");
- for (const type of ["tv", "movie", undefined, null, ["game"]]) {
- expect(gameSearchEnabled(type)).toBe(false);
- }
- expect(() => gameSearchEnabled("game")).toThrow(
- "NEXT_PUBLIC_GAMES_LIBRARY",
- );
- });
-});
diff --git a/lib/search/games-flag.ts b/lib/search/games-flag.ts
deleted file mode 100644
index 5f83d3c6..00000000
--- a/lib/search/games-flag.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { getGamesLibrary } from "@/lib/env";
-
-/**
- * Whether this request may search games: its raw `type` is `game` and the
- * games library is on (spec 0029, AC-1, AC-17).
- *
- * The flag is read only for a raw `type=game`, so an invalid flag value can
- * never take a show or movie search down. The results, the filter bar and
- * `/api/search` all ask here, so the three cannot drift apart.
- */
-export function gameSearchEnabled(rawType: unknown): boolean {
- return rawType === "game" && getGamesLibrary() === "on";
-}
diff --git a/lib/search/params.test.ts b/lib/search/params.test.ts
index f838e2f9..b0843318 100644
--- a/lib/search/params.test.ts
+++ b/lib/search/params.test.ts
@@ -10,6 +10,7 @@ import {
hasAnyFilter,
parseGameSearchParams,
parseSearchParams,
+ parseTmdbSearchParams,
searchHref,
type TmdbSearchParams,
} from "./params";
@@ -46,15 +47,11 @@ const PLATFORMS: CatalogRef[] = [
})),
];
-function parseGame(
- raw: Record,
- gamesEnabled = true,
-) {
+function parseGame(raw: Record) {
return parseSearchParams(
{ type: "game", ...raw },
{ genres: GAME_GENRES, platforms: PLATFORMS },
YEAR,
- { gamesEnabled },
);
}
@@ -251,8 +248,7 @@ describe("searchMode", () => {
});
describe("parseSearchParams for games (spec 0029)", () => {
- it("accepts type=game only while the games library is on (AC-1)", () => {
- expect(parseGame({}, false)).toEqual({ ok: false, param: "type" });
+ it("always accepts type=game (AC-1; spec 0032, AC-12)", () => {
expect(parseGame({})).toEqual({ ok: true, params: gameParams() });
});
@@ -313,11 +309,19 @@ describe("parseSearchParams for games (spec 0029)", () => {
{ type: "game", genre: "5" },
{ genres: GAME_GENRES, platforms: [] },
YEAR,
- { gamesEnabled: true },
).ok,
).toBe(true);
});
+ it("narrows a show or movie parse, refusing a games URL (spec 0032, AC-12)", () => {
+ expect(
+ parseTmdbSearchParams({ type: "tv" }, { genres: TV_GENRES }, YEAR),
+ ).toEqual({ ok: true, params: params() });
+ expect(
+ parseTmdbSearchParams({ type: "game" }, { genres: TV_GENRES }, YEAR),
+ ).toEqual({ ok: false, param: "type" });
+ });
+
it("narrows a games parse, refusing a URL that is not a games search (AC-1)", () => {
const lists = { genres: GAME_GENRES, platforms: PLATFORMS };
const parsed = parseGameSearchParams(
diff --git a/lib/search/params.ts b/lib/search/params.ts
index 119ba733..4c6d2356 100644
--- a/lib/search/params.ts
+++ b/lib/search/params.ts
@@ -15,8 +15,8 @@ import { MAX_QUERY_LENGTH, RATING_STEPS, type RatingStep } from "./constants";
/**
* The three catalogs `/search` covers: the navbar tab's type, so search and
- * the library pages share one definition (feature 22). `game` only reaches
- * it while the games library is on (spec 0029, AC-1).
+ * the library pages share one definition (feature 22), `game` included
+ * (spec 0029, AC-1).
*/
export type SearchType = LibraryType;
@@ -74,19 +74,14 @@ export type SearchLists = {
};
/**
- * A parse's answer. `P` narrows to `TmdbSearchParams` when games were not
- * accepted, so a caller never casts what the parser already proved.
+ * A parse's answer. `P` narrows to `TmdbSearchParams` or `GameSearchParams`
+ * through `parseTmdbSearchParams` and `parseGameSearchParams`, so a caller
+ * never casts what the parser already proved.
*/
export type ParseResult =
| { ok: true; params: P }
| { ok: false; param: SearchParamName };
-/**
- * @param gamesEnabled Whether `type=game` is accepted: `gameSearchEnabled`,
- * which reads the flag only for a raw `type=game` (spec 0029, AC-1).
- */
-export type ParseOptions = { gamesEnabled?: boolean };
-
type RawValue = string | string[] | undefined;
type RawParams = Record;
@@ -158,29 +153,13 @@ function isCleanGameQuery(query: string): boolean {
* catalog is refused.
* @param currentYear The current UTC year, read at request time by the caller,
* so the year range moves on New Year's Day without a deploy.
- * @param options.gamesEnabled Whether `type=game` is accepted (spec 0029,
- * AC-1). Without it the result can only be a show or movie search, and the
- * return type says so.
*/
export function parseSearchParams(
raw: RawParams,
lists: SearchLists,
currentYear: number,
- options?: { gamesEnabled?: false },
-): ParseResult;
-export function parseSearchParams(
- raw: RawParams,
- lists: SearchLists,
- currentYear: number,
- options: ParseOptions,
-): ParseResult;
-export function parseSearchParams(
- raw: RawParams,
- lists: SearchLists,
- currentYear: number,
- { gamesEnabled = false }: ParseOptions = {},
): ParseResult {
- const type = parseLibraryTypeParam(raw.type, gamesEnabled);
+ const type = parseLibraryTypeParam(raw.type);
if (type === null) return { ok: false, param: "type" };
const isGame = type === "game";
@@ -274,7 +253,6 @@ export function parseGameSearchParams(
raw,
{ genres: lists.genres ?? [], platforms: lists.platforms ?? [] },
currentYear,
- { gamesEnabled: true },
);
if (!parsed.ok) return parsed;
const { params } = parsed;
@@ -283,6 +261,24 @@ export function parseGameSearchParams(
: { ok: false, param: "type" };
}
+/**
+ * `parseSearchParams` for a request already known to be a show or movie
+ * search, the mirror of `parseGameSearchParams`. Narrowed rather than cast:
+ * a URL whose `type` is `game` is refused by name.
+ */
+export function parseTmdbSearchParams(
+ raw: RawParams,
+ lists: SearchLists,
+ currentYear: number,
+): ParseResult {
+ const parsed = parseSearchParams(raw, lists, currentYear);
+ if (!parsed.ok) return parsed;
+ const { params } = parsed;
+ return params.type === "game"
+ ? { ok: false, param: "type" }
+ : { ok: true, params };
+}
+
/**
* The earliest year a type's year filter offers: TMDB's 1900, IGDB's 1950
* (spec 0029, AC-2).
diff --git a/lib/search/quick.test.ts b/lib/search/quick.test.ts
index fdd84a61..8d3c58fb 100644
--- a/lib/search/quick.test.ts
+++ b/lib/search/quick.test.ts
@@ -6,7 +6,7 @@ import { quickSearchRequestSchema, seeAllHref } from "./quick";
describe("quickSearchRequestSchema", () => {
it("trims the query before it checks the length", () => {
- const parsed = quickSearchRequestSchema().safeParse({
+ const parsed = quickSearchRequestSchema.safeParse({
type: "tv",
q: " dune ",
});
@@ -15,10 +15,10 @@ describe("quickSearchRequestSchema", () => {
it("accepts exactly two characters and exactly 100", () => {
expect(
- quickSearchRequestSchema().safeParse({ type: "movie", q: "du" }).success,
+ quickSearchRequestSchema.safeParse({ type: "movie", q: "du" }).success,
).toBe(true);
expect(
- quickSearchRequestSchema().safeParse({
+ quickSearchRequestSchema.safeParse({
type: "movie",
q: "x".repeat(100),
}).success,
@@ -27,9 +27,9 @@ describe("quickSearchRequestSchema", () => {
it("does not count padding toward the 100 character limit", () => {
const q = ` ${"x".repeat(100)} `;
- expect(
- quickSearchRequestSchema().safeParse({ type: "tv", q }).success,
- ).toBe(true);
+ expect(quickSearchRequestSchema.safeParse({ type: "tv", q }).success).toBe(
+ true,
+ );
});
it.each([
@@ -40,24 +40,15 @@ describe("quickSearchRequestSchema", () => {
[{ type: "tv", q: " " }],
[{ type: "tv", q: "x".repeat(101) }],
])("refuses %o", (input) => {
- expect(quickSearchRequestSchema().safeParse(input).success).toBe(false);
+ expect(quickSearchRequestSchema.safeParse(input).success).toBe(false);
});
});
-describe("quickSearchRequestSchema for games (spec 0029, AC-17)", () => {
- it("accepts game only while the games library is on", () => {
+describe("quickSearchRequestSchema for games (spec 0029, AC-17; spec 0032, AC-12)", () => {
+ it("always accepts game", () => {
expect(
- quickSearchRequestSchema({ gamesEnabled: true }).safeParse({
- type: "game",
- q: "zelda",
- }).success,
+ quickSearchRequestSchema.safeParse({ type: "game", q: "zelda" }).success,
).toBe(true);
- expect(
- quickSearchRequestSchema({ gamesEnabled: false }).safeParse({
- type: "game",
- q: "zelda",
- }).success,
- ).toBe(false);
});
});
diff --git a/lib/search/quick.ts b/lib/search/quick.ts
index ec1b589c..8061c5ba 100644
--- a/lib/search/quick.ts
+++ b/lib/search/quick.ts
@@ -12,24 +12,14 @@ import type { SearchType } from "./params";
const query = z.string().trim().min(QUICK_MIN_CHARS).max(MAX_QUERY_LENGTH);
/**
- * Validates the two query parameters the Route Handler accepts. `game` is
- * one of the types only while the games library is on (spec 0029, AC-17).
- *
- * @param options.gamesEnabled `gameSearchEnabled(rawType)`, which reads the
- * flag only for a raw `type=game`.
+ * Validates the two query parameters the Route Handler accepts, `game`
+ * always among the types since games launched (spec 0029, AC-17; spec 0032,
+ * AC-12).
*/
-export function quickSearchRequestSchema({
- gamesEnabled = false,
-}: {
- gamesEnabled?: boolean;
-} = {}) {
- return z.object({
- type: z
- .enum(["tv", "movie", "game"])
- .refine((type) => gamesEnabled || type !== "game"),
- q: query,
- });
-}
+export const quickSearchRequestSchema = z.object({
+ type: z.enum(["tv", "movie", "game"]),
+ q: query,
+});
/** One row of the quick search panel, already shaped for display. */
export type QuickResult = {
diff --git a/lib/seo/AGENTS.md b/lib/seo/AGENTS.md
index 6183e6a2..fcd8caa9 100644
--- a/lib/seo/AGENTS.md
+++ b/lib/seo/AGENTS.md
@@ -5,24 +5,24 @@ How BeStats presents itself to search engines and link previews. Governed by [sp
## Files
- `site.ts`: `siteUrl()` (the http(s) origin from `NEXT_PUBLIC_SITE_URL`, or null), `absoluteUrl()` (that origin plus a path, or null) and `isIndexableDeployment()` (true only with `VERCEL_ENV=production` and a valid site URL).
-- `metadata.ts`: `catalogMetadata()` for every public catalog page, `landingMetadata()` for `/movies`, `/shows` and their list pages (called through `lib/landing-metadata.ts`, which sits outside this folder because it catches `TmdbError`), `shareMetadata()` for the Open Graph and X fields every page shares, `SITE_NAME` and `siteCardUrl()`.
-- `json-ld.ts`: `movieJsonLd()`, `showJsonLd()` and `jsonLdScript()`. Rendered by `components/catalog/json-ld.tsx`.
-- `sitemap.ts`: `sitemapEntries()` and `DISCOVER_PAGES_PER_TYPE`, the pure half of `app/sitemap.ts`.
+- `metadata.ts`: `catalogMetadata()` for every public catalog page, `landingMetadata()` for `/movies`, `/shows`, `/games` and their list pages (called through `lib/landing-metadata.ts`, which sits outside this folder because it catches `TmdbError` and `IgdbError`), `shareMetadata()` for the Open Graph and X fields every page shares, `SITE_NAME` and `siteCardUrl()`.
+- `json-ld.ts`: `movieJsonLd()`, `showJsonLd()`, `gameJsonLd()` (a `VideoGame`, spec 0032) and `jsonLdScript()`. Rendered by `components/catalog/json-ld.tsx`.
+- `sitemap.ts`: `sitemapEntries()`, `DISCOVER_PAGES_PER_TYPE` and `GAME_SITEMAP_LIMIT` (the first 200 of IGDB's Popular list), the pure half of `app/sitemap.ts`.
- `theme-tokens.ts`: `rootTokens()`, which reads colours from the `:root` block of `app/globals.css` for `app/opengraph-image.tsx`.
- The routes that use them: `app/robots.ts`, `app/sitemap.ts`, `app/opengraph-image.tsx`.
## Rules
-- This folder is pure: no `server-only`, no runtime TMDB import. Pages pass normalized TMDB values in, so tests import these modules directly.
+- This folder is pure: no `server-only`, no runtime TMDB or IGDB import. Pages pass normalized TMDB and IGDB values in, so tests import these modules directly.
- No SEO surface reads cookies, headers or the session, or imports `lib/supabase/`. `request-scope.test.ts` greps for this. Nothing user specific may reach a share card, the sitemap or robots, and request state would also cost the public pages their prerendered shells.
- `siteUrl()` reads only its one variable, not `getPublicEnv()`, so a missing Supabase key never switches SEO off. It never throws.
- Build every URL (canonical, `og:url`, share image, sitemap entry, JSON-LD `url`) as an absolute string from `siteUrl()`. When it is null, leave the field out. Never rely on `metadataBase` to resolve a relative path, because that fails the build without a site URL.
- A new public page builds its metadata through `catalogMetadata()`, or through `shareMetadata()` when it has no canonical (as `/search` does). Both set `openGraph.images` explicitly every time, because Next merges metadata shallowly and a page that sets `openGraph` otherwise loses the root site card.
-- Not found and failed branches are `noindex` with no canonical and no Open Graph. Canonicals are built from the id, never from the request URL, so a query string never reaches them. The only query a canonical may carry is a list page's `?page=N` (spec 0021); the `/movies` and `/shows` landings are not paged.
-- Leave out any field whose TMDB value is null, empty or zero. Never emit a rating of any kind in metadata or JSON-LD.
-- Serialize JSON-LD only through `jsonLdScript()`. It escapes `<`, U+2028 and U+2029, so TMDB text cannot close the script tag. Render it only in a page's found branch.
+- Not found and failed branches are `noindex` with no canonical and no Open Graph. Canonicals are built from the id, never from the request URL, so a query string never reaches them. The only query a canonical may carry is a list page's `?page=N` (spec 0021); the `/movies`, `/shows` and `/games` landings are not paged.
+- Leave out any field whose TMDB or IGDB value is null, empty or zero. Never emit a rating of any kind in metadata or JSON-LD.
+- Serialize JSON-LD only through `jsonLdScript()`. It escapes `<`, U+2028 and U+2029, so TMDB or IGDB text cannot close the script tag. Render it only in a page's found branch.
- `/robots.txt`, `/sitemap.xml` and `/opengraph-image` are prerendered, so `VERCEL_ENV` and `NEXT_PUBLIC_SITE_URL` are read when `pnpm build` runs, not per request. To check the indexable branch locally, build with `VERCEL_ENV=production` and a site URL set.
-- The sitemap reads discover pages with the exact calls `/movies/popular` and `/shows/popular` make (page 1 is also each landing's Popular row), so they share cache entries and every listed title is linked from a public page. Its `cacheLife` is chosen once, after all reads settle: `days` when all succeed, `minutes` when any fail.
+- The sitemap reads discover pages with the exact calls `/movies/popular` and `/shows/popular` make (page 1 is also each landing's Popular row), and IGDB's Popular list with the exact read `/games/popular` makes, so they share cache entries and every listed title is linked from a public page. A `TmdbError` drops only its page and an `IgdbError` only the game ids; any other error is rethrown. Its `cacheLife` is chosen once, after all reads settle: `days` when all succeed, `minutes` when any fail.
- `app/opengraph-image.tsx` holds no colour literal (`design-tokens-boundary.test.ts`). It reads tokens from `globals.css` inside a cached scope, and a missing token fails the build.
_Drafted by /sync from the introducing change, worth a quick human pass._
diff --git a/lib/seo/json-ld.test.ts b/lib/seo/json-ld.test.ts
index f3d287db..bbe7bdd5 100644
--- a/lib/seo/json-ld.test.ts
+++ b/lib/seo/json-ld.test.ts
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
+ type GameJsonLdInput,
+ gameJsonLd,
jsonLdScript,
type MovieJsonLdInput,
movieJsonLd,
@@ -9,7 +11,7 @@ import {
} from "./json-ld";
/**
- * covers: spec 0016, AC-21, AC-22, AC-23
+ * covers: spec 0016, AC-21, AC-22, AC-23; spec 0032, AC-3
*
* Structured data is read by machines that take it at face value, so the
* omission rule (never a placeholder, never a rating) and the escaping that
@@ -166,6 +168,83 @@ describe("showJsonLd (AC-22)", () => {
});
});
+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 5546c994..98bc4fc0 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 `