diff --git a/apps/journal/app/entry.client.tsx b/apps/journal/app/entry.client.tsx index f77e5e9..8e3f019 100644 --- a/apps/journal/app/entry.client.tsx +++ b/apps/journal/app/entry.client.tsx @@ -4,6 +4,7 @@ import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router"; +import { initI18nClient } from "@trails-cool/i18n"; const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ?? (import.meta.env.PROD ? "production" : "development"); @@ -24,6 +25,8 @@ Sentry.init({ enabled: import.meta.env.PROD && sentryEnvironment !== "ci", }); +initI18nClient(); + startTransition(() => { hydrateRoot( document, diff --git a/apps/journal/app/entry.server.tsx b/apps/journal/app/entry.server.tsx index 01dbf3d..0bdfe8f 100644 --- a/apps/journal/app/entry.server.tsx +++ b/apps/journal/app/entry.server.tsx @@ -6,6 +6,7 @@ import { ServerRouter } from "react-router"; import { isbot } from "isbot"; import type { RenderToPipeableStreamOptions } from "react-dom/server"; import { renderToPipeableStream } from "react-dom/server"; +import { initI18nServer, detectLanguage } from "@trails-cool/i18n"; const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development"); @@ -32,6 +33,8 @@ export default function handleRequest( routerContext: EntryContext, _loadContext: AppLoadContext, ) { + initI18nServer(detectLanguage(request)); + if (request.method.toUpperCase() === "HEAD") { return new Response(null, { status: responseStatusCode, diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index 159af7c..c041ae7 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -4,17 +4,15 @@ import type { LinksFunction } from "react-router"; import type { Route } from "./+types/root"; import * as Sentry from "@sentry/react"; import { useTranslation } from "react-i18next"; -import { initI18n } from "@trails-cool/i18n"; import { getSessionUser } from "~/lib/auth.server"; import stylesheet from "@trails-cool/ui/styles.css?url"; -initI18n(); - export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }]; export function Layout({ children }: { children: React.ReactNode }) { + const { i18n } = useTranslation(); return ( - + diff --git a/apps/planner/app/entry.client.tsx b/apps/planner/app/entry.client.tsx index a9813eb..f4230ed 100644 --- a/apps/planner/app/entry.client.tsx +++ b/apps/planner/app/entry.client.tsx @@ -4,6 +4,7 @@ import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router"; +import { initI18nClient } from "@trails-cool/i18n"; const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ?? (import.meta.env.PROD ? "production" : "development"); @@ -24,6 +25,8 @@ Sentry.init({ enabled: import.meta.env.PROD && sentryEnvironment !== "ci", }); +initI18nClient(); + startTransition(() => { hydrateRoot( document, diff --git a/apps/planner/app/entry.server.tsx b/apps/planner/app/entry.server.tsx new file mode 100644 index 0000000..9220df5 --- /dev/null +++ b/apps/planner/app/entry.server.tsx @@ -0,0 +1,79 @@ +import { PassThrough } from "node:stream"; +import type { AppLoadContext, EntryContext } from "react-router"; +import { createReadableStreamFromReadable } from "@react-router/node"; +import { ServerRouter } from "react-router"; +import { isbot } from "isbot"; +import type { RenderToPipeableStreamOptions } from "react-dom/server"; +import { renderToPipeableStream } from "react-dom/server"; +import { initI18nServer, detectLanguage } from "@trails-cool/i18n"; + +export const streamTimeout = 5_000; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + _loadContext: AppLoadContext, +) { + initI18nServer(detectLanguage(request)); + + if (request.method.toUpperCase() === "HEAD") { + return new Response(null, { + status: responseStatusCode, + headers: responseHeaders, + }); + } + + return new Promise((resolve, reject) => { + let shellRendered = false; + const userAgent = request.headers.get("user-agent"); + + const readyOption: keyof RenderToPipeableStreamOptions = + (userAgent && isbot(userAgent)) || routerContext.isSpaMode + ? "onAllReady" + : "onShellReady"; + + let timeoutId: ReturnType | undefined = setTimeout( + () => abort(), + streamTimeout + 1000, + ); + + const { pipe, abort } = renderToPipeableStream( + , + { + [readyOption]() { + shellRendered = true; + const body = new PassThrough({ + final(callback) { + clearTimeout(timeoutId); + timeoutId = undefined; + callback(); + }, + }); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + pipe(body); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + if (shellRendered) { + console.error(error); + } + }, + }, + ); + }); +} diff --git a/apps/planner/app/root.tsx b/apps/planner/app/root.tsx index 29653b5..cb6df2b 100644 --- a/apps/planner/app/root.tsx +++ b/apps/planner/app/root.tsx @@ -3,16 +3,14 @@ import type { LinksFunction } from "react-router"; import type { Route } from "./+types/root"; import * as Sentry from "@sentry/react"; import { useTranslation } from "react-i18next"; -import { initI18n } from "@trails-cool/i18n"; import stylesheet from "@trails-cool/ui/styles.css?url"; -initI18n(); - export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }]; export function Layout({ children }: { children: React.ReactNode }) { + const { i18n } = useTranslation(); return ( - + diff --git a/package.json b/package.json index 66b09f7..3434381 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "drizzle-postgis": "^1.1.1", "eslint": "^10.1.0", "eslint-config-prettier": "^10.1.8", - "i18next": "^25.10.4", + "i18next": "^26.0.1", "i18next-browser-languagedetector": "^8.2.1", "jsdom": "^29.0.1", "leaflet": "^1.9.4", @@ -59,7 +59,7 @@ "prettier": "^3.8.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-i18next": "^16.6.1", + "react-i18next": "^17.0.1", "react-leaflet": "^5.0.0", "react-router": "^7.13.2", "tailwindcss": "^4.2.2", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index f55bf10..f17b2dc 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -10,7 +10,7 @@ "types": "./src/index.ts", "peerDependencies": { "react": ">=18", - "i18next": ">=23", - "react-i18next": ">=14" + "i18next": ">=26", + "react-i18next": ">=17" } } diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 8f3e652..1582ae9 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -6,26 +6,68 @@ import de from "./locales/de.ts"; export const defaultNS = "common"; +export const supportedLngs = ["en", "de"] as const; +export type SupportedLng = (typeof supportedLngs)[number]; + export const resources = { en: { common: en.common, planner: en.planner, journal: en.journal }, de: { common: de.common, planner: de.planner, journal: de.journal }, } as const; -export function initI18n() { - return i18n +const commonOptions = { + resources, + defaultNS, + fallbackLng: "en" as const, + supportedLngs: [...supportedLngs], + interpolation: { escapeValue: false }, + initAsync: false, +}; + +/** + * Initialize i18next for server-side rendering. + * No language detector — language is determined from the request. + */ +export function initI18nServer(lng: SupportedLng = "en") { + if (i18n.isInitialized) { + i18n.changeLanguage(lng); + return; + } + i18n.use(initReactI18next).init({ ...commonOptions, lng }); +} + +/** + * Initialize i18next for the client. + * Uses LanguageDetector with htmlTag priority so hydration matches the server. + */ +export function initI18nClient() { + if (i18n.isInitialized) return; + i18n .use(LanguageDetector) .use(initReactI18next) .init({ - resources, - defaultNS, - fallbackLng: "en", - supportedLngs: ["en", "de"], - interpolation: { - escapeValue: false, + ...commonOptions, + detection: { + order: ["htmlTag", "localStorage", "navigator"], + caches: ["localStorage"], }, - showSupportNotice: false, - initImmediate: false, }); } +/** + * Detect the best supported language from a request's Accept-Language header. + */ +export function detectLanguage(request: Request): SupportedLng { + const header = request.headers.get("Accept-Language") ?? ""; + for (const part of header.split(",")) { + const lang = part.split(";")[0]?.trim().toLowerCase(); + if (!lang) continue; + for (const supported of supportedLngs) { + if (lang === supported || lang.startsWith(supported + "-")) { + return supported; + } + } + } + return "en"; +} + export { i18n }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f91dbd..8c7afc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,8 +113,8 @@ importers: specifier: ^10.1.8 version: 10.1.8(eslint@10.1.0(jiti@2.6.1)) i18next: - specifier: ^25.10.4 - version: 25.10.4(typescript@5.9.3) + specifier: ^26.0.1 + version: 26.0.1(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.2.1 version: 8.2.1 @@ -143,8 +143,8 @@ importers: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) react-i18next: - specifier: ^16.6.1 - version: 16.6.1(i18next@25.10.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + specifier: ^17.0.1 + version: 17.0.1(i18next@26.0.1(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-leaflet: specifier: ^5.0.0 version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -382,14 +382,14 @@ importers: packages/i18n: dependencies: i18next: - specifier: '>=23' - version: 25.10.4(typescript@5.9.3) + specifier: '>=26' + version: 26.0.1(typescript@5.9.3) react: specifier: '>=18' version: 19.2.4 react-i18next: - specifier: '>=14' - version: 16.6.1(i18next@25.10.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + specifier: '>=17' + version: 17.0.1(i18next@26.0.1(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) packages/map: dependencies: @@ -2744,10 +2744,10 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next@25.10.4: - resolution: {integrity: sha512-XsE/6eawy090meuFU0BTY9BtmWr1m9NSwLr0NK7/A04LA58wdAvDsi9WNOJ40Qb1E9NIPbvnVLZEN2fWDd3/3Q==} + i18next@26.0.1: + resolution: {integrity: sha512-vtz5sXU4+nkCm8yEU+JJ6yYIx0mkg9e68W0G0PXpnOsmzLajNsW5o28DJMqbajxfsfq0gV3XdrBudsDQnwxfsQ==} peerDependencies: - typescript: ^5 + typescript: ^5 || ^6 peerDependenciesMeta: typescript: optional: true @@ -3278,14 +3278,14 @@ packages: peerDependencies: react: ^19.2.4 - react-i18next@16.6.1: - resolution: {integrity: sha512-izjXh+AkBLy3h3xe3sh6Gg1flhFHc3UyzsMftMKYJr2Z7WvAZQIdjjpHypctN41zFoeLdJUNGDgP1+Qich2fYg==} + react-i18next@17.0.1: + resolution: {integrity: sha512-iG65FGnFHcYyHNuT01ukffYWCOBFTWSdVD8EZd/dCVWgtjFPObcSsvYYNwcsokO/rDcTb5d6D8Acv8MrOdm6Hw==} peerDependencies: - i18next: '>= 25.6.2' + i18next: '>= 26.0.1' react: '>= 16.8.0' react-dom: '*' react-native: '*' - typescript: ^5 + typescript: ^5 || ^6 peerDependenciesMeta: react-dom: optional: true @@ -6169,7 +6169,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next@25.10.4(typescript@5.9.3): + i18next@26.0.1(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 optionalDependencies: @@ -6637,11 +6637,11 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-i18next@16.6.1(i18next@25.10.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + react-i18next@17.0.1(i18next@26.0.1(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 25.10.4(typescript@5.9.3) + i18next: 26.0.1(typescript@5.9.3) react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) optionalDependencies: