/** * Translation helpers (Session 12). * * Two surfaces: * - `getTranslations(locale)` — synchronous loader used by server * components. Returns `{ t, locale, dir }`. * - `useT()` + `` — client-side hook backed by a * React context populated by the * root layout. * * Translation keys use dot notation: `t('nav.home')` → 'Home'. Missing * keys fall back to English, then to the key itself (visible during * dev so the gap is obvious, harmless in prod). * * No async fetch — JSON files are bundled at build time. The bundle * cost is ~3 KB per locale gzipped (we ship all 10 even on en pages * for now; a future optimization is dynamic import per locale). */ import en from '@/locales/en.json'; import es from '@/locales/es.json'; import fr from '@/locales/fr.json'; import pt from '@/locales/pt.json'; import ar from '@/locales/ar.json'; import sw from '@/locales/sw.json'; import hi from '@/locales/hi.json'; import ja from '@/locales/ja.json'; import ko from '@/locales/ko.json'; import zh from '@/locales/zh.json'; import { Locale, DEFAULT_LOCALE, isLocale, LOCALE_META } from './locales'; type Dict = Record; const DICTS: Record = { en: en as Dict, es: es as Dict, fr: fr as Dict, pt: pt as Dict, ar: ar as Dict, sw: sw as Dict, hi: hi as Dict, ja: ja as Dict, ko: ko as Dict, zh: zh as Dict, }; function getByPath(dict: Dict, path: string): string | null { const parts = path.split('.'); let cursor: unknown = dict; for (const part of parts) { if (!cursor || typeof cursor !== 'object') return null; cursor = (cursor as Dict)[part]; } return typeof cursor === 'string' ? cursor : null; } export type TFunction = (key: string, vars?: Record) => string; function interpolate(template: string, vars?: Record): string { if (!vars) return template; return template.replace(/\{(\w+)\}/g, (_, name) => { const v = vars[name]; return v == null ? `{${name}}` : String(v); }); } function makeT(locale: Locale): TFunction { const primary = DICTS[locale] || DICTS[DEFAULT_LOCALE]; const fallback = DICTS[DEFAULT_LOCALE]; return function t(key, vars) { const hit = getByPath(primary, key); if (hit !== null) return interpolate(hit, vars); // Fallback to English so we never render a raw key in prod just // because a translation is missing. const fallbackHit = getByPath(fallback, key); if (fallbackHit !== null) return interpolate(fallbackHit, vars); // Last resort — the key itself. Makes missing strings visible // during dev without crashing. return key; }; } export interface TranslationBundle { locale: Locale; dir: 'ltr' | 'rtl'; t: TFunction; } export function getTranslations(locale: string | null | undefined): TranslationBundle { const resolved: Locale = isLocale(locale) ? locale : DEFAULT_LOCALE; return { locale: resolved, dir: LOCALE_META[resolved].dir, t: makeT(resolved), }; } /** * Server-component convenience — reads the locale header that the * middleware stamped on the request and returns a translation bundle. * Only safe to call in a server component (uses next/headers). * * The dynamic import keeps `next/headers` out of client bundles even * though this file is imported from both contexts. */ export async function getServerTranslations(): Promise { // Inline require so the client bundle never sees `next/headers`. // eslint-disable-next-line @typescript-eslint/no-require-imports const { headers } = require('next/headers'); const hdr = await headers(); // The header name lives in lib/locales to keep middleware + here in sync. const { LOCALE_HEADER } = await import('./locales'); return getTranslations(hdr.get(LOCALE_HEADER)); }