diff --git a/src/workspaces/i18n/src/I18nProvider.tsx b/src/workspaces/i18n/src/I18nProvider.tsx index 41c66a74..b5f15755 100644 --- a/src/workspaces/i18n/src/I18nProvider.tsx +++ b/src/workspaces/i18n/src/I18nProvider.tsx @@ -11,15 +11,19 @@ export const I18nContext = createContext({ 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(() => { - 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 {children}; }; diff --git a/src/workspaces/i18n/src/index.ts b/src/workspaces/i18n/src/index.ts index ff005cfc..14777e5c 100644 --- a/src/workspaces/i18n/src/index.ts +++ b/src/workspaces/i18n/src/index.ts @@ -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'; diff --git a/src/workspaces/i18n/src/parity.ts b/src/workspaces/i18n/src/parity.ts new file mode 100644 index 00000000..286cb408 --- /dev/null +++ b/src/workspaces/i18n/src/parity.ts @@ -0,0 +1,27 @@ +import { isPluralForms } from './resolve'; + +type Catalog = Record; + +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): 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 })); + }); +}; diff --git a/src/workspaces/i18n/src/resolve.test.ts b/src/workspaces/i18n/src/resolve.test.ts new file mode 100644 index 00000000..4338050c --- /dev/null +++ b/src/workspaces/i18n/src/resolve.test.ts @@ -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([]); + }); +}); diff --git a/src/workspaces/i18n/src/resolve.ts b/src/workspaces/i18n/src/resolve.ts index e344c1bd..dde151f7 100644 Binary files a/src/workspaces/i18n/src/resolve.ts and b/src/workspaces/i18n/src/resolve.ts differ diff --git a/src/workspaces/i18n/src/types.ts b/src/workspaces/i18n/src/types.ts index a3636400..4d4626fe 100644 --- a/src/workspaces/i18n/src/types.ts +++ b/src/workspaces/i18n/src/types.ts @@ -2,6 +2,12 @@ export type TranslationMap = Record>; export type InterpolationParams = Record; +/** + * 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>; + export type I18nContextValue = { t: (key: string, params?: InterpolationParams) => string; locale: string; diff --git a/src/workspaces/i18n/src/useFormat.ts b/src/workspaces/i18n/src/useFormat.ts new file mode 100644 index 00000000..d260a379 --- /dev/null +++ b/src/workspaces/i18n/src/useFormat.ts @@ -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]); +};