trails/packages/i18n/src/index.ts
Ullrich Schäfer 38ada3bd4f
Pass full locale from Accept-Language to ClientDate
Add detectLocale() that extracts the full locale tag (e.g. "de-DE")
from the request, not just the language. The journal root loader passes
it via LocaleContext so ClientDate renders with the correct locale on
both server and client — no more hardcoded "en-US" fallback.

Also fixes settings page hydration mismatch by using ClientDate for
passkey creation dates instead of inline toLocaleDateString().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:46:21 +02:00

92 lines
2.6 KiB
TypeScript

import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import en from "./locales/en.ts";
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;
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({
...commonOptions,
detection: {
order: ["htmlTag", "localStorage", "navigator"],
caches: ["localStorage"],
},
});
}
/**
* 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";
}
/**
* Extract the full locale (e.g. "de-DE") from a request's Accept-Language header.
* Falls back to the supported language (e.g. "en") if no region tag is present.
*/
export function detectLocale(request: Request): string {
const header = request.headers.get("Accept-Language") ?? "";
for (const part of header.split(",")) {
const tag = part.split(";")[0]?.trim();
if (!tag) continue;
const lang = tag.toLowerCase();
for (const supported of supportedLngs) {
if (lang === supported || lang.startsWith(supported + "-")) {
return tag; // preserve original casing (e.g. "de-DE")
}
}
}
return "en";
}
export { i18n };