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
92 changes: 92 additions & 0 deletions frontend/app/core/lib/seo-meta.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest"
import { registerTranslations } from "./i18n"
import {
getServerDocumentMeta,
resolveMetaLanguage,
translateMeta,
} from "./seo-meta"
import type { Server } from "./api"

registerTranslations("fr", {
seo: {
homeTitle: "Accueil",
homeDescription: "Description",
notFoundTitle: "Serveur introuvable",
notFoundDescription: "Serveur non trouvé",
serverTitleSuffix: "Statistiques | MS",
serverDescription: "Voir {{name}} ({{ip}}).",
statusOnline: "En ligne : {{count}}.",
statusOffline: "Hors ligne.",
},
})

const onlineServer = {
id: 1,
name: "Hypixel",
ip: "mc.hypixel.net",
last_status: "online",
last_connected: 50000,
} as unknown as Server

const offlineServer = {
...onlineServer,
last_status: "offline",
} as unknown as Server

describe("translateMeta", () => {
it("never leaks the raw key when the dictionary is missing", () => {
expect(translateMeta("ja", "seo.homeTitle")).toBe("Accueil")
})

it("returns an empty string when the key exists nowhere", () => {
expect(translateMeta("fr", "seo.nope")).toBe("")
expect(translateMeta("ja", "seo.nope")).toBe("")
})
})

describe("resolveMetaLanguage", () => {
it("reads serverLanguage from the root match loaderData", () => {
expect(
resolveMetaLanguage([
{ id: "root", loaderData: { serverLanguage: "ja" } },
{ id: "other", loaderData: {} },
])
).toBe("ja")
})

it("falls back to fr when the root match is absent or invalid", () => {
expect(resolveMetaLanguage(undefined)).toBe("fr")
expect(resolveMetaLanguage([])).toBe("fr")
expect(
resolveMetaLanguage([{ id: "root", loaderData: { serverLanguage: "xx" } }])
).toBe("fr")
})
})

describe("getServerDocumentMeta", () => {
it("returns the not-found strings when the server is null", () => {
expect(getServerDocumentMeta(null, "fr")).toEqual({
title: "Serveur introuvable",
description: "Serveur non trouvé",
})
})

it("returns the server title and stats for an online server", () => {
const meta = getServerDocumentMeta(onlineServer, "fr")
expect(meta.title).toBe("Hypixel - Statistiques | MS")
expect(meta.description).toContain("Hypixel")
expect(meta.description).toContain("mc.hypixel.net")
})

it("returns the offline status for an offline server", () => {
const meta = getServerDocumentMeta(offlineServer, "fr")
expect(meta.title).toBe("Hypixel - Statistiques | MS")
expect(meta.description).toContain("Hors ligne.")
})

it("falls back to fr instead of leaking keys", () => {
const meta = getServerDocumentMeta(onlineServer, "ja")
expect(meta.title).toBe("Hypixel - Statistiques | MS")
expect(meta.title).not.toContain("seo.")
})
})
22 changes: 22 additions & 0 deletions frontend/app/core/lib/seo-meta.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { translate, type Language } from "@/core/lib/i18n";
import { APP_URL } from "@/core/lib/config";
import { formatNumber } from "./utils";
import type { Server } from "@/core/lib/api";

export const DEFAULT_LANGUAGE: Language = "fr";

Expand Down Expand Up @@ -46,6 +48,7 @@ export interface DocumentMeta {

export interface RouteMetaHandle {
getDocumentMeta?: (data: unknown, lang: Language) => DocumentMeta | null;
syncLiveTitle?: boolean;
}

export function applyDocumentMeta(meta: DocumentMeta): void {
Expand All @@ -57,6 +60,25 @@ export function applyDocumentMeta(meta: DocumentMeta): void {
document.head.querySelector('meta[name="twitter:description"]')?.setAttribute("content", meta.description);
}

export function getServerDocumentMeta(server: Server | null, lang: Language): DocumentMeta {
if (!server) {
return {
title: translateMeta(lang, "seo.notFoundTitle"),
description: translateMeta(lang, "seo.notFoundDescription"),
};
}
const title = `${server.name} - ${translateMeta(lang, "seo.serverTitleSuffix")}`;
const isOnline = server.last_status === "online";
const players = isOnline ? formatNumber(lang, server.last_connected ?? 0) : "0";
const playersText = isOnline
? ` ${translateMeta(lang, "seo.statusOnline", { count: players })}`
: server.last_status === "offline"
? ` ${translateMeta(lang, "seo.statusOffline")}`
: "";
const description = `${translateMeta(lang, "seo.serverDescription", { name: server.name, ip: server.ip })}${playersText}`;
return { title, description };
}

export function truncateDescription(text: string, max = 157): string {
if (text.length <= max) return text;
const cut = text.slice(0, max);
Expand Down
31 changes: 7 additions & 24 deletions frontend/app/pages/server-detail/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useMemo, lazy, Suspense } from "react"
import { useEffect, 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 DocumentMeta, type Language, type RouteMetaHandle } from "@/core/lib/seo-meta"
import { applyDocumentMeta, getServerDocumentMeta, type Language, type RouteMetaHandle } from "@/core/lib/seo-meta"

const PlayerChart = lazy(() =>
import("@/pages/server-detail/components/PlayerChart").then((m) => ({
Expand Down Expand Up @@ -82,28 +82,6 @@ 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

Expand Down Expand Up @@ -146,6 +124,7 @@ export const meta: MetaFunction<typeof loader> = (args) => {
}

export const handle: RouteMetaHandle = {
syncLiveTitle: true,
getDocumentMeta: (data: unknown, lang: Language) => {
const server =
(data as { initialServer?: Server | null } | undefined)?.initialServer ?? null
Expand All @@ -172,6 +151,10 @@ export default function ServerDetail() {
const TIME_RANGES = useMemo(() => getTimeRanges(t), [t])
const INTERVALS = useMemo(() => getIntervals(t), [t])

useEffect(() => {
if (server) applyDocumentMeta(getServerDocumentMeta(server, language))
}, [server, language])

// Données structurées rendues dans le JSX (donc présentes dans le HTML SSR),
// à partir des données du loader pour que SSR et client soient identiques.
// Remplace l'ancienne injection via useEffect, invisible aux crawlers sans JS.
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/ui/layout/DocumentMetaSync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export function DocumentMetaSync() {
"function"
)
if (!leaf) return
const meta = (leaf.handle as RouteMetaHandle).getDocumentMeta?.(leaf.data, language)
const handle = leaf.handle as RouteMetaHandle
if (handle.syncLiveTitle) return
const meta = handle.getDocumentMeta?.(leaf.data, language)
if (meta) applyDocumentMeta(meta)
}, [language, matches])

Expand Down
Loading