From d5898e01a78506bffcc3b45074ebad2d6e3ac7fb Mon Sep 17 00:00:00 2001 From: "arrz.dev" <55147716+arrzdev@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:02:05 +0000 Subject: [PATCH] feat(game): link each guide to the other three at the end of the page A Related guides block closes /rules, /strategy, /opening-moves and /starting-positions. Labels reuse each page's heading; the block heading is one new key in every locale. Plain links in the server HTML, under the page's language prefix, below the play block. --- .../src/components/manual/related-guides.tsx | 52 ++++++++++++ apps/game/src/i18n/locales/de/game.json | 3 +- apps/game/src/i18n/locales/en/game.json | 3 +- apps/game/src/i18n/locales/es/game.json | 3 +- apps/game/src/i18n/locales/fr/game.json | 3 +- apps/game/src/i18n/locales/hi/game.json | 3 +- apps/game/src/i18n/locales/it/game.json | 3 +- apps/game/src/i18n/locales/ja/game.json | 3 +- apps/game/src/i18n/locales/ko/game.json | 3 +- apps/game/src/i18n/locales/nl/game.json | 3 +- apps/game/src/i18n/locales/pl/game.json | 3 +- apps/game/src/i18n/locales/pt/game.json | 3 +- apps/game/src/i18n/locales/tr/game.json | 3 +- apps/game/src/i18n/locales/zh/game.json | 3 +- .../src/routing/pages/opening-moves.page.tsx | 3 + apps/game/src/routing/pages/rules.page.tsx | 3 + .../routing/pages/starting-positions.page.tsx | 3 + apps/game/src/routing/pages/strategy.page.tsx | 3 + apps/game/src/routing/seo-pages.test.ts | 8 +- apps/game/src/routing/seo-pages.ts | 8 +- e2e/related-guides.spec.ts | 85 +++++++++++++++++++ e2e/seo.spec.ts | 8 +- e2e/strategy-page.spec.ts | 2 + 23 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 apps/game/src/components/manual/related-guides.tsx create mode 100644 e2e/related-guides.spec.ts diff --git a/apps/game/src/components/manual/related-guides.tsx b/apps/game/src/components/manual/related-guides.tsx new file mode 100644 index 00000000..0d935a8a --- /dev/null +++ b/apps/game/src/components/manual/related-guides.tsx @@ -0,0 +1,52 @@ +import { Link } from "@tanstack/react-router" +import { useTranslation } from "react-i18next" + +/** The guides, in reading order, each named by its page's own heading. */ +const GUIDES = [ + ["/rules", "game:rules.heading"], + ["/strategy", "game:strategy.heading"], + ["/opening-moves", "game:openings.heading"], + ["/starting-positions", "game:positions.heading"], +] as const + +export type GuidePath = (typeof GUIDES)[number][0] + +/** + * The other guides, at the end of a guide: a reader who finished one finds the + * next, and a crawler finds every guide from each of them. Plain links, which + * the router gives the page's language prefix (/de/strategy). After the last + * chapter and outside it, so the way to the board above stays where it was. + */ +export function RelatedGuides({ current }: { current: GuidePath }) { + const { t } = useTranslation() + + return ( + + ) +} diff --git a/apps/game/src/i18n/locales/de/game.json b/apps/game/src/i18n/locales/de/game.json index 5cd66c40..daa0b0ca 100644 --- a/apps/game/src/i18n/locales/de/game.json +++ b/apps/game/src/i18n/locales/de/game.json @@ -332,5 +332,6 @@ "next": "Weiter", "done": "Verstanden", "skip": "Überspringen" - } + }, + "related_guides": "Weitere Anleitungen" } diff --git a/apps/game/src/i18n/locales/en/game.json b/apps/game/src/i18n/locales/en/game.json index 158ae144..e5577aec 100644 --- a/apps/game/src/i18n/locales/en/game.json +++ b/apps/game/src/i18n/locales/en/game.json @@ -515,5 +515,6 @@ "next": "Next", "done": "Got it", "skip": "Skip" - } + }, + "related_guides": "Related guides" } diff --git a/apps/game/src/i18n/locales/es/game.json b/apps/game/src/i18n/locales/es/game.json index 761a6bc1..aaa6d9fa 100644 --- a/apps/game/src/i18n/locales/es/game.json +++ b/apps/game/src/i18n/locales/es/game.json @@ -332,5 +332,6 @@ "next": "Siguiente", "done": "Entendido", "skip": "Omitir" - } + }, + "related_guides": "Guías relacionadas" } diff --git a/apps/game/src/i18n/locales/fr/game.json b/apps/game/src/i18n/locales/fr/game.json index 71ff6fd1..64855d64 100644 --- a/apps/game/src/i18n/locales/fr/game.json +++ b/apps/game/src/i18n/locales/fr/game.json @@ -332,5 +332,6 @@ "next": "Suivant", "done": "Compris", "skip": "Passer" - } + }, + "related_guides": "Guides associés" } diff --git a/apps/game/src/i18n/locales/hi/game.json b/apps/game/src/i18n/locales/hi/game.json index 34824396..3f250e44 100644 --- a/apps/game/src/i18n/locales/hi/game.json +++ b/apps/game/src/i18n/locales/hi/game.json @@ -332,5 +332,6 @@ "next": "आगे", "done": "समझ गया", "skip": "छोड़ें" - } + }, + "related_guides": "संबंधित गाइड" } diff --git a/apps/game/src/i18n/locales/it/game.json b/apps/game/src/i18n/locales/it/game.json index 2ec00e1d..c4124ad5 100644 --- a/apps/game/src/i18n/locales/it/game.json +++ b/apps/game/src/i18n/locales/it/game.json @@ -332,5 +332,6 @@ "next": "Avanti", "done": "Ho capito", "skip": "Salta" - } + }, + "related_guides": "Guide correlate" } diff --git a/apps/game/src/i18n/locales/ja/game.json b/apps/game/src/i18n/locales/ja/game.json index 4633c3d8..0da24ae3 100644 --- a/apps/game/src/i18n/locales/ja/game.json +++ b/apps/game/src/i18n/locales/ja/game.json @@ -331,5 +331,6 @@ "next": "次へ", "done": "わかった", "skip": "スキップ" - } + }, + "related_guides": "関連ガイド" } diff --git a/apps/game/src/i18n/locales/ko/game.json b/apps/game/src/i18n/locales/ko/game.json index 8cb4326c..c9ce1ab4 100644 --- a/apps/game/src/i18n/locales/ko/game.json +++ b/apps/game/src/i18n/locales/ko/game.json @@ -331,5 +331,6 @@ "next": "다음", "done": "알겠어요", "skip": "건너뛰기" - } + }, + "related_guides": "관련 가이드" } diff --git a/apps/game/src/i18n/locales/nl/game.json b/apps/game/src/i18n/locales/nl/game.json index 89614840..e6ff5395 100644 --- a/apps/game/src/i18n/locales/nl/game.json +++ b/apps/game/src/i18n/locales/nl/game.json @@ -332,5 +332,6 @@ "next": "Volgende", "done": "Begrepen", "skip": "Overslaan" - } + }, + "related_guides": "Gerelateerde gidsen" } diff --git a/apps/game/src/i18n/locales/pl/game.json b/apps/game/src/i18n/locales/pl/game.json index 7845a006..62a7f5c2 100644 --- a/apps/game/src/i18n/locales/pl/game.json +++ b/apps/game/src/i18n/locales/pl/game.json @@ -333,5 +333,6 @@ "next": "Dalej", "done": "Rozumiem", "skip": "Pomiń" - } + }, + "related_guides": "Powiązane poradniki" } diff --git a/apps/game/src/i18n/locales/pt/game.json b/apps/game/src/i18n/locales/pt/game.json index 8c4bc977..45996b47 100644 --- a/apps/game/src/i18n/locales/pt/game.json +++ b/apps/game/src/i18n/locales/pt/game.json @@ -332,5 +332,6 @@ "next": "Seguinte", "done": "Percebi", "skip": "Saltar" - } + }, + "related_guides": "Guias relacionados" } diff --git a/apps/game/src/i18n/locales/tr/game.json b/apps/game/src/i18n/locales/tr/game.json index acaff69c..87ed20fc 100644 --- a/apps/game/src/i18n/locales/tr/game.json +++ b/apps/game/src/i18n/locales/tr/game.json @@ -331,5 +331,6 @@ "next": "İleri", "done": "Anladım", "skip": "Geç" - } + }, + "related_guides": "İlgili rehberler" } diff --git a/apps/game/src/i18n/locales/zh/game.json b/apps/game/src/i18n/locales/zh/game.json index 9c0e24d0..86ed3405 100644 --- a/apps/game/src/i18n/locales/zh/game.json +++ b/apps/game/src/i18n/locales/zh/game.json @@ -331,5 +331,6 @@ "next": "下一步", "done": "知道了", "skip": "跳过" - } + }, + "related_guides": "相关指南" } diff --git a/apps/game/src/routing/pages/opening-moves.page.tsx b/apps/game/src/routing/pages/opening-moves.page.tsx index e35958e4..bc29d6c4 100644 --- a/apps/game/src/routing/pages/opening-moves.page.tsx +++ b/apps/game/src/routing/pages/opening-moves.page.tsx @@ -3,6 +3,7 @@ import { Trans, useTranslation } from "react-i18next" import type { ManualChapter } from "@/components/manual/chapter" import { Chapter } from "@/components/manual/chapter" import { ManualHero, ManualPage } from "@/components/manual/manual-page" +import { RelatedGuides } from "@/components/manual/related-guides" import { PlayBlock } from "@/components/play-block" import { SubpageHeader } from "@/components/ui/subpage-header" import { COMPUTER_GAME } from "@/hooks/use-play-actions" @@ -118,6 +119,8 @@ function OpeningMovesPage() { {playBlock} + + ) diff --git a/apps/game/src/routing/pages/rules.page.tsx b/apps/game/src/routing/pages/rules.page.tsx index 2f284777..f3fb45fe 100644 --- a/apps/game/src/routing/pages/rules.page.tsx +++ b/apps/game/src/routing/pages/rules.page.tsx @@ -9,6 +9,7 @@ import { Chapter, Plate } from "@/components/manual/chapter" import { formatChapterNumber } from "@/components/manual/chapter-number" import { FaqList } from "@/components/manual/faq-list" import { ManualHero, ManualPage } from "@/components/manual/manual-page" +import { RelatedGuides } from "@/components/manual/related-guides" import { MarbleGlyph } from "@/components/marble-glyph" import { PlayBlock } from "@/components/play-block" import { RuleDiagram } from "@/components/rule-diagram" @@ -498,6 +499,8 @@ function RulesPage() {
{playBlock}
+ + ) diff --git a/apps/game/src/routing/pages/starting-positions.page.tsx b/apps/game/src/routing/pages/starting-positions.page.tsx index 5737e7d3..2a70df17 100644 --- a/apps/game/src/routing/pages/starting-positions.page.tsx +++ b/apps/game/src/routing/pages/starting-positions.page.tsx @@ -4,6 +4,7 @@ import { Trans, useTranslation } from "react-i18next" import type { ManualChapter } from "@/components/manual/chapter" import { Chapter, Plate } from "@/components/manual/chapter" import { ManualHero, ManualPage } from "@/components/manual/manual-page" +import { RelatedGuides } from "@/components/manual/related-guides" import { PlayBlock } from "@/components/play-block" import { RuleDiagram } from "@/components/rule-diagram" import { buttonClassName } from "@/components/ui/button" @@ -266,6 +267,8 @@ function StartingPositionsPage() { />

+ + ) diff --git a/apps/game/src/routing/pages/strategy.page.tsx b/apps/game/src/routing/pages/strategy.page.tsx index 0f772341..cc57c1d2 100644 --- a/apps/game/src/routing/pages/strategy.page.tsx +++ b/apps/game/src/routing/pages/strategy.page.tsx @@ -8,6 +8,7 @@ import { } from "@/components/manual/chapter-number" import { FaqList } from "@/components/manual/faq-list" import { ManualHero, ManualPage } from "@/components/manual/manual-page" +import { RelatedGuides } from "@/components/manual/related-guides" import { PlayBlock } from "@/components/play-block" import { SubpageHeader } from "@/components/ui/subpage-header" import { COMPUTER_GAME } from "@/hooks/use-play-actions" @@ -182,6 +183,8 @@ function StrategyPage() { {playBlock} + + ) diff --git a/apps/game/src/routing/seo-pages.test.ts b/apps/game/src/routing/seo-pages.test.ts index 8d539d51..9a29ab79 100644 --- a/apps/game/src/routing/seo-pages.test.ts +++ b/apps/game/src/routing/seo-pages.test.ts @@ -101,11 +101,11 @@ describe("SITEMAP", () => { it("dates each page by the day its text last changed", () => { expect(SITEMAP).toEqual({ "/": "2026-11-01", - "/rules": "2026-10-19", - "/strategy": "2026-10-19", + "/rules": "2026-11-02", + "/strategy": "2026-11-02", "/play-online": "2026-11-01", - "/opening-moves": "2026-10-18", - "/starting-positions": "2026-10-18", + "/opening-moves": "2026-11-02", + "/starting-positions": "2026-11-02", }) }) }) diff --git a/apps/game/src/routing/seo-pages.ts b/apps/game/src/routing/seo-pages.ts index f1c99557..e025643e 100644 --- a/apps/game/src/routing/seo-pages.ts +++ b/apps/game/src/routing/seo-pages.ts @@ -96,11 +96,11 @@ export const SITEMAP: Record< string > = { "/": "2026-11-01", - "/rules": "2026-10-19", - "/strategy": "2026-10-19", + "/rules": "2026-11-02", + "/strategy": "2026-11-02", "/play-online": "2026-11-01", - "/opening-moves": "2026-10-18", - "/starting-positions": "2026-10-18", + "/opening-moves": "2026-11-02", + "/starting-positions": "2026-11-02", } /** diff --git a/e2e/related-guides.spec.ts b/e2e/related-guides.spec.ts new file mode 100644 index 00000000..0b5a67c1 --- /dev/null +++ b/e2e/related-guides.spec.ts @@ -0,0 +1,85 @@ +import { expect, test } from "@playwright/test" +import { openApp } from "./support/app" + +// Each guide ends with links to the other three, so a reader who finished one +// finds the next and a crawler reaches every guide from each of them. The links +// are in the served HTML, in the page's language (/de/rules links /de/strategy), +// and sit below the way to the board, which stays on the first screen. + +const GUIDES = [ + "/rules", + "/strategy", + "/opening-moves", + "/starting-positions", +] as const + +const CTA = "Play against the computer" + +/** The hrefs inside the served related-guides block. */ +function relatedLinks(html: string): string[] { + const start = html.indexOf("data-related-guides") + if (start === -1) return [] + const block = html.slice(start, html.indexOf("", start)) + return [...block.matchAll(/]*href="([^"]*)"/g)].map( + (match) => match[1], + ) +} + +test.describe("related guides in the served HTML", () => { + test.skip( + ({ browserName }) => browserName !== "chromium", + "one HTTP pass is enough", + ) + + for (const prefix of ["", "/de"]) { + for (const guide of GUIDES) { + const path = `${prefix}${guide}` + + test(`${path} links the other three guides, each a 200 page`, async ({ + request, + }) => { + const response = await request.get(path, { maxRedirects: 0 }) + expect(response.status()).toBe(200) + + const links = relatedLinks(await response.text()) + expect(links.sort()).toEqual( + GUIDES.filter((other) => other !== guide) + .map((other) => `${prefix}${other}`) + .sort(), + ) + + for (const link of links) { + const target = await request.get(link, { maxRedirects: 0 }) + expect(target.status(), link).toBe(200) + } + }) + } + } +}) + +test.describe("related guides at 375×667", () => { + test.use({ viewport: { width: 375, height: 667 } }) + + for (const guide of GUIDES) { + test(`${guide}: the play button is on the first screen, the guides below it`, async ({ + page, + }) => { + await openApp(page, guide) + + const play = page + .locator("main") + .getByRole("link", { name: CTA, exact: true }) + .first() + const playBox = await play.boundingBox() + expect(playBox).not.toBeNull() + expect( + (playBox?.y ?? 0) + (playBox?.height ?? 0), + ).toBeLessThanOrEqual(667) + + const related = page.locator("[data-related-guides]") + await expect(related.getByRole("link")).toHaveCount(3) + const relatedBox = await related.boundingBox() + expect(relatedBox?.y ?? 0).toBeGreaterThan(667) + }) + } +}) diff --git a/e2e/seo.spec.ts b/e2e/seo.spec.ts index 4c411dce..c34590fe 100644 --- a/e2e/seo.spec.ts +++ b/e2e/seo.spec.ts @@ -26,11 +26,11 @@ const INDEXABLE = { // page that answers "play abalone". const SITEMAP = { "/": "2026-11-01", - "/rules": "2026-10-19", - "/strategy": "2026-10-19", + "/rules": "2026-11-02", + "/strategy": "2026-11-02", "/play-online": "2026-11-01", - "/opening-moves": "2026-10-18", - "/starting-positions": "2026-10-18", + "/opening-moves": "2026-11-02", + "/starting-positions": "2026-11-02", } as const // A language's own URL: /pt, /pt/rules diff --git a/e2e/strategy-page.spec.ts b/e2e/strategy-page.spec.ts index ea598fdf..078a6231 100644 --- a/e2e/strategy-page.spec.ts +++ b/e2e/strategy-page.spec.ts @@ -24,6 +24,8 @@ const SECTIONS = [ ["computer", "How strong is the computer?"], ["faq", "Frequently asked"], ["practise", "Practise"], + //the links to the other guides close the page (related-guides.spec.ts) + ["related-guides", "Related guides"], ] as const /** The served HTML as the text a reader sees: tags out, entities decoded. */