diff --git a/frontend/app/core/lib/seo-meta.test.ts b/frontend/app/core/lib/seo-meta.test.ts new file mode 100644 index 0000000..ce427ff --- /dev/null +++ b/frontend/app/core/lib/seo-meta.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest" +import { registerTranslations } from "./i18n" +import { + getServerDocumentMeta, + resolveMetaLanguage, + translateMeta, +} from "./seo-meta" +import type { Server } from "./api" + +registerTranslations("fr", { + seo: { + homeTitle: "Accueil", + homeDescription: "Description", + notFoundTitle: "Serveur introuvable", + notFoundDescription: "Serveur non trouvé", + serverTitleSuffix: "Statistiques | MS", + serverDescription: "Voir {{name}} ({{ip}}).", + statusOnline: "En ligne : {{count}}.", + statusOffline: "Hors ligne.", + }, +}) + +const onlineServer = { + id: 1, + name: "Hypixel", + ip: "mc.hypixel.net", + last_status: "online", + last_connected: 50000, +} as unknown as Server + +const offlineServer = { + ...onlineServer, + last_status: "offline", +} as unknown as Server + +describe("translateMeta", () => { + it("never leaks the raw key when the dictionary is missing", () => { + expect(translateMeta("ja", "seo.homeTitle")).toBe("Accueil") + }) + + it("returns an empty string when the key exists nowhere", () => { + expect(translateMeta("fr", "seo.nope")).toBe("") + expect(translateMeta("ja", "seo.nope")).toBe("") + }) +}) + +describe("resolveMetaLanguage", () => { + it("reads serverLanguage from the root match loaderData", () => { + expect( + resolveMetaLanguage([ + { id: "root", loaderData: { serverLanguage: "ja" } }, + { id: "other", loaderData: {} }, + ]) + ).toBe("ja") + }) + + it("falls back to fr when the root match is absent or invalid", () => { + expect(resolveMetaLanguage(undefined)).toBe("fr") + expect(resolveMetaLanguage([])).toBe("fr") + expect( + resolveMetaLanguage([{ id: "root", loaderData: { serverLanguage: "xx" } }]) + ).toBe("fr") + }) +}) + +describe("getServerDocumentMeta", () => { + it("returns the not-found strings when the server is null", () => { + expect(getServerDocumentMeta(null, "fr")).toEqual({ + title: "Serveur introuvable", + description: "Serveur non trouvé", + }) + }) + + it("returns the server title and stats for an online server", () => { + const meta = getServerDocumentMeta(onlineServer, "fr") + expect(meta.title).toBe("Hypixel - Statistiques | MS") + expect(meta.description).toContain("Hypixel") + expect(meta.description).toContain("mc.hypixel.net") + }) + + it("returns the offline status for an offline server", () => { + const meta = getServerDocumentMeta(offlineServer, "fr") + expect(meta.title).toBe("Hypixel - Statistiques | MS") + expect(meta.description).toContain("Hors ligne.") + }) + + it("falls back to fr instead of leaking keys", () => { + const meta = getServerDocumentMeta(onlineServer, "ja") + expect(meta.title).toBe("Hypixel - Statistiques | MS") + expect(meta.title).not.toContain("seo.") + }) +}) diff --git a/frontend/app/core/lib/seo-meta.ts b/frontend/app/core/lib/seo-meta.ts index 23f79f5..349177e 100644 --- a/frontend/app/core/lib/seo-meta.ts +++ b/frontend/app/core/lib/seo-meta.ts @@ -1,5 +1,7 @@ import { translate, type Language } from "@/core/lib/i18n"; import { APP_URL } from "@/core/lib/config"; +import { formatNumber } from "./utils"; +import type { Server } from "@/core/lib/api"; export const DEFAULT_LANGUAGE: Language = "fr"; @@ -46,6 +48,7 @@ export interface DocumentMeta { export interface RouteMetaHandle { getDocumentMeta?: (data: unknown, lang: Language) => DocumentMeta | null; + syncLiveTitle?: boolean; } export function applyDocumentMeta(meta: DocumentMeta): void { @@ -57,6 +60,25 @@ export function applyDocumentMeta(meta: DocumentMeta): void { document.head.querySelector('meta[name="twitter:description"]')?.setAttribute("content", meta.description); } +export function getServerDocumentMeta(server: Server | null, lang: Language): DocumentMeta { + if (!server) { + return { + title: translateMeta(lang, "seo.notFoundTitle"), + description: translateMeta(lang, "seo.notFoundDescription"), + }; + } + const title = `${server.name} - ${translateMeta(lang, "seo.serverTitleSuffix")}`; + const isOnline = server.last_status === "online"; + const players = isOnline ? formatNumber(lang, server.last_connected ?? 0) : "0"; + const playersText = isOnline + ? ` ${translateMeta(lang, "seo.statusOnline", { count: players })}` + : server.last_status === "offline" + ? ` ${translateMeta(lang, "seo.statusOffline")}` + : ""; + const description = `${translateMeta(lang, "seo.serverDescription", { name: server.name, ip: server.ip })}${playersText}`; + return { title, description }; +} + export function truncateDescription(text: string, max = 157): string { if (text.length <= max) return text; const cut = text.slice(0, max); diff --git a/frontend/app/pages/server-detail/index.tsx b/frontend/app/pages/server-detail/index.tsx index 556a0fe..16a9d55 100644 --- a/frontend/app/pages/server-detail/index.tsx +++ b/frontend/app/pages/server-detail/index.tsx @@ -1,10 +1,10 @@ -import { useMemo, lazy, Suspense } from "react" +import { useEffect, useMemo, lazy, Suspense } from "react" import { Link, useLoaderData, useRouteError } from "react-router" import type { LoaderFunctionArgs, MetaFunction } from "react-router" import { fetchServer, getServerIconUrl } from "@/core/lib/api" import type { Server } from "@/core/lib/api" import { APP_URL } from "@/core/lib/config" -import { translateMeta, type DocumentMeta, type Language, type RouteMetaHandle } from "@/core/lib/seo-meta" +import { applyDocumentMeta, getServerDocumentMeta, type Language, type RouteMetaHandle } from "@/core/lib/seo-meta" const PlayerChart = lazy(() => import("@/pages/server-detail/components/PlayerChart").then((m) => ({ @@ -82,28 +82,6 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { } } -function getServerDocumentMeta(server: Server | null, lang: Language): DocumentMeta { - const L = (path: string, vars?: Record) => translateMeta(lang, path, vars) - if (!server) { - return { - title: L("seo.notFoundTitle"), - description: L("seo.notFoundDescription"), - } - } - const title = `${server.name} - ${L("seo.serverTitleSuffix")}` - const isOnline = server.last_status === "online" - const players = isOnline - ? formatNumber(lang, server.last_connected ?? 0) - : "0" - const playersText = isOnline - ? ` ${L("seo.statusOnline", { count: players })}` - : server.last_status === "offline" - ? ` ${L("seo.statusOffline")}` - : "" - const description = `${L("seo.serverDescription", { name: server.name, ip: server.ip })}${playersText}` - return { title, description } -} - export const meta: MetaFunction = (args) => { const { loaderData: data } = args @@ -146,6 +124,7 @@ export const meta: MetaFunction = (args) => { } export const handle: RouteMetaHandle = { + syncLiveTitle: true, getDocumentMeta: (data: unknown, lang: Language) => { const server = (data as { initialServer?: Server | null } | undefined)?.initialServer ?? null @@ -172,6 +151,10 @@ export default function ServerDetail() { const TIME_RANGES = useMemo(() => getTimeRanges(t), [t]) const INTERVALS = useMemo(() => getIntervals(t), [t]) + useEffect(() => { + if (server) applyDocumentMeta(getServerDocumentMeta(server, language)) + }, [server, language]) + // Données structurées rendues dans le JSX (donc présentes dans le HTML SSR), // à partir des données du loader pour que SSR et client soient identiques. // Remplace l'ancienne injection via useEffect, invisible aux crawlers sans JS. diff --git a/frontend/app/ui/layout/DocumentMetaSync.tsx b/frontend/app/ui/layout/DocumentMetaSync.tsx index 208c9a3..0e48f1d 100644 --- a/frontend/app/ui/layout/DocumentMetaSync.tsx +++ b/frontend/app/ui/layout/DocumentMetaSync.tsx @@ -16,7 +16,9 @@ export function DocumentMetaSync() { "function" ) if (!leaf) return - const meta = (leaf.handle as RouteMetaHandle).getDocumentMeta?.(leaf.data, language) + const handle = leaf.handle as RouteMetaHandle + if (handle.syncLiveTitle) return + const meta = handle.getDocumentMeta?.(leaf.data, language) if (meta) applyDocumentMeta(meta) }, [language, matches])