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
45 changes: 40 additions & 5 deletions frontend/app/core/lib/seo-meta.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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 <title> 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. */
Expand Down
5 changes: 2 additions & 3 deletions frontend/app/pages/LegalNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})
}
Expand Down
5 changes: 2 additions & 3 deletions frontend/app/pages/Privacy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})
}
Expand Down
5 changes: 2 additions & 3 deletions frontend/app/pages/Terms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})
}
Expand Down
5 changes: 2 additions & 3 deletions frontend/app/pages/compare/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
}

Expand Down
7 changes: 3 additions & 4 deletions frontend/app/pages/home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,16 @@ 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.
// Jeu de tags complet : le meta() feuille remplace celui du root.
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: "/",
})
}
Expand Down
9 changes: 5 additions & 4 deletions frontend/app/pages/server-detail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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 [
Expand Down
7 changes: 4 additions & 3 deletions frontend/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ 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;

Expand Down Expand Up @@ -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") },
Expand Down Expand Up @@ -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",
Expand Down
Loading