From dcd11a3b1746dfbccff09dd14527d81445dc3f6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 03:42:46 +0000 Subject: [PATCH] i18n: plural selection via Intl.PluralRules, dev-mode miss warning, useFormat, key-parity helper --- src/workspaces/i18n/src/I18nProvider.tsx | 12 ++- src/workspaces/i18n/src/index.ts | 7 +- src/workspaces/i18n/src/parity.ts | 27 ++++++ src/workspaces/i18n/src/resolve.test.ts | 102 +++++++++++++++++++++++ src/workspaces/i18n/src/resolve.ts | Bin 823 -> 2762 bytes src/workspaces/i18n/src/types.ts | 6 ++ src/workspaces/i18n/src/useFormat.ts | 32 +++++++ 7 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 src/workspaces/i18n/src/parity.ts create mode 100644 src/workspaces/i18n/src/resolve.test.ts create mode 100644 src/workspaces/i18n/src/useFormat.ts 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 e344c1bdcb9eebc09f5af52d6c6b9042de2d7d89..dde151f7f1fd1db5665508272e6d27318b5beedc 100644 GIT binary patch literal 2762 zcmZuz+in{-5bd+SViE-2-Bez+eG23%hEcml5G66}q%X2zQM)5)D~c3It`tQP(2wXB z_DecLE>}{L2SZ+7=5o%=8CHv>HV)ioiLiw?%Ar|mDO{!1tuSI?J8&y&Bjlgj@W)3Z zlnwju#1eKeF?s<>-en6mIX^imw6YFv2cPaP2Ty-oe!RZNY7nu*P z-VK7~yISJ#5&AGpzM#=b2NJE2f85%CO>oU$3!yfC)6LK%kK_tJ`{0Ae${qwt*3y-b zHTIxZbEVfR>w#Z@${uC}{nsbp)7O*cK)>Io2*!V7;SxAKrR{L>8Lg{Y%CPqA(ipMH zE9-xQvn*_V@OSe$Zfvuj;|k3tP3IZ;`yQLTQiZHbwCNm#{SiLdv1uRBx5iiAIP<`% zfKS6ZQ>kf^wY*rAlvhBCL7FhFy+cGgAax-mAr~%ppwP8)J<5GCMngD;^?;neKUBC5 zQSTYGX)u%J!DZ^LWRGxh0sd01nMeLi_GPmca(*k^j6)*|TJ}1yq}Aw}VrW=pIJd-* zLE<~tk{ckmT#go}3*IAS*idelvT|vXCj>0t2dE}=Hd<(i{`xgmM%>{V zh_#VXMZ4N4ilxL!rLbhDa1cjpkVw75LYwmUh-nl5>ulJBM)eG^qrb*zs5>lJIS4=m3>t29<&mSA-_*T)l$-{`nVd zB^epoSsGo|1-E_&*2E10F$C9YDl0pKiI8$Eia8X*n2I8(Tn;b0O}}$&92r&HTih^x z-&PU%Z~2R~G3eyAKx=bUtNi_ktLvxh_kV@r-N%zetQn-H-Ai&Zda(^|c0aw?l7rod z53toktn!(#X;l){0b4$Ev@gn%_oHzwG3WhsG^~Dor5<4s5@QJAd2saHS66V(YfTu> zM`VQ=BO*?KxzKY$O2H@`7$p`+j0i&~q@H#dVcLw?4t(d0SOf$}VcnTQ%lbU7r@nAK zV~W*;4iqy{DfTi(w??vKa7yF}NrGi6p`3-Z)P?PO-_;kd>iMFCtKYI5E}K555Ck>I ztXfi_me5@+shjZJlOF?PFZj8#WYSAGw-y)2lx6_`5;A8?a_TnVko#?>YgvMjEczRm zi4{`L^>pTPGSxj+Zq8Az@NK9YBFF8A3~AHx$m!62z%o`7uaN)4&lq;VGwx8qB&ZTT z@5SgMYFg*yP}RI3gd$_A`cx}Hl+wevh9QW@&{(nT`~}j8RuVE_Y5UGNYR6b`E>0d_ z8>EOo_IR9C>zUHwijTt2Q}0;(qonw$5hFbsm(7^U$W$vpjXjT4uonhE0v9Bw`zaFc z#;MUviwt_r0N%?yal{hUI-u{+E5~EJeS%OHloe7r3U@G#sLbeDLfrk}vGN^_49v4| zBX&O(XfVl0G-4Zz}iS-NUX+jha4rreiJnS$prNYZ~kiDIY)G zM%!)Bh}~|K?d1UOJ`P~Kp|)1jT1*jaJ$8aDD^2~qfeAk?LQw@>*3{yYnkEdwNSL$h zn-Nggjb?cEW-uc4$I{YWKL!e2;(-{Gr>DW?`lx}9x;@TuFgc1vM%qfDxTGjEFI~abPNAAh0cuQHVoqu5WJPwhC5RC)l>7-V|5g=Q!6#Cff|caOG=CK6iO-!QuEUkzy>JT+S)3pL(ErKu!jm-fwWk2 P)oyNMQed3i!tn?IB~C>? 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]); +};