Files
HealthLog/src/lib/i18n/context.tsx
T
Marc-André Bombeck 3ec1f44d74 chore(lib): extract shared allMessages and resolveKey between context and server-translator
Lifts the per-locale message map and the dotted-key resolver out of
`src/lib/i18n/context.tsx` and `src/lib/i18n/server-translator.ts`
into a new `src/lib/i18n/shared-resolve.ts`. The two surfaces had
each carried their own private copy of the same data structure and
the same six-step path resolver; the maps drifted twice when a new
locale was added during v1.4.18 and v1.4.25 polishing passes.
Pinning both call-sites to a shared module keeps client and server
fallback semantics in lockstep and shrinks the surface a future
locale addition has to touch from two files to one.
2026-05-15 17:39:01 +02:00

145 lines
4.5 KiB
TypeScript

"use client";
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
useCallback,
type ReactNode,
} from "react";
import { locales, defaultLocale, type Locale } from "./config";
import { makeFormatters, type Formatters } from "../format-locale";
import { allMessages, resolveKey } from "./shared-resolve";
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string, params?: Record<string, string | number>) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
function detectSystemLocale(): Locale {
if (typeof window === "undefined") return defaultLocale;
const browserLang = navigator.language.split("-")[0];
return (locales as readonly string[]).includes(browserLang)
? (browserLang as Locale)
: defaultLocale;
}
function readLocaleCookie(): Locale | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(/(?:^|;\s*)healthlog-locale=([^;]+)/);
const value = match?.[1];
if (value && (locales as readonly string[]).includes(value)) {
return value as Locale;
}
return null;
}
function getSavedLocale(): Locale | null {
if (typeof window === "undefined") return null;
// Cookie wins over localStorage: the root layout already rendered the
// server HTML using the cookie, so matching the cookie on first client
// render avoids a hydration flash of the wrong language.
const fromCookie = readLocaleCookie();
if (fromCookie) return fromCookie;
const saved = localStorage.getItem("healthlog-locale");
if (saved && (locales as readonly string[]).includes(saved)) {
return saved as Locale;
}
return null;
}
export function I18nProvider({
children,
initialLocale,
}: {
children: ReactNode;
initialLocale?: Locale;
}) {
const [locale, setLocaleState] = useState<Locale>(() => {
// Prefer the server-resolved initial locale to eliminate the hydration
// flash where the server renders EN ("Loading…") and the client then
// flips to DE ("Laden…") once localStorage/cookie is read at mount.
if (
initialLocale &&
(locales as readonly string[]).includes(initialLocale)
) {
return initialLocale;
}
return getSavedLocale() ?? detectSystemLocale();
});
const setLocale = useCallback((newLocale: Locale) => {
if ((locales as readonly string[]).includes(newLocale)) {
setLocaleState(newLocale);
localStorage.setItem("healthlog-locale", newLocale);
// Also mirror to cookie so SSR (layout, metadata) renders in the
// user's language. 1-year expiry, Lax samesite, not HttpOnly so the
// client continues to own it.
document.cookie = `healthlog-locale=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
document.documentElement.lang = newLocale;
}
}, []);
// Keep the HTML lang and the cookie in sync with the active locale on
// mount. The cookie acts as the SSR handoff for the next request.
useEffect(() => {
document.documentElement.lang = locale;
document.cookie = `healthlog-locale=${locale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
}, [locale]);
const t = useCallback(
(key: string, params?: Record<string, string | number>): string => {
let value = resolveKey(allMessages[locale], key);
// Fallback to English if key missing in current locale
if (value === undefined && locale !== "en") {
value = resolveKey(allMessages.en, key);
}
// Fallback to key itself
if (value === undefined) {
return key;
}
// Replace {param} placeholders
if (params) {
for (const [k, v] of Object.entries(params)) {
value = value.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
}
}
return value;
},
[locale],
);
return (
<I18nContext.Provider value={{ locale, setLocale, t }}>
{children}
</I18nContext.Provider>
);
}
export function useTranslations() {
const context = useContext(I18nContext);
if (!context) {
throw new Error("useTranslations must be used within I18nProvider");
}
return context;
}
/**
* Locale-aware formatters tied to the active UI locale. Use for every number,
* date, and time rendered in the UI so regional conventions (70,5 vs 70.5,
* 19.02.2026 vs Feb 19, 2026) follow the user's language choice.
*/
export function useFormatters(): Formatters {
const { locale } = useTranslations();
return useMemo(() => makeFormatters(locale), [locale]);
}