Agent-Logs-Url: https://github.com/trails-cool/trails/sessions/7546ba18-5628-4f50-bc8b-104fa7ba6afe Co-authored-by: stigi <13815+stigi@users.noreply.github.com>
136 lines
4.1 KiB
TypeScript
136 lines
4.1 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, mobile: en.mobile },
|
|
de: { common: de.common, planner: de.planner, journal: de.journal, mobile: de.mobile },
|
|
} 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.
|
|
*
|
|
* Re-adds resource bundles on every call so dev-mode HMR edits to the
|
|
* locale files are reflected without a Node restart (the i18n singleton
|
|
* otherwise caches the resources from the first init call).
|
|
*/
|
|
export function initI18nServer(lng: SupportedLng = "en") {
|
|
if (i18n.isInitialized) {
|
|
syncResources();
|
|
i18n.changeLanguage(lng);
|
|
return;
|
|
}
|
|
i18n.use(initReactI18next).init({ ...commonOptions, lng });
|
|
}
|
|
|
|
function syncResources() {
|
|
for (const [lang, namespaces] of Object.entries(resources)) {
|
|
for (const [ns, bundle] of Object.entries(namespaces)) {
|
|
i18n.addResourceBundle(lang, ns, bundle, true, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize i18next for the client.
|
|
* Uses LanguageDetector with htmlTag priority so hydration matches the server.
|
|
* No localStorage caching — we don't want to set non-essential storage
|
|
* without user consent. Language is resolved per-request from the server
|
|
* (Accept-Language → htmlTag) or the browser's navigator as a fallback.
|
|
*/
|
|
export function initI18nClient() {
|
|
if (i18n.isInitialized) return;
|
|
i18n
|
|
.use(LanguageDetector)
|
|
.use(initReactI18next)
|
|
.init({
|
|
...commonOptions,
|
|
detection: {
|
|
order: ["htmlTag", "navigator"],
|
|
convertDetectedLanguage: (lng: string) => matchSupportedLng(lng),
|
|
caches: [],
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initialize i18next for React Native.
|
|
* No browser language detector — pass the device locale directly.
|
|
* Use `expo-localization` to get the device locale:
|
|
*
|
|
* ```ts
|
|
* import { getLocales } from "expo-localization";
|
|
* initI18nMobile(getLocales()[0]?.languageCode ?? "en");
|
|
* ```
|
|
*/
|
|
export function initI18nMobile(deviceLanguage?: string) {
|
|
if (i18n.isInitialized) return;
|
|
const lng = matchSupportedLng(deviceLanguage);
|
|
i18n.use(initReactI18next).init({ ...commonOptions, lng });
|
|
}
|
|
|
|
function matchSupportedLng(lang?: string): SupportedLng {
|
|
if (!lang) return "en";
|
|
const lower = lang.toLowerCase();
|
|
for (const supported of supportedLngs) {
|
|
if (lower === supported || lower.startsWith(supported + "-")) {
|
|
return supported;
|
|
}
|
|
}
|
|
return "en";
|
|
}
|
|
|
|
/**
|
|
* 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 };
|