116 lines
3.9 KiB
TypeScript
116 lines
3.9 KiB
TypeScript
/**
|
|
* Translation helpers (Session 12).
|
|
*
|
|
* Two surfaces:
|
|
* - `getTranslations(locale)` — synchronous loader used by server
|
|
* components. Returns `{ t, locale, dir }`.
|
|
* - `useT()` + `<LocaleProvider>` — 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<string, unknown>;
|
|
|
|
const DICTS: Record<Locale, Dict> = {
|
|
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, string | number>) => string;
|
|
|
|
function interpolate(template: string, vars?: Record<string, string | number>): 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<TranslationBundle> {
|
|
// 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));
|
|
}
|