diff --git a/ab-testing/config/abTests.ts b/ab-testing/config/abTests.ts index 1c3a6f2ef42..765823c2a3f 100644 --- a/ab-testing/config/abTests.ts +++ b/ab-testing/config/abTests.ts @@ -157,6 +157,26 @@ const ABTests: ABTest[] = [ groups: ["control", "variant"], shouldForceMetricsCollection: false, }, + /** + * Puzzles & Games rollout, tier v0 (the master switch). + * + * Gates the baseline Puzzles & Games experience: the new Puzzles Hub + * page, and the 6 V0 puzzle pages (sudoku easy/medium/hard/killer, + * word-wheel, wordiply). At v0, there is no archive, no calendar, no + * progress indicators, no sign-in-to-track-progress prompt, no "more + * from puzzles" rail, and the hub's sub-nav has no links yet. + * + * This is the master switch for the whole Puzzles & Games experience: + * turning it off (or down to 0%) hides everything: the hub, the V0 + * puzzle pages, and (by the cumulative design below) every later tier + * too, since v1/v2 only take effect when this is also enabled. + * + * See `puzzles-new-hub-v1`/`puzzles-new-hub-v2` below for the later + * rollout tiers, and `src/lib/puzzlesHubVersionExperiment.ts` / + * `src/lib/puzzlesHubExperiment.ts` in dotcom-rendering for the + * corresponding cumulative gate-check helpers + * (`isPuzzlesHubEnabled`/`isPuzzlesHubV1Enabled`/`isPuzzlesHubV2Enabled`). + */ { name: "puzzles-new-hub", description: "Rollout of the new Puzzles Hub experience", @@ -169,6 +189,68 @@ const ABTests: ABTest[] = [ groups: ["control", "variant"], shouldForceMetricsCollection: false, }, + /** + * Puzzles & Games rollout, tier v1 (w/c 12 Oct launch). + * + * Only takes effect when `puzzles-new-hub` (v0) is ALSO enabled for the + * reader. This test does nothing on its own, by design, so the + * rollout can never end up in an inconsistent state (e.g. v1 features + * showing while the v0 baseline they build on is switched off). + * + * On top of v0, this tier activates: the full hub sub-nav links (to + * /word-games, /logic-puzzles, /trivia-and-quizzes), a + * sign-in-to-track-progress message, a calendar/archive view for + * crosswords/logic-puzzles/word-games (not Wordiply, which has no + * archive), progress indicators (Available/Completed), the "More from + * Puzzles & Games" related-content rail, newsletter signup, and + * changes to the existing crossword page (print CTA repositioning, a + * "play other puzzles" container). + * + * To roll back from v1 to v0 without a deploy: flip this test's + * `audienceSize` to `0 / 100` (or `status` to `"OFF"`) while leaving + * `puzzles-new-hub` untouched. + */ + { + name: "puzzles-new-hub-v1", + description: + "Rollout of the v1 Puzzles & Games features (w/c 12 Oct), on top of the puzzles-new-hub v0 baseline", + owners: ["puzzles.team@guardian.co.uk"], + status: "ON", + expirationDate: "2026-12-31", + type: "server", + audienceSize: 0 / 100, + audienceSpace: "A", + groups: ["control", "variant"], + shouldForceMetricsCollection: false, + }, + /** + * Puzzles & Games rollout, tier v2 (future, no launch date confirmed + * yet as of this writing). + * + * Only takes effect when BOTH `puzzles-new-hub` (v0) AND + * `puzzles-new-hub-v1` are ALSO enabled for the reader, same + * cumulative-by-design principle as v1 above, applied one tier further. + * + * On top of v0+v1, this tier activates: the On the Ball and Film Reveal + * iframe games (Trivia and Quizzes group), a "Most played" container, + * EventKit-driven navigation, migrating existing crossword pages onto + * the new Puzzle Page template, and search-engine mobile app nudges. + * + * Kept at 0% until that work begins; there is nothing to roll back yet. + */ + { + name: "puzzles-new-hub-v2", + description: + "Rollout of the v2 Puzzles & Games features (no date confirmed yet), on top of the puzzles-new-hub/puzzles-new-hub-v1 baseline", + owners: ["puzzles.team@guardian.co.uk"], + status: "ON", + expirationDate: "2026-12-31", + type: "server", + audienceSize: 0 / 100, + audienceSpace: "A", + groups: ["control", "variant"], + shouldForceMetricsCollection: false, + }, { name: "commercial-prebid-transaction-ids", description: diff --git a/dotcom-rendering/docs/puzzle-page.md b/dotcom-rendering/docs/puzzle-page.md new file mode 100644 index 00000000000..ae3dc19ae08 --- /dev/null +++ b/dotcom-rendering/docs/puzzle-page.md @@ -0,0 +1,508 @@ +# Puzzle Page + +## What is implemented + +Puzzle Page is a single, generic page template for the Guardian's +**iframe-based** puzzles (sudoku, word games, etc.). **dotcom-rendering +(DCR) owns the whole thing on the rendering side**: the `POST /PuzzlePage` +endpoint, the `PuzzleConfig` registry that decides how each puzzle behaves +and renders, and the `PuzzlePageLayout` layout/styling. The `frontend` +(Play/Scala) repo is responsible for fetching/assembling per-instance +content and POSTing it to this endpoint as JSON. See the `frontend` repo's +`docs/puzzle-page.md` (formerly `docs/game-page.md`; its standalone +`GamePageController` no longer exists either, having been merged into +`PuzzlesPageController` there) for the exact JSON payload it sends and how +to wire up a new puzzle from the content-fetching side. + +**Crosswords are explicitly out of scope**, by product decision, and remain +entirely on their existing, separate `/crosswords/*` flow +(`ArticleDesign.Crossword` / `src/layouts/CrosswordLayout.tsx` / the generic +Article pipeline). That flow is unrelated to Puzzle Page and is not +described further in this file. + +Readers reach individual puzzles via `frontend`'s public, top-level URLs, +mirroring how crosswords are already routed, e.g. `/sudoku/easy`, +`/word-wheel`, `/wordiply` (nested only where the puzzle itself has +variants, like sudoku's difficulty levels). This is separate from the +Puzzles Hub (the directory/listing page, unrelated to Puzzle Page), which +stays at `/puzzles-and-games`. None of this is DCR's own routing, it's +`frontend`'s public URL structure, and does not affect DCR's `/PuzzlePage` +endpoint/contract at all; it's mentioned here only so example URLs +elsewhere in this doc stay accurate. + +**Access control lives entirely on the `frontend` side, not here.** DCR's +own `/PuzzlePage` endpoint is, and remains, ungated (see "Hitting it +locally" below). `frontend` gates reader access to these routes via its +existing `PuzzlesHubExperiment`/`puzzles-new-hub` AB test before it ever +POSTs to DCR. DCR does not re-implement or duplicate that gating. + +### The V0 puzzle set + +The `PuzzleConfig` registry (`src/model/puzzles/puzzleConfigs.ts`) currently +contains exactly 6 slugs, all rendered via the generic sandboxed +`PuzzleIframe.island.tsx` component: + +| `slug` | `puzzleGroup` | provider | +| --------------- | --------------- | ---------------------- | +| `sudoku-easy` | `logic-puzzles` | AmuseLabs | +| `sudoku-medium` | `logic-puzzles` | AmuseLabs | +| `sudoku-hard` | `logic-puzzles` | AmuseLabs | +| `sudoku-killer` | `logic-puzzles` | AmuseLabs | +| `word-wheel` | `word-games` | AmuseLabs | +| `wordiply` | `word-games` | bespoke (wordiply.com) | + +Codeword, futoshiki, suguru, and the trivia/quizzes puzzles (on-the-ball, +film-reveal) were removed from the registry for V0 and may return later. +All AmuseLabs-hosted entries share one URL template +(`https://tg.amuselabs.com/guardian/date-picker?set=guardian-{slug}&embed=1&idx=1`), +differing only by the `{slug}` substitution. + +### Hitting it locally + +Start the dev server (from the `dotcom-rendering` sub-directory): + +``` +make dev +``` + +This starts webpack-dev-server on `http://localhost:3030` +(`webpack/webpack.config.dev-server.js`). + +There is currently **no AB gate** on this route. +`src/server/handler.puzzlePage.web.ts` validates the body +(`validateAsPuzzlePageType`), looks up the `PuzzleConfig` for the request's +`slug` (`404` if unknown), and renders unconditionally otherwise, with no +`serverSideABTests`/participation check of any kind (see "Open questions" +below for the AB-gate/kill-switch situation). + +Generate fixture JSON for all 6 slugs using the `tsx` devDependency (no +extra install needed) and `fixtures/manual/puzzlePage.ts`'s +`createPuzzlePage`/`puzzlePageFixtures`: + +``` +cat > /tmp/dump-puzzle-fixtures.ts <<'EOF' +import * as fs from 'fs'; +import { puzzlePageFixtures } from './fixtures/manual/puzzlePage'; + +fs.mkdirSync('/tmp/puzzle-fixtures', { recursive: true }); +for (const [slug, page] of Object.entries(puzzlePageFixtures)) { + fs.writeFileSync(`/tmp/puzzle-fixtures/${slug}.json`, JSON.stringify(page, null, 2)); +} +console.log('wrote', Object.keys(puzzlePageFixtures).length, 'fixtures to /tmp/puzzle-fixtures'); +EOF +pnpm exec tsx /tmp/dump-puzzle-fixtures.ts +``` + +Then hit the route directly. **This is DCR's own local `POST` endpoint, not +a real, browsable end-user URL.** `/PuzzlePage` only accepts `POST` +requests with a JSON body; DCR is not directly browsable by real users +without `frontend` in front of it constructing and sending that body. + +| `slug` | local command | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `sudoku-easy` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/sudoku-easy.json` | +| `sudoku-medium` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/sudoku-medium.json` | +| `sudoku-hard` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/sudoku-hard.json` | +| `sudoku-killer` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/sudoku-killer.json` | +| `word-wheel` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/word-wheel.json` | +| `wordiply` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/wordiply.json` | + +All six should return `200`, hydrating into a sandboxed iframe pointed at +that slug's resolved provider URL. An unknown slug (or a known slug renamed +to something else in the request body) returns `404`. + +### How to configure/add a new puzzle + +Both steps are config-only. The layout does not need any changes for a new +iframe-based slug: + +1. Add a new key to `src/model/puzzles/puzzleConfigs.ts`'s `puzzleConfigs` + record (`slug`, `puzzleGroup`, `iframe: { provider, urlTemplate }`, + `shareEnabled`, `printEnabled`, `hasArchive`, `title`, `description`, + optional `image`). If it's another AmuseLabs-hosted puzzle, reuse the + `amuseLabsPuzzle(slug, puzzleGroup, title, description)` helper (note: + this helper doesn't take `image`, set it afterwards on the returned + object if/when a real image is available for that puzzle). + `validatePuzzleConfigs` runs once at module load and throws immediately + if the entry is malformed (mismatched `slug`, unknown `puzzleGroup`, + empty `iframe.provider`/`iframe.urlTemplate`, empty `title`/ + `description`, or a present-but-empty `image`). + **Write real, curated copy for `title`/`description`**, sourced from + the product team's SEO spreadsheet for that puzzle (see "SEO" below for + the exact `{date}` templating mechanism). It becomes the page's + `` and `<meta name="description">` and their derived Open + Graph/Twitter equivalents, don't copy-paste one template string across + entries with only the slug swapped in. +2. Nothing else changes on the DCR side: `PuzzlePageLayout.tsx`'s + `PuzzlePageContent` unconditionally renders `PuzzleIframe` pointed at + `resolveIframeUrl(puzzleConfig)` for every registry entry. The only thing + needed from `frontend` is a request whose `slug` matches the new + registry key exactly (see the `frontend` repo's `docs/puzzle-page.md`). + +### SEO: title, meta description, Open Graph, Twitter card + +Each `PuzzleConfig` entry carries `title` and `description` templates +(sourced verbatim from the product team's SEO spreadsheet, confirmed +against per-field character budgets, title max 60 characters, description +max 157 characters, both accounting for the date's length) and an +optional `image` (a full preview/share image URL). Both `title` and +`description` may contain a `{date}` placeholder token, substituted at +render time with `instance.puzzleDate` formatted as a short "d MMM yy" +date (e.g. `"2026-09-15" -> "15 Sep 26"`, via `formatPuzzleDateShort` in +`src/lib/puzzleDate.ts`, deliberately distinct from `formatPuzzleDate`'s +long, human-readable on-page display form, e.g. "15 September 2026"). +For example, `sudoku-easy`'s `title` template +`"Easy sudoku {date} - logic puzzle | The Guardian"` resolves, for +`puzzleDate: "2026-09-15"`, to +`"Easy sudoku 15 Sep 26 - logic puzzle | The Guardian"`. If `puzzleDate` +is absent, the placeholder and any now-redundant surrounding +whitespace/punctuation are tidied up automatically (see +`resolvePuzzleTitle`/`resolvePuzzleDescription`'s implementation), rather +than leaving a literal double space or a stray space before a full stop. + +`render.puzzlePage.web.tsx` derives the page's SEO metadata from these via +a small, pure, directly-unit-tested function, +`buildPuzzlePageMetaData(puzzleConfig, puzzleDate)` +(`src/server/render.puzzlePage.web.test.ts`): + +- The resolved `title` becomes the page's `<title>` tag. +- The resolved `description` becomes the page's + `<meta name="description">` (previously hardcoded to `''`, which + silently fell back to DCR's generic, site-wide description, a real SEO + gap, since a generic/absent description risks Google or social previews + auto-generating a snippet from page content instead of showing clean, + curated copy). +- `openGraphData: { 'og:title': title, 'og:description': description }`, + plus `'og:image': image` **only when `puzzleConfig.image` is set**. +- `twitterData: { 'twitter:title': title, 'twitter:description': description }`, + plus `'twitter:image': image` **only when `puzzleConfig.image` is set**. + +**`webTitle` (the plain string `frontend` sends, e.g. `"Sudoku (easy)"`) +is _not_ used for the `<title>` tag or `og:title`/`twitter:title` any +more.** It has no date and no SEO suffix, so it can't satisfy the +spreadsheet's exact copy. Investigating its other uses in the render +pipeline before this change confirmed exactly one other real use: +`PuzzlePageLayout.tsx` still passes `webTitle` to `ShareButton.island.tsx` +for the share button's pre-filled share text/subject (native share sheet +title/text, email subject line), which is unrelated to SEO metadata and +is unaffected by this change. `webTitle` remains a required field in the +`FEPuzzlePageType` contract for that reason. + +**When `image` is unset, `og:image`/`twitter:image` are omitted entirely** +(not sent empty, not defaulted to a placeholder). `htmlPageTemplate`'s +`generateMetaTags()` only emits a `<meta>` tag for keys actually present in +the object it's given, so an absent key simply produces no tag. This is a +deliberate, confirmed decision, not an oversight: **DCR has no site-wide +default/fallback share image anywhere** for pages without one (checked +`frontend`'s `MetaData.opengraphProperties`/`SimplePage`, no image is set +by default there either, only via explicit per-page overrides), so an +unset `image` here matches existing sitewide behaviour rather than needing +a new default asset. **None of the 6 current V0 puzzles have a real image +configured.** This is a placeholder capability for whenever real, +licensed preview images are provided by the team, not filled in as part of +adding the field. + +Puzzle Page has no separate source of Open Graph/Twitter copy (unlike +Article, where `frontend` sends its own `openGraphData`/`twitterData`), so +these are derived directly from `title`/`description`/`image` rather than +requiring bespoke copy per field. + +**Target search terms are documented, not implemented as a meta tag.** +The product spreadsheet also includes a "Search terms" column per puzzle +(e.g. for `word-wheel`: "daily word wheel, word wheel puzzle, word wheel +online, word wheel game, guardian word wheel, word wheel for today, +guardian word wheel today"). This is content/SEO-strategy reference, the +search terms the copy should naturally support, not a literal meta tag: +major search engines ignore `<meta name="keywords">` entirely today, so it +provides no real SEO benefit. Each puzzle's search-term list is recorded +as a code comment directly above its registry entry in +`puzzleConfigs.ts`, for content-team/future-maintainer traceability, and +is **not** rendered as a `<meta name="keywords">` tag anywhere. + +**Crawl/index behaviour already matches the product requirement (no code +change needed).** The spreadsheet asks for all 6 V0 puzzle pages to allow +robots.txt and be indexed. `htmlPageTemplate.ts`'s `doNotIndex()` only +forces `noindex` outside `PROD`, or for canonical URLs containing +`tracking/commissioningdesk` (an unrelated, allow-listed exception for a +couple of specific URLs). None of the 6 puzzle pages' canonical URLs match +that pattern, so none of them hit the `noindex` branch in production; +they are indexed normally, as required. + +**Out of scope for this registry, not implemented:** the product +spreadsheet includes SEO copy for several other pages, the Puzzles & Games +hub page, a "Word games" landing page, a "Logic puzzles" landing page, a +"Trivia and quizzes" landing page, a generic "Sudoku" landing page, and +Crosswords/Word games/Logic puzzles archive pages. None of these pages +exist in this codebase yet (no routes, no controllers), some are +explicitly future V1/V2 work per the rollout plan (see "Feature-tier +rollout gating" below). Their SEO copy is not implementable here until +those pages are actually built (elsewhere, e.g. the separate, existing +Puzzles Hub feature for the hub page, or future archive/landing page +work), this doc note exists so that work isn't discovered as a surprise +gap later. + +### The `FEPuzzlePageType` request contract + +`POST /PuzzlePage` validates the body against `FEPuzzlePageType` +(`src/types/puzzlePage.ts`, validated by `validateAsPuzzlePageType` in +`src/model/validate.puzzlePage.ts`): + +| Field | Type | Notes | +| ---------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | `string` | Any stable identifier for the page instance. | +| `slug` | `string` | Looked up in the `PuzzleConfig` registry; unknown slug → `404`. | +| `webTitle` | `string` | Used for the share button's pre-filled share text/subject only. **Not** used for the `<title>` tag or `og:title`/`twitter:title` (see "SEO" above), those come from the resolved `PuzzleConfig.title` instead. | +| `config` | `ConfigType` | Same shape frontend sends for `/Article`, `/PuzzlesPage`, etc. Only checked for a `serverSideABTests: Record<string, string>` shape, content otherwise unused (no AB gate today). | +| `nav` | `FENavType` | Same shape as other routes. | +| `pageFooter` | `FooterType` | Same shape as other routes. | +| `canonicalUrl` | `string` | Canonical link tag. | +| `editionId` | `EditionId` (`'UK' \| 'US' \| 'AU' \| 'INT' \| 'EUR'`) | Validated against the known edition set. | +| `instance.title` | `string` (required) | Rendered as the page `<h1>` and the iframe `title` attribute. | +| `instance.puzzleDate` | `string?` (e.g. `"2026-09-11"`) | Which day's puzzle the reader wants to see. Rendered as a human-readable date (e.g. "11 September 2026") next to the page title, passed through unformatted as `PuzzleContext.puzzleDate` to the puzzle iframe (see below), and used (short-formatted) to resolve the `{date}` placeholder in `PuzzleConfig.title`/`description` (see "SEO" above). `frontend` now always resolves and sends this for every request (its Puzzle Page URLs carry a date segment), though DCR still treats the field as optional and simply omits/tidies up the display/context/SEO value when absent. | +| `instance.moreFromPuzzlesAndGames` | `PuzzleItem[]?` (from `src/types/puzzlesPage.ts`) | Rendered as a plain "More from Puzzles & games" list when present and non-empty. | + +### User/context info passed to the puzzle iframe + +`src/components/PuzzleIframe.island.tsx` passes a combined `PuzzleContext` +about the current reader to the puzzle provider two ways: + +- As a single JSON-encoded `guardian-puzzle-context` query parameter on the + iframe `src` (e.g. + `?set=guardian-sudoku-easy&embed=1&idx=1&guardian-puzzle-context=%7B%22userId%22%3Anull%2C%22darkMode%22%3Afalse%2C%22puzzleDate%22%3Anull%7D`, + which decodes to `{"userId":null,"darkMode":false,"puzzleDate":null}`), + present from the iframe's very first request. Unlike the parameter's + previous `userId`-only form, this is always included: the context shape + always carries all three fields, so there's no "nothing to add" case to + omit it for. +- Via `window.postMessage({ type: 'guardian-puzzle-context', context }, '*')` + (the `PuzzleContextMessage` shape), sent to the iframe once it has loaded. + +```ts +interface PuzzleContext { + userId: string | null; + darkMode: boolean; + puzzleDate: string | null; +} +``` + +- **`userId`** is the reader's `idToken.claims.legacy_identity_id` (resolved + via `src/lib/identity.ts`'s `getAuthStatus()`), the same identifier + already used to build MyAccount links elsewhere in DCR + (`TopBarMyAccount.tsx`). This is **not** the OIDC `sub` claim some other, + newer API integrations in DCR use instead. `null` when the reader is + signed out. +- **`darkMode`** is whether dark mode is currently actually active for this + reader. Both of the following must be true: + 1. `darkModeAvailable`, the existing server-side `webx-dark-mode-web` AB + test flag for this page/request, already read via `useConfig()` in + `PuzzlePage.tsx` and threaded down through `PuzzlePageLayout.tsx` to + `PuzzleIframe` the same way it already reaches `rootStyles()` for the + page chrome's own dark mode support (see `src/lib/rootStyles.ts`). No + new source of truth was introduced for this. + 2. The reader's OS/browser actually preferring dark + (`prefers-color-scheme: dark`), checked reactively via DCR's existing, + generic `src/lib/useMatchMedia.ts` hook (already used elsewhere in DCR, + e.g. `ArticleMeta.web.tsx`). Not a new media-query mechanism. + + When `darkModeAvailable` is `false`, `darkMode` is always `false` and the + media query isn't even consulted. + +- **`puzzleDate`** is `instance.puzzleDate` passed straight through + unformatted (the raw `YYYY-MM-DD` string, not the "11 September 2026" + display text rendered next to the title), so third-party providers get + the machine-readable form. `null` when `instance.puzzleDate` is absent. + DCR does not parse the Puzzle Page URL or own the date-in-path/ + redirect-to-archive logic itself: it purely receives whatever date + `frontend` resolved and sent in the request payload, and passes it on. See + `frontend`'s own documentation for how it resolves and redirects on the + date-in-URL structure. + +The iframe reloads automatically whenever either half of the context +changes while the reader is already on the page: sign in, sign out, +switching accounts, or the reader's OS switching light/dark theme. The +component subscribes to both auth state changes +(`src/lib/identity.ts`'s `subscribeToAuthStateChange()`, a thin wrapper +around the `@guardian/identity-auth` client's own +`authStateManager.subscribe`) and colour-scheme changes (via +`useMatchMedia`'s own reactivity), and since the iframe's `src` is derived +directly from the current context, React gives the `<iframe>` a new `src` +value whenever either changes, which the browser treats as a fresh +navigation, so no manual reload call is needed. The `postMessage` above +fires again after every such reload too. + +## Open questions / known limitations + +- **The `PuzzleContextMessage` shape needs confirming with + AmuseLabs/Wordiply.** + `{ type: 'guardian-puzzle-context', context: { userId: string | null, +darkMode: boolean } }` and the `?guardian-puzzle-context=<JSON>` query + parameter are DCR's proposal, documented in code + (`src/components/PuzzleIframe.island.tsx`), but neither has been confirmed + against what AmuseLabs or Wordiply actually expect to receive, including + whether `legacy_identity_id` (rather than the OIDC `sub` claim) is the + right identifier format for them, and whether either provider's iframe + even supports a dark-mode signal in the first place (see the dark-mode + bullet below). This needs external coordination before relying on it for + anything beyond best-effort personalisation. +- **The auth-state-change subscription is a new mechanism in this + codebase.** `subscribeToAuthStateChange()` uses the underlying + `@guardian/identity-auth` client's own public `authStateManager.subscribe` + API (not an invented event bus), but this is its first use anywhere in + DCR. Every other existing call site only checks auth status once, on + mount. It has unit test coverage but has not been validated against a + real sign-in flow in a running browser; treat it as unproven until that + happens. +- **Whether the `userId` passthrough is actually useful to + AmuseLabs/Wordiply for anything (personalisation, analytics, save state) + has not been validated end-to-end.** This ships the plumbing DCR can + control (URL param + postMessage), not a confirmed integration. +- **No saved puzzle state / progress persistence.** There is no API today + for a puzzle's in-progress state to be saved against a Guardian account + and restored later (e.g. via `postMessage` round-tripping progress data). + This has been deliberately deferred until such an API exists. +- **The real AmuseLabs archive URL is still unknown.** `PuzzleConfig.hasArchive` + exists on every registry entry (currently always `true`) but is **not + consumed anywhere in rendering.** There is no archive-link UI, and no + archive URL field exists in the registry at all. A URL seen during the + original proof-of-concept was only there as an illustrative example, not + a verified production AmuseLabs archive URL. The correct URL needs to be + sourced from the team before an archive feature can be built on top of + `hasArchive`; do not guess or reuse the POC URL as-is. +- **Dark mode: the page chrome supports it, and a dark-mode signal is now + sent to the puzzle iframe, but whether the provider actually honours it is + unverified.** DCR has genuine, pre-existing dark mode support + (`src/lib/rootStyles.ts`, gated behind the `webx-dark-mode-web` + server-side AB test flag via `darkModeAvailable`), and Puzzle Page wires + this through identically to every other DCR page type + (`render.puzzlePage.web.tsx` → `PuzzlePage.tsx` → `rootStyles()`), so the + masthead/footer/text/background chrome should follow dark mode correctly + when that flag is enabled. `PuzzleIframe` also now sends `darkMode` (see + "User/context info passed to the puzzle iframe" above) via the + `guardian-puzzle-context` query parameter and `postMessage`, but whether + AmuseLabs or Wordiply actually read or honour that signal at all is + unconfirmed (see the `PuzzleContextMessage` open question above). This has + not been visually verified in either light or dark mode. +- **Responsive/mobile layout has not been explicitly verified** for Puzzle + Page or the puzzle iframes themselves (which are entirely provider- + controlled content). +- **DCR does not validate that `puzzleDate` is a real, sensible calendar + date.** Beyond the existing shape check (a non-empty string), nothing in + DCR confirms `puzzleDate` is an actual calendar date (e.g. rejecting a + nonexistent `"2026-02-30"`) or a sensible one (e.g. rejecting a wildly + out-of-range date). Deeper, format-level validation of the date-in-URL + value is `frontend`'s responsibility at the route level (per its own + task); true calendar/business-logic validity (e.g. "did this puzzle + actually exist on this date") is not validated anywhere in the stack yet. +- **DCR's `/PuzzlePage` endpoint itself still has no route-level access + control** (unchanged from before). `frontend`'s existing + `PuzzlesHubExperiment`/`puzzles-new-hub` AB test gate decides whether a + reader ever reaches one of these puzzle-page URLs in the first place; + DCR's endpoint renders unconditionally for any request with a known + `slug`. **What has changed**: DCR now has a real, cumulative, + code-change-free kill-switch for individual _feature tiers_ within the + rendered page, see "Feature-tier rollout gating (v0/v1/v2)" below. This + addresses the previous "no kill-switch" limitation for feature-level + rollback; it does not add route-level gating to `/PuzzlePage` itself + (that remains `frontend`'s responsibility, unchanged). +- **The Puzzles Hub (`src/layouts/PuzzlesLayout.tsx` and friends) is a + separate, unrelated feature** (a directory/listing page) and is not + documented in this file. + +### Feature-tier rollout gating (v0/v1/v2) + +The Puzzles & Games rollout uses a 3-tier, **cumulative** AB-test/ +kill-switch structure (`ab-testing/config/abTests.ts`), so any rollout +phase can be turned on/off, or rolled back to an earlier phase, without +a DCR code change or redeploy. This is per the product rollout plan (v0 = +w/c 5 Oct launch, v1 = w/c 12 Oct launch, v2 = no date confirmed yet). + +- **`puzzles-new-hub` (v0, the master switch)**: gates the baseline + experience, the new Puzzles Hub page, and the 6 V0 puzzle pages (sudoku + x4, word-wheel, wordiply) with no archive, no calendar, no progress + indicators, no sign-in prompt, no related-content rail, and a hub + sub-nav with no links yet. Turning this off hides everything, including + every later tier. +- **`puzzles-new-hub-v1`**: the w/c 12 Oct layer, **on top of v0**. It does + nothing unless `puzzles-new-hub` is _also_ enabled. Activates: full hub + sub-nav links, a sign-in-to-track-progress message, a calendar/archive + view for crosswords/logic-puzzles/word-games (not Wordiply), progress + indicators, the "More from Puzzles & Games" rail, newsletter signup, and + changes to the existing crossword page (print CTA repositioning, "play + other puzzles" container). +- **`puzzles-new-hub-v2`**: a future layer, **on top of v0+v1**. It does + nothing unless both `puzzles-new-hub` and `puzzles-new-hub-v1` are + _also_ enabled. Activates: On the Ball/Film Reveal (Trivia and Quizzes), + a "Most played" container, EventKit-driven navigation, migrating + existing crossword pages onto the Puzzle Page template, and + search-engine mobile app nudges. No launch date confirmed yet; kept at + 0% until that work begins. + +The cumulative design is deliberate: it's impossible to end up with, say, +v2 features showing while v0 is switched off, since each tier's gate +function requires every tier below it to also pass. To roll back a single +phase without a deploy, flip only that tier's `audienceSize`/`status` in +`abTests.ts` and leave the tier(s) below it untouched (e.g. to roll back +from v1 to v0, turn off `puzzles-new-hub-v1` only). + +The corresponding gate-check helpers live in DCR: + +- `isPuzzlesHubEnabled` (`src/lib/puzzlesHubExperiment.ts`), v0 only. +- `isPuzzlesHubV1Enabled`/`isPuzzlesHubV2Enabled` + (`src/lib/puzzlesHubVersionExperiment.ts`), cumulative, as described + above. + +**Current state**: all three tiers sit at `audienceSize: 0/100`, hidden +from the public entirely, same as before this structure existed. Today, +only one DCR-rendered feature actually checks a tier gate: +`PuzzlePageLayout.tsx`'s "More from Puzzles & Games" rail, gated behind +`isPuzzlesHubV1Enabled` (since that rail is v1-scoped, not v0). Every +other v0-scoped feature currently in this codebase renders unconditionally +at the DCR level. v0's "gating" today is really just `frontend`'s +route-level `PuzzlesHubExperiment` check deciding whether a request +reaches `/PuzzlePage` at all, not a DCR-side render-time check. When +future v1/v2 work is implemented (calendar, progress indicators, sign-in +message, on-the-ball/film-reveal, etc.), it should be gated behind +`isPuzzlesHubV1Enabled`/`isPuzzlesHubV2Enabled` respectively, using the +helpers above, the same way the related-content rail already is. + +**No `frontend` repo changes are needed for any of this.** `frontend` +doesn't render Puzzle Page UI itself, so feature-tier gating naturally +lives entirely on the DCR side. `frontend`'s existing route-level +`PuzzlesHubExperiment` gate (already reusing `puzzles-new-hub`) is +unaffected by `puzzles-new-hub-v1`/`puzzles-new-hub-v2` and doesn't need +to check them. It only ever needed to decide whether a reader reaches +`/PuzzlePage` at all, which is still governed by v0 alone. + +### SEO risks to revisit before shipping calendar/archive features + +**Read this before adding date-specific URLs (V1 calendar navigation) or +any archive/pagination UI to Puzzle Page.** No page in Puzzle Page today +creates unbounded or paginated URLs (there is no archive UI yet, despite +`PuzzleConfig.hasArchive` existing, see above), so this isn't an active +problem yet. It becomes one the moment calendar or archive work begins, and +should be raised as an explicit design question at the _start_ of that +work, not discovered after launch. + +- **Date-specific URLs risk creating duplicate/thin indexable pages.** + Once `instance.puzzleDate` (or a real calendar UI) lets readers reach a + specific past date's puzzle via a URL, whether a query param or a path + segment, every such URL must either (a) carry a `canonical` pointing + back to the puzzle's main/"today" URL, if individual dates aren't meant + to be indexed separately, or (b) be a deliberate, explicit decision to + index each date individually with genuinely distinct content/copy per + date. This must be decided explicitly before shipping, not left as an + accidental side effect of adding date-awareness to the URL. +- **Archive/pagination features carry a known, real risk of poor search + indexing if built carelessly.** A concrete, existing cautionary example + elsewhere on the Guardian site: the crossword archive/search listing is + currently indexed by Google with a generic, unhelpful title + ("Crossword | Page 2 of 1082") and a garbled, listing-style meta + description auto-scraped from page content (a concatenated list of + puzzle names) rather than a clean, curated one, a direct consequence of + paginated listing pages being indexed individually without proper + `canonical`/`noindex`/curated-metadata handling. Any future Puzzle Page + archive feature must avoid this from the start: genuinely curated + titles/descriptions per archive page (never auto-generated from a list of + contents, the same principle behind `PuzzleConfig.description` above), + and an explicit `canonical`/`noindex`/pagination-indexing strategy decided + upfront, not defaulting to "index everything" and finding out later. diff --git a/dotcom-rendering/fixtures/manual/puzzlePage.ts b/dotcom-rendering/fixtures/manual/puzzlePage.ts new file mode 100644 index 00000000000..1f0035874be --- /dev/null +++ b/dotcom-rendering/fixtures/manual/puzzlePage.ts @@ -0,0 +1,116 @@ +import type { PuzzleConfig } from '../../src/model/puzzles/puzzleConfigs'; +import { puzzleConfigs } from '../../src/model/puzzles/puzzleConfigs'; +import type { FEPuzzlePageType } from '../../src/types/puzzlePage'; +import type { PuzzleItem } from '../../src/types/puzzlesPage'; +import { Standard } from '../generated/fe-articles/Standard'; + +const sampleMoreFromPuzzlesAndGames: PuzzleItem[] = [ + { + id: 'sudoku-easy-daily', + title: 'Sudoku easy', + type: 'sudoku-easy', + set: 'all', + cardVariant: 'compact', + cadence: 'Daily', + slug: 'sudoku-easy', + }, + { + id: 'word-wheel-daily', + title: 'Word wheel', + type: 'word-wheel', + set: 'all', + cardVariant: 'compact', + cadence: 'Daily', + slug: 'word-wheel', + }, +]; + +/** + * Illustrative canonical URL matching `frontend`'s public URL shape for + * puzzle pages (top-level, mirroring crosswords, distinct from the + * `/puzzles-and-games` hub), purely a fixture value, not something DCR + * validates or enforces the shape of. + */ +const canonicalUrlForSlug = (slug: string): string => { + const sudokuMatch = /^sudoku-(.+)$/.exec(slug); + if (sudokuMatch) { + return `https://www.theguardian.com/sudoku/${sudokuMatch[1]}`; + } + return `https://www.theguardian.com/${slug}`; +}; + +/** + * Builds a `FEPuzzlePageType` fixture for the given `slug`, defaulting to a + * generic instance for that slug's `PuzzleConfig`. Pass `overrides` to + * customise individual fields (deep-merged only one level for `instance`). + */ +export const createPuzzlePage = ( + slug: string, + overrides: Partial<FEPuzzlePageType> = {}, +): FEPuzzlePageType => { + const puzzleConfig = puzzleConfigs[slug]; + + if (!puzzleConfig) { + throw new Error(`Unknown puzzle slug in fixture: ${slug}`); + } + + return { + id: `puzzle-page-${slug}`, + slug, + webTitle: `${slug} | The Guardian`, + config: { + ...Standard.config, + contentType: 'Game', + // DCR no longer gates /PuzzlePage on any AB test participation + // (the former 'game-page-experiment' gate was removed; routes + // will be mapped/exposed via a different project instead), so + // this is left empty rather than implying any particular value + // is required. + serverSideABTests: {}, + }, + nav: Standard.nav, + pageFooter: Standard.pageFooter, + canonicalUrl: canonicalUrlForSlug(slug), + editionId: Standard.editionId, + instance: { + title: `${slug} puzzle`, + puzzleDate: '2026-09-11', + moreFromPuzzlesAndGames: sampleMoreFromPuzzlesAndGames, + }, + ...overrides, + }; +}; + +/** One fixture per supported slug, for local dev preview and tests. */ +export const puzzlePageFixtures: Record<string, FEPuzzlePageType> = Object.keys( + puzzleConfigs, +).reduce<Record<string, FEPuzzlePageType>>((acc, slug) => { + acc[slug] = createPuzzlePage(slug); + return acc; +}, {}); + +/** + * A fixture-only illustrative preview/share image URL. None of the real + * `puzzleConfigs` registry entries have a real image configured yet (see + * docs/puzzle-page.md) - this exists purely so both the with-image and + * without-image branches of Puzzle Page's OG/Twitter metadata have fixture + * and test coverage, without inventing a placeholder image for the real + * registry itself. + */ +export const samplePuzzleImageUrl = + 'https://i.guim.co.uk/img/media/fixture-only-example/puzzle-preview.jpg?width=1200&height=630&quality=85'; + +/** + * Returns a copy of `slug`'s real `PuzzleConfig` with `image` set to + * `samplePuzzleImageUrl` - a fixture-only variant for exercising the + * with-image branch (the real registry entry itself is left untouched). + */ +export const createPuzzleConfigWithImage = (slug: string): PuzzleConfig => { + const puzzleConfig = puzzleConfigs[slug]; + + if (!puzzleConfig) { + throw new Error(`Unknown puzzle slug in fixture: ${slug}`); + } + + return { ...puzzleConfig, image: samplePuzzleImageUrl }; +}; diff --git a/dotcom-rendering/src/components/PuzzleIframe.island.test.tsx b/dotcom-rendering/src/components/PuzzleIframe.island.test.tsx new file mode 100644 index 00000000000..0d2610c5f4b --- /dev/null +++ b/dotcom-rendering/src/components/PuzzleIframe.island.test.tsx @@ -0,0 +1,352 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { getAuthStatus, subscribeToAuthStateChange } from '../lib/identity'; +import { useMatchMedia } from '../lib/useMatchMedia'; +import { + buildPuzzleIframeSrcWithContext, + type PuzzleContext, + PuzzleIframe, +} from './PuzzleIframe.island'; + +jest.mock('../lib/identity', () => ({ + getAuthStatus: jest.fn(), + subscribeToAuthStateChange: jest.fn(), +})); + +jest.mock('../lib/useMatchMedia', () => ({ + useMatchMedia: jest.fn(() => false), +})); + +const mockedGetAuthStatus = jest.mocked(getAuthStatus); +const mockedSubscribeToAuthStateChange = jest.mocked( + subscribeToAuthStateChange, +); +const mockedUseMatchMedia = jest.mocked(useMatchMedia); + +const signedIn = (legacyIdentityId: string) => ({ + kind: 'SignedIn' as const, + accessToken: {} as never, + idToken: { + claims: { legacy_identity_id: legacyIdentityId }, + } as never, +}); + +const signedOut = () => ({ kind: 'SignedOut' as const }); + +const contextParam = (context: PuzzleContext) => + `guardian-puzzle-context=${encodeURIComponent(JSON.stringify(context))}`; + +describe('buildPuzzleIframeSrcWithContext', () => { + it('appends the context as a JSON query param when the src has none', () => { + const context: PuzzleContext = { + userId: null, + darkMode: false, + puzzleDate: null, + }; + expect( + buildPuzzleIframeSrcWithContext( + 'https://example.com/puzzle', + context, + ), + ).toBe(`https://example.com/puzzle?${contextParam(context)}`); + }); + + it('preserves existing query params when appending the context', () => { + const context: PuzzleContext = { + userId: 'abc123', + darkMode: true, + puzzleDate: '2026-09-15', + }; + expect( + buildPuzzleIframeSrcWithContext( + 'https://example.com/puzzle?set=guardian-sudoku-easy&embed=1', + context, + ), + ).toBe( + `https://example.com/puzzle?set=guardian-sudoku-easy&embed=1&${contextParam(context)}`, + ); + }); + + it('always includes the context, even when signed out and dark mode is off', () => { + const context: PuzzleContext = { + userId: null, + darkMode: false, + puzzleDate: null, + }; + expect( + buildPuzzleIframeSrcWithContext( + 'https://example.com/puzzle', + context, + ), + ).toContain('guardian-puzzle-context='); + }); + + it('returns the src unchanged if it cannot be parsed as an absolute URL', () => { + expect( + buildPuzzleIframeSrcWithContext('not-a-url', { + userId: null, + darkMode: false, + puzzleDate: null, + }), + ).toBe('not-a-url'); + }); +}); + +describe('PuzzleIframe', () => { + let unsubscribe: jest.Mock; + + beforeEach(() => { + jest.resetAllMocks(); + unsubscribe = jest.fn(); + mockedSubscribeToAuthStateChange.mockReturnValue(unsubscribe); + mockedUseMatchMedia.mockReturnValue(false); + }); + + const getContextFromSrc = (src: string): PuzzleContext => { + const url = new URL(src); + return JSON.parse( + url.searchParams.get('guardian-puzzle-context') ?? '{}', + ) as PuzzleContext; + }; + + it('renders userId: null and darkMode: false while signed out with dark mode unavailable', async () => { + mockedGetAuthStatus.mockResolvedValue(signedOut()); + + render( + <PuzzleIframe + src="https://example.com/puzzle" + title="Puzzle" + darkModeAvailable={false} + puzzleDate={null} + />, + ); + + const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle'); + await waitFor(() => + expect(getContextFromSrc(iframe.src)).toEqual({ + userId: null, + darkMode: false, + puzzleDate: null, + }), + ); + }); + + it('includes the userId in the context once signed in', async () => { + mockedGetAuthStatus.mockResolvedValue(signedIn('user-123')); + + render( + <PuzzleIframe + src="https://example.com/puzzle" + title="Puzzle" + darkModeAvailable={false} + puzzleDate={null} + />, + ); + + const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle'); + await waitFor(() => + expect(getContextFromSrc(iframe.src)).toEqual({ + userId: 'user-123', + darkMode: false, + puzzleDate: null, + }), + ); + }); + + it('does not check prefers-color-scheme at all when darkModeAvailable is false', async () => { + mockedGetAuthStatus.mockResolvedValue(signedOut()); + mockedUseMatchMedia.mockReturnValue(true); + + render( + <PuzzleIframe + src="https://example.com/puzzle" + title="Puzzle" + darkModeAvailable={false} + puzzleDate={null} + />, + ); + + const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle'); + await waitFor(() => + expect(getContextFromSrc(iframe.src).darkMode).toBe(false), + ); + }); + + it('reports darkMode: true only when darkModeAvailable AND the OS/browser prefers dark', async () => { + mockedGetAuthStatus.mockResolvedValue(signedOut()); + mockedUseMatchMedia.mockReturnValue(true); + + render( + <PuzzleIframe + src="https://example.com/puzzle" + title="Puzzle" + darkModeAvailable={true} + puzzleDate={null} + />, + ); + + const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle'); + await waitFor(() => + expect(getContextFromSrc(iframe.src).darkMode).toBe(true), + ); + }); + + it('reacts to the OS/browser colour-scheme preference changing while mounted', async () => { + mockedGetAuthStatus.mockResolvedValue(signedOut()); + mockedUseMatchMedia.mockReturnValue(false); + + const { rerender } = render( + <PuzzleIframe + src="https://example.com/puzzle" + title="Puzzle" + darkModeAvailable={true} + puzzleDate={null} + />, + ); + + const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle'); + await waitFor(() => + expect(getContextFromSrc(iframe.src).darkMode).toBe(false), + ); + + // Simulate useMatchMedia reacting to a live prefers-color-scheme + // change (it is itself reactive via useSyncExternalStore) by + // updating its mocked return value and re-rendering, mirroring how + // a real OS theme switch would cause useMatchMedia to return a new + // value and this component to re-render. + mockedUseMatchMedia.mockReturnValue(true); + rerender( + <PuzzleIframe + src="https://example.com/puzzle" + title="Puzzle" + darkModeAvailable={true} + puzzleDate={null} + />, + ); + + await waitFor(() => + expect(getContextFromSrc(iframe.src).darkMode).toBe(true), + ); + }); + + it('posts the puzzle context to the iframe once loaded', async () => { + mockedGetAuthStatus.mockResolvedValue(signedIn('user-123')); + mockedUseMatchMedia.mockReturnValue(true); + + render( + <PuzzleIframe + src="https://example.com/puzzle" + title="Puzzle" + darkModeAvailable={true} + puzzleDate={null} + />, + ); + + const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle'); + const postMessage = jest.fn(); + Object.defineProperty(iframe, 'contentWindow', { + value: { postMessage }, + configurable: true, + }); + + await act(async () => { + iframe.dispatchEvent(new Event('load')); + }); + + expect(postMessage).toHaveBeenCalledWith( + { + type: 'guardian-puzzle-context', + context: { + userId: 'user-123', + darkMode: true, + puzzleDate: null, + }, + }, + '*', + ); + }); + + it('subscribes to auth state changes and updates the context if the user signs out', async () => { + mockedGetAuthStatus.mockResolvedValueOnce(signedIn('user-123')); + + render( + <PuzzleIframe + src="https://example.com/puzzle" + title="Puzzle" + darkModeAvailable={false} + puzzleDate={null} + />, + ); + + const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle'); + await waitFor(() => + expect(getContextFromSrc(iframe.src).userId).toBe('user-123'), + ); + + expect(mockedSubscribeToAuthStateChange).toHaveBeenCalledTimes(1); + const onAuthStateChange = + mockedSubscribeToAuthStateChange.mock.calls[0]![0]; + + mockedGetAuthStatus.mockResolvedValueOnce(signedOut()); + await act(async () => { + onAuthStateChange(); + }); + + await waitFor(() => + expect(getContextFromSrc(iframe.src).userId).toBe(null), + ); + }); + + it('unsubscribes from auth state changes on unmount', async () => { + mockedGetAuthStatus.mockResolvedValue(signedOut()); + + const { unmount } = render( + <PuzzleIframe + src="https://example.com/puzzle" + title="Puzzle" + darkModeAvailable={false} + puzzleDate={null} + />, + ); + + await screen.findByTitle('Puzzle'); + unmount(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('includes puzzleDate in the context when provided', async () => { + mockedGetAuthStatus.mockResolvedValue(signedOut()); + + render( + <PuzzleIframe + src="https://example.com/puzzle" + title="Puzzle" + darkModeAvailable={false} + puzzleDate="2026-09-15" + />, + ); + + const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle'); + await waitFor(() => + expect(getContextFromSrc(iframe.src).puzzleDate).toBe('2026-09-15'), + ); + }); + + it('reports puzzleDate: null in the context when not provided', async () => { + mockedGetAuthStatus.mockResolvedValue(signedOut()); + + render( + <PuzzleIframe + src="https://example.com/puzzle" + title="Puzzle" + darkModeAvailable={false} + puzzleDate={null} + />, + ); + + const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle'); + await waitFor(() => + expect(getContextFromSrc(iframe.src).puzzleDate).toBeNull(), + ); + }); +}); diff --git a/dotcom-rendering/src/components/PuzzleIframe.island.tsx b/dotcom-rendering/src/components/PuzzleIframe.island.tsx new file mode 100644 index 00000000000..0215f9a15e3 --- /dev/null +++ b/dotcom-rendering/src/components/PuzzleIframe.island.tsx @@ -0,0 +1,214 @@ +import { css } from '@emotion/react'; +import { useEffect, useState } from 'react'; +import { getAuthStatus, subscribeToAuthStateChange } from '../lib/identity'; +import { useMatchMedia } from '../lib/useMatchMedia'; + +interface Props { + /** The already-resolved iframe src URL (with `{slug}` substituted). */ + src: string; + title: string; + /** + * Whether dark mode is available for this page/request at all (the + * `webx-dark-mode-web` server-side AB test flag, already threaded down + * from `PuzzlePage.tsx`/`useConfig()` the same way it reaches + * `rootStyles()`). Combined client-side with the reader's real OS/browser + * preference (`prefers-color-scheme`) to decide `PuzzleContext.darkMode`. + */ + darkModeAvailable: boolean; + /** + * Which day's puzzle is being shown, as the raw `YYYY-MM-DD` string + * `frontend` resolved (`instance.puzzleDate`), or `null` if not + * provided. Passed straight through to `PuzzleContext.puzzleDate` + * unformatted, third-party providers need the machine-readable form, + * not the human-readable display text rendered next to the title. + */ + puzzleDate: string | null; +} + +const frameStyles = css` + width: 100%; + min-height: 500px; + border: none; +`; + +/** + * The context posted to (and encoded in the URL of) the puzzle iframe, + * carrying enough about the current Guardian reader for puzzle providers + * (AmuseLabs, Wordiply) to personalise/save progress against a real + * account and render consistently with the reader's colour scheme, rather + * than guessing at either. + */ +export interface PuzzleContext { + /** + * The reader's `idToken.claims.legacy_identity_id` (their Guardian + * "identity ID", the same identifier already used to build MyAccount + * links elsewhere in DCR - see `TopBarMyAccount.tsx`) - not the OIDC + * `sub` claim some newer API integrations elsewhere in DCR use instead. + * `null` when the reader is signed out (or the auth check hasn't + * resolved yet). + * + * Whether `legacy_identity_id` is actually the ID format + * AmuseLabs/Wordiply expect has not been confirmed with those providers + * - see the "Open questions" section of docs/puzzle-page.md. + */ + userId: string | null; + /** + * Whether dark mode is currently actually active for this reader: both + * `darkModeAvailable` (the server-side AB flag for this page/request) + * AND the reader's OS/browser actually preferring dark + * (`prefers-color-scheme: dark`) must be true. See `usePuzzleDarkMode`. + */ + darkMode: boolean; + /** + * Which day's puzzle is being shown, as the raw `YYYY-MM-DD` string + * `frontend` resolved (`instance.puzzleDate`). `null` when not provided. + * DCR does not parse the Puzzle Page URL or own the date-in-path/ + * redirect-to-archive logic itself, it simply passes through whatever + * `frontend` resolved and sent (see `docs/puzzle-page.md`). + */ + puzzleDate: string | null; +} + +export interface PuzzleContextMessage { + type: 'guardian-puzzle-context'; + context: PuzzleContext; +} + +/** + * Reactively resolves the current signed-in user's Guardian identity ID (or + * `undefined` if signed out/unknown), re-checking whenever the underlying + * identity-auth client reports an auth state change (e.g. the reader signs + * in or out while already on this page, via a sign-in modal or another + * tab) - not just once on mount. + */ +const usePuzzleUserId = (): string | undefined => { + const [userId, setUserId] = useState<string | undefined>(undefined); + + useEffect(() => { + let isMounted = true; + + const refresh = () => { + void getAuthStatus().then((authStatus) => { + if (!isMounted) return; + setUserId( + authStatus.kind === 'SignedIn' + ? authStatus.idToken.claims.legacy_identity_id + : undefined, + ); + }); + }; + + refresh(); + const unsubscribe = subscribeToAuthStateChange(refresh); + + return () => { + isMounted = false; + unsubscribe(); + }; + }, []); + + return userId; +}; + +/** + * Reactively resolves whether dark mode is currently actually active: + * returns `false` immediately (without touching `matchMedia` at all) when + * `darkModeAvailable` is `false` for this page/request; otherwise reuses + * the existing, generic `useMatchMedia` hook (already used elsewhere in DCR + * for `prefers-color-scheme` and other media queries) to check - and stay + * reactively subscribed to - the reader's real OS/browser preference, so + * this updates live if the reader switches their OS theme while the page + * is open. + */ +const usePuzzleDarkMode = (darkModeAvailable: boolean): boolean => { + const prefersDark = useMatchMedia('(prefers-color-scheme: dark)'); + return darkModeAvailable && prefersDark; +}; + +const buildPuzzleContext = ( + userId: string | undefined, + darkMode: boolean, + puzzleDate: string | null, +): PuzzleContext => ({ + userId: userId ?? null, + darkMode, + puzzleDate, +}); + +/** + * Encodes `context` as JSON into a `guardian-puzzle-context` query + * parameter on `src`, preserving any existing query parameters (e.g. + * AmuseLabs' `?set=...&embed=1&idx=1`). Always includes the parameter - + * unlike the previous `userId`-only mechanism, the context shape itself + * always carries both fields, so there's no "nothing to add" case to omit + * it for. Returns `src` unchanged if it cannot be parsed as an absolute + * URL. + */ +export const buildPuzzleIframeSrcWithContext = ( + src: string, + context: PuzzleContext, +): string => { + try { + const url = new URL(src); + url.searchParams.set( + 'guardian-puzzle-context', + JSON.stringify(context), + ); + return url.toString(); + } catch { + return src; + } +}; + +const postContextMessage = ( + iframe: HTMLIFrameElement, + context: PuzzleContext, +) => { + const message: PuzzleContextMessage = { + type: 'guardian-puzzle-context', + context, + }; + iframe.contentWindow?.postMessage(message, '*'); +}; + +/** + * Generic sandboxed iframe wrapper for third-party (or in-house, non-React) + * puzzle providers, such as AmuseLabs-hosted puzzles or bespoke providers + * like wordiply.com. Used for every `PuzzleConfig` entry (all iframe-based). + * + * Passes a `PuzzleContext` (the current signed-in user's identity, whether + * dark mode is currently active, and which day's puzzle is being shown) to + * the puzzle provider two ways: + * as a `guardian-puzzle-context` query parameter (JSON-encoded) on the + * iframe `src` (so it is present from the very first request the iframe + * makes), and via `postMessage` once the iframe has loaded (`{ type: + * 'guardian-puzzle-context', context }` - see `PuzzleContextMessage`). + * Because `src` is derived from the reactive `usePuzzleUserId()`/ + * `usePuzzleDarkMode()` results, the iframe is automatically reloaded by + * the browser (a fresh `src` triggers a new navigation) whenever the + * reader's sign-in state or OS colour-scheme preference changes while on + * the page - no manual reload fallback is needed for that case, though the + * `onLoad` `postMessage` still fires again after each such reload too. + */ +export const PuzzleIframe = ({ + src, + title, + darkModeAvailable, + puzzleDate, +}: Props) => { + const userId = usePuzzleUserId(); + const darkMode = usePuzzleDarkMode(darkModeAvailable); + const context = buildPuzzleContext(userId, darkMode, puzzleDate); + const iframeSrc = buildPuzzleIframeSrcWithContext(src, context); + + return ( + <iframe + css={frameStyles} + src={iframeSrc} + title={title} + loading="lazy" + sandbox="allow-scripts allow-same-origin allow-popups allow-forms" + onLoad={(event) => postContextMessage(event.currentTarget, context)} + /> + ); +}; diff --git a/dotcom-rendering/src/components/PuzzlePage.tsx b/dotcom-rendering/src/components/PuzzlePage.tsx new file mode 100644 index 00000000000..3c878753bea --- /dev/null +++ b/dotcom-rendering/src/components/PuzzlePage.tsx @@ -0,0 +1,65 @@ +import { Global } from '@emotion/react'; +import { StrictMode } from 'react'; +import { + PuzzlePageLayout, + type ResolvedPuzzlePage, +} from '../layouts/PuzzlePageLayout'; +import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; +import { rootStyles } from '../lib/rootStyles'; +import type { NavType } from '../model/extract-nav'; +import { AdmiralScript } from './AdmiralScript.island'; +import { AlreadyVisited } from './AlreadyVisited.island'; +import { useConfig } from './ConfigContext'; +import { FocusStyles } from './FocusStyles.island'; +import { Island } from './Island'; +import { Metrics } from './Metrics.island'; +import { SetABTests } from './SetABTests.island'; +import { SkipTo } from './SkipTo'; + +type Props = { + puzzlePage: ResolvedPuzzlePage; + NAV: NavType; +}; + +export const PuzzlePage = ({ puzzlePage, NAV }: Props) => { + const format = { + display: ArticleDisplay.Standard, + design: ArticleDesign.Standard, + theme: Pillar.News, + }; + const { darkModeAvailable } = useConfig(); + + return ( + <StrictMode> + <Global styles={rootStyles(format, darkModeAvailable)} /> + <SkipTo id="maincontent" label="Skip to main content" /> + <SkipTo id="navigation" label="Skip to navigation" /> + <Island priority="feature" defer={{ until: 'idle' }}> + <AlreadyVisited /> + </Island> + <Island priority="feature" defer={{ until: 'idle' }}> + <AdmiralScript /> + </Island> + <Island priority="feature" defer={{ until: 'idle' }}> + <FocusStyles /> + </Island> + <Island priority="critical"> + <Metrics + commercialMetricsEnabled={ + !!puzzlePage.config.switches.commercialMetrics + } + /> + </Island> + <Island priority="critical"> + <SetABTests + serverSideABTests={puzzlePage.config.serverSideABTests} + /> + </Island> + <PuzzlePageLayout + puzzlePage={puzzlePage} + NAV={NAV} + darkModeAvailable={darkModeAvailable} + /> + </StrictMode> + ); +}; diff --git a/dotcom-rendering/src/layouts/PuzzlePageLayout.test.tsx b/dotcom-rendering/src/layouts/PuzzlePageLayout.test.tsx new file mode 100644 index 00000000000..debfd8b4163 --- /dev/null +++ b/dotcom-rendering/src/layouts/PuzzlePageLayout.test.tsx @@ -0,0 +1,160 @@ +import { render, screen } from '@testing-library/react'; +import { createPuzzlePage } from '../../fixtures/manual/puzzlePage'; +import { ConfigProvider } from '../components/ConfigContext'; +import { + puzzlesHubExperiment, + puzzlesHubParticipation, +} from '../lib/puzzlesHubExperiment'; +import { + puzzlesHubV1Experiment, + puzzlesHubV1Participation, +} from '../lib/puzzlesHubVersionExperiment'; +import { extractNAV } from '../model/extract-nav'; +import { getPuzzleConfig } from '../model/puzzles/puzzleConfigs'; +import { PuzzlePageLayout } from './PuzzlePageLayout'; + +jest.mock('../lib/bridgetApi', () => jest.fn()); +jest.mock('../lib/useMatchMedia', () => ({ + ...jest.requireActual('../lib/useMatchMedia'), + useMatchMedia: jest.fn(() => true), +})); + +const v0AndV1On = { + ...puzzlesHubParticipation(puzzlesHubExperiment.variant), + ...puzzlesHubV1Participation(puzzlesHubV1Experiment.variant), +}; + +const renderPuzzlePageLayout = ( + slug: string, + overrides: Parameters<typeof createPuzzlePage>[1] = {}, +) => { + const puzzlePage = createPuzzlePage(slug, overrides); + const puzzleConfig = getPuzzleConfig(slug); + if (!puzzleConfig) throw new Error(`missing config for ${slug}`); + + return render( + <ConfigProvider + value={{ + renderingTarget: 'Web', + darkModeAvailable: false, + assetOrigin: '/', + editionId: 'UK', + }} + > + <PuzzlePageLayout + puzzlePage={{ ...puzzlePage, puzzleConfig }} + NAV={extractNAV(puzzlePage.nav)} + darkModeAvailable={false} + /> + </ConfigProvider>, + ); +}; + +describe('PuzzlePageLayout', () => { + it('renders the page title', () => { + renderPuzzlePageLayout('sudoku-easy'); + + expect( + screen.getByRole('heading', { + level: 1, + name: 'sudoku-easy puzzle', + }), + ).toBeInTheDocument(); + }); + + it('renders a human-readable puzzleDate next to the title when present', () => { + renderPuzzlePageLayout('sudoku-easy'); + + expect(screen.getByText('11 September 2026')).toBeInTheDocument(); + }); + + it('does not render a date when puzzleDate is absent', () => { + renderPuzzlePageLayout('sudoku-easy', { + instance: { + ...createPuzzlePage('sudoku-easy').instance, + puzzleDate: undefined, + }, + }); + + expect(screen.queryByText('11 September 2026')).not.toBeInTheDocument(); + }); + + it('renders the puzzleGroup label as plain, non-linked text', () => { + renderPuzzlePageLayout('sudoku-easy'); + + expect( + screen.queryByRole('link', { name: 'Logic puzzles' }), + ).not.toBeInTheDocument(); + expect(screen.getByText('Logic puzzles')).toBeInTheDocument(); + }); + + describe('"More from Puzzles & Games" rail (v1-scoped feature)', () => { + it('renders the rail when data is present AND v0+v1 are both enabled', () => { + renderPuzzlePageLayout('sudoku-easy', { + config: { + ...createPuzzlePage('sudoku-easy').config, + serverSideABTests: v0AndV1On, + }, + }); + + expect( + screen.getByText('More from Puzzles & games'), + ).toBeInTheDocument(); + }); + + it('does not render the rail when moreFromPuzzlesAndGames is empty, even with v0+v1 enabled', () => { + renderPuzzlePageLayout('sudoku-easy', { + config: { + ...createPuzzlePage('sudoku-easy').config, + serverSideABTests: v0AndV1On, + }, + instance: { + ...createPuzzlePage('sudoku-easy').instance, + moreFromPuzzlesAndGames: [], + }, + }); + + expect( + screen.queryByText('More from Puzzles & games'), + ).not.toBeInTheDocument(); + }); + + it('does not render the rail when data is present but neither v0 nor v1 is enabled (default fixture state)', () => { + renderPuzzlePageLayout('sudoku-easy'); + + expect( + screen.queryByText('More from Puzzles & games'), + ).not.toBeInTheDocument(); + }); + + it('does not render the rail when data is present and v1 is enabled but v0 is not', () => { + renderPuzzlePageLayout('sudoku-easy', { + config: { + ...createPuzzlePage('sudoku-easy').config, + serverSideABTests: puzzlesHubV1Participation( + puzzlesHubV1Experiment.variant, + ), + }, + }); + + expect( + screen.queryByText('More from Puzzles & games'), + ).not.toBeInTheDocument(); + }); + + it('does not render the rail when data is present and v0 is enabled but v1 is not', () => { + renderPuzzlePageLayout('sudoku-easy', { + config: { + ...createPuzzlePage('sudoku-easy').config, + serverSideABTests: puzzlesHubParticipation( + puzzlesHubExperiment.variant, + ), + }, + }); + + expect( + screen.queryByText('More from Puzzles & games'), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/dotcom-rendering/src/layouts/PuzzlePageLayout.tsx b/dotcom-rendering/src/layouts/PuzzlePageLayout.tsx new file mode 100644 index 00000000000..f0308c932ac --- /dev/null +++ b/dotcom-rendering/src/layouts/PuzzlePageLayout.tsx @@ -0,0 +1,385 @@ +import { css } from '@emotion/react'; +import { + from, + palette as sourcePalette, + until, +} from '@guardian/source/foundations'; +import { StraightLines } from '@guardian/source-development-kitchen/react-components'; +import { AdSlot, MobileStickyContainer } from '../components/AdSlot.web'; +import { Footer } from '../components/Footer'; +import { GridItem } from '../components/GridItem'; +import { HeaderAdSlot } from '../components/HeaderAdSlot'; +import { Island } from '../components/Island'; +import { Masthead } from '../components/Masthead/Masthead'; +import { PuzzleIframe } from '../components/PuzzleIframe.island'; +import { Section } from '../components/Section'; +import { ShareButton } from '../components/ShareButton.island'; +import { SubNav } from '../components/SubNav.island'; +import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; +import { formatPuzzleDate } from '../lib/puzzleDate'; +import { isPuzzlesHubV1Enabled } from '../lib/puzzlesHubVersionExperiment'; +import type { NavType } from '../model/extract-nav'; +import { + type PuzzleConfig, + resolveIframeUrl, +} from '../model/puzzles/puzzleConfigs'; +import { palette as themePalette } from '../palette'; +import type { FEPuzzlePageType } from '../types/puzzlePage'; + +/** + * A fresh, self-contained layout for generic Puzzle Pages. It intentionally + * does not reuse Article-domain composite components (`ArticleMeta`, + * `ArticleTitle`, `ArticleBody`) as those require a full `ArticleFormat` + + * `TagType[]` + branding/podcast/avatar machinery that doesn't apply to a + * generic puzzle page. It does directly reuse existing generic building + * blocks (Masthead, Section, Footer, AdSlot, ShareButton.island) rather than + * duplicating them. + * + * Puzzle Page is scoped to iframe-based puzzles only, crosswords remain on + * their existing, separate `/crosswords/*` flow + * (`ArticleDesign.Crossword` / `src/layouts/CrosswordLayout.tsx`), which is + * unrelated to this layout. There is accordingly no setter byline, PDF + * link, or comments rendering here, none of the current `PuzzleConfig` + * registry entries have any equivalent concept. + */ + +const puzzleGroupLabels: Record<PuzzleConfig['puzzleGroup'], string> = { + 'logic-puzzles': 'Logic puzzles', + 'word-games': 'Word games', +}; + +/** + * `ShareButton.island` only needs an `ArticleFormat` to branch a handful of + * minor style decisions (e.g. LiveBlog-specific spacing). Puzzle pages have + * no equivalent concept, so a minimal, fixed format value is used to satisfy + * its prop contract without fabricating article-specific data (tags, + * branding, etc.). This is read-only reuse of existing exported enum + * values, it does not modify `articleFormat.ts` or any crossword decision + * logic. + */ +const puzzlePageFormat = { + display: ArticleDisplay.Standard, + design: ArticleDesign.Standard, + theme: Pillar.News, +} as const; + +const headerGrid = css` + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + 'label' + 'title' + 'meta' + 'body'; + row-gap: 8px; + + ${from.leftCol} { + grid-template-columns: 140px 1fr; + column-gap: 20px; + grid-template-areas: + 'label title' + '. meta' + 'body body'; + } +`; + +const puzzleTypeLabel = css` + color: ${themePalette('--crossword-clues-header-border-top')}; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; +`; + +const puzzleDateStyles = css` + display: block; + color: ${themePalette('--sub-meta-text')}; + font-weight: 400; +`; + +const metaRow = css` + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +`; + +const printButtonStyles = css` + background: none; + border: 1px solid currentColor; + border-radius: 100px; + padding: 4px 12px; + cursor: pointer; + font-size: inherit; + color: inherit; + + @media print { + display: none; + } +`; + +const PrintButton = () => ( + <button + type="button" + css={printButtonStyles} + onClick={() => window.print()} + > + Print + </button> +); + +const relatedRailStyles = css` + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 0; + + ${until.tablet} { + padding: 16px 20px; + } +`; + +const relatedRailHeading = css` + font-weight: 700; +`; + +/** + * The `/PuzzlePage` handler resolves and validates the `PuzzleConfig` for + * the request's `slug` before rendering; it is passed alongside the raw + * payload rather than re-derived here so `PuzzlePageLayout` has a single, + * already-narrowed source of truth for rendering decisions. + */ +export type ResolvedPuzzlePage = FEPuzzlePageType & { + puzzleConfig: PuzzleConfig; +}; + +const PuzzlePageContent = ({ + puzzlePage, + darkModeAvailable, +}: { + puzzlePage: ResolvedPuzzlePage; + darkModeAvailable: boolean; +}) => { + const { instance, puzzleConfig } = puzzlePage; + + return ( + <Island priority="critical" defer={{ until: 'visible' }}> + <PuzzleIframe + src={resolveIframeUrl(puzzleConfig)} + title={instance.title} + darkModeAvailable={darkModeAvailable} + puzzleDate={instance.puzzleDate ?? null} + /> + </Island> + ); +}; + +const RelatedPuzzlesRail = ({ + items, +}: { + items: NonNullable<FEPuzzlePageType['instance']['moreFromPuzzlesAndGames']>; +}) => ( + <div css={relatedRailStyles}> + <h2 css={relatedRailHeading}>More from Puzzles & games</h2> + <ul> + {items.map((item) => ( + <li key={item.id}> + {item.url ? ( + <a href={item.url}>{item.title}</a> + ) : ( + item.title + )} + </li> + ))} + </ul> + </div> +); + +interface Props { + puzzlePage: ResolvedPuzzlePage; + NAV: NavType; + darkModeAvailable: boolean; +} + +export const PuzzlePageLayout = ({ + puzzlePage, + NAV, + darkModeAvailable, +}: Props) => { + const { config, instance, editionId, puzzleConfig } = puzzlePage; + + const showShare = puzzleConfig.shareEnabled; + const showPrint = puzzleConfig.printEnabled; + // The "More from Puzzles & Games" rail is a v1-scoped feature (per the + // Puzzles & Games rollout plan - see abTests.ts's puzzles-new-hub-v1 + // JSDoc), not a v0 one - so it must not render just because + // instance.moreFromPuzzlesAndGames happens to be non-empty. Gating on + // isPuzzlesHubV1Enabled too means the rail can be reliably kept hidden + // before v1 launches even if frontend ever populates this field early + // (accidentally or during testing). + const showRelated = + !!instance.moreFromPuzzlesAndGames?.length && + isPuzzlesHubV1Enabled(config); + const labelText = puzzleGroupLabels[puzzleConfig.puzzleGroup]; + const displayDate = formatPuzzleDate(instance.puzzleDate); + + return ( + <> + <div data-print-layout="hide"> + <Section + fullWidth={true} + showTopBorder={false} + showSideBorders={false} + padSides={false} + shouldCenter={false} + > + <HeaderAdSlot /> + </Section> + + <Masthead + nav={NAV} + editionId={editionId} + idUrl={config.idUrl} + mmaUrl={config.mmaUrl} + discussionApiUrl={config.discussionApiUrl} + idApiUrl={config.idApiUrl} + contributionsServiceUrl="" + showSubNav={true} + showSlimNav={false} + hasPageSkin={false} + hasPageSkinContentSelfConstrain={false} + pageId={puzzlePage.id} + tagIds={[]} + sectionId={config.section} + contentType="Game" + /> + </div> + + <main data-layout="PuzzlePageLayout"> + <Section + fullWidth={true} + showTopBorder={false} + backgroundColour={themePalette('--article-background')} + borderColour={themePalette('--article-border')} + element="article" + > + <div css={headerGrid}> + <GridItem area="label" element="aside"> + <span css={puzzleTypeLabel}>{labelText}</span> + </GridItem> + <GridItem area="title"> + <h1>{instance.title}</h1> + {displayDate && ( + <span css={puzzleDateStyles}> + {displayDate} + </span> + )} + </GridItem> + <GridItem area="meta" element="aside"> + <div css={metaRow}> + {showShare && ( + <ShareButton + pageId={puzzlePage.id} + webTitle={puzzlePage.webTitle} + format={puzzlePageFormat} + context="ArticleMeta" + /> + )} + {showPrint && <PrintButton />} + </div> + </GridItem> + <GridItem area="body" element="article"> + <PuzzlePageContent + puzzlePage={puzzlePage} + darkModeAvailable={darkModeAvailable} + /> + </GridItem> + </div> + </Section> + + <Section + fullWidth={true} + showTopBorder={false} + padSides={false} + backgroundColour={themePalette('--article-background')} + hideFromPrintLayout={true} + > + <StraightLines + count={4} + color={themePalette('--straight-lines')} + cssOverrides={css` + display: block; + `} + /> + </Section> + + {showRelated && ( + <Section + fullWidth={true} + showTopBorder={false} + backgroundColour={themePalette('--article-background')} + > + <RelatedPuzzlesRail + items={instance.moreFromPuzzlesAndGames ?? []} + /> + </Section> + )} + + <Section + fullWidth={true} + padSides={false} + showTopBorder={false} + showSideBorders={false} + backgroundColour={themePalette('--ad-background')} + element="aside" + > + <AdSlot + data-print-layout="hide" + position="merchandising-high" + /> + </Section> + + <Section + fullWidth={true} + padSides={false} + showTopBorder={false} + showSideBorders={false} + backgroundColour={themePalette('--ad-background')} + element="aside" + > + <AdSlot position="merchandising" /> + </Section> + </main> + + {NAV.subNavSections && ( + <Section fullWidth={true} padSides={false} element="aside"> + <Island priority="enhancement" defer={{ until: 'visible' }}> + <SubNav + subNavSections={NAV.subNavSections} + currentNavLink={NAV.currentNavLink} + position="footer" + /> + </Island> + </Section> + )} + + <Section + fullWidth={true} + padSides={false} + backgroundColour={sourcePalette.brand[400]} + borderColour={sourcePalette.brand[600]} + showSideBorders={false} + element="footer" + > + <Footer + pageFooter={puzzlePage.pageFooter} + selectedPillar={NAV.selectedPillar} + pillars={NAV.pillars} + urls={NAV.readerRevenueLinks.footer} + editionId={editionId} + /> + </Section> + + <MobileStickyContainer data-print-layout="hide" /> + </> + ); +}; diff --git a/dotcom-rendering/src/lib/identity.ts b/dotcom-rendering/src/lib/identity.ts index a8bde523387..f4e31d8b641 100644 --- a/dotcom-rendering/src/lib/identity.ts +++ b/dotcom-rendering/src/lib/identity.ts @@ -74,3 +74,30 @@ export const getAuthStatus = async (): Promise<AuthStatus> => { const authState = await getAuthState(); return getSignedInStatus(authState); }; + +/** + * Subscribes to auth state changes (sign-in/sign-out) via the underlying + * `@guardian/identity-auth` client's own `authStateManager`, so callers can + * react when a reader signs in/out while already on the page (e.g. via a + * sign-in modal or another tab), rather than only being able to check the + * auth state once on mount. + * + * This wraps `getIdentityAuth().authStateManager.subscribe`/`unsubscribe`, + * which is the identity-auth library's own public, documented mechanism for + * this (see its `AuthStateManager`/`Emitter` types) - not a bespoke event + * bus invented for this purpose. At the time of writing this is its first + * use anywhere in this codebase (existing DCR call sites only ever check + * auth status once, e.g. `useAuthStatus`), so treat it as a new pattern + * that hasn't yet been proven out elsewhere here. + * + * @returns an unsubscribe function + */ +export const subscribeToAuthStateChange = ( + callback: () => void, +): (() => void) => { + const auth = getIdentityAuth(); + auth.authStateManager.subscribe(callback); + return () => { + auth.authStateManager.unsubscribe(callback); + }; +}; diff --git a/dotcom-rendering/src/lib/puzzleDate.test.ts b/dotcom-rendering/src/lib/puzzleDate.test.ts new file mode 100644 index 00000000000..16060ae644c --- /dev/null +++ b/dotcom-rendering/src/lib/puzzleDate.test.ts @@ -0,0 +1,41 @@ +import { formatPuzzleDate, formatPuzzleDateShort } from './puzzleDate'; + +describe('formatPuzzleDate', () => { + it('formats a YYYY-MM-DD string as a human-readable date', () => { + expect(formatPuzzleDate('2026-09-15')).toBe('15 September 2026'); + }); + + it('formats single-digit days/months correctly', () => { + expect(formatPuzzleDate('2026-01-05')).toBe('5 January 2026'); + }); + + it('returns null when puzzleDate is undefined', () => { + expect(formatPuzzleDate(undefined)).toBeNull(); + }); + + it('returns null for an unparseable date string', () => { + expect(formatPuzzleDate('not-a-date')).toBeNull(); + }); +}); + +describe('formatPuzzleDateShort', () => { + it('formats a YYYY-MM-DD string as a short "d MMM yy" date', () => { + expect(formatPuzzleDateShort('2026-09-15')).toBe('15 Sep 26'); + }); + + it('formats single-digit days correctly, with no leading zero', () => { + expect(formatPuzzleDateShort('2026-01-05')).toBe('5 Jan 26'); + }); + + it('uses a three-letter month abbreviation for September', () => { + expect(formatPuzzleDateShort('2026-09-01')).toBe('1 Sep 26'); + }); + + it('returns null when puzzleDate is undefined', () => { + expect(formatPuzzleDateShort(undefined)).toBeNull(); + }); + + it('returns null for an unparseable date string', () => { + expect(formatPuzzleDateShort('not-a-date')).toBeNull(); + }); +}); diff --git a/dotcom-rendering/src/lib/puzzleDate.ts b/dotcom-rendering/src/lib/puzzleDate.ts new file mode 100644 index 00000000000..75d99bc020d --- /dev/null +++ b/dotcom-rendering/src/lib/puzzleDate.ts @@ -0,0 +1,75 @@ +/** + * Formats a `PuzzlePageInstance.puzzleDate` (a plain `YYYY-MM-DD` string, + * e.g. `"2026-09-15"`) as a human-readable date, e.g. `"15 September 2026"`, + * for display next to the Puzzle Page title. Returns `null` for a missing + * or unparseable value so callers can simply skip rendering. + * + * Parsed as UTC midnight (rather than via the parseable-but-timezone-shifting + * `new Date("2026-09-15")` in some environments) so the displayed date + * always matches the calendar date frontend resolved, regardless of the + * server/reader's local timezone. + */ +export const formatPuzzleDate = ( + puzzleDate: string | undefined, +): string | null => { + if (!puzzleDate) return null; + + const date = new Date(`${puzzleDate}T00:00:00Z`); + if (Number.isNaN(date.getTime())) return null; + + return new Intl.DateTimeFormat('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }).format(date); +}; + +const shortMonths = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +/** + * Formats a `PuzzlePageInstance.puzzleDate` (a plain `YYYY-MM-DD` string) as + * the short "d MMM yy" form used in SEO title/meta-description templates + * (`PuzzleConfig.title`/`description`, resolved via + * `resolvePuzzleTitle`/`resolvePuzzleDescription`), e.g. + * `"2026-09-15" -> "15 Sep 26"`. Deliberately distinct from + * `formatPuzzleDate` above (the long, human-readable on-page display + * form, e.g. "15 September 2026") - the two are used in different places + * and should not be conflated. + * + * Built with a fixed month-abbreviation lookup (mirroring the existing + * `getMonthString` pattern in `src/lib/discussionDateFormatter.ts`) rather + * than `Intl.DateTimeFormat`'s `month: 'short'`, since `en-GB` renders + * September as "Sept" (four letters), not the three-letter "Sep" the + * product spreadsheet's copy requires. + * + * Returns `null` for a missing or unparseable value so callers can simply + * skip interpolation. + */ +export const formatPuzzleDateShort = ( + puzzleDate: string | undefined, +): string | null => { + if (!puzzleDate) return null; + + const date = new Date(`${puzzleDate}T00:00:00Z`); + if (Number.isNaN(date.getTime())) return null; + + const day = date.getUTCDate(); + const month = shortMonths[date.getUTCMonth()]; + const year = String(date.getUTCFullYear()).slice(-2); + + return `${day} ${month} ${year}`; +}; diff --git a/dotcom-rendering/src/lib/puzzlesHubVersionExperiment.test.ts b/dotcom-rendering/src/lib/puzzlesHubVersionExperiment.test.ts new file mode 100644 index 00000000000..7951dcb0eb8 --- /dev/null +++ b/dotcom-rendering/src/lib/puzzlesHubVersionExperiment.test.ts @@ -0,0 +1,111 @@ +import { + puzzlesHubExperiment, + puzzlesHubParticipation, +} from './puzzlesHubExperiment'; +import { + isPuzzlesHubV1Enabled, + isPuzzlesHubV2Enabled, + puzzlesHubV1Experiment, + puzzlesHubV1Participation, + puzzlesHubV2Experiment, + puzzlesHubV2Participation, +} from './puzzlesHubVersionExperiment'; + +const v0On = puzzlesHubParticipation(puzzlesHubExperiment.variant); +const v0Off = puzzlesHubParticipation(puzzlesHubExperiment.control); +const v1On = puzzlesHubV1Participation(puzzlesHubV1Experiment.variant); +const v1Off = puzzlesHubV1Participation(puzzlesHubV1Experiment.control); +const v2On = puzzlesHubV2Participation(puzzlesHubV2Experiment.variant); +const v2Off = puzzlesHubV2Participation(puzzlesHubV2Experiment.control); + +describe('isPuzzlesHubV1Enabled', () => { + it('is true when both v0 and v1 are in variant', () => { + expect( + isPuzzlesHubV1Enabled({ + serverSideABTests: { ...v0On, ...v1On }, + }), + ).toBe(true); + }); + + it('is false when v1 is in variant but v0 is off', () => { + expect( + isPuzzlesHubV1Enabled({ + serverSideABTests: { ...v0Off, ...v1On }, + }), + ).toBe(false); + }); + + it('is false when v0 is in variant but v1 is off', () => { + expect( + isPuzzlesHubV1Enabled({ + serverSideABTests: { ...v0On, ...v1Off }, + }), + ).toBe(false); + }); + + it('is false when both v0 and v1 are off', () => { + expect( + isPuzzlesHubV1Enabled({ + serverSideABTests: { ...v0Off, ...v1Off }, + }), + ).toBe(false); + }); + + it('is false when v1 is in variant but v0 participation is absent entirely', () => { + expect( + isPuzzlesHubV1Enabled({ + serverSideABTests: { ...v1On }, + }), + ).toBe(false); + }); + + it('is false when neither test has any participation', () => { + expect(isPuzzlesHubV1Enabled({ serverSideABTests: {} })).toBe(false); + }); +}); + +describe('isPuzzlesHubV2Enabled', () => { + it('is true when v0, v1, and v2 are all in variant', () => { + expect( + isPuzzlesHubV2Enabled({ + serverSideABTests: { ...v0On, ...v1On, ...v2On }, + }), + ).toBe(true); + }); + + it('is false when v2 is in variant but v1 is off (v0 on)', () => { + expect( + isPuzzlesHubV2Enabled({ + serverSideABTests: { ...v0On, ...v1Off, ...v2On }, + }), + ).toBe(false); + }); + + it('is false when v2 is in variant but v0 is off (v1 on)', () => { + expect( + isPuzzlesHubV2Enabled({ + serverSideABTests: { ...v0Off, ...v1On, ...v2On }, + }), + ).toBe(false); + }); + + it('is false when v0 and v1 are on but v2 is off', () => { + expect( + isPuzzlesHubV2Enabled({ + serverSideABTests: { ...v0On, ...v1On, ...v2Off }, + }), + ).toBe(false); + }); + + it('is false when all three tests are off', () => { + expect( + isPuzzlesHubV2Enabled({ + serverSideABTests: { ...v0Off, ...v1Off, ...v2Off }, + }), + ).toBe(false); + }); + + it('is false when no test has any participation', () => { + expect(isPuzzlesHubV2Enabled({ serverSideABTests: {} })).toBe(false); + }); +}); diff --git a/dotcom-rendering/src/lib/puzzlesHubVersionExperiment.ts b/dotcom-rendering/src/lib/puzzlesHubVersionExperiment.ts new file mode 100644 index 00000000000..e3c99dd32d8 --- /dev/null +++ b/dotcom-rendering/src/lib/puzzlesHubVersionExperiment.ts @@ -0,0 +1,86 @@ +import type { ConfigType } from '../types/config'; +import { puzzlesHubExperiment } from './puzzlesHubExperiment'; + +/** + * The v1/v2 tiers of the Puzzles & Games rollout, layered cumulatively on + * top of the `puzzles-new-hub` (v0) baseline defined alongside it in + * `ab-testing/config/abTests.ts` (see the JSDoc comments there for what + * each tier actually gates on the product side). + * + * Each tier is a genuinely separate AB test entry, but is only meaningful + * in combination with the tier(s) below it: `puzzles-new-hub-v1` does + * nothing unless `puzzles-new-hub` (v0) is also enabled, and + * `puzzles-new-hub-v2` does nothing unless BOTH `puzzles-new-hub` and + * `puzzles-new-hub-v1` are also enabled. This is deliberate - it prevents + * an inconsistent state (e.g. a later tier's features appearing while the + * baseline they build on is switched off), and means each tier can be + * rolled back independently (flip just that tier's `audienceSize`/`status` + * in `abTests.ts`) without touching the tiers below it. + */ +export const puzzlesHubV1Experiment = { + name: 'puzzles-new-hub-v1', + variant: 'variant', + control: 'control', +} as const; + +export const puzzlesHubV2Experiment = { + name: 'puzzles-new-hub-v2', + variant: 'variant', + control: 'control', +} as const; + +type PuzzlesVersionExperimentConfig = Pick<ConfigType, 'serverSideABTests'>; + +const isInVariant = ( + { serverSideABTests }: PuzzlesVersionExperimentConfig, + testName: string, + variant: string, +): boolean => serverSideABTests[testName] === variant; + +/** + * True only when BOTH `puzzles-new-hub` (v0) AND `puzzles-new-hub-v1` are + * in their `variant` group for this request. `puzzles-new-hub-v1` being in + * `variant` on its own, with v0 off, is NOT enough - see the cumulative + * design note above. + */ +export const isPuzzlesHubV1Enabled = ( + config: PuzzlesVersionExperimentConfig, +): boolean => + isInVariant( + config, + puzzlesHubExperiment.name, + puzzlesHubExperiment.variant, + ) && + isInVariant( + config, + puzzlesHubV1Experiment.name, + puzzlesHubV1Experiment.variant, + ); + +/** + * True only when `puzzles-new-hub` (v0), `puzzles-new-hub-v1`, AND + * `puzzles-new-hub-v2` are ALL in their `variant` group for this request. + * Any one of the three being off is enough to keep v2 features hidden - + * see the cumulative design note above. + */ +export const isPuzzlesHubV2Enabled = ( + config: PuzzlesVersionExperimentConfig, +): boolean => + isPuzzlesHubV1Enabled(config) && + isInVariant( + config, + puzzlesHubV2Experiment.name, + puzzlesHubV2Experiment.variant, + ); + +export const puzzlesHubV1Participation = ( + group: string, +): Record<string, string> => ({ + [puzzlesHubV1Experiment.name]: group, +}); + +export const puzzlesHubV2Participation = ( + group: string, +): Record<string, string> => ({ + [puzzlesHubV2Experiment.name]: group, +}); diff --git a/dotcom-rendering/src/model/puzzles/puzzleConfigs.test.ts b/dotcom-rendering/src/model/puzzles/puzzleConfigs.test.ts new file mode 100644 index 00000000000..8ec938c53e6 --- /dev/null +++ b/dotcom-rendering/src/model/puzzles/puzzleConfigs.test.ts @@ -0,0 +1,255 @@ +import { + getPuzzleConfig, + puzzleConfigs, + resolveIframeUrl, + resolvePuzzleDescription, + resolvePuzzleTitle, + validatePuzzleConfigs, +} from './puzzleConfigs'; + +describe('puzzleConfigs registry', () => { + it('has an entry for every documented slug', () => { + expect(Object.keys(puzzleConfigs).sort()).toEqual( + [ + 'sudoku-easy', + 'sudoku-hard', + 'sudoku-killer', + 'sudoku-medium', + 'word-wheel', + 'wordiply', + ].sort(), + ); + }); + + it('does not throw for the current registry', () => { + expect(() => validatePuzzleConfigs(puzzleConfigs)).not.toThrow(); + }); + + it('rejects a registry entry whose slug does not match its key', () => { + expect(() => + validatePuzzleConfigs({ + ...puzzleConfigs, + wordiply: { ...puzzleConfigs.wordiply!, slug: 'not-wordiply' }, + }), + ).toThrow(TypeError); + }); + + it('rejects an entry with an unknown puzzleGroup', () => { + expect(() => + validatePuzzleConfigs({ + ...puzzleConfigs, + wordiply: { + ...puzzleConfigs.wordiply!, + puzzleGroup: 'not-a-real-group' as never, + }, + }), + ).toThrow(TypeError); + }); + + it('rejects an entry with an empty iframe urlTemplate', () => { + expect(() => + validatePuzzleConfigs({ + ...puzzleConfigs, + wordiply: { + ...puzzleConfigs.wordiply!, + iframe: { + ...puzzleConfigs.wordiply!.iframe, + urlTemplate: '', + }, + }, + }), + ).toThrow(TypeError); + }); + + it('requires every entry to have a non-empty title', () => { + expect( + Object.values(puzzleConfigs).every( + (config) => config.title.trim().length > 0, + ), + ).toBe(true); + }); + + it('requires every entry to have a distinct title (not a templated copy)', () => { + const titles = Object.values(puzzleConfigs).map( + (config) => config.title, + ); + expect(new Set(titles).size).toBe(titles.length); + }); + + it('rejects an entry with an empty title', () => { + expect(() => + validatePuzzleConfigs({ + ...puzzleConfigs, + wordiply: { ...puzzleConfigs.wordiply!, title: '' }, + }), + ).toThrow(TypeError); + }); + + it('rejects an entry with a whitespace-only title', () => { + expect(() => + validatePuzzleConfigs({ + ...puzzleConfigs, + wordiply: { ...puzzleConfigs.wordiply!, title: ' ' }, + }), + ).toThrow(TypeError); + }); + + it('requires every entry to have a non-empty description', () => { + expect( + Object.values(puzzleConfigs).every( + (config) => config.description.trim().length > 0, + ), + ).toBe(true); + }); + + it('requires every entry to have a distinct description (not a templated copy)', () => { + const descriptions = Object.values(puzzleConfigs).map( + (config) => config.description, + ); + expect(new Set(descriptions).size).toBe(descriptions.length); + }); + + it('rejects an entry with an empty description', () => { + expect(() => + validatePuzzleConfigs({ + ...puzzleConfigs, + wordiply: { ...puzzleConfigs.wordiply!, description: '' }, + }), + ).toThrow(TypeError); + }); + + it('rejects an entry with a whitespace-only description', () => { + expect(() => + validatePuzzleConfigs({ + ...puzzleConfigs, + wordiply: { ...puzzleConfigs.wordiply!, description: ' ' }, + }), + ).toThrow(TypeError); + }); + + it('allows every entry to have no image configured (the current state)', () => { + expect( + Object.values(puzzleConfigs).every( + (config) => config.image === undefined, + ), + ).toBe(true); + }); + + it('does not throw when an entry has a valid, non-empty image set', () => { + expect(() => + validatePuzzleConfigs({ + ...puzzleConfigs, + wordiply: { + ...puzzleConfigs.wordiply!, + image: 'https://example.com/wordiply.jpg', + }, + }), + ).not.toThrow(); + }); + + it('rejects an entry with an empty-string image', () => { + expect(() => + validatePuzzleConfigs({ + ...puzzleConfigs, + wordiply: { ...puzzleConfigs.wordiply!, image: '' }, + }), + ).toThrow(TypeError); + }); + + it('rejects an entry with a whitespace-only image', () => { + expect(() => + validatePuzzleConfigs({ + ...puzzleConfigs, + wordiply: { ...puzzleConfigs.wordiply!, image: ' ' }, + }), + ).toThrow(TypeError); + }); + + describe('getPuzzleConfig', () => { + it('returns the config for a known slug', () => { + expect(getPuzzleConfig('sudoku-easy')?.puzzleGroup).toBe( + 'logic-puzzles', + ); + }); + + it('returns undefined for an unknown slug', () => { + expect(getPuzzleConfig('not-a-real-puzzle')).toBeUndefined(); + }); + }); + + describe('resolveIframeUrl', () => { + it('substitutes the slug into the AmuseLabs URL template', () => { + expect(resolveIframeUrl(puzzleConfigs['sudoku-easy']!)).toBe( + 'https://tg.amuselabs.com/guardian/date-picker?set=guardian-sudoku-easy&embed=1&idx=1', + ); + }); + + it('returns the bespoke provider URL unchanged when it has no placeholder', () => { + expect(resolveIframeUrl(puzzleConfigs.wordiply!)).toBe( + 'https://www.wordiply.com/', + ); + }); + }); + + describe('resolvePuzzleTitle', () => { + it('substitutes {date} with the short-formatted puzzleDate', () => { + expect( + resolvePuzzleTitle(puzzleConfigs['sudoku-easy']!, '2026-09-15'), + ).toBe('Easy sudoku 15 Sep 26 - logic puzzle | The Guardian'); + }); + + it('produces the exact verbatim copy for every V0 puzzle on a given date', () => { + expect( + resolvePuzzleTitle(puzzleConfigs['word-wheel']!, '2026-09-15'), + ).toBe('Word wheel 15 Sep 26 - word game | The Guardian'); + expect( + resolvePuzzleTitle(puzzleConfigs.wordiply!, '2026-09-15'), + ).toBe('Wordiply 15 Sep 26 - word game | The Guardian'); + expect( + resolvePuzzleTitle( + puzzleConfigs['sudoku-medium']!, + '2026-09-15', + ), + ).toBe('Medium sudoku 15 Sep 26 - logic puzzle | The Guardian'); + expect( + resolvePuzzleTitle(puzzleConfigs['sudoku-hard']!, '2026-09-15'), + ).toBe('Hard sudoku 15 Sep 26 - logic puzzle | The Guardian'); + expect( + resolvePuzzleTitle( + puzzleConfigs['sudoku-killer']!, + '2026-09-15', + ), + ).toBe('Killer sudoku 15 Sep 26 - logic puzzle | The Guardian'); + }); + + it('tidies up the double space left behind when puzzleDate is undefined', () => { + expect( + resolvePuzzleTitle(puzzleConfigs['sudoku-easy']!, undefined), + ).toBe('Easy sudoku - logic puzzle | The Guardian'); + }); + }); + + describe('resolvePuzzleDescription', () => { + it('substitutes {date} with the short-formatted puzzleDate', () => { + expect( + resolvePuzzleDescription( + puzzleConfigs['sudoku-easy']!, + '2026-09-15', + ), + ).toBe( + 'Easy sudoku 15 Sep 26. Ease yourself in with this easy sudoku. Fill the grid with the numbers 1 to 9, appearing only once in every column, row and 3x3 box.', + ); + }); + + it('tidies up the space before the following full stop when puzzleDate is undefined', () => { + expect( + resolvePuzzleDescription( + puzzleConfigs['sudoku-easy']!, + undefined, + ), + ).toBe( + 'Easy sudoku. Ease yourself in with this easy sudoku. Fill the grid with the numbers 1 to 9, appearing only once in every column, row and 3x3 box.', + ); + }); + }); +}); diff --git a/dotcom-rendering/src/model/puzzles/puzzleConfigs.ts b/dotcom-rendering/src/model/puzzles/puzzleConfigs.ts new file mode 100644 index 00000000000..dc2c2c62a85 --- /dev/null +++ b/dotcom-rendering/src/model/puzzles/puzzleConfigs.ts @@ -0,0 +1,265 @@ +import { formatPuzzleDateShort } from '../../lib/puzzleDate'; + +/** + * DCR's single source of truth for the structural/rendering behaviour of + * each supported Puzzle Page slug. + * + * Puzzle Page is scoped to iframe-based puzzles only, crosswords remain on + * their existing, separate `/crosswords/*` flow + * (`ArticleDesign.Crossword` / `src/layouts/CrosswordLayout.tsx`), which is + * unrelated to this registry and is not unified into Puzzle Page. See + * `docs/puzzle-page.md` for the full picture. + * + * This registry currently only contains the V0 puzzle set (per PR #16700 + * review feedback): sudoku (4 difficulties), word wheel, and wordiply. + * Codeword, futoshiki, suguru, and the trivia/quizzes puzzles + * (on-the-ball, film-reveal) were removed for V0 and may return later once + * the team is ready to support them. + * + * This registry is deliberately data-driven: all AmuseLabs-hosted puzzles + * (the sudoku variants, and word-wheel) share the exact same iframe URL + * template and differ only by the `{slug}` substitution, so they are + * modelled as data rather than near-duplicate code paths. + */ + +export const puzzleGroups = ['logic-puzzles', 'word-games'] as const; + +export type PuzzleGroup = (typeof puzzleGroups)[number]; + +export interface PuzzleIframeConfig { + provider: string; + /** + * The iframe src URL. May contain a `{slug}` placeholder token, which is + * substituted with the puzzle's `slug` at render time. + */ + urlTemplate: string; +} + +export interface PuzzleConfig { + slug: string; + puzzleGroup: PuzzleGroup; + iframe: PuzzleIframeConfig; + shareEnabled: boolean; + printEnabled: boolean; + hasArchive: boolean; + /** + * The SEO title template for this puzzle, sourced verbatim from the + * product team's SEO spreadsheet (confirmed against a 60-character + * budget, date included). May contain a `{date}` placeholder token, + * substituted at render time (via `resolvePuzzleTitle`) with the + * puzzle's `instance.puzzleDate` formatted as a short "d MMM yy" date + * (e.g. `"15 Sep 26"`, see `formatPuzzleDateShort`). Used for the + * `<title>` tag and `og:title`/`twitter:title` in + * `render.puzzlePage.web.tsx`, deliberately *not* `webTitle` (the plain + * string `frontend` sends, e.g. "Sudoku (easy)", which is still used + * for other purposes such as the share button's pre-filled text, see + * `docs/puzzle-page.md`). + */ + title: string; + /** + * The SEO meta description template for this puzzle, sourced verbatim + * from the product team's SEO spreadsheet (confirmed against a + * 157-character budget, date included). May contain a `{date}` + * placeholder token, substituted at render time (via + * `resolvePuzzleDescription`) the same way as `title` above. Used as + * the page's `<meta name="description">` (and, derived from it, its + * Open Graph/Twitter card description) - see `render.puzzlePage.web.tsx`. + * This exists specifically so every Puzzle Page has a clean, distinct + * description rather than falling back to DCR's generic, site-wide + * description (which risks Google/social previews auto-generating a + * snippet from page content instead - see "SEO risks to revisit..." in + * docs/puzzle-page.md for a concrete example of that failure mode + * elsewhere on the site). + */ + description: string; + /** + * An optional, full preview/share image URL for this puzzle, used to + * populate `og:image`/`twitter:image` in `render.puzzlePage.web.tsx`. + * Deliberately optional and unset on every current registry entry - DCR + * has no site-wide default/fallback share image for pages without one + * (confirmed by investigation; see docs/puzzle-page.md), so an unset + * `image` simply omits `og:image`/`twitter:image` entirely, matching + * existing sitewide behaviour rather than needing a placeholder. Leave + * unset until a real, licensed preview image is provided for a given + * puzzle - do not invent a placeholder URL here. + */ + image?: string; +} + +const amuseLabsUrlTemplate = + 'https://tg.amuselabs.com/guardian/date-picker?set=guardian-{slug}&embed=1&idx=1'; + +const amuseLabsPuzzle = ( + slug: string, + puzzleGroup: PuzzleGroup, + title: string, + description: string, +): PuzzleConfig => ({ + slug, + puzzleGroup, + iframe: { provider: 'amuselabs', urlTemplate: amuseLabsUrlTemplate }, + shareEnabled: true, + printEnabled: true, + hasArchive: true, + title, + description, +}); + +/** + * The full set of supported Puzzle Page slugs. Keys match each entry's + * `slug` field (validated at load time by `validatePuzzleConfigs` below). + * + * The `title`/`description` copy on every entry below is sourced verbatim + * from the product team's SEO spreadsheet, do not paraphrase or "improve" + * it, the wording and character counts have already been confirmed against + * the spreadsheet's per-page budgets. Each entry's "target search terms" + * comment is likewise from that spreadsheet's "Search terms" column: it is + * reference-only context for future copy/content work, not implemented as + * a `<meta name="keywords">` tag (major search engines ignore that tag + * entirely, so it provides no real SEO benefit today, see + * docs/puzzle-page.md). + */ +export const puzzleConfigs: Record<string, PuzzleConfig> = { + // Target search terms (reference only, not implemented as a meta tag): + // easy online sudoku, free online sudoku, guardian sudoku + 'sudoku-easy': amuseLabsPuzzle( + 'sudoku-easy', + 'logic-puzzles', + 'Easy sudoku {date} - logic puzzle | The Guardian', + 'Easy sudoku {date}. Ease yourself in with this easy sudoku. Fill the grid with the numbers 1 to 9, appearing only once in every column, row and 3x3 box.', + ), + // Target search terms (reference only, not implemented as a meta tag): + // medium sudoku + 'sudoku-medium': amuseLabsPuzzle( + 'sudoku-medium', + 'logic-puzzles', + 'Medium sudoku {date} - logic puzzle | The Guardian', + 'Medium sudoku {date}. Ready to master the medium sudoku? Fill the grid with the numbers 1 to 9, appearing only once in every column, row and 3x3 box.', + ), + // Target search terms (reference only, not implemented as a meta tag): + // hard sudoku + 'sudoku-hard': amuseLabsPuzzle( + 'sudoku-hard', + 'logic-puzzles', + 'Hard sudoku {date} - logic puzzle | The Guardian', + 'Hard sudoku {date}. Ready to take on the hard sudoku? Fill the grid with the numbers 1 to 9, appearing only once in every column, row and 3x3 box.', + ), + // Target search terms (reference only, not implemented as a meta tag): + // killer sudoku + 'sudoku-killer': amuseLabsPuzzle( + 'sudoku-killer', + 'logic-puzzles', + 'Killer sudoku {date} - logic puzzle | The Guardian', + 'Killer sudoku {date}. Killer sudoku adds a twist. Fill the grid with the numbers 1 to 9, appearing only once in every column, row and 3x3 box.', + ), + // Target search terms (reference only, not implemented as a meta tag): + // daily word wheel, word wheel puzzle, word wheel online, word wheel + // game, guardian word wheel, word wheel for today, guardian word wheel + // today + 'word-wheel': amuseLabsPuzzle( + 'word-wheel', + 'word-games', + 'Word wheel {date} - word game | The Guardian', + 'Word wheel {date}. See how many words you can make out of the nine-letter daily word wheel, including the panagram.', + ), + // Target search terms (reference only, not implemented as a meta tag): + // guardian wordiply, wordiply today + wordiply: { + slug: 'wordiply', + puzzleGroup: 'word-games', + iframe: { + provider: 'wordiply', + urlTemplate: 'https://www.wordiply.com/', + }, + shareEnabled: true, + printEnabled: true, + hasArchive: true, + title: 'Wordiply {date} - word game | The Guardian', + description: + 'Wordiply {date}. Guess the longest word in five guesses that includes the starter word. The closer you are, the higher your length score.', + }, +}; + +/** + * Look up a puzzle's structural config by slug. Returns `undefined` for an + * unknown slug so callers (e.g. the `/PuzzlePage` handler) can decide how to + * respond (404). + */ +export const getPuzzleConfig = (slug: string): PuzzleConfig | undefined => + puzzleConfigs[slug]; + +/** + * Resolve the final iframe src URL for a puzzle, expanding the `{slug}` + * placeholder token in `PuzzleIframeConfig.urlTemplate`. + */ +export const resolveIframeUrl = (config: PuzzleConfig): string => + config.iframe.urlTemplate.replaceAll('{slug}', config.slug); + +/** + * Substitutes `{date}` in a `title`/`description` template with the given + * `puzzleDate` (formatted short, via `formatPuzzleDateShort`), tidying up + * the surrounding punctuation/whitespace if `puzzleDate` is absent (e.g. + * `"Word wheel {date} - word game"` becomes `"Word wheel - word game"`, + * not `"Word wheel - word game"`, if there's no date to interpolate). + */ +const resolveDateTemplate = ( + template: string, + puzzleDate: string | undefined, +): string => { + const shortDate = formatPuzzleDateShort(puzzleDate) ?? ''; + return template + .replace('{date}', shortDate) + .replace(/\s{2,}/g, ' ') + .replace(/\s+([.,])/g, '$1') + .trim(); +}; + +/** + * Resolve the final, date-substituted SEO title for a puzzle. See + * `PuzzleConfig.title`'s doc comment for what feeds this and what it's used + * for. + */ +export const resolvePuzzleTitle = ( + config: PuzzleConfig, + puzzleDate: string | undefined, +): string => resolveDateTemplate(config.title, puzzleDate); + +/** + * Resolve the final, date-substituted SEO meta description for a puzzle. + * See `PuzzleConfig.description`'s doc comment for what feeds this and what + * it's used for. + */ +export const resolvePuzzleDescription = ( + config: PuzzleConfig, + puzzleDate: string | undefined, +): string => resolveDateTemplate(config.description, puzzleDate); + +const isValidPuzzleConfig = (key: string, config: PuzzleConfig): boolean => { + if (config.slug !== key) return false; + if (!puzzleGroups.includes(config.puzzleGroup)) return false; + if (!config.iframe.provider || !config.iframe.urlTemplate) return false; + if (!config.title.trim()) return false; + if (!config.description.trim()) return false; + if (config.image !== undefined && !config.image.trim()) return false; + return true; +}; + +/** + * Fail fast if the registry itself is malformed (e.g. a mismatched slug key, + * a missing/empty `iframe` config, a missing/empty `title`/`description`, + * or a present-but-empty `image`). Run once at module load so a bad + * registry entry surfaces immediately rather than at request time. + */ +export const validatePuzzleConfigs = ( + configs: Record<string, PuzzleConfig>, +): void => { + for (const [key, config] of Object.entries(configs)) { + if (!isValidPuzzleConfig(key, config)) { + throw new TypeError( + `Invalid PuzzleConfig registry entry for slug "${key}".`, + ); + } + } +}; + +validatePuzzleConfigs(puzzleConfigs); diff --git a/dotcom-rendering/src/model/validate.puzzlePage.test.ts b/dotcom-rendering/src/model/validate.puzzlePage.test.ts new file mode 100644 index 00000000000..671a299fcef --- /dev/null +++ b/dotcom-rendering/src/model/validate.puzzlePage.test.ts @@ -0,0 +1,97 @@ +import { createPuzzlePage } from '../../fixtures/manual/puzzlePage'; +import { validateAsPuzzlePageType } from './validate.puzzlePage'; + +const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T; + +const expectInvalid = (page: unknown) => + expect(() => validateAsPuzzlePageType(page)).toThrow( + 'Unable to validate request body for puzzle page.', + ); + +describe('validateAsPuzzlePageType', () => { + it('accepts a valid iframe payload', () => { + const page = createPuzzlePage('sudoku-easy'); + expect(validateAsPuzzlePageType(page).slug).toBe('sudoku-easy'); + }); + + it.each([ + 'id', + 'slug', + 'webTitle', + 'canonicalUrl', + 'editionId', + 'instance', + ])('rejects a missing required page field: %s', (field) => { + const page = clone( + createPuzzlePage('sudoku-easy'), + ) as unknown as Record<string, unknown>; + delete page[field]; + expectInvalid(page); + }); + + it('rejects a config without server-side participations', () => { + const page = clone(createPuzzlePage('sudoku-easy')) as unknown as { + config: Record<string, unknown>; + }; + delete page.config.serverSideABTests; + expectInvalid(page); + }); + + it('rejects navigation that is not an object', () => { + const page = clone(createPuzzlePage('sudoku-easy')) as unknown as { + nav: unknown; + }; + page.nav = []; + expectInvalid(page); + }); + + it('rejects an unknown edition id', () => { + const page = clone(createPuzzlePage('sudoku-easy')) as unknown as { + editionId: string; + }; + page.editionId = 'NOT_AN_EDITION'; + expectInvalid(page); + }); + + it('rejects an instance missing its required title', () => { + const page = clone(createPuzzlePage('sudoku-easy')); + (page.instance as unknown as { title?: string }).title = undefined; + expectInvalid(page); + }); + + it('rejects an invalid item in moreFromPuzzlesAndGames', () => { + const page = clone(createPuzzlePage('sudoku-easy')); + page.instance.moreFromPuzzlesAndGames = [ + { id: 'bad' }, + ] as unknown as typeof page.instance.moreFromPuzzlesAndGames; + expectInvalid(page); + }); + + it('accepts a payload with puzzleDate present', () => { + const page = createPuzzlePage('sudoku-easy', { + instance: { + ...createPuzzlePage('sudoku-easy').instance, + puzzleDate: '2026-09-11', + }, + }); + expect(validateAsPuzzlePageType(page).instance.puzzleDate).toBe( + '2026-09-11', + ); + }); + + it('accepts a payload with puzzleDate absent', () => { + const page = clone(createPuzzlePage('sudoku-easy')); + delete (page.instance as { puzzleDate?: string }).puzzleDate; + expect( + validateAsPuzzlePageType(page).instance.puzzleDate, + ).toBeUndefined(); + }); + + it('rejects a non-string puzzleDate', () => { + const page = clone(createPuzzlePage('sudoku-easy')) as unknown as { + instance: { puzzleDate?: unknown }; + }; + page.instance.puzzleDate = 20260911; + expectInvalid(page); + }); +}); diff --git a/dotcom-rendering/src/model/validate.puzzlePage.ts b/dotcom-rendering/src/model/validate.puzzlePage.ts new file mode 100644 index 00000000000..0588be17fd9 --- /dev/null +++ b/dotcom-rendering/src/model/validate.puzzlePage.ts @@ -0,0 +1,61 @@ +import { isString } from '@guardian/libs'; +import type { FEPuzzlePageType } from '../types/puzzlePage'; +import { + editions, + isNonEmptyString, + isOptionalString, + isPuzzleItem, + isPuzzlesConfig, + isRecord, +} from './validate'; + +/** + * Puzzle Page's own validation, split out from the general `validate.ts` + * per PR #16700 review feedback ("wondering if it might be better placed + * in something like puzzles.validate.ts... so we're not mixing too much + * code with the user-related logic"). Reuses the small set of generic + * helpers (`isRecord`, `isNonEmptyString`, `isPuzzlesConfig`, `isPuzzleItem`, + * `editions`) exported from `validate.ts` rather than duplicating them. + * + * Note: unlike some other DCR page types, there was no pre-existing + * `validate.<pageType>.ts` file to mirror here, every other page type's + * validator (including the unrelated Puzzles Hub's `validateAsPuzzlesPageType`) + * still lives in the shared `validate.ts`, only their *tests* are split + * into per-page-type files (e.g. `validate.puzzlesPage.test.ts`). This file + * establishes the new, more separated convention requested in review for + * Puzzle Page specifically, rather than claiming to follow an existing one. + */ + +const isPuzzlePageInstance = (value: unknown): boolean => { + if (!isRecord(value)) return false; + + return ( + isNonEmptyString(value.title) && + isOptionalString(value.puzzleDate) && + (value.moreFromPuzzlesAndGames === undefined || + (Array.isArray(value.moreFromPuzzlesAndGames) && + value.moreFromPuzzlesAndGames.every((item) => + isPuzzleItem(item, false), + ))) + ); +}; + +export const validateAsPuzzlePageType = (data: unknown): FEPuzzlePageType => { + if ( + isRecord(data) && + isNonEmptyString(data.id) && + isNonEmptyString(data.slug) && + isNonEmptyString(data.webTitle) && + isPuzzlesConfig(data.config) && + isRecord(data.nav) && + isRecord(data.pageFooter) && + isNonEmptyString(data.canonicalUrl) && + isString(data.editionId) && + editions.has(String(data.editionId)) && + isPuzzlePageInstance(data.instance) + ) { + return data as unknown as FEPuzzlePageType; + } + + throw new TypeError('Unable to validate request body for puzzle page.'); +}; diff --git a/dotcom-rendering/src/model/validate.ts b/dotcom-rendering/src/model/validate.ts index cf82d176740..8676914e186 100644 --- a/dotcom-rendering/src/model/validate.ts +++ b/dotcom-rendering/src/model/validate.ts @@ -174,15 +174,15 @@ export const validateAsFootballMatchPageType = ( }; const stableIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const colourPattern = /^#[0-9a-f]{6}$/i; -const editions = new Set(['UK', 'US', 'AU', 'INT', 'EUR']); +export const editions = new Set(['UK', 'US', 'AU', 'INT', 'EUR']); -const isRecord = (value: unknown): value is Record<string, unknown> => +export const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value); -const isNonEmptyString = (value: unknown): value is string => +export const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; -const isOptionalString = (value: unknown): boolean => +export const isOptionalString = (value: unknown): boolean => value === undefined || typeof value === 'string'; const isOptionalColour = (value: unknown): boolean => @@ -192,10 +192,10 @@ const isOptionalColour = (value: unknown): boolean => const isStringRecord = (value: unknown): boolean => isRecord(value) && Object.values(value).every(isString); -const isPuzzlesConfig = (value: unknown): boolean => +export const isPuzzlesConfig = (value: unknown): boolean => isRecord(value) && isStringRecord(value.serverSideABTests); -const isPuzzleItem = (value: unknown, archiveSlot: boolean): boolean => { +export const isPuzzleItem = (value: unknown, archiveSlot: boolean): boolean => { if (!isRecord(value)) return false; const cardVariant = value.cardVariant; const pageVariant = value.variant; diff --git a/dotcom-rendering/src/server/handler.puzzlePage.web.test.ts b/dotcom-rendering/src/server/handler.puzzlePage.web.test.ts new file mode 100644 index 00000000000..037b5836b6f --- /dev/null +++ b/dotcom-rendering/src/server/handler.puzzlePage.web.test.ts @@ -0,0 +1,123 @@ +import type { Request, Response } from 'express'; +import { createPuzzlePage } from '../../fixtures/manual/puzzlePage'; +import { handlePuzzlePage } from './handler.puzzlePage.web'; +import { renderPuzzlePage } from './render.puzzlePage.web'; + +jest.mock('./render.puzzlePage.web', () => ({ + renderPuzzlePage: jest.fn(), +})); + +const mockedRenderPuzzlePage = jest.mocked(renderPuzzlePage); + +const response = () => { + const res = { + status: jest.fn(), + set: jest.fn(), + send: jest.fn(), + sendStatus: jest.fn(), + }; + res.status.mockReturnValue(res); + res.set.mockReturnValue(res); + res.send.mockReturnValue(res); + res.sendStatus.mockReturnValue(res); + return res; +}; + +const invokeHandler = (body: unknown, res: ReturnType<typeof response>) => + handlePuzzlePage( + { body } as Request, + res as unknown as Response, + jest.fn(), + ); + +describe('handlePuzzlePage', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedRenderPuzzlePage.mockReturnValue({ + html: '<html>Puzzle</html>', + prefetchScripts: ['/assets/index.js'], + }); + }); + + it('renders the page for a known slug regardless of serverSideABTests', () => { + const res = response(); + const page = createPuzzlePage('sudoku-easy'); + + invokeHandler(page, res); + + expect(mockedRenderPuzzlePage).toHaveBeenCalledWith({ + puzzlePage: { + ...page, + puzzleConfig: expect.objectContaining({ + slug: 'sudoku-easy', + }), + }, + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.set).toHaveBeenCalledWith( + 'Link', + expect.stringContaining('/assets/index.js'), + ); + expect(res.send).toHaveBeenCalledWith('<html>Puzzle</html>'); + }); + + it.each([ + 'sudoku-easy', + 'sudoku-medium', + 'sudoku-hard', + 'sudoku-killer', + 'word-wheel', + 'wordiply', + ])('renders iframe-based slug %s', (slug) => { + const res = response(); + const page = createPuzzlePage(slug); + + invokeHandler(page, res); + + expect(mockedRenderPuzzlePage).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it.each([ + ['absent', {}], + ['unrelated', { 'another-test': 'variant' }], + ])( + 'renders the page regardless of serverSideABTests content (%s)', + (_, serverSideABTests) => { + const res = response(); + const page = createPuzzlePage('sudoku-easy', { + config: { + ...createPuzzlePage('sudoku-easy').config, + serverSideABTests, + }, + }); + + invokeHandler(page, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(mockedRenderPuzzlePage).toHaveBeenCalled(); + }, + ); + + it('returns 404 for an unknown slug', () => { + const res = response(); + const page = createPuzzlePage('sudoku-easy'); + page.slug = 'not-a-real-puzzle'; + + invokeHandler(page, res); + + expect(res.sendStatus).toHaveBeenCalledWith(404); + expect(mockedRenderPuzzlePage).not.toHaveBeenCalled(); + }); + + it('rejects an invalid payload without invoking the renderer', () => { + const res = response(); + const invalidPage = createPuzzlePage( + 'sudoku-easy', + ) as unknown as Record<string, unknown>; + delete invalidPage.instance; + + expect(() => invokeHandler(invalidPage, res)).toThrow(TypeError); + expect(mockedRenderPuzzlePage).not.toHaveBeenCalled(); + }); +}); diff --git a/dotcom-rendering/src/server/handler.puzzlePage.web.ts b/dotcom-rendering/src/server/handler.puzzlePage.web.ts new file mode 100644 index 00000000000..75678de6320 --- /dev/null +++ b/dotcom-rendering/src/server/handler.puzzlePage.web.ts @@ -0,0 +1,21 @@ +import type { RequestHandler } from 'express'; +import { getPuzzleConfig } from '../model/puzzles/puzzleConfigs'; +import { validateAsPuzzlePageType } from '../model/validate.puzzlePage'; +import { makePrefetchHeader } from './lib/header'; +import { renderPuzzlePage } from './render.puzzlePage.web'; + +export const handlePuzzlePage: RequestHandler = ({ body }, res) => { + const puzzlePage = validateAsPuzzlePageType(body); + + const puzzleConfig = getPuzzleConfig(puzzlePage.slug); + + if (!puzzleConfig) { + res.sendStatus(404); + return; + } + + const { html, prefetchScripts } = renderPuzzlePage({ + puzzlePage: { ...puzzlePage, puzzleConfig }, + }); + res.status(200).set('Link', makePrefetchHeader(prefetchScripts)).send(html); +}; diff --git a/dotcom-rendering/src/server/render.puzzlePage.web.test.ts b/dotcom-rendering/src/server/render.puzzlePage.web.test.ts new file mode 100644 index 00000000000..a69982d7cbc --- /dev/null +++ b/dotcom-rendering/src/server/render.puzzlePage.web.test.ts @@ -0,0 +1,66 @@ +import { + createPuzzleConfigWithImage, + samplePuzzleImageUrl, +} from '../../fixtures/manual/puzzlePage'; +import { puzzleConfigs } from '../model/puzzles/puzzleConfigs'; +import { buildPuzzlePageMetaData } from './render.puzzlePage.web'; + +describe('buildPuzzlePageMetaData', () => { + const withoutImage = puzzleConfigs.wordiply!; + const withImage = createPuzzleConfigWithImage('wordiply'); + const puzzleDate = '2026-09-15'; + + it('resolves the date-templated title/description for the given puzzleDate', () => { + const { title, description } = buildPuzzlePageMetaData( + withoutImage, + puzzleDate, + ); + + expect(title).toBe('Wordiply 15 Sep 26 - word game | The Guardian'); + expect(description).toBe( + 'Wordiply 15 Sep 26. Guess the longest word in five guesses that includes the starter word. The closer you are, the higher your length score.', + ); + }); + + it('tidies up the template when puzzleDate is undefined', () => { + const { title, description } = buildPuzzlePageMetaData( + withoutImage, + undefined, + ); + + expect(title).toBe('Wordiply - word game | The Guardian'); + expect(description).toBe( + 'Wordiply. Guess the longest word in five guesses that includes the starter word. The closer you are, the higher your length score.', + ); + }); + + it('builds og:title/og:description and twitter:title/twitter:description from the resolved title/description', () => { + const { title, description, openGraphData, twitterData } = + buildPuzzlePageMetaData(withoutImage, puzzleDate); + + expect(openGraphData['og:title']).toBe(title); + expect(openGraphData['og:description']).toBe(description); + expect(twitterData['twitter:title']).toBe(title); + expect(twitterData['twitter:description']).toBe(description); + }); + + it('omits og:image/twitter:image entirely when puzzleConfig.image is unset', () => { + const { openGraphData, twitterData } = buildPuzzlePageMetaData( + withoutImage, + puzzleDate, + ); + + expect(openGraphData).not.toHaveProperty('og:image'); + expect(twitterData).not.toHaveProperty('twitter:image'); + }); + + it('includes og:image/twitter:image when puzzleConfig.image is set', () => { + const { openGraphData, twitterData } = buildPuzzlePageMetaData( + withImage, + puzzleDate, + ); + + expect(openGraphData['og:image']).toBe(samplePuzzleImageUrl); + expect(twitterData['twitter:image']).toBe(samplePuzzleImageUrl); + }); +}); diff --git a/dotcom-rendering/src/server/render.puzzlePage.web.tsx b/dotcom-rendering/src/server/render.puzzlePage.web.tsx new file mode 100644 index 00000000000..885246f8410 --- /dev/null +++ b/dotcom-rendering/src/server/render.puzzlePage.web.tsx @@ -0,0 +1,155 @@ +import { ConfigProvider } from '../components/ConfigContext'; +import { PuzzlePage } from '../components/PuzzlePage'; +import type { ResolvedPuzzlePage } from '../layouts/PuzzlePageLayout'; +import { + ASSET_ORIGIN, + generateScriptTags, + getModulesBuild, + getPathFromManifest, +} from '../lib/assets'; +import { renderToStringWithEmotion } from '../lib/emotion'; +import { polyfillIO } from '../lib/polyfill.io'; +import { extractNAV } from '../model/extract-nav'; +import { createGuardian } from '../model/guardian'; +import { + type PuzzleConfig, + resolvePuzzleDescription, + resolvePuzzleTitle, +} from '../model/puzzles/puzzleConfigs'; +import type { Config } from '../types/configContext'; +import { htmlPageTemplate } from './htmlPageTemplate'; + +type Props = { puzzlePage: ResolvedPuzzlePage }; + +/** + * Builds the SEO metadata for a Puzzle Page from its resolved + * `PuzzleConfig` and `puzzleDate`: the date-substituted `<title>`/ + * `<meta name="description">` values, plus `openGraphData`/`twitterData` + * for `htmlPageTemplate`'s `generateMetaTags()`. Pulled out as a small, + * pure function (rather than inlined in `renderPuzzlePage`) specifically + * so it's directly unit testable without needing to invoke the full render + * pipeline (which requires a webpack build manifest not present in the + * test environment - there is no existing render.*.web.tsx unit test + * convention in this repo to extend). + * + * Deliberately does **not** take `webTitle` (the plain string `frontend` + * sends, e.g. "Sudoku (easy)"): that field has no date or SEO suffix, and + * is kept for its one other real use in this codebase, the share button's + * pre-filled share text/subject (`ShareButton.island.tsx`, fed from + * `PuzzlePageLayout.tsx`), which is unaffected by this change. The + * `<title>`/`og:title`/`twitter:title` now come from `PuzzleConfig.title` + * (resolved here) instead. See docs/puzzle-page.md. + * + * `og:image`/`twitter:image` are only included when `puzzleConfig.image` + * is set - DCR has no site-wide default/fallback share image for pages + * without one (confirmed by investigation - see docs/puzzle-page.md), so + * when `image` is unset these keys are omitted entirely rather than sent + * empty or with a placeholder, matching `generateMetaTags()`'s behaviour + * of only emitting a `<meta>` tag for keys actually present in the object. + */ +export const buildPuzzlePageMetaData = ( + puzzleConfig: PuzzleConfig, + puzzleDate: string | undefined, +): { + title: string; + description: string; + openGraphData: Record<string, string>; + twitterData: Record<string, string>; +} => { + const { image } = puzzleConfig; + const title = resolvePuzzleTitle(puzzleConfig, puzzleDate); + const description = resolvePuzzleDescription(puzzleConfig, puzzleDate); + + return { + title, + description, + openGraphData: { + 'og:title': title, + 'og:description': description, + ...(image ? { 'og:image': image } : {}), + }, + twitterData: { + 'twitter:title': title, + 'twitter:description': description, + ...(image ? { 'twitter:image': image } : {}), + }, + }; +}; + +export const renderPuzzlePage = ({ + puzzlePage, +}: Props): { html: string; prefetchScripts: string[] } => { + const NAV = extractNAV(puzzlePage.nav); + const darkModeAvailable = + puzzlePage.config.serverSideABTests['webx-dark-mode-web'] === 'enable'; + const config = { + renderingTarget: 'Web', + darkModeAvailable, + assetOrigin: ASSET_ORIGIN, + editionId: puzzlePage.editionId, + } satisfies Config; + + const { html, extractedCss } = renderToStringWithEmotion( + <ConfigProvider value={config}> + <PuzzlePage puzzlePage={puzzlePage} NAV={NAV} /> + </ConfigProvider>, + ); + + const build = getModulesBuild(); + const prefetchScripts = [ + polyfillIO, + getPathFromManifest(build, 'frameworks.js'), + getPathFromManifest(build, 'index.js'), + process.env.COMMERCIAL_BUNDLE_URL ?? + puzzlePage.config.commercialBundleUrl, + ]; + const scriptTags = generateScriptTags(prefetchScripts); + const guardian = createGuardian({ + editionId: puzzlePage.editionId, + stage: puzzlePage.config.stage, + frontendAssetsFullURL: puzzlePage.config.frontendAssetsFullURL, + revisionNumber: puzzlePage.config.revisionNumber, + sentryPublicApiKey: puzzlePage.config.sentryPublicApiKey, + sentryHost: puzzlePage.config.sentryHost, + keywordIds: puzzlePage.config.keywordIds, + dfpAccountId: puzzlePage.config.dfpAccountId, + adUnit: puzzlePage.config.adUnit, + ajaxUrl: puzzlePage.config.ajaxUrl, + shouldHideReaderRevenue: puzzlePage.config.shouldHideReaderRevenue, + isPaidContent: puzzlePage.config.isPaidContent, + googletagUrl: puzzlePage.config.googletagUrl, + switches: puzzlePage.config.switches, + serverSideABTests: puzzlePage.config.serverSideABTests, + contentType: puzzlePage.config.contentType, + brazeApiKey: puzzlePage.config.brazeApiKey, + googleRecaptchaSiteKey: puzzlePage.config.googleRecaptchaSiteKey, + googleRecaptchaSiteKeyVisible: + puzzlePage.config.googleRecaptchaSiteKeyVisible, + unknownConfig: puzzlePage.config, + }); + + const { title, description, openGraphData, twitterData } = + buildPuzzlePageMetaData( + puzzlePage.puzzleConfig, + puzzlePage.instance.puzzleDate, + ); + + return { + html: htmlPageTemplate({ + scriptTags, + css: extractedCss, + html, + title, + description, + openGraphData, + twitterData, + guardian, + section: puzzlePage.config.section, + renderingTarget: 'Web', + weAreHiring: !!puzzlePage.config.switches.weAreHiring, + config, + canonicalUrl: puzzlePage.canonicalUrl, + }), + prefetchScripts, + }; +}; diff --git a/dotcom-rendering/src/server/server.dev.ts b/dotcom-rendering/src/server/server.dev.ts index 08836483a16..f121af27a5e 100644 --- a/dotcom-rendering/src/server/server.dev.ts +++ b/dotcom-rendering/src/server/server.dev.ts @@ -18,6 +18,7 @@ import { handleAppsAssets } from './handler.assets.apps'; import { handleEditionsCrossword } from './handler.editionsCrossword'; import { handleFootballMatchDayEmbed } from './handler.footballMatchDayEmbed'; import { handleFront, handleTagPage } from './handler.front.web'; +import { handlePuzzlePage } from './handler.puzzlePage.web'; import { handlePuzzlesPage } from './handler.puzzlesPage.web'; import { handleAppsFootballMatchPage, @@ -114,6 +115,7 @@ renderer.get('/Blocks/*url', handleBlocks); renderer.get('/Front/*url', handleFront); renderer.get('/TagPage/*url', handleTagPage); renderer.get('/PuzzlesPage/*url', handlePuzzlesPage); +renderer.get('/PuzzlePage/*url', handlePuzzlePage); renderer.get('/EmailNewsletters/*url', handleAllEditorialNewslettersPage); renderer.get('/AppsArticle/*url', handleAppsArticle); renderer.get('/AppsInteractive/*url', handleAppsInteractive); @@ -135,6 +137,7 @@ renderer.post('/Blocks', handleBlocks); renderer.post('/Front', handleFront); renderer.post('/TagPage', handleTagPage); renderer.post('/PuzzlesPage', handlePuzzlesPage); +renderer.post('/PuzzlePage', handlePuzzlePage); renderer.post('/EmailNewsletters', handleAllEditorialNewslettersPage); renderer.post('/AppsArticle', handleAppsArticle); renderer.post('/AppsInteractive', handleAppsInteractive); diff --git a/dotcom-rendering/src/server/server.prod.ts b/dotcom-rendering/src/server/server.prod.ts index 821df4b58f6..dc070061ccf 100644 --- a/dotcom-rendering/src/server/server.prod.ts +++ b/dotcom-rendering/src/server/server.prod.ts @@ -19,6 +19,7 @@ import { handleAppsAssets } from './handler.assets.apps'; import { handleEditionsCrossword } from './handler.editionsCrossword'; import { handleFootballMatchDayEmbed } from './handler.footballMatchDayEmbed'; import { handleFront, handleTagPage } from './handler.front.web'; +import { handlePuzzlePage } from './handler.puzzlePage.web'; import { handlePuzzlesPage } from './handler.puzzlesPage.web'; import { handleAppsFootballMatchPage, @@ -72,6 +73,7 @@ export const prodServer = (): void => { app.post('/Front', handleFront); app.post('/TagPage', handleTagPage); app.post('/PuzzlesPage', handlePuzzlesPage); + app.post('/PuzzlePage', handlePuzzlePage); app.post('/FootballMatchListPage', handleFootballMatchListPage); app.post('/FootballTablesPage', handleFootballTablesPage); app.post('/FootballMatchSummaryPage', handleFootballMatchPage); diff --git a/dotcom-rendering/src/types/puzzlePage.ts b/dotcom-rendering/src/types/puzzlePage.ts new file mode 100644 index 00000000000..19a16a42992 --- /dev/null +++ b/dotcom-rendering/src/types/puzzlePage.ts @@ -0,0 +1,55 @@ +import type { EditionId } from '../lib/edition'; +import type { ConfigType } from './config'; +import type { FooterType } from './footer'; +import type { FENavType } from './frontend'; +import type { PuzzleItem } from './puzzlesPage'; + +/** + * The instance-specific data for a single Puzzle Page: the concrete content + * (currently just the title, plus optional related-content links) resolved + * by frontend for a given puzzle `slug`. + * + * Puzzle Page is scoped to iframe-based puzzles only, there is no + * component-rendered case (crosswords remain on their existing, separate + * `/crosswords/*` flow), so this type carries no crossword-specific fields. + */ +export interface PuzzlePageInstance { + title: string; + /** + * Which day's puzzle the reader wants to see, as a plain date string + * (e.g. `"2026-09-11"`). `frontend` now always resolves and sends this + * for every request (its Puzzle Page URLs carry a date segment), so it + * is rendered as a human-readable date next to the page title + * (`formatPuzzleDate` in `src/lib/puzzleDate.ts`) and passed straight + * through, unformatted, as `PuzzleContext.puzzleDate` to the puzzle + * iframe (`src/components/PuzzleIframe.island.tsx`). DCR still treats + * the field as optional and does not parse the URL or own the + * date-in-path/redirect-to-archive logic itself, it just receives + * whatever `frontend` resolved. See "Open questions" in + * `docs/puzzle-page.md`. + * + * Unrelated to the removed crossword-only `date` field this type used + * to have (a formatted *display* string like "Mon 7 Sep 2026"): + * `puzzleDate` is a *request/selection* input, not display text. + */ + puzzleDate?: string; + moreFromPuzzlesAndGames?: PuzzleItem[]; +} + +/** + * The request payload contract for `POST /PuzzlePage`, modeled closely on + * `FEPuzzlesPageType` (see `src/types/puzzlesPage.ts`) for consistency of + * conventions between the two, unrelated, puzzles-related page types. + */ +export interface FEPuzzlePageType { + id: string; + /** Looked up in DCR's `PuzzleConfig` registry (`src/model/puzzles/puzzleConfigs.ts`). */ + slug: string; + webTitle: string; + config: ConfigType; + nav: FENavType; + pageFooter: FooterType; + canonicalUrl: string; + editionId: EditionId; + instance: PuzzlePageInstance; +} diff --git a/dotcom-rendering/webpack/webpack.config.dev-server.js b/dotcom-rendering/webpack/webpack.config.dev-server.js index 5aaf51a7d46..8662390bae3 100644 --- a/dotcom-rendering/webpack/webpack.config.dev-server.js +++ b/dotcom-rendering/webpack/webpack.config.dev-server.js @@ -55,15 +55,9 @@ module.exports = { devServer.app.use(express.json({ limit: '10mb' })); devServer.app.get('/', (req, res) => { - res.sendFile( - path.join( - __dirname, - '..', - 'src', - 'server', - 'dev-index.html', - ), - ); + res.sendFile('dev-index.html', { + root: path.join(__dirname, '..', 'src', 'server'), + }); }); // webpack-hot-server-middleware needs to run after webpack-dev-middleware