From f7033fc4ce458f081b1cb7da49bf073628c72a3b Mon Sep 17 00:00:00 2001 From: Flash303 Date: Sun, 11 Oct 2026 13:17:23 +0200 Subject: [PATCH] feat: refresh document title on language switch --- frontend/app/core/lib/seo-meta.ts | 38 +++++++-------- frontend/app/pages/LegalNotice.tsx | 16 +++++-- frontend/app/pages/Privacy.tsx | 16 +++++-- frontend/app/pages/Terms.tsx | 16 +++++-- frontend/app/pages/compare/index.tsx | 18 +++++-- frontend/app/pages/home/index.tsx | 19 +++++--- frontend/app/pages/server-detail/index.tsx | 53 ++++++++++++++------- frontend/app/ui/layout/DocumentMetaSync.tsx | 24 ++++++++++ frontend/app/ui/layout/index.tsx | 2 + 9 files changed, 142 insertions(+), 60 deletions(-) create mode 100644 frontend/app/ui/layout/DocumentMetaSync.tsx diff --git a/frontend/app/core/lib/seo-meta.ts b/frontend/app/core/lib/seo-meta.ts index ea62ebd..23f79f5 100644 --- a/frontend/app/core/lib/seo-meta.ts +++ b/frontend/app/core/lib/seo-meta.ts @@ -11,15 +11,6 @@ function isSupportedLanguage(lang: unknown): lang is Language { return typeof lang === "string" && SUPPORTED_LANGUAGES.has(lang); } -/** - * Langue résolue côté serveur par le loader root (cookie puis Accept-Language). - * Utilisée par les meta() SSR pour rendre title/description dans la bonne langue. - * - * Attention : dans les meta(), les matches React Router exposent les données - * sous `loaderData` (pas `data`). Lire `.data` renvoie toujours `undefined` et - * force le fallback français — puis des clés i18n brutes côté client quand le - * dictionnaire français n'y est pas chargé. - */ export function resolveMetaLanguage( matches: Array<{ id?: string; loaderData?: unknown }> | undefined ): Language { @@ -30,11 +21,6 @@ export function resolveMetaLanguage( return isSupportedLanguage(lang) ? lang : DEFAULT_LANGUAGE; } -/** - * Traduction sûre pour les balises SEO : ne renvoie JAMAIS la clé brute. - * Chaîne de fallback : langue demandée → langue par défaut → chaîne vide. - * Une clé i18n dans un serait indexée telle quelle par Google. - */ export function translateMeta( lang: Language, path: string, @@ -49,12 +35,28 @@ export function translateMeta( return ""; } -/** Titre suffixé du nom du site, ex. "Comparaison de serveurs | Minecraft-Stats". */ export function siteTitle(lang: Language, titleKey: string): string { return `${translateMeta(lang, titleKey)} | Minecraft-Stats`; } -/** Tronque une description au-delà de `max` caractères, sans couper en plein mot. */ +export interface DocumentMeta { + title: string; + description: string; +} + +export interface RouteMetaHandle { + getDocumentMeta?: (data: unknown, lang: Language) => DocumentMeta | null; +} + +export function applyDocumentMeta(meta: DocumentMeta): void { + document.title = meta.title; + document.head.querySelector('meta[name="description"]')?.setAttribute("content", meta.description); + document.head.querySelector('meta[property="og:title"]')?.setAttribute("content", meta.title); + document.head.querySelector('meta[property="og:description"]')?.setAttribute("content", meta.description); + document.head.querySelector('meta[name="twitter:title"]')?.setAttribute("content", meta.title); + document.head.querySelector('meta[name="twitter:description"]')?.setAttribute("content", meta.description); +} + export function truncateDescription(text: string, max = 157): string { if (text.length <= max) return text; const cut = text.slice(0, max); @@ -62,14 +64,10 @@ export function truncateDescription(text: string, max = 157): string { return `${(lastSpace > 100 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`; } -/** Balise canonical vers l'URL officielle de la page (lutte contre le duplicate content). */ export function canonicalLink(path: string) { return { tagName: "link", rel: "canonical", href: `${APP_URL}${path}` } as const; } -/** Balises title/description + Open Graph + Twitter pour une page statique. - * Jeu complet et autosuffisant : le meta() de la route feuille remplace celui - * du root, donc chaque page doit déclarer tous ses tags elle-même. */ export function staticPageMeta(opts: { title: string; description: string; path: string }) { const url = `${APP_URL}${opts.path}`; const image = `${APP_URL}/opengraph.webp`; diff --git a/frontend/app/pages/LegalNotice.tsx b/frontend/app/pages/LegalNotice.tsx index 477eed6..2c017c3 100644 --- a/frontend/app/pages/LegalNotice.tsx +++ b/frontend/app/pages/LegalNotice.tsx @@ -1,16 +1,24 @@ import type { MetaFunction } from "react-router" +import type { Language } from "@/core/lib/i18n" import { useLanguage } from "@/core/contexts/LanguageContext" import { legalReplacements } from "@/core/lib/legal-info" -import { resolveMetaLanguage, siteTitle, staticPageMeta, truncateDescription, translateMeta } from "@/core/lib/seo-meta" +import { resolveMetaLanguage, siteTitle, staticPageMeta, truncateDescription, translateMeta, type DocumentMeta, type RouteMetaHandle } from "@/core/lib/seo-meta" export const meta: MetaFunction = ({ matches }) => { const lang = resolveMetaLanguage(matches) - return staticPageMeta({ + return staticPageMeta({ ...noticeMetaStrings(lang), path: "/legal" }) +} + +function noticeMetaStrings(lang: Language): DocumentMeta { + return { title: siteTitle(lang, "legal.notice.title"), description: truncateDescription(translateMeta(lang, "legal.notice.p1", legalReplacements)), - path: "/legal", - }) + } +} + +export const handle: RouteMetaHandle = { + getDocumentMeta: (_data: unknown, lang: Language) => noticeMetaStrings(lang), } const SECTIONS = [1, 2, 3, 4, 5, 6, 7] as const diff --git a/frontend/app/pages/Privacy.tsx b/frontend/app/pages/Privacy.tsx index 43a2883..67180da 100644 --- a/frontend/app/pages/Privacy.tsx +++ b/frontend/app/pages/Privacy.tsx @@ -1,16 +1,24 @@ import type { MetaFunction } from "react-router" +import type { Language } from "@/core/lib/i18n" import { useLanguage } from "@/core/contexts/LanguageContext" import { legalReplacements } from "@/core/lib/legal-info" -import { resolveMetaLanguage, siteTitle, staticPageMeta, truncateDescription, translateMeta } from "@/core/lib/seo-meta" +import { resolveMetaLanguage, siteTitle, staticPageMeta, truncateDescription, translateMeta, type DocumentMeta, type RouteMetaHandle } from "@/core/lib/seo-meta" export const meta: MetaFunction = ({ matches }) => { const lang = resolveMetaLanguage(matches) - return staticPageMeta({ + return staticPageMeta({ ...privacyMetaStrings(lang), path: "/privacy" }) +} + +function privacyMetaStrings(lang: Language): DocumentMeta { + return { title: siteTitle(lang, "legal.privacy.title"), description: truncateDescription(translateMeta(lang, "legal.privacy.p1", legalReplacements)), - path: "/privacy", - }) + } +} + +export const handle: RouteMetaHandle = { + getDocumentMeta: (_data: unknown, lang: Language) => privacyMetaStrings(lang), } const SECTIONS = [1, 2, 3, 4, 5, 6, 7] as const diff --git a/frontend/app/pages/Terms.tsx b/frontend/app/pages/Terms.tsx index 1350a4d..1e86566 100644 --- a/frontend/app/pages/Terms.tsx +++ b/frontend/app/pages/Terms.tsx @@ -1,16 +1,24 @@ import type { MetaFunction } from "react-router" +import type { Language } from "@/core/lib/i18n" import { useLanguage } from "@/core/contexts/LanguageContext" import { legalReplacements } from "@/core/lib/legal-info" -import { resolveMetaLanguage, siteTitle, staticPageMeta, truncateDescription, translateMeta } from "@/core/lib/seo-meta" +import { resolveMetaLanguage, siteTitle, staticPageMeta, truncateDescription, translateMeta, type DocumentMeta, type RouteMetaHandle } from "@/core/lib/seo-meta" export const meta: MetaFunction = ({ matches }) => { const lang = resolveMetaLanguage(matches) - return staticPageMeta({ + return staticPageMeta({ ...termsMetaStrings(lang), path: "/terms" }) +} + +function termsMetaStrings(lang: Language): DocumentMeta { + return { title: siteTitle(lang, "legal.terms.title"), description: truncateDescription(translateMeta(lang, "legal.terms.p1")), - path: "/terms", - }) + } +} + +export const handle: RouteMetaHandle = { + getDocumentMeta: (_data: unknown, lang: Language) => termsMetaStrings(lang), } const SECTIONS = [1, 2, 3, 4, 5, 6, 7] as const diff --git a/frontend/app/pages/compare/index.tsx b/frontend/app/pages/compare/index.tsx index 2f43006..2a67149 100644 --- a/frontend/app/pages/compare/index.tsx +++ b/frontend/app/pages/compare/index.tsx @@ -8,13 +8,23 @@ import { TimeIntervalSelector } from "@/ui/components/TimeIntervalSelector" import { SelectedServersTags } from "@/pages/compare/components/SelectedServersTags" import { useCompareRecords } from "@/pages/compare/hooks/useCompareRecords" import type { MetaFunction } from "react-router" -import { resolveMetaLanguage, siteTitle, staticPageMeta, translateMeta } from "@/core/lib/seo-meta" +import type { Language } from "@/core/lib/i18n" +import { resolveMetaLanguage, siteTitle, staticPageMeta, translateMeta, type DocumentMeta, type RouteMetaHandle } from "@/core/lib/seo-meta" export const meta: MetaFunction = ({ matches }) => { const lang = resolveMetaLanguage(matches) - const title = siteTitle(lang, "comparison.title") - const description = translateMeta(lang, "comparison.noSelectionDesc") - return staticPageMeta({ title, description, path: "/compare" }) + return staticPageMeta({ ...compareMetaStrings(lang), path: "/compare" }) +} + +function compareMetaStrings(lang: Language): DocumentMeta { + return { + title: siteTitle(lang, "comparison.title"), + description: translateMeta(lang, "comparison.noSelectionDesc"), + } +} + +export const handle: RouteMetaHandle = { + getDocumentMeta: (_data: unknown, lang: Language) => compareMetaStrings(lang), } export default function ServerComparison() { diff --git a/frontend/app/pages/home/index.tsx b/frontend/app/pages/home/index.tsx index e0d76b3..9b2310e 100644 --- a/frontend/app/pages/home/index.tsx +++ b/frontend/app/pages/home/index.tsx @@ -21,18 +21,23 @@ import { FaqSection } from "@/pages/home/components/FaqSection" import { Pagination } from "@/ui/components/pagination" import type { LoaderFunctionArgs, MetaFunction } from "react-router" -import { resolveMetaLanguage, staticPageMeta, translateMeta } from "@/core/lib/seo-meta" +import type { Language } from "@/core/lib/i18n" +import { resolveMetaLanguage, staticPageMeta, translateMeta, type DocumentMeta, type RouteMetaHandle } from "@/core/lib/seo-meta" -// La home est la seule page avec des variantes filtrées/paginées (?tab=&page=...) : -// le canonical pointe toujours vers l'URL clean pour consolider l'indexation. -// Jeu de tags complet : le meta() feuille remplace celui du root. export const meta: MetaFunction = ({ matches }) => { const lang = resolveMetaLanguage(matches) - return staticPageMeta({ + return staticPageMeta({ ...homeMetaStrings(lang), path: "/" }) +} + +function homeMetaStrings(lang: Language): DocumentMeta { + return { title: translateMeta(lang, "seo.homeTitle"), description: translateMeta(lang, "seo.homeDescription"), - path: "/", - }) + } +} + +export const handle: RouteMetaHandle = { + getDocumentMeta: (_data: unknown, lang: Language) => homeMetaStrings(lang), } // Réduit chaque serveur à ce dont la home a réellement besoin avant sérialisation diff --git a/frontend/app/pages/server-detail/index.tsx b/frontend/app/pages/server-detail/index.tsx index d6a58ba..556a0fe 100644 --- a/frontend/app/pages/server-detail/index.tsx +++ b/frontend/app/pages/server-detail/index.tsx @@ -2,8 +2,9 @@ import { 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 Language } from "@/core/lib/seo-meta" +import { translateMeta, type DocumentMeta, type Language, type RouteMetaHandle } from "@/core/lib/seo-meta" const PlayerChart = lazy(() => import("@/pages/server-detail/components/PlayerChart").then((m) => ({ @@ -81,36 +82,46 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { } } +function getServerDocumentMeta(server: Server | null, lang: Language): DocumentMeta { + const L = (path: string, vars?: Record<string, string>) => 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<typeof loader> = (args) => { const { loaderData: data } = args - // Langue résolue par le loader root (cookie ou Accept-Language). - // Les matches React Router exposent les données sous `loaderData` (pas `data`). const rootData = args.matches?.find((m) => m.id === "root")?.loaderData as | { serverLanguage?: Language } | undefined const lang: Language = rootData?.serverLanguage ?? "fr" - const L = (path: string, vars?: Record<string, string>) => translateMeta(lang, path, vars) if (!data || !data.initialServer) { + const { title, description } = getServerDocumentMeta(null, lang) return [ - { title: L("seo.notFoundTitle") }, - { name: "description", content: L("seo.notFoundDescription") }, + { title }, + { name: "description", content: description }, { name: "robots", content: "noindex" } ] } const server = data.initialServer - 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}` + const { title, description } = getServerDocumentMeta(server, lang) return [ { title }, { name: "description", content: description }, @@ -134,6 +145,14 @@ export const meta: MetaFunction<typeof loader> = (args) => { ] } +export const handle: RouteMetaHandle = { + getDocumentMeta: (data: unknown, lang: Language) => { + const server = + (data as { initialServer?: Server | null } | undefined)?.initialServer ?? null + return getServerDocumentMeta(server, lang) + }, +} + export default function ServerDetail() { const { t, language } = useLanguage() diff --git a/frontend/app/ui/layout/DocumentMetaSync.tsx b/frontend/app/ui/layout/DocumentMetaSync.tsx new file mode 100644 index 0000000..208c9a3 --- /dev/null +++ b/frontend/app/ui/layout/DocumentMetaSync.tsx @@ -0,0 +1,24 @@ +import { useEffect } from "react" +import { useMatches } from "react-router" +import { useLanguage } from "@/core/contexts/LanguageContext" +import { applyDocumentMeta, type RouteMetaHandle } from "@/core/lib/seo-meta" + +export function DocumentMetaSync() { + const { language } = useLanguage() + const matches = useMatches() + + useEffect(() => { + const leaf = [...matches] + .reverse() + .find( + (m) => + typeof (m.handle as RouteMetaHandle | undefined)?.getDocumentMeta === + "function" + ) + if (!leaf) return + const meta = (leaf.handle as RouteMetaHandle).getDocumentMeta?.(leaf.data, language) + if (meta) applyDocumentMeta(meta) + }, [language, matches]) + + return null +} diff --git a/frontend/app/ui/layout/index.tsx b/frontend/app/ui/layout/index.tsx index 3ebe171..db03a41 100644 --- a/frontend/app/ui/layout/index.tsx +++ b/frontend/app/ui/layout/index.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react" import { Outlet } from "react-router" import { Header } from "./Header" import { Footer } from "./Footer" +import { DocumentMetaSync } from "./DocumentMetaSync" import { LayoutContext } from "./LayoutContext" export default function Layout() { @@ -10,6 +11,7 @@ export default function Layout() { return ( <LayoutContext.Provider value={{ setLeftContent }}> + <DocumentMetaSync /> <div className="flex min-h-screen flex-col"> <Header leftContent={leftContent} /> <main className="container mx-auto flex-1 px-4 py-6">