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
15 changes: 11 additions & 4 deletions apps/game/src/routing/seo-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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),
],
}
}
149 changes: 146 additions & 3 deletions apps/game/src/routing/structured-data.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,14 +14,37 @@ const ORIGIN = "https://abalone.tudu.dev"

type Json = Record<string, unknown>

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("/")
Expand Down Expand Up @@ -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("<")
}
}
})
})
})
66 changes: 65 additions & 1 deletion apps/game/src/routing/structured-data.ts
Original file line number Diff line number Diff line change
@@ -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"

/**
Expand All @@ -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 `<script type="application/ld+json">` in the route head. */
export type StructuredDataScript = {
Expand Down Expand Up @@ -147,6 +152,65 @@ function toScript(data: Json): StructuredDataScript {
}
}

//the nav labels of every language ("Rules", "Regras"): the same words the
//header calls these pages, so the trail in a result matches the site
const LOCALE_FILES = import.meta.glob<{
nav?: Record<string, string>
}>("../i18n/locales/*/common.json", { eager: true, import: "default" })

/** The guides that sit one level under home, and what the trail calls each. */
const GUIDE_NAMES: Partial<
Record<IndexablePath, (language: Language) => string>
> = {
"/rules": (language) => navLabel("rules", language),
"/strategy": (language) => navLabel("strategy", language),
"/opening-moves": () => enGame.openings.title,
"/starting-positions": () => enGame.positions.title,
}

function navLabel(key: "rules" | "strategy", language: Language): string {
return (
LOCALE_FILES[`../i18n/locales/${language}/common.json`]?.nav?.[key] ??
enCommon.nav[key]
)
}

/**
* Home, then the page: what a search result shows in place of the bare URL.
* Only the guides have one; home is the top of the trail, and the game screen
* and the online landing are not guides.
*
* `language` is the one the page is served in, so the second URL is the page's
* own canonical and the first is the home of the same language.
*/
export function breadcrumbScripts(
path: IndexablePath,
language: Language = DEFAULT_LANGUAGE,
): StructuredDataScript[] {
const name = GUIDE_NAMES[path]
if (!name) return []
return [
toScript({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: BRAND,
item: siteUrl(localePath("/", language)),
},
{
"@type": "ListItem",
position: 2,
name: name(language),
item: siteUrl(localePath(path, language)),
},
],
}),
]
}

/**
* The JSON-LD for one indexable page. The game screen is a tool, not a
* document, so it carries none.
Expand Down
39 changes: 37 additions & 2 deletions e2e/structured-data.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ test("rules is a HowTo whose steps are sections on the page", async ({
}) => {
const { html, blocks } = await served(request, "/rules")

expect(blocks).toHaveLength(2)
expect(blocks).toHaveLength(3)
const [howTo] = blocks
expect(howTo["@type"]).toBe("HowTo")
expect(howTo.url).toBe(`${ORIGIN}/rules`)
Expand Down Expand Up @@ -123,7 +123,7 @@ test("rules is also a FAQPage that says what the FAQ section says", async ({
test("strategy is an Article headed by its H1", async ({ request }) => {
const { html, blocks } = await served(request, "/strategy")

expect(blocks).toHaveLength(2)
expect(blocks).toHaveLength(3)
const [article] = blocks
expect(article["@type"]).toBe("Article")
expect(article.headline).toBe(h1Of(html))
Expand Down Expand Up @@ -154,6 +154,7 @@ test("starting positions is an Article and a FAQPage the page shows", async ({
expect(blocks.map((block) => block["@type"])).toEqual([
"Article",
"FAQPage",
"BreadcrumbList",
])
const [article, faq] = blocks
expect(article.headline).toBe(h1Of(html))
Expand All @@ -169,11 +170,45 @@ test("starting positions is an Article and a FAQPage the page shows", async ({
}
})

test("each guide ends with a trail from home to itself, on its own URL", async ({
request,
}) => {
for (const [path, name] of [
["/rules", "Rules"],
["/strategy", "Strategy"],
["/opening-moves", "Opening moves"],
["/starting-positions", "Starting positions"],
["/pt/rules", "Regras"],
] as const) {
const { html, blocks } = await served(request, path)
const trails = blocks.filter(
(block) => block["@type"] === "BreadcrumbList",
)
expect(trails, path).toHaveLength(1)
const items = trails[0].itemListElement as Json[]
expect(
items.map((item) => item.position),
path,
).toEqual([1, 2])
expect(items[0].item, path).toBe(
path.startsWith("/pt") ? `${ORIGIN}/pt` : `${ORIGIN}/`,
)
expect(items[1].name, path).toBe(name)
expect(items[1].item, path).toBe(`${ORIGIN}${path}`)
// the trail names the page's own canonical
expect(html, path).toContain(`rel="canonical" href="${items[1].item}"`)
}
})

test("the offline game carries none: its setup panel shows options only", async ({
request,
}) => {
const { blocks } = await served(request, "/offline")
expect(blocks).toEqual([])
const home = await served(request, "/")
expect(home.blocks.map((block) => block["@type"])).not.toContain(
"BreadcrumbList",
)
})

test("play online is a FAQPage that says what its FAQ says", async ({
Expand Down
Loading