Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions apps/game/src/components/manual/related-guides.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav
data-related-guides
aria-labelledby="related-guides"
className="border-t border-border pt-10 lg:pt-16"
>
<h2
id="related-guides"
className="font-display text-2xl leading-[1.2] font-bold text-balance text-foreground lg:text-[30px] lg:leading-[1.15]"
>
{t("game:related_guides")}
</h2>
<ul className="mt-4 max-w-[62ch]">
{GUIDES.filter(([path]) => path !== current).map(([path, key]) => (
<li
key={path}
className="border-t border-border first:border-t-0"
>
<Link
to={path}
className="flex min-h-11 items-center py-2 text-base font-semibold text-brand-light underline-offset-2 hover:text-white hover:underline"
>
{t(key)}
</Link>
</li>
))}
</ul>
</nav>
)
}
3 changes: 2 additions & 1 deletion apps/game/src/i18n/locales/de/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,5 +332,6 @@
"next": "Weiter",
"done": "Verstanden",
"skip": "Überspringen"
}
},
"related_guides": "Weitere Anleitungen"
}
3 changes: 2 additions & 1 deletion apps/game/src/i18n/locales/en/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -515,5 +515,6 @@
"next": "Next",
"done": "Got it",
"skip": "Skip"
}
},
"related_guides": "Related guides"
}
3 changes: 2 additions & 1 deletion apps/game/src/i18n/locales/es/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,5 +332,6 @@
"next": "Siguiente",
"done": "Entendido",
"skip": "Omitir"
}
},
"related_guides": "Guías relacionadas"
}
3 changes: 2 additions & 1 deletion apps/game/src/i18n/locales/fr/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,5 +332,6 @@
"next": "Suivant",
"done": "Compris",
"skip": "Passer"
}
},
"related_guides": "Guides associés"
}
3 changes: 2 additions & 1 deletion apps/game/src/i18n/locales/hi/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,5 +332,6 @@
"next": "आगे",
"done": "समझ गया",
"skip": "छोड़ें"
}
},
"related_guides": "संबंधित गाइड"
}
3 changes: 2 additions & 1 deletion apps/game/src/i18n/locales/it/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,5 +332,6 @@
"next": "Avanti",
"done": "Ho capito",
"skip": "Salta"
}
},
"related_guides": "Guide correlate"
}
3 changes: 2 additions & 1 deletion apps/game/src/i18n/locales/ja/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -331,5 +331,6 @@
"next": "次へ",
"done": "わかった",
"skip": "スキップ"
}
},
"related_guides": "関連ガイド"
}
3 changes: 2 additions & 1 deletion apps/game/src/i18n/locales/ko/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -331,5 +331,6 @@
"next": "다음",
"done": "알겠어요",
"skip": "건너뛰기"
}
},
"related_guides": "관련 가이드"
}
3 changes: 2 additions & 1 deletion apps/game/src/i18n/locales/nl/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,5 +332,6 @@
"next": "Volgende",
"done": "Begrepen",
"skip": "Overslaan"
}
},
"related_guides": "Gerelateerde gidsen"
}
3 changes: 2 additions & 1 deletion apps/game/src/i18n/locales/pl/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -333,5 +333,6 @@
"next": "Dalej",
"done": "Rozumiem",
"skip": "Pomiń"
}
},
"related_guides": "Powiązane poradniki"
}
3 changes: 2 additions & 1 deletion apps/game/src/i18n/locales/pt/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,5 +332,6 @@
"next": "Seguinte",
"done": "Percebi",
"skip": "Saltar"
}
},
"related_guides": "Guias relacionados"
}
3 changes: 2 additions & 1 deletion apps/game/src/i18n/locales/tr/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -331,5 +331,6 @@
"next": "İleri",
"done": "Anladım",
"skip": "Geç"
}
},
"related_guides": "İlgili rehberler"
}
3 changes: 2 additions & 1 deletion apps/game/src/i18n/locales/zh/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -331,5 +331,6 @@
"next": "下一步",
"done": "知道了",
"skip": "跳过"
}
},
"related_guides": "相关指南"
}
3 changes: 3 additions & 0 deletions apps/game/src/routing/pages/opening-moves.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -118,6 +119,8 @@ function OpeningMovesPage() {
<Chapter id="practise" title={t("game:openings.practise.title")}>
{playBlock}
</Chapter>

<RelatedGuides current="/opening-moves" />
</ManualPage>
</>
)
Expand Down
3 changes: 3 additions & 0 deletions apps/game/src/routing/pages/rules.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -498,6 +499,8 @@ function RulesPage() {
<div className="border-t border-border pt-10 lg:pt-16">
{playBlock}
</div>

<RelatedGuides current="/rules" />
</ManualPage>
</>
)
Expand Down
3 changes: 3 additions & 0 deletions apps/game/src/routing/pages/starting-positions.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -266,6 +267,8 @@ function StartingPositionsPage() {
/>
</p>
</Chapter>

<RelatedGuides current="/starting-positions" />
</ManualPage>
</>
)
Expand Down
3 changes: 3 additions & 0 deletions apps/game/src/routing/pages/strategy.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -182,6 +183,8 @@ function StrategyPage() {
<Chapter id="practise" title={t("game:strategy.practise.title")}>
{playBlock}
</Chapter>

<RelatedGuides current="/strategy" />
</ManualPage>
</>
)
Expand Down
8 changes: 4 additions & 4 deletions apps/game/src/routing/seo-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})
})
})
Expand Down
8 changes: 4 additions & 4 deletions apps/game/src/routing/seo-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}

/**
Expand Down
85 changes: 85 additions & 0 deletions e2e/related-guides.spec.ts
Original file line number Diff line number Diff line change
@@ -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("</nav>", start))
return [...block.matchAll(/<a[^>]*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)
})
}
})
8 changes: 4 additions & 4 deletions e2e/seo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions e2e/strategy-page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading