@chipmobilesdk/rn-i18n 0.2.0 → 0.4.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.
Files changed (201) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +48 -17
  3. package/lib/commonjs/config/catalog.js +29 -0
  4. package/lib/commonjs/config/catalog.js.map +1 -0
  5. package/lib/commonjs/config/diagnostics.js +56 -0
  6. package/lib/commonjs/config/diagnostics.js.map +1 -0
  7. package/lib/commonjs/config/errors.js +35 -0
  8. package/lib/commonjs/config/errors.js.map +1 -0
  9. package/lib/commonjs/config/types.js +13 -0
  10. package/lib/commonjs/config/types.js.map +1 -0
  11. package/lib/commonjs/config/validateConfig.js +134 -0
  12. package/lib/commonjs/config/validateConfig.js.map +1 -0
  13. package/lib/commonjs/format/currency.js +35 -0
  14. package/lib/commonjs/format/currency.js.map +1 -0
  15. package/lib/commonjs/format/date.js +36 -0
  16. package/lib/commonjs/format/date.js.map +1 -0
  17. package/lib/commonjs/format/invalid.js +24 -0
  18. package/lib/commonjs/format/invalid.js.map +1 -0
  19. package/lib/commonjs/format/number.js +29 -0
  20. package/lib/commonjs/format/number.js.map +1 -0
  21. package/lib/commonjs/format/units.js +184 -0
  22. package/lib/commonjs/format/units.js.map +1 -0
  23. package/lib/commonjs/i18n.js +234 -0
  24. package/lib/commonjs/i18n.js.map +1 -0
  25. package/lib/commonjs/index.js +93 -0
  26. package/lib/commonjs/index.js.map +1 -0
  27. package/lib/commonjs/locale/activeLocale.js +37 -0
  28. package/lib/commonjs/locale/activeLocale.js.map +1 -0
  29. package/lib/commonjs/locale/deviceLocale.js +52 -0
  30. package/lib/commonjs/locale/deviceLocale.js.map +1 -0
  31. package/lib/commonjs/locale/keyValueStorage.js +29 -0
  32. package/lib/commonjs/locale/keyValueStorage.js.map +1 -0
  33. package/lib/commonjs/locale/persistence.js +74 -0
  34. package/lib/commonjs/locale/persistence.js.map +1 -0
  35. package/lib/commonjs/package.json +1 -0
  36. package/lib/commonjs/react/I18nProvider.js +36 -0
  37. package/lib/commonjs/react/I18nProvider.js.map +1 -0
  38. package/lib/commonjs/react/hooks.js +47 -0
  39. package/lib/commonjs/react/hooks.js.map +1 -0
  40. package/lib/commonjs/translation/interpolate.js +24 -0
  41. package/lib/commonjs/translation/interpolate.js.map +1 -0
  42. package/lib/commonjs/translation/plurals.js +122 -0
  43. package/lib/commonjs/translation/plurals.js.map +1 -0
  44. package/lib/commonjs/translation/resolver.js +124 -0
  45. package/lib/commonjs/translation/resolver.js.map +1 -0
  46. package/lib/commonjs/translation/runtimeKeys.js +55 -0
  47. package/lib/commonjs/translation/runtimeKeys.js.map +1 -0
  48. package/lib/module/config/catalog.js +25 -0
  49. package/lib/module/config/catalog.js.map +1 -0
  50. package/lib/module/config/diagnostics.js +51 -0
  51. package/lib/module/config/diagnostics.js.map +1 -0
  52. package/lib/module/config/errors.js +30 -0
  53. package/lib/module/config/errors.js.map +1 -0
  54. package/lib/module/config/types.js +26 -0
  55. package/lib/module/config/types.js.map +1 -0
  56. package/lib/module/config/validateConfig.js +131 -0
  57. package/lib/module/config/validateConfig.js.map +1 -0
  58. package/lib/module/format/currency.js +32 -0
  59. package/lib/module/format/currency.js.map +1 -0
  60. package/lib/module/format/date.js +33 -0
  61. package/lib/module/format/date.js.map +1 -0
  62. package/lib/module/format/invalid.js +21 -0
  63. package/lib/module/format/invalid.js.map +1 -0
  64. package/lib/module/format/number.js +26 -0
  65. package/lib/module/format/number.js.map +1 -0
  66. package/lib/module/format/units.js +171 -0
  67. package/lib/module/format/units.js.map +1 -0
  68. package/lib/module/i18n.js +230 -0
  69. package/lib/module/i18n.js.map +1 -0
  70. package/lib/module/index.js +23 -0
  71. package/lib/module/index.js.map +1 -0
  72. package/lib/module/locale/activeLocale.js +33 -0
  73. package/lib/module/locale/activeLocale.js.map +1 -0
  74. package/lib/module/locale/deviceLocale.js +46 -0
  75. package/lib/module/locale/deviceLocale.js.map +1 -0
  76. package/lib/module/locale/keyValueStorage.js +25 -0
  77. package/lib/module/locale/keyValueStorage.js.map +1 -0
  78. package/lib/module/locale/persistence.js +66 -0
  79. package/lib/module/locale/persistence.js.map +1 -0
  80. package/lib/module/package.json +1 -0
  81. package/lib/module/react/I18nProvider.js +29 -0
  82. package/lib/module/react/I18nProvider.js.map +1 -0
  83. package/lib/module/react/hooks.js +42 -0
  84. package/lib/module/react/hooks.js.map +1 -0
  85. package/lib/module/translation/interpolate.js +20 -0
  86. package/lib/module/translation/interpolate.js.map +1 -0
  87. package/lib/module/translation/plurals.js +114 -0
  88. package/lib/module/translation/plurals.js.map +1 -0
  89. package/lib/module/translation/resolver.js +118 -0
  90. package/lib/module/translation/resolver.js.map +1 -0
  91. package/lib/module/translation/runtimeKeys.js +50 -0
  92. package/lib/module/translation/runtimeKeys.js.map +1 -0
  93. package/lib/typescript/commonjs/config/catalog.d.ts +18 -0
  94. package/lib/typescript/commonjs/config/catalog.d.ts.map +1 -0
  95. package/lib/typescript/commonjs/config/diagnostics.d.ts +21 -0
  96. package/lib/typescript/commonjs/config/diagnostics.d.ts.map +1 -0
  97. package/lib/typescript/commonjs/config/errors.d.ts +16 -0
  98. package/lib/typescript/commonjs/config/errors.d.ts.map +1 -0
  99. package/lib/typescript/commonjs/config/types.d.ts +127 -0
  100. package/lib/typescript/commonjs/config/types.d.ts.map +1 -0
  101. package/lib/typescript/commonjs/config/validateConfig.d.ts +8 -0
  102. package/lib/typescript/commonjs/config/validateConfig.d.ts.map +1 -0
  103. package/lib/typescript/commonjs/format/currency.d.ts +12 -0
  104. package/lib/typescript/commonjs/format/currency.d.ts.map +1 -0
  105. package/lib/typescript/commonjs/format/date.d.ts +14 -0
  106. package/lib/typescript/commonjs/format/date.d.ts.map +1 -0
  107. package/lib/typescript/commonjs/format/invalid.d.ts +12 -0
  108. package/lib/typescript/commonjs/format/invalid.d.ts.map +1 -0
  109. package/lib/typescript/commonjs/format/number.d.ts +8 -0
  110. package/lib/typescript/commonjs/format/number.d.ts.map +1 -0
  111. package/lib/typescript/commonjs/format/units.d.ts +23 -0
  112. package/lib/typescript/commonjs/format/units.d.ts.map +1 -0
  113. package/lib/typescript/commonjs/i18n.d.ts +46 -0
  114. package/lib/typescript/commonjs/i18n.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/index.d.ts +27 -0
  116. package/lib/typescript/commonjs/index.d.ts.map +1 -0
  117. package/lib/typescript/commonjs/locale/activeLocale.d.ts +17 -0
  118. package/lib/typescript/commonjs/locale/activeLocale.d.ts.map +1 -0
  119. package/lib/typescript/commonjs/locale/deviceLocale.d.ts +19 -0
  120. package/lib/typescript/commonjs/locale/deviceLocale.d.ts.map +1 -0
  121. package/lib/typescript/commonjs/locale/keyValueStorage.d.ts +13 -0
  122. package/lib/typescript/commonjs/locale/keyValueStorage.d.ts.map +1 -0
  123. package/lib/typescript/commonjs/locale/persistence.d.ts +24 -0
  124. package/lib/typescript/commonjs/locale/persistence.d.ts.map +1 -0
  125. package/lib/typescript/commonjs/package.json +1 -0
  126. package/lib/typescript/commonjs/react/I18nProvider.d.ts +16 -0
  127. package/lib/typescript/commonjs/react/I18nProvider.d.ts.map +1 -0
  128. package/lib/typescript/commonjs/react/hooks.d.ts +21 -0
  129. package/lib/typescript/commonjs/react/hooks.d.ts.map +1 -0
  130. package/lib/typescript/commonjs/translation/interpolate.d.ts +8 -0
  131. package/lib/typescript/commonjs/translation/interpolate.d.ts.map +1 -0
  132. package/lib/typescript/commonjs/translation/plurals.d.ts +13 -0
  133. package/lib/typescript/commonjs/translation/plurals.d.ts.map +1 -0
  134. package/lib/typescript/commonjs/translation/resolver.d.ts +24 -0
  135. package/lib/typescript/commonjs/translation/resolver.d.ts.map +1 -0
  136. package/lib/typescript/commonjs/translation/runtimeKeys.d.ts +16 -0
  137. package/lib/typescript/commonjs/translation/runtimeKeys.d.ts.map +1 -0
  138. package/lib/typescript/module/config/catalog.d.ts +18 -0
  139. package/lib/typescript/module/config/catalog.d.ts.map +1 -0
  140. package/lib/typescript/module/config/diagnostics.d.ts +21 -0
  141. package/lib/typescript/module/config/diagnostics.d.ts.map +1 -0
  142. package/lib/typescript/module/config/errors.d.ts +16 -0
  143. package/lib/typescript/module/config/errors.d.ts.map +1 -0
  144. package/lib/typescript/module/config/types.d.ts +127 -0
  145. package/lib/typescript/module/config/types.d.ts.map +1 -0
  146. package/lib/typescript/module/config/validateConfig.d.ts +8 -0
  147. package/lib/typescript/module/config/validateConfig.d.ts.map +1 -0
  148. package/lib/typescript/module/format/currency.d.ts +12 -0
  149. package/lib/typescript/module/format/currency.d.ts.map +1 -0
  150. package/lib/typescript/module/format/date.d.ts +14 -0
  151. package/lib/typescript/module/format/date.d.ts.map +1 -0
  152. package/lib/typescript/module/format/invalid.d.ts +12 -0
  153. package/lib/typescript/module/format/invalid.d.ts.map +1 -0
  154. package/lib/typescript/module/format/number.d.ts +8 -0
  155. package/lib/typescript/module/format/number.d.ts.map +1 -0
  156. package/lib/typescript/module/format/units.d.ts +23 -0
  157. package/lib/typescript/module/format/units.d.ts.map +1 -0
  158. package/lib/typescript/module/i18n.d.ts +46 -0
  159. package/lib/typescript/module/i18n.d.ts.map +1 -0
  160. package/lib/typescript/module/index.d.ts +27 -0
  161. package/lib/typescript/module/index.d.ts.map +1 -0
  162. package/lib/typescript/module/locale/activeLocale.d.ts +17 -0
  163. package/lib/typescript/module/locale/activeLocale.d.ts.map +1 -0
  164. package/lib/typescript/module/locale/deviceLocale.d.ts +19 -0
  165. package/lib/typescript/module/locale/deviceLocale.d.ts.map +1 -0
  166. package/lib/typescript/module/locale/keyValueStorage.d.ts +13 -0
  167. package/lib/typescript/module/locale/keyValueStorage.d.ts.map +1 -0
  168. package/lib/typescript/module/locale/persistence.d.ts +24 -0
  169. package/lib/typescript/module/locale/persistence.d.ts.map +1 -0
  170. package/lib/typescript/module/package.json +1 -0
  171. package/lib/typescript/module/react/I18nProvider.d.ts +16 -0
  172. package/lib/typescript/module/react/I18nProvider.d.ts.map +1 -0
  173. package/lib/typescript/module/react/hooks.d.ts +21 -0
  174. package/lib/typescript/module/react/hooks.d.ts.map +1 -0
  175. package/lib/typescript/module/translation/interpolate.d.ts +8 -0
  176. package/lib/typescript/module/translation/interpolate.d.ts.map +1 -0
  177. package/lib/typescript/module/translation/plurals.d.ts +13 -0
  178. package/lib/typescript/module/translation/plurals.d.ts.map +1 -0
  179. package/lib/typescript/module/translation/resolver.d.ts +24 -0
  180. package/lib/typescript/module/translation/resolver.d.ts.map +1 -0
  181. package/lib/typescript/module/translation/runtimeKeys.d.ts +16 -0
  182. package/lib/typescript/module/translation/runtimeKeys.d.ts.map +1 -0
  183. package/package.json +41 -18
  184. package/skills/sdk-rn-i18n/SKILL.md +27 -0
  185. package/src/config/diagnostics.ts +2 -0
  186. package/src/format/currency.ts +5 -0
  187. package/src/format/date.ts +10 -4
  188. package/src/i18n.ts +93 -9
  189. package/src/index.ts +5 -3
  190. package/src/locale/activeLocale.ts +10 -0
  191. package/src/locale/persistence.ts +19 -0
  192. package/src/translation/plurals.ts +74 -5
  193. package/src/translation/runtimeKeys.ts +28 -1
  194. package/src/tooling/cli.ts +0 -76
  195. package/src/tooling/generateKeys.ts +0 -62
  196. package/src/tooling/index.ts +0 -27
  197. package/src/tooling/report.ts +0 -109
  198. package/src/tooling/runner.ts +0 -111
  199. package/src/tooling/scan.ts +0 -193
  200. package/src/tooling/sync.ts +0 -193
  201. package/src/tooling/types.ts +0 -119
package/src/i18n.ts CHANGED
@@ -15,10 +15,12 @@ import { validateConfig } from './config/validateConfig';
15
15
  import { createActiveLocaleStore } from './locale/activeLocale';
16
16
  import { defaultDeviceLocaleProvider, matchSupportedLocale } from './locale/deviceLocale';
17
17
  import {
18
+ clearPersistedLocale,
18
19
  readPersistedLocale,
19
20
  readPersistedLocaleSync,
20
21
  writePersistedLocale,
21
22
  } from './locale/persistence';
23
+ import { hasPluralRule } from './translation/plurals';
22
24
  import { translate } from './translation/resolver';
23
25
  import type { ResolverContext } from './translation/resolver';
24
26
  import { createRuntimeRegistry, registerRuntimeEntries } from './translation/runtimeKeys';
@@ -42,9 +44,23 @@ export interface I18n<TKey extends string = string> {
42
44
  getLocale(): ActiveLocalePreference;
43
45
  /** Switches at runtime without restart and persists best-effort. */
44
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>;
45
53
  getSupportedLocales(): readonly LocaleConfig[];
46
54
  formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
47
- 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;
48
64
  formatCurrency(value: number, currency?: string, options?: Intl.NumberFormatOptions): string;
49
65
  formatUnit(value: number, options?: UnitFormatOptions): string;
50
66
  convertUnit(value: number, from: UnitId, to: UnitId): number;
@@ -81,6 +97,21 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
81
97
  report,
82
98
  );
83
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
+ }
114
+
84
115
  const resolverContext: ResolverContext = {
85
116
  localesByCode,
86
117
  resources: config.resources,
@@ -123,13 +154,33 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
123
154
  );
124
155
  };
125
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
+
126
180
  return {
127
- async init() {
128
- resolveInitialLocale(
129
- persistenceEnabled && config.storage
130
- ? await readPersistedLocale(config.storage, report)
131
- : null,
132
- );
181
+ init() {
182
+ initialized ??= runInit();
183
+ return initialized;
133
184
  },
134
185
 
135
186
  initSync() {
@@ -162,12 +213,34 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
162
213
  if (!localesByCode.has(code)) {
163
214
  throw new Error(`[rn-i18n] Cannot switch to unsupported locale "${code}".`);
164
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
+ }
165
223
  store.set({ code, source: 'runtimeSwitch', recovered: false });
166
224
  if (persistenceEnabled && config.storage) {
167
225
  await writePersistedLocale(config.storage, code, report);
168
226
  }
169
227
  },
170
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
+
171
244
  getSupportedLocales() {
172
245
  return locales;
173
246
  },
@@ -183,9 +256,16 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
183
256
  );
184
257
  },
185
258
 
186
- formatDate(value, options) {
259
+ formatDate(value, options, formatting) {
187
260
  const locale = activeLocale();
188
- 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
+ );
189
269
  },
190
270
 
191
271
  formatCurrency(value, currency, options) {
@@ -218,6 +298,10 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
218
298
 
219
299
  registerRuntimeEntries(entries) {
220
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();
221
305
  },
222
306
 
223
307
  subscribe(listener) {
package/src/index.ts CHANGED
@@ -2,9 +2,11 @@
2
2
  * @chipmobilesdk/rn-i18n — reusable localization for ChipMobileSdk-family
3
3
  * React Native apps.
4
4
  *
5
- * This is the single React Native entrypoint. Node-only extraction and
6
- * synchronization tooling is exposed separately via
7
- * `@chipmobilesdk/rn-i18n/tooling` and must never be imported from here.
5
+ * This is the single entrypoint, and the package is React Native code only. The
6
+ * Node-only extraction and synchronization tooling is a package of its own,
7
+ * `@chipmobilesdk/rn-i18n-tooling` (the `chip-i18n` CLI): it used to ship inside
8
+ * this tarball as TypeScript source under `./tooling`, which no consumer could
9
+ * execute.
8
10
  */
9
11
 
10
12
  export { createI18n } from './i18n';
@@ -7,6 +7,12 @@ export interface ActiveLocaleStore {
7
7
  get(): ActiveLocalePreference;
8
8
  set(preference: ActiveLocalePreference): void;
9
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;
10
16
  /** Monotonic change counter consumed by useSyncExternalStore. */
11
17
  getVersion(): number;
12
18
  }
@@ -36,6 +42,10 @@ export function createActiveLocaleStore(
36
42
  // app showed one locale and stored another.
37
43
  listeners.notify();
38
44
  },
45
+ bump() {
46
+ version += 1;
47
+ listeners.notify();
48
+ },
39
49
  subscribe: listener => listeners.add(listener),
40
50
  getVersion: () => version,
41
51
  };
@@ -57,3 +57,22 @@ export async function writePersistedLocale(
57
57
  });
58
58
  }
59
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
+ });
77
+ }
78
+ }
@@ -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;
@@ -1,4 +1,4 @@
1
- import type { RuntimeTranslationEntry } from '../config/types';
1
+ import { I18nConfigError, type RuntimeTranslationEntry } from '../config/types';
2
2
 
3
3
  /**
4
4
  * Registry of declared runtime-only translation entries. Entries participate
@@ -17,10 +17,37 @@ export function createRuntimeRegistry(
17
17
  return registry;
18
18
  }
19
19
 
20
+ /**
21
+ * The same checks the configured entries get (I18N-06): an entry registered at
22
+ * runtime by a lazily loaded feature used to be written in unvalidated, so a
23
+ * missing key or a non-string default surfaced only when a screen rendered it.
24
+ */
25
+ export function validateRuntimeEntries(entries: readonly RuntimeTranslationEntry[]): void {
26
+ const issues: string[] = [];
27
+ const seen = new Set<string>();
28
+ for (const entry of entries) {
29
+ if (!entry?.key) {
30
+ issues.push('Runtime-only entries must declare a non-empty key.');
31
+ continue;
32
+ }
33
+ if (seen.has(entry.key)) {
34
+ issues.push(`Duplicate runtime-only entry "${entry.key}".`);
35
+ }
36
+ seen.add(entry.key);
37
+ if (typeof entry.defaultText !== 'string') {
38
+ issues.push(`Runtime-only entry "${entry.key}" must declare default text.`);
39
+ }
40
+ }
41
+ if (issues.length > 0) {
42
+ throw new I18nConfigError(issues);
43
+ }
44
+ }
45
+
20
46
  export function registerRuntimeEntries(
21
47
  registry: RuntimeEntryRegistry,
22
48
  entries: readonly RuntimeTranslationEntry[],
23
49
  ): void {
50
+ validateRuntimeEntries(entries);
24
51
  for (const entry of entries) {
25
52
  registry.set(entry.key, entry);
26
53
  }
@@ -1,76 +0,0 @@
1
- /**
2
- * Extraction/synchronization CLI. Node-only; run through the root npm
3
- * script under Node >= 22.11 with TypeScript type stripping:
4
- *
5
- * npm run i18n:sync
6
- * node --experimental-strip-types packages/rn-i18n/src/tooling/cli.ts --config i18n-sync.config.json
7
- *
8
- * Exit codes: 0 success or warnings, 1 blocking findings (conflicts or
9
- * malformed locale files), 2 invalid configuration or internal error.
10
- */
11
- import fs from 'node:fs';
12
- import path from 'node:path';
13
- import process from 'node:process';
14
- import { renderReport } from './report.ts';
15
- import { SyncConfigError, runSync } from './runner.ts';
16
- import type { SyncConfig } from './types.ts';
17
-
18
- function parseArgs(argv: readonly string[]): { configPath: string } {
19
- let configPath = 'i18n-sync.config.json';
20
- for (let index = 0; index < argv.length; index += 1) {
21
- if (argv[index] === '--config' && argv[index + 1]) {
22
- configPath = argv[index + 1];
23
- index += 1;
24
- }
25
- }
26
- return { configPath };
27
- }
28
-
29
- async function main(): Promise<number> {
30
- const rootDir = process.cwd();
31
- const { configPath } = parseArgs(process.argv.slice(2));
32
- const absoluteConfigPath = path.resolve(rootDir, configPath);
33
-
34
- if (!fs.existsSync(absoluteConfigPath)) {
35
- console.error(`[rn-i18n] Sync configuration not found: ${configPath}`);
36
- return 2;
37
- }
38
-
39
- let config: SyncConfig;
40
- try {
41
- config = JSON.parse(fs.readFileSync(absoluteConfigPath, 'utf8')) as SyncConfig;
42
- } catch (error) {
43
- console.error(`[rn-i18n] Sync configuration is not valid JSON: ${String(error)}`);
44
- return 2;
45
- }
46
-
47
- try {
48
- const { report, generatedKeysChanged } = await runSync(rootDir, config);
49
- console.log(renderReport(report));
50
- if (config.generateKeysFile) {
51
- console.log(
52
- generatedKeysChanged
53
- ? `Typed keys regenerated: ${config.generateKeysFile}`
54
- : `Typed keys unchanged: ${config.generateKeysFile}`,
55
- );
56
- }
57
- return report.outcome === 'failed' ? 1 : 0;
58
- } catch (error) {
59
- if (error instanceof SyncConfigError) {
60
- console.error(`[rn-i18n] ${error.message}`);
61
- } else {
62
- console.error(`[rn-i18n] Sync failed: ${String(error)}`);
63
- }
64
- return 2;
65
- }
66
- }
67
-
68
- main().then(
69
- code => {
70
- process.exitCode = code;
71
- },
72
- error => {
73
- console.error(`[rn-i18n] Unexpected failure: ${String(error)}`);
74
- process.exitCode = 2;
75
- },
76
- );
@@ -1,62 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import type { RuntimeEntryConfig, TranslationTemplate } from './types.ts';
4
-
5
- /**
6
- * Renders the typed key contract: the union of scanned template keys and
7
- * declared runtime-only keys (FR-041). Apps bind lookups to the generated
8
- * type so `tsc --noEmit` reports invalid or stale key usage before release.
9
- */
10
- export function renderKeysModule(
11
- template: TranslationTemplate,
12
- runtimeEntries: readonly RuntimeEntryConfig[],
13
- typeName = 'AppI18nKey',
14
- ): string {
15
- const templateKeys = template.keys.map(key => key.key).sort((a, b) => a.localeCompare(b, 'en'));
16
- const runtimeKeys = runtimeEntries
17
- .map(entry => entry.key)
18
- .sort((a, b) => a.localeCompare(b, 'en'));
19
-
20
- const renderList = (keys: readonly string[]) =>
21
- keys.length === 0 ? '[]' : `[\n${keys.map(key => `\t'${key}',`).join('\n')}\n]`;
22
-
23
- return [
24
- '// AUTO-GENERATED by @chipmobilesdk/rn-i18n tooling. Do not edit by hand.',
25
- '// Regenerate with: npm run i18n:sync',
26
- '',
27
- `export const I18N_TEMPLATE_KEYS = ${renderList(templateKeys)} as const;`,
28
- '',
29
- `export const I18N_RUNTIME_ONLY_KEYS = ${renderList(runtimeKeys)} as const;`,
30
- '',
31
- 'export const I18N_KEYS = [',
32
- '\t...I18N_TEMPLATE_KEYS,',
33
- '\t...I18N_RUNTIME_ONLY_KEYS,',
34
- '] as const;',
35
- '',
36
- `export type ${typeName} = (typeof I18N_KEYS)[number];`,
37
- '',
38
- ].join('\n');
39
- }
40
-
41
- export interface GenerateKeysResult {
42
- filePath: string;
43
- changed: boolean;
44
- }
45
-
46
- /** Writes the typed key artifact and reports whether it changed (staleness). */
47
- export function generateKeysFile(
48
- rootDir: string,
49
- relativePath: string,
50
- template: TranslationTemplate,
51
- runtimeEntries: readonly RuntimeEntryConfig[],
52
- typeName?: string,
53
- ): GenerateKeysResult {
54
- const filePath = path.join(rootDir, relativePath);
55
- const content = renderKeysModule(template, runtimeEntries, typeName);
56
- const previous = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : undefined;
57
- if (previous !== content) {
58
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
59
- fs.writeFileSync(filePath, content, 'utf8');
60
- }
61
- return { filePath, changed: previous !== content };
62
- }
@@ -1,27 +0,0 @@
1
- /**
2
- * Programmatic surface of the Node-only extraction and synchronization
3
- * tooling, exposed as `@chipmobilesdk/rn-i18n/tooling`. This entrypoint must
4
- * never be imported from React Native code; the runtime API lives at the
5
- * package root (LIB-003).
6
- */
7
-
8
- export { globToRegExp, listSourceFiles, scanSource } from './scan.ts';
9
- export { flattenTranslations, nestTranslations, syncLocaleFiles } from './sync.ts';
10
- export { buildReport, renderReport } from './report.ts';
11
- export { generateKeysFile, renderKeysModule } from './generateKeys.ts';
12
- export type { GenerateKeysResult } from './generateKeys.ts';
13
- export { SyncConfigError, loadRuntimeEntries, runSync, validateSyncConfig } from './runner.ts';
14
- export type { SyncRunResult } from './runner.ts';
15
- export type {
16
- AmbiguousUsage,
17
- ExtractionReport,
18
- KeyConflict,
19
- LocaleSyncResult,
20
- PatternConfig,
21
- ReportOutcome,
22
- RuntimeEntryConfig,
23
- SourceLocation,
24
- SyncConfig,
25
- TemplateKey,
26
- TranslationTemplate,
27
- } from './types.ts';
@@ -1,109 +0,0 @@
1
- import type {
2
- ExtractionReport,
3
- LocaleSyncResult,
4
- ReportOutcome,
5
- RuntimeEntryConfig,
6
- TranslationTemplate,
7
- } from './types.ts';
8
-
9
- function perLocale(
10
- results: readonly LocaleSyncResult[],
11
- select: (result: LocaleSyncResult) => string[],
12
- ): Record<string, string[]> {
13
- const record: Record<string, string[]> = {};
14
- for (const result of results) {
15
- record[result.locale] = select(result);
16
- }
17
- return record;
18
- }
19
-
20
- /**
21
- * Assembles the extraction/synchronization report distinguishing scanned
22
- * template keys, runtime-only keys, stale removals, ambiguous usages, and
23
- * conflicts (FR-026, FR-036).
24
- */
25
- export function buildReport(
26
- template: TranslationTemplate,
27
- syncResults: readonly LocaleSyncResult[],
28
- runtimeEntries: readonly RuntimeEntryConfig[],
29
- ): ExtractionReport {
30
- const errors = syncResults.filter(result => result.error).map(result => result.error!);
31
-
32
- let outcome: ReportOutcome = 'success';
33
- if (
34
- template.ambiguous.length > 0 ||
35
- syncResults.some(result => result.addedKeys.length > 0 || result.runtimeReported.length > 0)
36
- ) {
37
- outcome = 'completedWithWarnings';
38
- }
39
- if (errors.length > 0 || template.conflicts.length > 0) {
40
- outcome = 'failed';
41
- }
42
-
43
- return {
44
- outcome,
45
- discovered: template.keys.map(key => ({
46
- key: key.key,
47
- defaultText: key.defaultText,
48
- locations: key.locations.map(location => `${location.file}:${location.line}`),
49
- })),
50
- createdLocaleFiles: syncResults
51
- .filter(result => result.created && !result.error)
52
- .map(result => ({ locale: result.locale, filePath: result.filePath })),
53
- addedKeys: perLocale(syncResults, result => result.addedKeys),
54
- preservedKeys: perLocale(syncResults, result => result.preservedKeys),
55
- removedStaleKeys: perLocale(syncResults, result => result.removedKeys),
56
- missingPerLocale: perLocale(syncResults, result =>
57
- [...result.addedKeys, ...result.runtimeAdded, ...result.runtimeReported].sort((a, b) =>
58
- a.localeCompare(b, 'en'),
59
- ),
60
- ),
61
- runtimeOnly: {
62
- declared: runtimeEntries.map(entry => entry.key),
63
- preserved: perLocale(syncResults, result => result.runtimePreserved),
64
- added: perLocale(syncResults, result => result.runtimeAdded),
65
- reported: perLocale(syncResults, result => result.runtimeReported),
66
- },
67
- ambiguous: template.ambiguous,
68
- conflicts: template.conflicts,
69
- errors,
70
- };
71
- }
72
-
73
- /** Renders the report as readable text for CLI output. */
74
- export function renderReport(report: ExtractionReport): string {
75
- const lines: string[] = [];
76
- lines.push(`i18n sync outcome: ${report.outcome}`);
77
- lines.push(`Discovered keys: ${report.discovered.length}`);
78
- for (const created of report.createdLocaleFiles) {
79
- lines.push(`Created locale file: ${created.locale} -> ${created.filePath}`);
80
- }
81
- for (const [locale, keys] of Object.entries(report.addedKeys)) {
82
- if (keys.length > 0) {
83
- lines.push(`Added [${locale}]: ${keys.join(', ')}`);
84
- }
85
- }
86
- for (const [locale, keys] of Object.entries(report.removedStaleKeys)) {
87
- if (keys.length > 0) {
88
- lines.push(`Removed stale [${locale}]: ${keys.join(', ')}`);
89
- }
90
- }
91
- for (const [locale, keys] of Object.entries(report.runtimeOnly.reported)) {
92
- if (keys.length > 0) {
93
- lines.push(`Runtime-only missing [${locale}]: ${keys.join(', ')}`);
94
- }
95
- }
96
- for (const usage of report.ambiguous) {
97
- lines.push(`Ambiguous usage at ${usage.file}:${usage.line}: ${usage.snippet}`);
98
- }
99
- for (const conflict of report.conflicts) {
100
- const sources = conflict.values
101
- .map(value => `"${value.text}" (${value.location.file}:${value.location.line})`)
102
- .join(' vs ');
103
- lines.push(`Conflict for key "${conflict.key}": ${sources}`);
104
- }
105
- for (const error of report.errors) {
106
- lines.push(`Error: ${error}`);
107
- }
108
- return lines.join('\n');
109
- }