Session 12: i18n (10 languages, cookie-based), Africa tier .99, locale switcher, RTL Arabic (1305 tests)

This commit is contained in:
Kev
2026-06-10 22:24:40 -04:00
parent e5c45ecc8e
commit d957dee17b
27 changed files with 1834 additions and 29 deletions
+115
View File
@@ -0,0 +1,115 @@
/**
* 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));
}