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) => 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
- // 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) => 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 = (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 (
+