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
38 changes: 18 additions & 20 deletions frontend/app/core/lib/seo-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 <title> serait indexée telle quelle par Google.
*/
export function translateMeta(
lang: Language,
path: string,
Expand All @@ -49,27 +35,39 @@ 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);
const lastSpace = cut.lastIndexOf(" ");
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`;
Expand Down
16 changes: 12 additions & 4 deletions frontend/app/pages/LegalNotice.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 12 additions & 4 deletions frontend/app/pages/Privacy.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 12 additions & 4 deletions frontend/app/pages/Terms.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 14 additions & 4 deletions frontend/app/pages/compare/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
19 changes: 12 additions & 7 deletions frontend/app/pages/home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 36 additions & 17 deletions frontend/app/pages/server-detail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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 },
Expand All @@ -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()

Expand Down
24 changes: 24 additions & 0 deletions frontend/app/ui/layout/DocumentMetaSync.tsx
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions frontend/app/ui/layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ 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() {
const [leftContent, setLeftContent] = useState<ReactNode | undefined>()

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">
Expand Down
Loading