From 5de65526659548506628a6cd7ed35f0e093f490c Mon Sep 17 00:00:00 2001 From: "arrz.dev" <55147716+arrzdev@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:59:44 +0000 Subject: [PATCH] feat(game): BreadcrumbList structured data on the four guide pages Each guide (/rules, /strategy, /opening-moves, /starting-positions) emits one BreadcrumbList: home, then the page, with absolute URLs equal to the page's own canonical. Translated guides carry it in their language; home, the offline game and the online landing carry none. --- apps/game/src/routing/seo-pages.ts | 15 +- apps/game/src/routing/structured-data.test.ts | 149 +++++++++++++++++- apps/game/src/routing/structured-data.ts | 66 +++++++- e2e/structured-data.spec.ts | 39 ++++- 4 files changed, 259 insertions(+), 10 deletions(-) diff --git a/apps/game/src/routing/seo-pages.ts b/apps/game/src/routing/seo-pages.ts index f1c99557..b9c3fc7c 100644 --- a/apps/game/src/routing/seo-pages.ts +++ b/apps/game/src/routing/seo-pages.ts @@ -3,7 +3,10 @@ import { SUPPORTED_LANGUAGES } from "@/i18n/languages" import { DEFAULT_LANGUAGE } from "@/i18n/locale-path" import type { SocialImage } from "@/routing/page-head" import { pageHead } from "@/routing/page-head" -import { structuredDataScripts } from "@/routing/structured-data" +import { + breadcrumbScripts, + structuredDataScripts, +} from "@/routing/structured-data" /** * The pages a search engine should list, and the words it lists them under. @@ -161,6 +164,8 @@ export function seoCopy(path: IndexablePath, language: Language): SeoPage { * the English page's own: the canonical names the English URL and says no more * languages than there are. The JSON-LD stays English-only: it quotes the * page's English questions and steps, which a translated page does not show. + * The breadcrumb trail is the exception: it quotes no body copy, only the + * page's own name and URLs, so every language carries its own. */ export function seoPageHead( path: IndexablePath, @@ -178,9 +183,11 @@ export function seoPageHead( language: pageLanguage, alternates: PAGE_LANGUAGES[path], }), - scripts: - pageLanguage === DEFAULT_LANGUAGE + scripts: [ + ...(pageLanguage === DEFAULT_LANGUAGE ? structuredDataScripts(path, page) - : [], + : []), + ...breadcrumbScripts(path, pageLanguage), + ], } } diff --git a/apps/game/src/routing/structured-data.test.ts b/apps/game/src/routing/structured-data.test.ts index f240381c..5c50b85d 100644 --- a/apps/game/src/routing/structured-data.test.ts +++ b/apps/game/src/routing/structured-data.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest" +import type { Language } from "@/i18n/languages" +import { SUPPORTED_LANGUAGES } from "@/i18n/languages" +import { localePath } from "@/i18n/locale-path" import type { IndexablePath } from "@/routing/seo-pages" -import { seoPageHead } from "@/routing/seo-pages" +import { PAGE_LANGUAGES, seoPageHead } from "@/routing/seo-pages" //the JSON-LD each indexable page carries (tech-seo-spec item 4 on TUD-626). //search engines read it as a description of what is on the page, so it may only @@ -11,14 +14,37 @@ const ORIGIN = "https://abalone.tudu.dev" type Json = Record -function structuredData(path: IndexablePath): Json[] { - const scripts = seoPageHead(path).scripts ?? [] +function allStructuredData( + path: IndexablePath, + language?: Language, +): Json[] { + const scripts = seoPageHead(path, language).scripts ?? [] return scripts.map((script) => { expect(script.type).toBe("application/ld+json") return JSON.parse(script.children) as Json }) } +//what describes the page itself; the breadcrumb trail has its own tests below +function structuredData(path: IndexablePath): Json[] { + return allStructuredData(path).filter( + (block) => block["@type"] !== "BreadcrumbList", + ) +} + +function breadcrumbs(path: IndexablePath, language?: Language): Json[] { + return allStructuredData(path, language).filter( + (block) => block["@type"] === "BreadcrumbList", + ) +} + +function canonicalOf(path: IndexablePath, language?: Language): string { + const link = seoPageHead(path, language).links.find( + (entry) => entry.rel === "canonical", + ) + return link?.href ?? "" +} + describe("structured data", () => { it("describes the home page as a free web game, with its FAQ", () => { const [game, faq, ...rest] = structuredData("/") @@ -226,4 +252,121 @@ describe("structured data", () => { }, }) }) + + describe("breadcrumb trail", () => { + const GUIDES = [ + "/rules", + "/strategy", + "/opening-moves", + "/starting-positions", + ] as const + + it("lists home then the page on each guide, with absolute URLs", () => { + for (const path of GUIDES) { + const trails = breadcrumbs(path) + expect(trails, path).toHaveLength(1) + const [trail] = trails + expect(trail["@context"]).toBe("https://schema.org") + const items = trail.itemListElement as Json[] + expect(items, path).toHaveLength(2) + expect( + items.map((item) => item.position), + path, + ).toEqual([1, 2]) + for (const item of items) expect(item["@type"]).toBe("ListItem") + expect(items[0].name).toBe("Abalone") + expect(items[0].item).toBe(`${ORIGIN}/`) + expect(items[1].item).toBe(`${ORIGIN}${path}`) + expect(items[1].item).toBe(canonicalOf(path)) + } + }) + + it("names the page the way the site calls it", () => { + const names = GUIDES.map((path) => { + const [trail] = breadcrumbs(path) + return (trail.itemListElement as Json[])[1].name + }) + expect(names).toEqual([ + "Rules", + "Strategy", + "Opening moves", + "Starting positions", + ]) + }) + + it("is one script each in every language the guide is translated into", () => { + for (const path of GUIDES) { + for (const language of PAGE_LANGUAGES[path]) { + const trails = breadcrumbs(path, language) + expect(trails, `${language} ${path}`).toHaveLength(1) + const items = trails[0].itemListElement as Json[] + expect(items.map((item) => item.position)).toEqual([1, 2]) + expect(items[0].item).toBe( + `${ORIGIN}${localePath("/", language)}`, + ) + expect(items[1].item).toBe(canonicalOf(path, language)) + expect(items[1].item).toBe( + `${ORIGIN}${localePath(path, language)}`, + ) + } + } + }) + + it("is written in the page's language", () => { + const [pt] = breadcrumbs("/rules", "pt") + const ptItems = pt.itemListElement as Json[] + expect(ptItems[0]).toEqual({ + "@type": "ListItem", + position: 1, + name: "Abalone", + item: `${ORIGIN}/pt`, + }) + expect(ptItems[1]).toEqual({ + "@type": "ListItem", + position: 2, + name: "Regras", + item: `${ORIGIN}/pt/rules`, + }) + const [de] = breadcrumbs("/strategy", "de") + expect((de.itemListElement as Json[])[1].name).toBe("Strategie") + }) + + //the other payloads quote English copy, so a translated page carries the + //trail and nothing else + it("is all a translated page carries", () => { + expect(allStructuredData("/rules", "pt")).toHaveLength(1) + }) + + //a guide with no translation serves English at /pt/...: its trail is the + //English page's, on the canonical it names + it("follows the canonical of a guide that has no translation", () => { + const [trail] = breadcrumbs("/opening-moves", "pt") + const items = trail.itemListElement as Json[] + expect(items[0].item).toBe(`${ORIGIN}/`) + expect(items[1].item).toBe(`${ORIGIN}/opening-moves`) + expect(canonicalOf("/opening-moves", "pt")).toBe( + `${ORIGIN}/opening-moves`, + ) + }) + + it("is left off home, the game screen and the online landing", () => { + for (const path of ["/", "/offline", "/play-online"] as const) { + for (const language of SUPPORTED_LANGUAGES) { + expect( + breadcrumbs(path, language), + `${language} ${path}`, + ).toEqual([]) + } + } + }) + + it("escapes < like the other scripts", () => { + for (const language of PAGE_LANGUAGES["/rules"]) { + for (const script of seoPageHead("/rules", language).scripts ?? + []) { + expect(script.children).not.toContain("<") + } + } + }) + }) }) diff --git a/apps/game/src/routing/structured-data.ts b/apps/game/src/routing/structured-data.ts index da0e304c..2682c7f5 100644 --- a/apps/game/src/routing/structured-data.ts +++ b/apps/game/src/routing/structured-data.ts @@ -1,6 +1,9 @@ +import type { Language } from "@/i18n/languages" +import { DEFAULT_LANGUAGE, localePath } from "@/i18n/locale-path" import enCommon from "@/i18n/locales/en/common.json" import enGame from "@/i18n/locales/en/game.json" import type { IndexablePath, SeoPage } from "@/routing/seo-pages" +import { BRAND } from "@/utils/brand" import { siteUrl } from "@/utils/site" /** @@ -11,7 +14,9 @@ import { siteUrl } from "@/utils/site" * own English copy rather than written a second time beside it. */ -type Json = { [key: string]: Json | Json[] | string | string[] } +type Json = { + [key: string]: Json | Json[] | string | string[] | number +} /** A `