From 38b953cac63f8ed5519822b53594a7481f00e7a1 Mon Sep 17 00:00:00 2001 From: Flash303 Date: Sun, 11 Oct 2026 11:11:37 +0200 Subject: [PATCH 1/2] fix: meta language resolution and key leak --- frontend/app/core/lib/seo-meta.ts | 45 +++++++++++++++++++--- frontend/app/pages/LegalNotice.tsx | 5 +-- frontend/app/pages/Privacy.tsx | 5 +-- frontend/app/pages/Terms.tsx | 5 +-- frontend/app/pages/compare/index.tsx | 5 +-- frontend/app/pages/home/index.tsx | 7 ++-- frontend/app/pages/server-detail/index.tsx | 9 +++-- frontend/app/root.tsx | 5 ++- 8 files changed, 59 insertions(+), 27 deletions(-) diff --git a/frontend/app/core/lib/seo-meta.ts b/frontend/app/core/lib/seo-meta.ts index b99c750..ea62ebd 100644 --- a/frontend/app/core/lib/seo-meta.ts +++ b/frontend/app/core/lib/seo-meta.ts @@ -1,22 +1,57 @@ import { translate, type Language } from "@/core/lib/i18n"; import { APP_URL } from "@/core/lib/config"; +export const DEFAULT_LANGUAGE: Language = "fr"; + +const SUPPORTED_LANGUAGES: ReadonlySet = new Set([ + "fr", "en", "es", "it", "de", "pt", "ru", "pl", "zh-CN", "ja", "ko", "nl", +]); + +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; data?: unknown }> | undefined + matches: Array<{ id?: string; loaderData?: unknown }> | undefined ): Language { - const rootData = matches?.find((m) => m.id === "root")?.data as - | { serverLanguage?: Language } + const rootData = matches?.find((m) => m.id === "root")?.loaderData as + | { serverLanguage?: unknown } | undefined; - return rootData?.serverLanguage ?? "fr"; + const lang = rootData?.serverLanguage ?? DEFAULT_LANGUAGE; + 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, + replacements?: Record<string, string> +): string { + const hit = translate(lang, path, replacements); + if (hit !== path) return hit; + if (lang !== DEFAULT_LANGUAGE) { + const fallback = translate(DEFAULT_LANGUAGE, path, replacements); + if (fallback !== path) return fallback; + } + return ""; } /** Titre suffixé du nom du site, ex. "Comparaison de serveurs | Minecraft-Stats". */ export function siteTitle(lang: Language, titleKey: string): string { - return `${translate(lang, titleKey)} | Minecraft-Stats`; + return `${translateMeta(lang, titleKey)} | Minecraft-Stats`; } /** Tronque une description au-delà de `max` caractères, sans couper en plein mot. */ diff --git a/frontend/app/pages/LegalNotice.tsx b/frontend/app/pages/LegalNotice.tsx index d65b4b7..477eed6 100644 --- a/frontend/app/pages/LegalNotice.tsx +++ b/frontend/app/pages/LegalNotice.tsx @@ -2,14 +2,13 @@ import type { MetaFunction } from "react-router" import { useLanguage } from "@/core/contexts/LanguageContext" import { legalReplacements } from "@/core/lib/legal-info" -import { translate } from "@/core/lib/i18n" -import { resolveMetaLanguage, siteTitle, staticPageMeta, truncateDescription } from "@/core/lib/seo-meta" +import { resolveMetaLanguage, siteTitle, staticPageMeta, truncateDescription, translateMeta } from "@/core/lib/seo-meta" export const meta: MetaFunction = ({ matches }) => { const lang = resolveMetaLanguage(matches) return staticPageMeta({ title: siteTitle(lang, "legal.notice.title"), - description: truncateDescription(translate(lang, "legal.notice.p1", legalReplacements)), + description: truncateDescription(translateMeta(lang, "legal.notice.p1", legalReplacements)), path: "/legal", }) } diff --git a/frontend/app/pages/Privacy.tsx b/frontend/app/pages/Privacy.tsx index 05fac19..43a2883 100644 --- a/frontend/app/pages/Privacy.tsx +++ b/frontend/app/pages/Privacy.tsx @@ -2,14 +2,13 @@ import type { MetaFunction } from "react-router" import { useLanguage } from "@/core/contexts/LanguageContext" import { legalReplacements } from "@/core/lib/legal-info" -import { translate } from "@/core/lib/i18n" -import { resolveMetaLanguage, siteTitle, staticPageMeta, truncateDescription } from "@/core/lib/seo-meta" +import { resolveMetaLanguage, siteTitle, staticPageMeta, truncateDescription, translateMeta } from "@/core/lib/seo-meta" export const meta: MetaFunction = ({ matches }) => { const lang = resolveMetaLanguage(matches) return staticPageMeta({ title: siteTitle(lang, "legal.privacy.title"), - description: truncateDescription(translate(lang, "legal.privacy.p1", legalReplacements)), + description: truncateDescription(translateMeta(lang, "legal.privacy.p1", legalReplacements)), path: "/privacy", }) } diff --git a/frontend/app/pages/Terms.tsx b/frontend/app/pages/Terms.tsx index e5fc1bf..1350a4d 100644 --- a/frontend/app/pages/Terms.tsx +++ b/frontend/app/pages/Terms.tsx @@ -2,14 +2,13 @@ import type { MetaFunction } from "react-router" import { useLanguage } from "@/core/contexts/LanguageContext" import { legalReplacements } from "@/core/lib/legal-info" -import { translate } from "@/core/lib/i18n" -import { resolveMetaLanguage, siteTitle, staticPageMeta, truncateDescription } from "@/core/lib/seo-meta" +import { resolveMetaLanguage, siteTitle, staticPageMeta, truncateDescription, translateMeta } from "@/core/lib/seo-meta" export const meta: MetaFunction = ({ matches }) => { const lang = resolveMetaLanguage(matches) return staticPageMeta({ title: siteTitle(lang, "legal.terms.title"), - description: truncateDescription(translate(lang, "legal.terms.p1")), + description: truncateDescription(translateMeta(lang, "legal.terms.p1")), path: "/terms", }) } diff --git a/frontend/app/pages/compare/index.tsx b/frontend/app/pages/compare/index.tsx index 3f5875a..2f43006 100644 --- a/frontend/app/pages/compare/index.tsx +++ b/frontend/app/pages/compare/index.tsx @@ -8,13 +8,12 @@ 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 { translate } from "@/core/lib/i18n" -import { resolveMetaLanguage, siteTitle, staticPageMeta } from "@/core/lib/seo-meta" +import { resolveMetaLanguage, siteTitle, staticPageMeta, translateMeta } from "@/core/lib/seo-meta" export const meta: MetaFunction = ({ matches }) => { const lang = resolveMetaLanguage(matches) const title = siteTitle(lang, "comparison.title") - const description = translate(lang, "comparison.noSelectionDesc") + const description = translateMeta(lang, "comparison.noSelectionDesc") return staticPageMeta({ title, description, path: "/compare" }) } diff --git a/frontend/app/pages/home/index.tsx b/frontend/app/pages/home/index.tsx index e4034e9..e0d76b3 100644 --- a/frontend/app/pages/home/index.tsx +++ b/frontend/app/pages/home/index.tsx @@ -21,8 +21,7 @@ import { FaqSection } from "@/pages/home/components/FaqSection" import { Pagination } from "@/ui/components/pagination" import type { LoaderFunctionArgs, MetaFunction } from "react-router" -import { translate } from "@/core/lib/i18n" -import { resolveMetaLanguage, staticPageMeta } from "@/core/lib/seo-meta" +import { resolveMetaLanguage, staticPageMeta, translateMeta } 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. @@ -30,8 +29,8 @@ import { resolveMetaLanguage, staticPageMeta } from "@/core/lib/seo-meta" export const meta: MetaFunction = ({ matches }) => { const lang = resolveMetaLanguage(matches) return staticPageMeta({ - title: translate(lang, "seo.homeTitle"), - description: translate(lang, "seo.homeDescription"), + title: translateMeta(lang, "seo.homeTitle"), + description: translateMeta(lang, "seo.homeDescription"), path: "/", }) } diff --git a/frontend/app/pages/server-detail/index.tsx b/frontend/app/pages/server-detail/index.tsx index 1ee0ab3..d6a58ba 100644 --- a/frontend/app/pages/server-detail/index.tsx +++ b/frontend/app/pages/server-detail/index.tsx @@ -3,7 +3,7 @@ import { Link, useLoaderData, useRouteError } from "react-router" import type { LoaderFunctionArgs, MetaFunction } from "react-router" import { fetchServer, getServerIconUrl } from "@/core/lib/api" import { APP_URL } from "@/core/lib/config" -import { translate, type Language } from "@/core/lib/i18n" +import { translateMeta, type Language } from "@/core/lib/seo-meta" const PlayerChart = lazy(() => import("@/pages/server-detail/components/PlayerChart").then((m) => ({ @@ -84,12 +84,13 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { export const meta: MetaFunction<typeof loader> = (args) => { const { loaderData: data } = args - // Langue résolue par le loader root (cookie ou Accept-Language) - const rootData = args.matches?.find((m) => m.id === "root")?.data as + // 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>) => translate(lang, path, vars) + const L = (path: string, vars?: Record<string, string>) => translateMeta(lang, path, vars) if (!data || !data.initialServer) { return [ diff --git a/frontend/app/root.tsx b/frontend/app/root.tsx index a129239..10ac918 100644 --- a/frontend/app/root.tsx +++ b/frontend/app/root.tsx @@ -25,6 +25,7 @@ import { LabyLogoSprite } from "@/ui/components/LabyLogo"; import { APP_URL } from "@/core/lib/config"; import { parseLanguageCookie, resolveLanguageFromHeader } from "@/core/lib/accept-language"; import { translate, type Language } from "@/core/lib/i18n"; +import { translateMeta } from "@/core/lib/seo-meta"; const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; @@ -63,7 +64,7 @@ export function shouldRevalidate() { export function meta({ data }: { data?: { serverLanguage?: Language } }) { const lang = data?.serverLanguage ?? "fr" - const L = (path: string) => translate(lang, path) + const L = (path: string) => translateMeta(lang, path) return [ { title: L("seo.homeTitle") }, @@ -135,7 +136,7 @@ export function Layout({ children }: { children: React.ReactNode }) { "@id": `${APP_URL}/#website`, "name": "Minecraft-Stats", "url": APP_URL, - "description": translate(lang, "seo.homeDescription"), + "description": translateMeta(lang, "seo.homeDescription"), "publisher": { "@id": `${APP_URL}/#organization` }, "potentialAction": { "@type": "SearchAction", From e3ec241e52dc3dce8bd824824375769876dffe16 Mon Sep 17 00:00:00 2001 From: Flash303 <Flash303mc@gmail.com> Date: Sun, 11 Oct 2026 12:47:19 +0200 Subject: [PATCH 2/2] fix: remove unused translate import --- frontend/app/root.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/root.tsx b/frontend/app/root.tsx index 10ac918..eb07162 100644 --- a/frontend/app/root.tsx +++ b/frontend/app/root.tsx @@ -24,7 +24,7 @@ import { BedrockLogoSprite } from "@/ui/components/BedrockLogo"; import { LabyLogoSprite } from "@/ui/components/LabyLogo"; import { APP_URL } from "@/core/lib/config"; import { parseLanguageCookie, resolveLanguageFromHeader } from "@/core/lib/accept-language"; -import { translate, type Language } from "@/core/lib/i18n"; +import { type Language } from "@/core/lib/i18n"; import { translateMeta } from "@/core/lib/seo-meta"; const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;