diff --git a/e2e/seo-metadata.spec.ts b/e2e/seo-metadata.spec.ts index 3b11a22..46a4431 100644 --- a/e2e/seo-metadata.spec.ts +++ b/e2e/seo-metadata.spec.ts @@ -1,4 +1,7 @@ import { expect, test } from "@playwright/test"; +import de from "../messages/de.json"; +import en from "../messages/en.json"; +import fr from "../messages/fr.json"; // Bloc 91/E2–E5: the SEO metadata signals — branded titles, per-page // descriptions, Open Graph / Twitter cards, the generated OG image and @@ -225,3 +228,100 @@ test("Bloc 91/F2: an inactive reference still renders but is noindex", async ({ /noindex/, ); }); + +// Bloc 95 (audit SEO Bloc 91/F1): the PWA manifest and its icons, so a player +// can install ML-Helper on a phone's home screen. +// +// Served at …/manifest.webmanifest, NOT …/manifest.json: that is the media +// type's own extension and the route the below points +// at. Browsers follow that link — they never guess a filename — so the +// asserted URL is taken from the tag rather than hardcoded, which is also what +// makes this test notice if the route moves. +test("Bloc 95: the manifest is linked, served, and describes an installable app", async ({ + page, + request, +}) => { + await page.goto("/fr/tools/villes"); + const href = await page.locator('link[rel="manifest"]').getAttribute("href"); + expect(href, "no in the document head").toBeTruthy(); + expect(href).toContain("/manifest.webmanifest"); + + const res = await request.get(href!); + expect(res.status()).toBe(200); + expect(res.headers()["content-type"]).toContain("manifest+json"); + + const manifest = JSON.parse(await res.text()); + expect(manifest.name).toBe(fr.Public.meta.siteTitle); + expect(manifest.short_name).toBe("ML-Helper"); + // standalone is what drops the browser address bar once installed. + expect(manifest.display).toBe("standalone"); + expect(manifest.start_url).toBe("/"); + expect(manifest.theme_color).toBe("#8b6bb8"); + expect(manifest.background_color).toBe("#1b2029"); + expect(manifest.icons).toHaveLength(2); +}); + +// Codex review (PR #120): the name in that manifest is what the install prompt +// shows, so it follows the language the visitor is reading — each locale links +// its own manifest instead of all five sharing one French document. +test("Bloc 95: each locale links a manifest naming the app in its own language", async ({ + page, + request, +}) => { + const names: string[] = []; + for (const [locale, messages] of [ + ["en", en], + ["de", de], + ] as const) { + await page.goto(`/${locale}/tools/villes`); + const href = await page + .locator('link[rel="manifest"]') + .getAttribute("href"); + expect(href, `no on the ${locale} page`).toBe( + `/${locale}/manifest.webmanifest`, + ); + + const res = await request.get(href!); + expect(res.status()).toBe(200); + const manifest = JSON.parse(await res.text()); + expect(manifest.name).toBe(messages.Public.meta.siteTitle); + names.push(manifest.name); + } + // The whole point: three locales, three different names in the prompt. + expect(new Set([...names, fr.Public.meta.siteTitle]).size).toBe(3); +}); + +test("Bloc 95: both manifest icons are served at the dimensions they declare", async ({ + request, +}) => { + const res = await request.get("/manifest.webmanifest"); + const { icons } = JSON.parse(await res.text()); + + for (const icon of icons) { + const file = await request.get(icon.src); + expect(file.status(), `${icon.src} is not served`).toBe(200); + expect(file.headers()["content-type"]).toContain("image/png"); + + // Width and height straight from the PNG IHDR chunk, so this compares the + // bytes actually served against what the manifest promises rather than + // trusting the declaration on both sides. + const bytes = await file.body(); + expect(bytes.subarray(1, 4).toString("ascii")).toBe("PNG"); + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + expect(`${width}x${height}`, `${icon.src} has the wrong size`).toBe( + icon.sizes, + ); + } +}); + +test("Bloc 95: the Apple touch icon is exposed for iOS home screens", async ({ + page, +}) => { + await page.goto("/fr/tools/villes"); + // iOS ignores the manifest icons for the home screen and uses this tag, + // which the src/app/apple-icon.png file convention emits. + const appleIcon = page.locator('link[rel="apple-touch-icon"]'); + await expect(appleIcon).toHaveCount(1); + expect(await appleIcon.getAttribute("href")).toContain("/apple-icon.png"); +}); diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index d2df4d1..44e0e99 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import type { ReactNode } from "react"; import { headers } from "next/headers"; import { notFound, redirect } from "next/navigation"; @@ -13,6 +14,19 @@ export function generateStaticParams() { return routing.locales.map((locale) => ({ locale })); } +// Bloc 95, Codex review (PR #120): point this language's pages at this +// language's manifest, so the name Chrome shows in the install prompt is the +// one the visitor is reading. Without this every locale would inherit the root +// file convention's single /manifest.webmanifest, which can only carry one +// language. Only `manifest` is set here — every other metadata field still +// comes from the root layout and each page's own generateMetadata. +export async function generateMetadata({ + params, +}: LayoutProps<"/[locale]">): Promise { + const { locale } = await params; + return { manifest: `/${locale}/manifest.webmanifest` }; +} + export default async function LocaleLayout({ children, params, diff --git a/src/app/[locale]/manifest.webmanifest/route.ts b/src/app/[locale]/manifest.webmanifest/route.ts new file mode 100644 index 0000000..befba11 --- /dev/null +++ b/src/app/[locale]/manifest.webmanifest/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { notFound } from "next/navigation"; +import { hasLocale } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { routing } from "@/i18n/routing"; +import { buildWebManifest } from "@/lib/web-manifest"; + +// Bloc 95, Codex review (PR #120): the manifest of the language the visitor is +// actually reading. The `app/manifest` file convention only exists at the app +// root and produces a single static document, so a translated name needs a +// route of its own under /[locale]/ — /fr/manifest.webmanifest, +// /en/manifest.webmanifest, … Each locale's public pages point at theirs (see +// generateMetadata in src/app/[locale]/layout.tsx), so the name Android and +// Chrome show in the install prompt is in the visitor's language rather than +// the one frozen into a shared file. +// +// Everything except the name is identical across locales and comes from +// buildWebManifest, so the two entry points can't drift apart. + +// Prerenders the 5 manifests at build time, like the locale layout does for +// the pages themselves. +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} + +export async function GET( + _request: Request, + { params }: RouteContext<"/[locale]/manifest.webmanifest">, +) { + const { locale } = await params; + // Unknown segment (not one of the 5 launched locales) → 404, same as the + // locale layout: a manifest for a language that doesn't exist would name the + // app in whatever the fallback happened to be. + if (!hasLocale(routing.locales, locale)) notFound(); + + const t = await getTranslations({ locale, namespace: "Public.meta" }); + return NextResponse.json(buildWebManifest(t("siteTitle")), { + // The manifest's own media type; browsers accept application/json too, but + // this is what the spec asks for and what the root file convention emits. + headers: { "content-type": "application/manifest+json" }, + }); +} diff --git a/src/app/apple-icon.png b/src/app/apple-icon.png new file mode 100644 index 0000000..96ebc04 Binary files /dev/null and b/src/app/apple-icon.png differ diff --git a/src/app/icon.png b/src/app/icon.png new file mode 100644 index 0000000..004a616 Binary files /dev/null and b/src/app/icon.png differ diff --git a/src/app/manifest.test.ts b/src/app/manifest.test.ts new file mode 100644 index 0000000..fd14311 --- /dev/null +++ b/src/app/manifest.test.ts @@ -0,0 +1,190 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +// The translator the manifest routes use, backed by the real messages/*.json +// files (through the same English-fallback merge the app uses at runtime), so +// these tests compare the served name against the actual translations rather +// than against a stub — a manifest that stopped following next-intl would show +// up here. +vi.mock("next-intl/server", () => ({ + getTranslations: async ({ + locale, + namespace, + }: { + locale: string; + namespace: string; + }) => { + const { getMessagesForLocale } = await import("@/i18n/config"); + const messages = await getMessagesForLocale(locale); + return (key: string) => + [...namespace.split("."), key].reduce( + (node, part) => (node as Record)?.[part], + messages, + ) as string; + }, +})); + +const { default: manifest } = await import("./manifest"); +const { GET, generateStaticParams } = + await import("./[locale]/manifest.webmanifest/route"); + +const globalsCss = readFileSync(path.join(__dirname, "globals.css"), "utf8"); + +/** The site name as it is really written in one locale's message file. */ +function siteTitle(locale: string): string { + const messages = JSON.parse( + readFileSync( + path.join(process.cwd(), "messages", `${locale}.json`), + "utf8", + ), + ); + return messages.Public.meta.siteTitle; +} + +/** Fetches one locale's manifest through its route handler. */ +async function localeManifest(locale: string) { + const response = await GET( + new Request(`http://x/${locale}/manifest.webmanifest`), + { + params: Promise.resolve({ locale }), + }, + ); + return { response, data: await response.json() }; +} + +// The dark theme's token block — the default the site renders in, and so the +// palette an installed app's splash screen and browser chrome should match. +const darkBlock = globalsCss.slice( + globalsCss.indexOf(":root,"), + globalsCss.indexOf(':root[data-theme="light"]'), +); + +/** Resolves a token to a hex, following one level of `var(--other)` aliasing. */ +function token(name: string): string { + const raw = new RegExp(`--${name}:\\s*([^;]+);`).exec(darkBlock); + if (!raw) throw new Error(`--${name} not found in the dark theme block`); + const value = raw[1].trim(); + const alias = /^var\(--([\w-]+)\)$/.exec(value); + return alias ? token(alias[1]) : value; +} + +/** Width and height straight from the PNG IHDR chunk (bytes 16-24). */ +function pngSize(file: string): { width: number; height: number } { + const buffer = readFileSync(path.join(__dirname, file)); + expect(buffer.subarray(1, 4).toString("ascii")).toBe("PNG"); + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20), + }; +} + +// Bloc 95 (audit SEO Bloc 91/F1): the manifest that makes ML-Helper +// installable on a phone's home screen. +const data = await manifest(); + +describe("web app manifest", () => { + it("names the app both in full and in the short form shown under the icon", async () => { + // Codex review (PR #120): the full name is user-visible text, so it comes + // from next-intl like everything else — this root document is the one the + // non-prefixed routes (/admin, /login) get, in the fallback language. + expect(data.name).toBe(siteTitle("en")); + expect(data.short_name).toBe("ML-Helper"); + // The short name is what a home screen actually has room for. + expect(data.short_name!.length).toBeLessThanOrEqual(12); + }); + + it("opens as an app, from the site root", () => { + expect(data.display).toBe("standalone"); + // "/" and not "/fr": src/proxy.ts redirects the bare root to the visitor's + // own language, so the installed app is not frozen to one locale. + expect(data.start_url).toBe("/"); + expect(data.id).toBe("/"); + }); + + // Guards the reason these two values exist: they are the site's own tokens. + // A palette change that left this file behind would show a mismatched splash + // screen before the first paint, which is exactly what nobody would notice. + it("takes its colours from the site's dark theme, not from new values", () => { + expect(data.background_color).toBe(token("bg")); + expect(data.theme_color).toBe(token("accent")); + }); + + it("lists both icons with the size and MIME type each file really has", () => { + const icons = data.icons ?? []; + expect(icons).toHaveLength(2); + + for (const icon of icons) { + expect(icon.type).toBe("image/png"); + // The declared size must match the file on disk: a manifest that lies + // about its icons gets them rejected or rendered blurry. + const file = icon.src!.replace(/^\//, ""); + const { width, height } = pngSize(file); + expect(`${width}x${height}`).toBe(icon.sizes); + expect(width).toBe(height); + } + + expect(icons.map((icon) => icon.src)).toEqual([ + "/icon.png", + "/apple-icon.png", + ]); + expect(icons.map((icon) => icon.sizes)).toEqual(["192x192", "512x512"]); + }); + + it("does not claim maskable, which would clip the shield", () => { + // The artwork spans ~83% of the square, wider than the 80% safe zone a + // maskable icon is cropped to. Declaring it would cut the shield's edges. + for (const icon of data.icons ?? []) + expect(icon.purpose ?? "any").toBe("any"); + }); +}); + +// Codex review (PR #120): a single manifest can only name the app in one +// language, and the name is what the install prompt shows. Each locale serves +// its own. +describe("per-locale manifest route", () => { + const locales = ["fr", "en", "de", "es", "tr"]; + + it("prerenders one manifest per launched locale", () => { + expect(generateStaticParams().map((entry) => entry.locale)).toEqual( + expect.arrayContaining(locales), + ); + }); + + it.each(locales)( + "names the app in %s, with the manifest media type", + async (locale) => { + const { response, data } = await localeManifest(locale); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("manifest+json"); + expect(data.name).toBe(siteTitle(locale)); + }, + ); + + it("really does serve five different names", async () => { + const names = await Promise.all( + locales.map(async (locale) => (await localeManifest(locale)).data.name), + ); + // The point of the route: one hardcoded name for all 5 would pass every + // assertion above that only checks shape. + expect(new Set(names).size).toBe(locales.length); + }); + + it("changes nothing but the name", async () => { + const { data: fr } = await localeManifest("fr"); + const root = await manifest(); + expect({ ...fr, name: null }).toEqual({ ...root, name: null }); + }); + + it("404s on a locale the site does not have", async () => { + // notFound() throws Next's NEXT_HTTP_ERROR_FALLBACK;404 signal. + await expect(localeManifest("it")).rejects.toThrow(); + }); +}); + +describe("installable icon files", () => { + it("ships the two Next.js file-convention icons at their intended sizes", () => { + expect(pngSize("icon.png")).toEqual({ width: 192, height: 192 }); + expect(pngSize("apple-icon.png")).toEqual({ width: 512, height: 512 }); + }); +}); diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..7b2f84d --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,24 @@ +import type { MetadataRoute } from "next"; +import { getTranslations } from "next-intl/server"; +import { fallbackLocale } from "@/i18n/config"; +import { buildWebManifest } from "@/lib/web-manifest"; + +// Bloc 95 (audit SEO Bloc 91/F1): the web app manifest, so players can install +// ML-Helper on a phone's home screen and open it without browser chrome. +// +// Codex review (PR #120): the installed app's name is text a user reads, so it +// goes through next-intl like every other visible string (AGENTS.md) — it +// reuses Public.meta.siteTitle, the site's own name, already translated in the +// 5 locales. A single file-convention manifest is one static document and +// cannot vary per visitor, so this one carries the English wording (the site's +// documented fallback) and covers the routes that are not locale-prefixed +// (/admin, /login). Every public page links to its own language's manifest +// instead — src/app/[locale]/manifest.webmanifest/route.ts, wired up by the +// locale layout's generateMetadata. +export default async function manifest(): Promise { + const t = await getTranslations({ + locale: fallbackLocale, + namespace: "Public.meta", + }); + return buildWebManifest(t("siteTitle")); +} diff --git a/src/lib/web-manifest.ts b/src/lib/web-manifest.ts new file mode 100644 index 0000000..3d9bd71 --- /dev/null +++ b/src/lib/web-manifest.ts @@ -0,0 +1,46 @@ +import type { MetadataRoute } from "next"; + +// Bloc 95 (audit SEO Bloc 91/F1): everything in the web app manifest that is +// the same in all 5 languages. Only the name is translated, so it is the one +// thing this builder takes as an argument — see src/app/manifest.ts (the +// English fallback served at /manifest.webmanifest) and +// src/app/[locale]/manifest.webmanifest/route.ts (the per-locale one every +// public page actually links to). +// +// Colours are the site's own tokens, not new values: --bg (#1b2029) is the +// dark theme's page background, so the splash screen matches what renders +// right after it; --accent (#8b6bb8, the violet) tints the browser UI. Both +// are asserted against globals.css by manifest.test.ts, so a palette change +// can't silently leave this file behind. +const themeColor = "#8b6bb8"; +const backgroundColor = "#1b2029"; + +export function buildWebManifest(name: string): MetadataRoute.Manifest { + return { + id: "/", + // start_url is "/" rather than a locale-prefixed path: src/proxy.ts + // redirects the bare root to the visitor's own language, so an installed + // app opens in the locale they actually use instead of one frozen at + // install time. + start_url: "/", + name, + // The short name (what fits under a home screen icon) is the brand alone, + // identical in every language — nothing to translate. + short_name: "ML-Helper", + display: "standalone", + theme_color: themeColor, + background_color: backgroundColor, + icons: [ + // Both files are Next.js file-convention icons (src/app/icon.png and + // src/app/apple-icon.png), which is what also emits the + // and tags. Listing them here is what makes + // them available to the install prompt itself. + // + // purpose is left at the default ("any") deliberately: the shield spans + // ~83% of the square, wider than a maskable icon's 80% safe zone, so + // declaring "maskable" would have Android clip its edges. + { src: "/icon.png", sizes: "192x192", type: "image/png" }, + { src: "/apple-icon.png", sizes: "512x512", type: "image/png" }, + ], + }; +}