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
100 changes: 100 additions & 0 deletions e2e/seo-metadata.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <link rel="manifest"> 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 <link rel=manifest> 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 <link rel=manifest> 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");
});
14 changes: 14 additions & 0 deletions src/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<Metadata> {
const { locale } = await params;
return { manifest: `/${locale}/manifest.webmanifest` };
}

export default async function LocaleLayout({
children,
params,
Expand Down
42 changes: 42 additions & 0 deletions src/app/[locale]/manifest.webmanifest/route.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
}
Binary file added src/app/apple-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/app/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
190 changes: 190 additions & 0 deletions src/app/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>(
(node, part) => (node as Record<string, unknown>)?.[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 });
});
});
24 changes: 24 additions & 0 deletions src/app/manifest.ts
Original file line number Diff line number Diff line change
@@ -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<MetadataRoute.Manifest> {
const t = await getTranslations({
locale: fallbackLocale,
namespace: "Public.meta",
});
return buildWebManifest(t("siteTitle"));
}
Loading
Loading