Session 12: i18n (10 languages, cookie-based), Africa tier .99, locale switcher, RTL Arabic (1305 tests)
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Locale registry (Session 12).
|
||||
*
|
||||
* Single source of truth for which languages the app supports.
|
||||
* Imported by the middleware, the translation loader, and the locale
|
||||
* switcher so adding a new language is a one-file change here plus a
|
||||
* matching JSON file in `web/src/locales/`.
|
||||
*
|
||||
* RTL languages get `dir: 'rtl'` so the root layout can toggle the
|
||||
* `<html dir>` attribute without a per-locale lookup.
|
||||
*/
|
||||
|
||||
export const LOCALES = [
|
||||
'en', 'es', 'fr', 'pt', 'ar', 'sw', 'hi', 'ja', 'ko', 'zh',
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'en';
|
||||
|
||||
export const LOCALE_META: Record<Locale, { label: string; native: string; dir: 'ltr' | 'rtl'; region: string }> = {
|
||||
en: { label: 'English', native: 'English', dir: 'ltr', region: 'Global' },
|
||||
es: { label: 'Spanish', native: 'Español', dir: 'ltr', region: 'Latin America / Spain' },
|
||||
fr: { label: 'French', native: 'Français', dir: 'ltr', region: 'France / West Africa' },
|
||||
pt: { label: 'Portuguese', native: 'Português', dir: 'ltr', region: 'Brazil / Portugal' },
|
||||
ar: { label: 'Arabic', native: 'العربية', dir: 'rtl', region: 'MENA' },
|
||||
sw: { label: 'Swahili', native: 'Kiswahili', dir: 'ltr', region: 'East Africa' },
|
||||
hi: { label: 'Hindi', native: 'हिन्दी', dir: 'ltr', region: 'India' },
|
||||
ja: { label: 'Japanese', native: '日本語', dir: 'ltr', region: 'Japan' },
|
||||
ko: { label: 'Korean', native: '한국어', dir: 'ltr', region: 'South Korea' },
|
||||
zh: { label: 'Chinese', native: '中文', dir: 'ltr', region: 'China' },
|
||||
};
|
||||
|
||||
// Localess that map to predominantly-African markets — used by the
|
||||
// pricing page to surface the Africa tier first. Browser region
|
||||
// codes (NG/KE/ZA/GH/...) are checked separately at the component
|
||||
// layer.
|
||||
export const AFRICA_LOCALES: ReadonlySet<Locale> = new Set(['sw']);
|
||||
|
||||
export function isLocale(value: string | null | undefined): value is Locale {
|
||||
return !!value && (LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
// Cookie name + locale-detection header name (set by middleware,
|
||||
// read by server components via next/headers).
|
||||
export const LOCALE_COOKIE = 'NEXT_LOCALE';
|
||||
export const LOCALE_HEADER = 'x-vyndr-locale';
|
||||
Reference in New Issue
Block a user