@chipmobilesdk/rn-i18n 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/i18n.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { createReporter } from './config/diagnostics';
2
+ import type { ResolvedInvalidFormatPolicy } from './format/invalid';
1
3
  import type {
2
4
  ActiveLocalePreference,
3
5
  I18nConfig,
@@ -12,7 +14,13 @@ import type {
12
14
  import { validateConfig } from './config/validateConfig';
13
15
  import { createActiveLocaleStore } from './locale/activeLocale';
14
16
  import { defaultDeviceLocaleProvider, matchSupportedLocale } from './locale/deviceLocale';
15
- import { readPersistedLocale, writePersistedLocale } from './locale/persistence';
17
+ import {
18
+ clearPersistedLocale,
19
+ readPersistedLocale,
20
+ readPersistedLocaleSync,
21
+ writePersistedLocale,
22
+ } from './locale/persistence';
23
+ import { hasPluralRule } from './translation/plurals';
16
24
  import { translate } from './translation/resolver';
17
25
  import type { ResolverContext } from './translation/resolver';
18
26
  import { createRuntimeRegistry, registerRuntimeEntries } from './translation/runtimeKeys';
@@ -25,13 +33,34 @@ import type { UnitFormatOptions } from './format/units';
25
33
  export interface I18n<TKey extends string = string> {
26
34
  /** Resolves the initial locale: persisted → device → primary fallback. */
27
35
  init(): Promise<void>;
36
+ /**
37
+ * `init()` without waiting, before the first render (PERF-08). Resolves the locale
38
+ * and returns true when it can do so synchronously: persistence is off, or the
39
+ * storage adapter has `getItemSync`. Returns false, changing nothing, when the
40
+ * adapter can only read asynchronously; call `init()` then.
41
+ */
42
+ initSync(): boolean;
28
43
  t(key: TKey, vars?: TranslateVars, options?: TranslateOptions): string;
29
44
  getLocale(): ActiveLocalePreference;
30
45
  /** Switches at runtime without restart and persists best-effort. */
31
46
  setLocale(code: string): Promise<void>;
47
+ /**
48
+ * Forgets the stored choice and follows the device again — the "System
49
+ * language" option a settings screen offers (I18N-17). Resolves to the
50
+ * locale now active.
51
+ */
52
+ useDeviceLocale(): Promise<ActiveLocalePreference>;
32
53
  getSupportedLocales(): readonly LocaleConfig[];
33
54
  formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
34
- formatDate(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
55
+ /**
56
+ * Options are merged over the locale's default (`{ month: 'long' }` keeps the
57
+ * year and day). `replaceDefaults` uses only what is passed.
58
+ */
59
+ formatDate(
60
+ value: Date | number,
61
+ options?: Intl.DateTimeFormatOptions,
62
+ formatting?: { replaceDefaults?: boolean },
63
+ ): string;
35
64
  formatCurrency(value: number, currency?: string, options?: Intl.NumberFormatOptions): string;
36
65
  formatUnit(value: number, options?: UnitFormatOptions): string;
37
66
  convertUnit(value: number, from: UnitId, to: UnitId): number;
@@ -55,15 +84,33 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
55
84
  const locales: readonly LocaleConfig[] = [...config.locales];
56
85
  const localesByCode = new Map(locales.map(locale => [locale.code, locale]));
57
86
  const missingTranslation = config.missingTranslation ?? DEFAULT_MISSING;
58
- const invalidFormat = config.invalidFormat ?? DEFAULT_INVALID;
87
+ const report = createReporter(config.onDiagnostic);
88
+ const invalidFormat: ResolvedInvalidFormatPolicy = {
89
+ ...(config.invalidFormat ?? DEFAULT_INVALID),
90
+ report,
91
+ };
59
92
  const persistenceEnabled = config.persistLocale !== false && !!config.storage;
60
93
  const runtimeEntries = createRuntimeRegistry(config.runtimeEntries);
61
94
 
62
- const store = createActiveLocaleStore({
63
- code: config.primaryLocale,
64
- source: 'primaryFallback',
65
- recovered: false,
66
- });
95
+ const store = createActiveLocaleStore(
96
+ { code: config.primaryLocale, source: 'primaryFallback', recovered: false },
97
+ report,
98
+ );
99
+
100
+ // A language this package has no plural rule for falls back to
101
+ // `count === 1 ? one : other`, which is wrong for most of the world. Said
102
+ // once per locale, at creation, rather than never (I18N-12).
103
+ for (const locale of locales) {
104
+ if (!hasPluralRule(locale)) {
105
+ report({
106
+ code: 'plural_rule_default',
107
+ locale: locale.code,
108
+ message:
109
+ `No plural rule for "${locale.code}"; falling back to "count === 1 selects one". ` +
110
+ 'Declare `pluralRule` on the locale if that is not how the language works.',
111
+ });
112
+ }
113
+ }
67
114
 
68
115
  const resolverContext: ResolverContext = {
69
116
  localesByCode,
@@ -71,6 +118,7 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
71
118
  runtimeEntries,
72
119
  missingTranslation,
73
120
  warned: new Set(),
121
+ report,
74
122
  };
75
123
 
76
124
  const activeLocale = (): LocaleConfig => {
@@ -82,37 +130,75 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
82
130
  return locale;
83
131
  };
84
132
 
133
+ /** persisted → device → primary fallback; shared by `init` and `initSync`. */
134
+ const resolveInitialLocale = (persisted: string | null): void => {
135
+ if (persisted) {
136
+ if (localesByCode.has(persisted)) {
137
+ store.set({ code: persisted, source: 'persisted', recovered: false });
138
+ return;
139
+ }
140
+ report({
141
+ code: 'locale_unsupported',
142
+ locale: persisted,
143
+ message: `Persisted locale "${persisted}" is no longer supported; recovering to "${config.primaryLocale}".`,
144
+ });
145
+ store.set({ code: config.primaryLocale, source: 'primaryFallback', recovered: true });
146
+ return;
147
+ }
148
+ const provider = config.deviceLocale ?? defaultDeviceLocaleProvider;
149
+ const match = matchSupportedLocale(provider(), locales);
150
+ store.set(
151
+ match
152
+ ? { code: match.code, source: match.source, recovered: false }
153
+ : { code: config.primaryLocale, source: 'primaryFallback', recovered: false },
154
+ );
155
+ };
156
+
157
+ /**
158
+ * `init()` runs once, and loses to the user.
159
+ *
160
+ * It used to be re-run by every caller (StrictMode mounts an effect twice),
161
+ * and the persisted value it had awaited was written in unconditionally — so
162
+ * a locale the user picked, or a deep link set, while the read was in flight
163
+ * was overwritten by the old one (I18N-07).
164
+ */
165
+ let initialized: Promise<void> | undefined;
166
+ let switched = false;
167
+
168
+ const runInit = async (): Promise<void> => {
169
+ const persisted =
170
+ persistenceEnabled && config.storage
171
+ ? await readPersistedLocale(config.storage, report)
172
+ : null;
173
+ if (switched) {
174
+ // The user chose while the read was in flight; their choice stands.
175
+ return;
176
+ }
177
+ resolveInitialLocale(persisted);
178
+ };
179
+
85
180
  return {
86
- async init() {
87
- if (persistenceEnabled && config.storage) {
88
- const persisted = await readPersistedLocale(config.storage);
89
- if (persisted) {
90
- if (localesByCode.has(persisted)) {
91
- store.set({ code: persisted, source: 'persisted', recovered: false });
92
- return;
93
- }
94
- console.warn(
95
- `[rn-i18n] Persisted locale "${persisted}" is no longer supported; recovering to "${config.primaryLocale}".`,
96
- );
97
- store.set({
98
- code: config.primaryLocale,
99
- source: 'primaryFallback',
100
- recovered: true,
101
- });
102
- return;
103
- }
181
+ init() {
182
+ initialized ??= runInit();
183
+ return initialized;
184
+ },
185
+
186
+ initSync() {
187
+ if (!persistenceEnabled || !config.storage) {
188
+ resolveInitialLocale(null);
189
+ return true;
104
190
  }
105
- const provider = config.deviceLocale ?? defaultDeviceLocaleProvider;
106
- const match = matchSupportedLocale(provider(), locales);
107
- store.set(
108
- match
109
- ? { code: match.code, source: match.source, recovered: false }
110
- : {
111
- code: config.primaryLocale,
112
- source: 'primaryFallback',
113
- recovered: false,
114
- },
191
+ const { storage } = config;
192
+ if (typeof storage.getItemSync !== 'function') {
193
+ return false;
194
+ }
195
+ resolveInitialLocale(
196
+ readPersistedLocaleSync(
197
+ storage as typeof storage & { getItemSync(key: string): string | null },
198
+ report,
199
+ ),
115
200
  );
201
+ return true;
116
202
  },
117
203
 
118
204
  t(key, vars, options) {
@@ -127,12 +213,34 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
127
213
  if (!localesByCode.has(code)) {
128
214
  throw new Error(`[rn-i18n] Cannot switch to unsupported locale "${code}".`);
129
215
  }
216
+ switched = true;
217
+ const current = store.get();
218
+ if (current.code === code && current.source === 'runtimeSwitch') {
219
+ // Already this locale, by this route: re-rendering the whole tree and
220
+ // rewriting storage would change nothing (I18N-07).
221
+ return;
222
+ }
130
223
  store.set({ code, source: 'runtimeSwitch', recovered: false });
131
224
  if (persistenceEnabled && config.storage) {
132
- await writePersistedLocale(config.storage, code);
225
+ await writePersistedLocale(config.storage, code, report);
133
226
  }
134
227
  },
135
228
 
229
+ async useDeviceLocale() {
230
+ switched = true;
231
+ if (persistenceEnabled && config.storage) {
232
+ await clearPersistedLocale(config.storage, report);
233
+ }
234
+ const provider = config.deviceLocale ?? defaultDeviceLocaleProvider;
235
+ const match = matchSupportedLocale(provider(), locales);
236
+ store.set(
237
+ match
238
+ ? { code: match.code, source: match.source, recovered: false }
239
+ : { code: config.primaryLocale, source: 'primaryFallback', recovered: false },
240
+ );
241
+ return store.get();
242
+ },
243
+
136
244
  getSupportedLocales() {
137
245
  return locales;
138
246
  },
@@ -148,9 +256,16 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
148
256
  );
149
257
  },
150
258
 
151
- formatDate(value, options) {
259
+ formatDate(value, options, formatting) {
152
260
  const locale = activeLocale();
153
- return formatDateValue(value, locale.code, locale.defaultDateFormat, options, invalidFormat);
261
+ return formatDateValue(
262
+ value,
263
+ locale.code,
264
+ locale.defaultDateFormat,
265
+ options,
266
+ invalidFormat,
267
+ formatting?.replaceDefaults === true,
268
+ );
154
269
  },
155
270
 
156
271
  formatCurrency(value, currency, options) {
@@ -183,6 +298,10 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
183
298
 
184
299
  registerRuntimeEntries(entries) {
185
300
  registerRuntimeEntries(runtimeEntries, entries);
301
+ // What `t()` answers has changed, so every subscriber has to read again:
302
+ // a feature that registered its entries after mounting used to leave the
303
+ // screen showing the key (I18N-06).
304
+ store.bump();
186
305
  },
187
306
 
188
307
  subscribe(listener) {
package/src/index.ts CHANGED
@@ -11,6 +11,14 @@ export { createI18n } from './i18n';
11
11
  export type { I18n } from './i18n';
12
12
 
13
13
  export { I18nConfigError } from './config/types';
14
+ export { runtimeEntriesFromCatalog } from './config/catalog';
15
+ export { localeStorageFromKeyValue } from './locale/keyValueStorage';
16
+ export type { I18nErrorCode } from './config/errors';
17
+ export type {
18
+ I18nDiagnostic,
19
+ I18nDiagnosticCode,
20
+ I18nDiagnosticHandler,
21
+ } from './config/diagnostics';
14
22
  export type {
15
23
  ActiveLocalePreference,
16
24
  DeviceLocaleProvider,
@@ -1,46 +1,52 @@
1
+ import { createListenerSet } from '@chipmobilesdk/rn-core';
2
+
3
+ import { defaultReporter, describeError, type I18nDiagnosticHandler } from '../config/diagnostics';
1
4
  import type { ActiveLocalePreference } from '../config/types';
2
5
 
3
6
  export interface ActiveLocaleStore {
4
7
  get(): ActiveLocalePreference;
5
8
  set(preference: ActiveLocalePreference): void;
6
9
  subscribe(listener: () => void): () => void;
10
+ /**
11
+ * Notifies subscribers without changing the locale: what reaches the screen
12
+ * changed, not which language it is in (a runtime entry registered by a
13
+ * lazily loaded feature, say).
14
+ */
15
+ bump(): void;
7
16
  /** Monotonic change counter consumed by useSyncExternalStore. */
8
17
  getVersion(): number;
9
18
  }
10
19
 
11
- export function createActiveLocaleStore(initial: ActiveLocalePreference): ActiveLocaleStore {
20
+ export function createActiveLocaleStore(
21
+ initial: ActiveLocalePreference,
22
+ report: I18nDiagnosticHandler = defaultReporter,
23
+ ): ActiveLocaleStore {
12
24
  let preference = initial;
13
25
  let version = 0;
14
- const listeners = new Set<() => void>();
26
+ const listeners = createListenerSet<[]>({
27
+ onListenerError: error =>
28
+ report({
29
+ code: 'listener_threw',
30
+ message: `A locale listener threw; the locale change still applied: ${describeError(error)}`,
31
+ }),
32
+ });
15
33
 
16
34
  return {
17
35
  get: () => preference,
18
36
  set(next) {
19
37
  preference = next;
20
38
  version += 1;
21
- // Snapshot, and isolate each listener (CORE-03). One that threw used to
22
- // skip every listener after it — useSyncExternalStore subscribers
23
- // included — and made setLocale reject after the switch but before the
24
- // choice was persisted, so the app showed one locale and stored another.
25
- for (const listener of [...listeners]) {
26
- // Unsubscribed by an earlier listener during this notification.
27
- if (!listeners.has(listener)) continue;
28
- try {
29
- listener();
30
- } catch (error) {
31
- console.warn(
32
- '[rn-i18n] A locale listener threw; the locale change still applied.',
33
- error,
34
- );
35
- }
36
- }
39
+ // Isolated (CORE-03). One that threw used to skip every listener after
40
+ // it — useSyncExternalStore subscribers included — and made setLocale
41
+ // reject after the switch but before the choice was persisted, so the
42
+ // app showed one locale and stored another.
43
+ listeners.notify();
37
44
  },
38
- subscribe(listener) {
39
- listeners.add(listener);
40
- return () => {
41
- listeners.delete(listener);
42
- };
45
+ bump() {
46
+ version += 1;
47
+ listeners.notify();
43
48
  },
49
+ subscribe: listener => listeners.add(listener),
44
50
  getVersion: () => version,
45
51
  };
46
52
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A `LocaleStorageAdapter` over rn-core's synchronous `KeyValuePort` (PERF-08).
3
+ *
4
+ * The adapter carries `getItemSync`, so `i18n.initSync()` resolves the persisted
5
+ * locale before the first render. Open the port the way the app opens its other
6
+ * durable state, for example `openDurable(() => createMMKV({ id: 'app' }))`.
7
+ */
8
+ import type { KeyValuePort } from '@chipmobilesdk/rn-core';
9
+
10
+ import type { LocaleStorageAdapter } from '../config/types';
11
+
12
+ export function localeStorageFromKeyValue(
13
+ port: KeyValuePort,
14
+ ): LocaleStorageAdapter & { getItemSync(key: string): string | null } {
15
+ return {
16
+ getItemSync: key => port.getString(key) ?? null,
17
+ async getItem(key) {
18
+ return port.getString(key) ?? null;
19
+ },
20
+ async setItem(key, value) {
21
+ port.set(key, value);
22
+ },
23
+ async removeItem(key) {
24
+ port.remove(key);
25
+ },
26
+ };
27
+ }
@@ -1,3 +1,4 @@
1
+ import { defaultReporter, describeError, type I18nDiagnosticHandler } from '../config/diagnostics';
1
2
  import type { LocaleStorageAdapter } from '../config/types';
2
3
 
3
4
  export const LOCALE_STORAGE_KEY = '@chipmobilesdk/rn-i18n:locale';
@@ -6,11 +7,33 @@ export const LOCALE_STORAGE_KEY = '@chipmobilesdk/rn-i18n:locale';
6
7
  * Reads the persisted locale preference. Adapter failures are reported and
7
8
  * treated as "no persisted value" so locale selection never crashes (FR-015).
8
9
  */
9
- export async function readPersistedLocale(storage: LocaleStorageAdapter): Promise<string | null> {
10
+ export async function readPersistedLocale(
11
+ storage: LocaleStorageAdapter,
12
+ report: I18nDiagnosticHandler = defaultReporter,
13
+ ): Promise<string | null> {
10
14
  try {
11
15
  return await storage.getItem(LOCALE_STORAGE_KEY);
12
16
  } catch (error) {
13
- console.warn(`[rn-i18n] Failed to read the persisted locale preference: ${String(error)}`);
17
+ report({
18
+ code: 'locale_read_failed',
19
+ message: `Failed to read the persisted locale preference: ${describeError(error)}`,
20
+ });
21
+ return null;
22
+ }
23
+ }
24
+
25
+ /** `readPersistedLocale` for a store with a synchronous read; same failure handling. */
26
+ export function readPersistedLocaleSync(
27
+ storage: LocaleStorageAdapter & { getItemSync(key: string): string | null },
28
+ report: I18nDiagnosticHandler = defaultReporter,
29
+ ): string | null {
30
+ try {
31
+ return storage.getItemSync(LOCALE_STORAGE_KEY);
32
+ } catch (error) {
33
+ report({
34
+ code: 'locale_read_failed',
35
+ message: `Failed to read the persisted locale preference: ${describeError(error)}`,
36
+ });
14
37
  return null;
15
38
  }
16
39
  }
@@ -22,10 +45,34 @@ export async function readPersistedLocale(storage: LocaleStorageAdapter): Promis
22
45
  export async function writePersistedLocale(
23
46
  storage: LocaleStorageAdapter,
24
47
  code: string,
48
+ report: I18nDiagnosticHandler = defaultReporter,
25
49
  ): Promise<void> {
26
50
  try {
27
51
  await storage.setItem(LOCALE_STORAGE_KEY, code);
28
52
  } catch (error) {
29
- console.warn(`[rn-i18n] Failed to persist locale "${code}": ${String(error)}`);
53
+ report({
54
+ code: 'locale_write_failed',
55
+ locale: code,
56
+ message: `Failed to persist locale "${code}": ${describeError(error)}`,
57
+ });
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Forgets the stored choice, so the next launch follows the device again. The
63
+ * adapter's `removeItem` was required by the contract and called by nothing
64
+ * until `useDeviceLocale()` (I18N-17). Best-effort, like the write.
65
+ */
66
+ export async function clearPersistedLocale(
67
+ storage: LocaleStorageAdapter,
68
+ report: I18nDiagnosticHandler = defaultReporter,
69
+ ): Promise<void> {
70
+ try {
71
+ await storage.removeItem(LOCALE_STORAGE_KEY);
72
+ } catch (error) {
73
+ report({
74
+ code: 'locale_write_failed',
75
+ message: `Failed to forget the persisted locale preference: ${describeError(error)}`,
76
+ });
30
77
  }
31
78
  }
@@ -1,4 +1,4 @@
1
- import { useSyncExternalStore } from 'react';
1
+ import { useCallback, useMemo, useSyncExternalStore } from 'react';
2
2
  import type {
3
3
  ActiveLocalePreference,
4
4
  LocaleConfig,
@@ -9,8 +9,8 @@ import type {
9
9
  import type { I18n } from '../i18n';
10
10
  import { useI18nContext } from './I18nProvider';
11
11
 
12
- function useLocaleVersion(i18n: I18n<string>): void {
13
- useSyncExternalStore(i18n.subscribe, i18n.getVersion, i18n.getVersion);
12
+ function useLocaleVersion(i18n: I18n<string>): number {
13
+ return useSyncExternalStore(i18n.subscribe, i18n.getVersion, i18n.getVersion);
14
14
  }
15
15
 
16
16
  export interface UseTranslationResult<TKey extends string> {
@@ -25,11 +25,19 @@ export interface UseTranslationResult<TKey extends string> {
25
25
  */
26
26
  export function useTranslation<TKey extends string = string>(): UseTranslationResult<TKey> {
27
27
  const i18n = useI18nContext();
28
- useLocaleVersion(i18n);
29
- return {
30
- t: (key, vars, options) => i18n.t(key, vars, options),
31
- locale: i18n.getLocale().code,
32
- };
28
+ const version = useLocaleVersion(i18n);
29
+ // One `t` and one result per locale version (I18N-05): a memoized child that
30
+ // takes `t` re-renders when the locale changes, not on every parent render.
31
+ return useMemo(
32
+ () => ({
33
+ t: (key: TKey, vars?: TranslateVars, options?: TranslateOptions) =>
34
+ i18n.t(key, vars, options),
35
+ locale: i18n.getLocale().code,
36
+ }),
37
+ // `version` is the dependency that matters: it changes with the locale.
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `version` is read to invalidate, not used
39
+ [i18n, version],
40
+ );
33
41
  }
34
42
 
35
43
  export interface UseLocaleResult {
@@ -44,6 +52,7 @@ export interface UseLocaleResult {
44
52
  export function useLocale(): UseLocaleResult {
45
53
  const i18n = useI18nContext();
46
54
  useLocaleVersion(i18n);
55
+ const setLocale = useCallback((code: string) => i18n.setLocale(code), [i18n]);
47
56
  const preference = i18n.getLocale();
48
57
  const supportedLocales = i18n.getSupportedLocales();
49
58
  const active = supportedLocales.find(locale => locale.code === preference.code);
@@ -52,6 +61,6 @@ export function useLocale(): UseLocaleResult {
52
61
  direction: active?.direction ?? 'ltr',
53
62
  preference,
54
63
  supportedLocales,
55
- setLocale: code => i18n.setLocale(code),
64
+ setLocale,
56
65
  };
57
66
  }
@@ -17,8 +17,11 @@ export function renderKeysModule(
17
17
  .map(entry => entry.key)
18
18
  .sort((a, b) => a.localeCompare(b, 'en'));
19
19
 
20
+ // Escaped, not interpolated: a key holding an apostrophe or a backslash wrote
21
+ // a file that did not parse, and `tsc` then failed for the whole app (I18N-20).
22
+ const literal = (key: string) => `'${key.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
20
23
  const renderList = (keys: readonly string[]) =>
21
- keys.length === 0 ? '[]' : `[\n${keys.map(key => `\t'${key}',`).join('\n')}\n]`;
24
+ keys.length === 0 ? '[]' : `[\n${keys.map(key => `\t${literal(key)},`).join('\n')}\n]`;
22
25
 
23
26
  return [
24
27
  '// AUTO-GENERATED by @chipmobilesdk/rn-i18n tooling. Do not edit by hand.',
@@ -11,22 +11,91 @@ export const PLURAL_CATEGORIES: readonly PluralCategory[] = [
11
11
 
12
12
  const oneOtherRule: PluralRule = count => (count === 1 ? 'one' : 'other');
13
13
  const otherOnlyRule: PluralRule = () => 'other';
14
+ /** French and Brazilian Portuguese: 0 and 1 are both singular. */
15
+ const zeroOneRule: PluralRule = count => (count === 0 || count === 1 ? 'one' : 'other');
16
+
17
+ /** Russian, Ukrainian, Polish, Czech and their neighbours (CLDR). */
18
+ const slavicRule: PluralRule = count => {
19
+ if (!Number.isInteger(count)) {
20
+ return 'many';
21
+ }
22
+ const mod10 = Math.abs(count) % 10;
23
+ const mod100 = Math.abs(count) % 100;
24
+ if (mod10 === 1 && mod100 !== 11) {
25
+ return 'one';
26
+ }
27
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
28
+ return 'few';
29
+ }
30
+ return 'many';
31
+ };
32
+
33
+ /** Arabic (CLDR), which uses every category. */
34
+ const arabicRule: PluralRule = count => {
35
+ if (count === 0) return 'zero';
36
+ if (count === 1) return 'one';
37
+ if (count === 2) return 'two';
38
+ const mod100 = Math.abs(count) % 100;
39
+ if (mod100 >= 3 && mod100 <= 10) return 'few';
40
+ if (mod100 >= 11 && mod100 <= 99) return 'many';
41
+ return 'other';
42
+ };
14
43
 
15
44
  /**
16
- * Package-owned plural rules. Intl.PluralRules is not implemented by Hermes,
17
- * so rules are resolved from configuration to keep behavior identical across
18
- * devices and test environments. Languages without a built-in rule use the
19
- * documented default (`count === 1` selects `one`, everything else `other`)
20
- * unless the locale configuration supplies a custom rule.
45
+ * Package-owned plural rules, a compact subset of CLDR. Intl.PluralRules is not
46
+ * implemented by Hermes, so rules are resolved from configuration to keep
47
+ * behavior identical across devices and test environments.
48
+ *
49
+ * A language that is not here falls back to `count === 1 ? one : other`, which
50
+ * is wrong for most of the world — Korean and Thai have no singular, French
51
+ * calls 0 singular, Russian has `few` and `many` — so the fallback now reports
52
+ * a diagnostic once per locale rather than being silently applied (I18N-12).
21
53
  */
22
54
  const BUILT_IN_RULES: Record<string, PluralRule> = {
55
+ // one/other
23
56
  en: oneOtherRule,
57
+ de: oneOtherRule,
58
+ es: oneOtherRule,
59
+ it: oneOtherRule,
60
+ nl: oneOtherRule,
61
+ sv: oneOtherRule,
62
+ da: oneOtherRule,
63
+ nb: oneOtherRule,
64
+ fi: oneOtherRule,
65
+ el: oneOtherRule,
66
+ he: oneOtherRule,
67
+ // other only
24
68
  ja: otherOnlyRule,
25
69
  vi: otherOnlyRule,
70
+ ko: otherOnlyRule,
71
+ zh: otherOnlyRule,
72
+ th: otherOnlyRule,
73
+ id: otherOnlyRule,
74
+ ms: otherOnlyRule,
75
+ // 0 and 1 are singular
76
+ fr: zeroOneRule,
77
+ pt: zeroOneRule,
78
+ // Slavic
79
+ ru: slavicRule,
80
+ uk: slavicRule,
81
+ pl: slavicRule,
82
+ cs: slavicRule,
83
+ sk: slavicRule,
84
+ // Every category
85
+ ar: arabicRule,
26
86
  };
27
87
 
28
88
  export const DEFAULT_PLURAL_RULE: PluralRule = oneOtherRule;
29
89
 
90
+ /** True when this package has a rule for the language, or the locale brought one. */
91
+ export function hasPluralRule(locale: LocaleConfig): boolean {
92
+ if (locale.pluralRule) {
93
+ return true;
94
+ }
95
+ const language = locale.code.split('-')[0]?.toLowerCase() ?? '';
96
+ return language in BUILT_IN_RULES;
97
+ }
98
+
30
99
  export function resolvePluralRule(locale: LocaleConfig): PluralRule {
31
100
  if (locale.pluralRule) {
32
101
  return locale.pluralRule;