-
Notifications
You must be signed in to change notification settings - Fork 34
Add Puzzle Page: generic template for iframe-based puzzles, print scoped to Sudoku #16700
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 11 commits
Commits
Show all changes
56 commits
Select commit
Hold shift + click to select a range
f073b2c
docs: add multi-phase plan for generic Game page
andresilva-guardian 0d2eccd
feat(game-page): add GameConfig registry, FEGamePageType, and AB gate
andresilva-guardian e64e1fe
feat(game-page): add GameLayout, GamePage, and iframe/component regis…
andresilva-guardian 8f67895
feat(game-page): add handler, render, and POST /GamePage route
andresilva-guardian 4de9def
test(game-page): add fixtures and tests for handler/gate/registry
andresilva-guardian 47b4e51
docs: finalize Game page plan with manual validation steps and fronte…
andresilva-guardian f4c21ef
Remove game-page-experiment AB gate (routes will be mapped in a separ…
andresilva-guardian bd8560e
Fix GameLayout visual parity: PDF link and section label link
andresilva-guardian cc86529
docs: add docs/game-page.md as the real reference doc for Game Page
andresilva-guardian c7110cd
docs(game-page): list local access command for each of the 12 slugs
andresilva-guardian ab6cb4d
fix: dev server root sendFile 404s when repo path contains a dot-segm…
andresilva-guardian 3fc8b78
Remove crossword from Game Page: simplify GameConfig registry + FEGam…
andresilva-guardian 5692b3f
Remove crossword rendering code from GameLayout
andresilva-guardian 354e3e1
Remove crossword fixtures/test cases for Game Page
andresilva-guardian bb0f5ba
docs(game-page): update for the iframe-only scope reduction
andresilva-guardian 3c35ae2
Rename Game Page -> Puzzle Page throughout (PR review: terminology)
andresilva-guardian 80d6981
Remove non-V0 puzzles from the registry (PR review feedback)
andresilva-guardian be85602
Move Puzzle Page validation into its own file (PR review feedback)
andresilva-guardian 8cd7f6d
Pass user ID to puzzle iframe + postMessage + reload on login (PR rev…
andresilva-guardian 3bd0204
docs: consolidate into a single docs/puzzle-page.md reference
andresilva-guardian 9b4404e
Add optional puzzleDate field to FEPuzzlePageType (PR review)
andresilva-guardian 1ba5a53
docs(puzzle-page): reflect puzzleDate, frontend gating, and new publi…
andresilva-guardian 3a800b6
docs+fixtures: reflect frontend's new top-level puzzle-page URL shape
andresilva-guardian b60a73d
Replace userId-only iframe context with combined guardian-puzzle-context
andresilva-guardian 975c1e6
docs(puzzle-page): reflect combined guardian-puzzle-context mechanism
andresilva-guardian cfaa3b3
Add curated per-puzzle SEO description to PuzzleConfig registry
andresilva-guardian 872b415
Wire PuzzleConfig.description into render.puzzlePage.web.tsx + OG/Twi…
andresilva-guardian 067215b
docs(puzzle-page): document SEO description/OG/Twitter wiring + futur…
andresilva-guardian 4af65b6
Add optional image field to PuzzleConfig for share/preview images
andresilva-guardian 4797dd7
Wire optional PuzzleConfig.image into og:image/twitter:image + test c…
andresilva-guardian 72ddf81
docs(puzzle-page): document optional PuzzleConfig.image field
andresilva-guardian 1e07c76
Add cumulative v1/v2 AB test entries for Puzzles & Games rollout
andresilva-guardian 5130cc5
Add cumulative isPuzzlesHubV1Enabled/isPuzzlesHubV2Enabled gate helpers
andresilva-guardian ebc184d
Gate the "More from Puzzles & Games" rail behind isPuzzlesHubV1Enabled
andresilva-guardian 5c59c20
docs(puzzle-page): document 3-tier v0/v1/v2 rollout gating structure
andresilva-guardian 53c842c
Remove em dashes across branch per new house style rule
andresilva-guardian 56348c6
Display instance.puzzleDate next to the Puzzle Page title
andresilva-guardian 47ea8be
Include puzzleDate in the guardian-puzzle-context sent to the iframe
andresilva-guardian 3399817
docs: update puzzle-page.md for the now-active puzzleDate
andresilva-guardian 7fab67a
Add formatPuzzleDateShort for SEO title/description templating
andresilva-guardian 5d7ff1e
Add exact SEO title/description copy with {date} templating
andresilva-guardian 4a20458
Wire the resolved SEO title into <title>/og:title/twitter:title
andresilva-guardian 2b3aa30
docs: document SEO title/description templating and its scope
andresilva-guardian 15f5eb8
Fix killer sudoku's wrong AmuseLabs URL, remove slug-derived template
andresilva-guardian d5ff801
Add confirmed uid query param alongside guardian-puzzle-context
andresilva-guardian 6f4ebbb
docs: document the killer-sudoku URL fix and the confirmed uid param
andresilva-guardian 38d818d
docs: document idx=1/archive-URL and future Puzzles Server architectu…
andresilva-guardian 2f43e22
Change PuzzleIframeConfig to a discriminated union by provider
andresilva-guardian 732d64e
Add per-provider iframe URL builder strategy (src/lib/puzzleIframeUrl…
andresilva-guardian c3a4c99
Wire PuzzleIframe to the new per-provider URL builder strategy
andresilva-guardian d744cea
Update PuzzleIframe.island.test.tsx for the refactored responsibility…
andresilva-guardian 726d040
docs: document the per-provider iframe URL strategy abstraction
andresilva-guardian 3bf3dd5
Increase puzzle iframe min-height below tablet for AmuseLabs menu reflow
andresilva-guardian f848d20
Scope the print button to Sudoku only, per explicit product guidance
andresilva-guardian 9a8535e
docs: document responsive iframe height estimate and Sudoku-only print
andresilva-guardian 93e55ce
Merge remote-tracking branch 'origin/main' into afs/puzzles-game-page
andresilva-guardian File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import type { CrosswordProps } from '@guardian/react-crossword'; | ||
| import { gameConfigs } from '../../src/model/games/gameConfigs'; | ||
| import type { FEGamePageType } from '../../src/types/gamePage'; | ||
| 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', | ||
| }, | ||
| ]; | ||
|
|
||
| const sampleCrosswordData: CrosswordProps['data'] = { | ||
| crosswordType: 'quick', | ||
| date: Date.now(), | ||
| dimensions: { cols: 3, rows: 1 }, | ||
| id: 'quick/1', | ||
| name: 'Quick crossword No 1', | ||
| number: 1, | ||
| solutionAvailable: false, | ||
| entries: [ | ||
| { | ||
| id: '1-across', | ||
| number: 1, | ||
| humanNumber: '1', | ||
| clue: 'Sample clue (3)', | ||
| direction: 'across', | ||
| position: { x: 0, y: 0 }, | ||
| separatorLocations: {}, | ||
| length: 3, | ||
| group: ['1-across'], | ||
| }, | ||
| ], | ||
| }; | ||
|
|
||
| /** | ||
| * Builds a `FEGamePageType` fixture for the given `slug`, defaulting to a | ||
| * generic instance for that slug's `GameConfig`. Pass `overrides` to | ||
| * customise individual fields (deep-merged only one level for `instance`). | ||
| */ | ||
| export const createGamePage = ( | ||
| slug: string, | ||
| overrides: Partial<FEGamePageType> = {}, | ||
| ): FEGamePageType => { | ||
| const gameConfig = gameConfigs[slug]; | ||
|
|
||
| if (!gameConfig) { | ||
| throw new Error(`Unknown game slug in fixture: ${slug}`); | ||
| } | ||
|
|
||
| const isCrossword = gameConfig.componentKey === 'crossword'; | ||
|
|
||
| return { | ||
| id: `game-page-${slug}`, | ||
| slug, | ||
| webTitle: `${slug} | The Guardian`, | ||
| config: { | ||
| ...Standard.config, | ||
| contentType: 'Game', | ||
| // DCR no longer gates /GamePage 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: `https://www.theguardian.com/games/${slug}`, | ||
| editionId: Standard.editionId, | ||
| instance: { | ||
| title: isCrossword ? 'Quick crossword No 1' : `${slug} puzzle`, | ||
| puzzleType: isCrossword ? 'Quick crossword' : undefined, | ||
| setterName: gameConfig.setterEnabled ? 'Sample Setter' : undefined, | ||
| date: '1 January 2025', | ||
| discussionId: gameConfig.commentsEnabled | ||
| ? `game/${slug}` | ||
| : undefined, | ||
| crosswordData: isCrossword ? sampleCrosswordData : undefined, | ||
| moreFromPuzzlesAndGames: sampleMoreFromPuzzlesAndGames, | ||
| }, | ||
| ...overrides, | ||
| }; | ||
| }; | ||
|
|
||
| /** One fixture per supported slug, for local dev preview and tests. */ | ||
| export const gamePageFixtures: Record<string, FEGamePageType> = Object.keys( | ||
| gameConfigs, | ||
| ).reduce<Record<string, FEGamePageType>>((acc, slug) => { | ||
| acc[slug] = createGamePage(slug); | ||
| return acc; | ||
| }, {}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { css } from '@emotion/react'; | ||
|
|
||
| interface Props { | ||
| /** The already-resolved iframe src URL (with `{slug}` substituted). */ | ||
| src: string; | ||
| title: string; | ||
| } | ||
|
|
||
| const frameStyles = css` | ||
| width: 100%; | ||
| min-height: 500px; | ||
| border: none; | ||
| `; | ||
|
|
||
| /** | ||
| * Generic sandboxed iframe wrapper for third-party (or in-house, non-React) | ||
| * puzzle/game providers, such as AmuseLabs-hosted games or bespoke providers | ||
| * like wordiply.com. Used for any `GameConfig` with `renderMode: 'iframe'`. | ||
| */ | ||
| export const GameIframe = ({ src, title }: Props) => ( | ||
| <iframe | ||
| css={frameStyles} | ||
| src={src} | ||
| title={title} | ||
| loading="lazy" | ||
| sandbox="allow-scripts allow-same-origin allow-popups allow-forms" | ||
| /> | ||
| ); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import { Global } from '@emotion/react'; | ||
| import { StrictMode } from 'react'; | ||
| import { GameLayout, type ResolvedGamePage } from '../layouts/GameLayout'; | ||
| 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 = { | ||
| gamePage: ResolvedGamePage; | ||
| NAV: NavType; | ||
| }; | ||
|
|
||
| export const GamePage = ({ gamePage, 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={ | ||
| !!gamePage.config.switches.commercialMetrics | ||
| } | ||
| /> | ||
| </Island> | ||
| <Island priority="critical"> | ||
| <SetABTests | ||
| serverSideABTests={gamePage.config.serverSideABTests} | ||
| /> | ||
| </Island> | ||
| <GameLayout gamePage={gamePage} NAV={NAV} /> | ||
| </StrictMode> | ||
| ); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import { render, screen } from '@testing-library/react'; | ||
| import { createGamePage } from '../../fixtures/manual/gamePage'; | ||
| import { ConfigProvider } from '../components/ConfigContext'; | ||
| import { extractNAV } from '../model/extract-nav'; | ||
| import { getGameConfig } from '../model/games/gameConfigs'; | ||
| import { GameLayout } from './GameLayout'; | ||
|
|
||
| jest.mock('../lib/bridgetApi', () => jest.fn()); | ||
| jest.mock('../lib/useMatchMedia', () => ({ | ||
| ...jest.requireActual('../lib/useMatchMedia'), | ||
| useMatchMedia: jest.fn(() => true), | ||
| })); | ||
|
|
||
| const renderGameLayout = ( | ||
| slug: string, | ||
| overrides: Parameters<typeof createGamePage>[1] = {}, | ||
| ) => { | ||
| const gamePage = createGamePage(slug, overrides); | ||
| const gameConfig = getGameConfig(slug); | ||
| if (!gameConfig) throw new Error(`missing config for ${slug}`); | ||
|
|
||
| return render( | ||
| <ConfigProvider | ||
| value={{ | ||
| renderingTarget: 'Web', | ||
| darkModeAvailable: false, | ||
| assetOrigin: '/', | ||
| editionId: 'UK', | ||
| }} | ||
| > | ||
| <GameLayout | ||
| gamePage={{ ...gamePage, gameConfig }} | ||
| NAV={extractNAV(gamePage.nav)} | ||
| /> | ||
| </ConfigProvider>, | ||
| ); | ||
| }; | ||
|
|
||
| describe('GameLayout', () => { | ||
| it('renders a working PDF version link when crosswordData.pdf is present', () => { | ||
| renderGameLayout('crossword', { | ||
| instance: { | ||
| ...createGamePage('crossword').instance, | ||
| crosswordData: { | ||
| ...(createGamePage('crossword').instance | ||
| .crosswordData as Record<string, unknown>), | ||
| pdf: 'https://example.com/crossword.pdf', | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| const pdfLink = screen.getByRole('link', { name: 'PDF version' }); | ||
| expect(pdfLink).toHaveAttribute( | ||
| 'href', | ||
| 'https://example.com/crossword.pdf', | ||
| ); | ||
| }); | ||
|
|
||
| it('does not render a PDF version link when crosswordData.pdf is absent', () => { | ||
| renderGameLayout('crossword'); | ||
|
|
||
| expect( | ||
| screen.queryByRole('link', { name: 'PDF version' }), | ||
| ).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('does not render CrosswordLinks for a non-crossword slug', () => { | ||
| renderGameLayout('sudoku-easy'); | ||
|
|
||
| expect( | ||
| screen.queryByRole('link', { name: 'PDF version' }), | ||
| ).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders the crosswords group label as a styled link to /crosswords', () => { | ||
| renderGameLayout('crossword'); | ||
|
|
||
| const label = screen.getByRole('link', { name: 'Quick crossword' }); | ||
| expect(label).toHaveAttribute('href', '/crosswords'); | ||
| }); | ||
|
|
||
| it('renders a non-crosswords group label as plain, non-linked text', () => { | ||
| renderGameLayout('sudoku-easy'); | ||
|
|
||
| expect( | ||
| screen.queryByRole('link', { name: 'Logic puzzles' }), | ||
| ).not.toBeInTheDocument(); | ||
| expect(screen.getByText('Logic puzzles')).toBeInTheDocument(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.