i18n: plural selection via Intl.PluralRules, dev-mode miss warning, useFormat, key-parity helper
This commit is contained in:
@@ -11,15 +11,19 @@ export const I18nContext = createContext<I18nContextValue>({
|
||||
type I18nProviderProps = {
|
||||
translations: TranslationMap;
|
||||
locale: string;
|
||||
/** The catalog `resolveKey` falls back to on a miss — a bug path, per D8, not a regional chain. */
|
||||
fallbackLocale?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const I18nProvider = ({ translations, locale, children }: I18nProviderProps) => {
|
||||
export const I18nProvider = ({ translations, locale, fallbackLocale = 'en', children }: I18nProviderProps) => {
|
||||
const value = useMemo<I18nContextValue>(() => {
|
||||
const t = (key: string, params?: InterpolationParams) =>
|
||||
interpolate(resolveKey(translations, locale, key), params);
|
||||
const t = (key: string, params?: InterpolationParams) => {
|
||||
const count = typeof params?.count === 'number' ? params.count : undefined;
|
||||
return interpolate(resolveKey(translations, locale, key, fallbackLocale, count), params);
|
||||
};
|
||||
return { t, locale };
|
||||
}, [translations, locale]);
|
||||
}, [translations, locale, fallbackLocale]);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export { I18nProvider, I18nContext } from './I18nProvider';
|
||||
export { useTranslation } from './useTranslation';
|
||||
export { resolveKey, interpolate } from './resolve';
|
||||
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys } from './types';
|
||||
export { useFormat } from './useFormat';
|
||||
export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||
export { findMissingKeys } from './parity';
|
||||
export type { MissingKey } from './parity';
|
||||
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys, PluralForms } from './types';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { isPluralForms } from './resolve';
|
||||
|
||||
type Catalog = Record<string, unknown>;
|
||||
|
||||
const flattenKeys = (obj: Catalog, prefix = ''): string[] =>
|
||||
Object.entries(obj).flatMap(([k, v]) => {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (v !== null && typeof v === 'object' && !Array.isArray(v) && !isPluralForms(v)) {
|
||||
return flattenKeys(v as Catalog, path);
|
||||
}
|
||||
return [path];
|
||||
});
|
||||
|
||||
export type MissingKey = { locale: string; key: string };
|
||||
|
||||
/**
|
||||
* Per D2, every shipped locale is a COMPLETE catalog — no regional inheritance. So a key `en` has
|
||||
* and a locale's own file lacks is not a fallback case, it's a red build (docs/localization.md,
|
||||
* "Tooling and guardrails").
|
||||
*/
|
||||
export const findMissingKeys = (en: Catalog, locales: Record<string, Catalog>): MissingKey[] => {
|
||||
const enKeys = flattenKeys(en);
|
||||
return Object.entries(locales).flatMap(([locale, catalog]) => {
|
||||
const ownKeys = new Set(flattenKeys(catalog));
|
||||
return enKeys.filter((key) => !ownKeys.has(key)).map((key) => ({ locale, key }));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||
import { findMissingKeys } from './parity';
|
||||
import type { TranslationMap } from './types';
|
||||
|
||||
describe('resolveKey', () => {
|
||||
const translations: TranslationMap = {
|
||||
'pt-PT': { greeting: 'Olá {{name}}' },
|
||||
'en-GB': { greeting: 'Hello {{name}}', onlyInEnglish: 'English only' },
|
||||
};
|
||||
|
||||
test('resolves from the active locale', () => {
|
||||
expect(resolveKey(translations, 'pt-PT', 'greeting', 'en-GB')).toBe('Olá {{name}}');
|
||||
});
|
||||
|
||||
test('falls back to the fallback locale on a miss', () => {
|
||||
expect(resolveKey(translations, 'pt-PT', 'onlyInEnglish', 'en-GB')).toBe('English only');
|
||||
});
|
||||
|
||||
test('renders the raw key when even the fallback is missing', () => {
|
||||
expect(resolveKey(translations, 'pt-PT', 'nowhere', 'en-GB')).toBe('nowhere');
|
||||
});
|
||||
|
||||
test('never walks a chain between two non-fallback locales', () => {
|
||||
const withThird: TranslationMap = { ...translations, 'pt-BR': {} };
|
||||
// pt-BR has no key of its own and must NOT inherit pt-PT's — only the fallback locale counts.
|
||||
expect(resolveKey(withThird, 'pt-BR', 'greeting', 'en-GB')).toBe('Hello {{name}}');
|
||||
});
|
||||
|
||||
test('selects a plural branch via Intl.PluralRules', () => {
|
||||
const plurals: TranslationMap = {
|
||||
'en-GB': { files: { one: '{{count}} file', other: '{{count}} files' } },
|
||||
};
|
||||
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 1)).toBe('{{count}} file');
|
||||
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 2)).toBe('{{count}} files');
|
||||
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 0)).toBe('{{count}} files');
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolate', () => {
|
||||
test('substitutes params', () => {
|
||||
expect(interpolate('Hello {{name}}', { name: 'Ana' })).toBe('Hello Ana');
|
||||
});
|
||||
|
||||
test('leaves an unknown placeholder untouched', () => {
|
||||
expect(interpolate('Hello {{name}}', {})).toBe('Hello {{name}}');
|
||||
});
|
||||
|
||||
test('passes a plain string through with no params', () => {
|
||||
expect(interpolate('Hello')).toBe('Hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPluralForms', () => {
|
||||
test('accepts an open record of CLDR categories', () => {
|
||||
expect(isPluralForms({ one: 'a', other: 'b' })).toBe(true);
|
||||
expect(isPluralForms({ other: 'b' })).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects a plain nested catalog node', () => {
|
||||
expect(isPluralForms({ title: 'a', description: 'b' })).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects non-objects', () => {
|
||||
expect(isPluralForms('a string')).toBe(false);
|
||||
expect(isPluralForms(null)).toBe(false);
|
||||
expect(isPluralForms(['one', 'other'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('localeDirection', () => {
|
||||
test('ltr for English and Portuguese', () => {
|
||||
expect(localeDirection('en-GB')).toBe('ltr');
|
||||
expect(localeDirection('pt-PT')).toBe('ltr');
|
||||
});
|
||||
|
||||
test('rtl for Arabic and Hebrew, by language subtag', () => {
|
||||
expect(localeDirection('ar')).toBe('rtl');
|
||||
expect(localeDirection('ar-EG')).toBe('rtl');
|
||||
expect(localeDirection('he-IL')).toBe('rtl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMissingKeys', () => {
|
||||
test('flags a key en has and a locale lacks', () => {
|
||||
const en = { common: { save: 'Save', cancel: 'Cancel' } };
|
||||
const ptPT = { common: { save: 'Guardar' } };
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([{ locale: 'pt-PT', key: 'common.cancel' }]);
|
||||
});
|
||||
|
||||
test('a complete catalog has no gaps', () => {
|
||||
const en = { common: { save: 'Save' } };
|
||||
const ptPT = { common: { save: 'Guardar' } };
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
});
|
||||
|
||||
test('a plural-forms leaf counts as one key, not one per category', () => {
|
||||
const en = { files: { one: '{{count}} file', other: '{{count}} files' } };
|
||||
const ptPT = { files: { one: '{{count}} ficheiro', other: '{{count}} ficheiros' } };
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -2,6 +2,12 @@ export type TranslationMap = Record<string, Record<string, unknown>>;
|
||||
|
||||
export type InterpolationParams = Record<string, string | number>;
|
||||
|
||||
/**
|
||||
* A pluralizable catalog entry — CLDR's category set (Intl.PluralRules), never a fixed
|
||||
* `{one, other}` shape, because some locales need up to all six (see docs/localization.md D7).
|
||||
*/
|
||||
export type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>>;
|
||||
|
||||
export type I18nContextValue = {
|
||||
t: (key: string, params?: InterpolationParams) => string;
|
||||
locale: string;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { I18nContext } from './I18nProvider';
|
||||
|
||||
type DateInput = Date | number | string;
|
||||
|
||||
/**
|
||||
* Date, time and number formatting bound to the active locale, via `Intl` (D7) — the replacement for
|
||||
* the ~23 `toLocale*()` call sites that otherwise follow the browser's locale and quietly contradict
|
||||
* the rest of the UI. Moving those call sites is phase 1-3 work; this is just the hook they move to.
|
||||
*/
|
||||
export const useFormat = () => {
|
||||
const { locale } = useContext(I18nContext);
|
||||
|
||||
return useMemo(() => {
|
||||
const date = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat(locale, options).format(new Date(value));
|
||||
|
||||
const time = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat(locale, { timeStyle: 'short', ...options }).format(new Date(value));
|
||||
|
||||
const dateTime = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', ...options }).format(new Date(value));
|
||||
|
||||
const number = (value: number, options?: Intl.NumberFormatOptions) =>
|
||||
new Intl.NumberFormat(locale, options).format(value);
|
||||
|
||||
const relative = (value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions) =>
|
||||
new Intl.RelativeTimeFormat(locale, options).format(value, unit);
|
||||
|
||||
return { date, time, dateTime, number, relative };
|
||||
}, [locale]);
|
||||
};
|
||||
Reference in New Issue
Block a user