@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/CHANGELOG.md CHANGED
@@ -1,5 +1,119 @@
1
1
  # @chipmobilesdk/rn-i18n
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - `registerRuntimeEntries` notifies subscribers, so copy a lazily loaded feature registers after its screen mounted appears instead of leaving the key on screen until something else re-rendered. Entries registered at runtime get the same validation as configured ones: a missing key or a non-string default throws `I18nConfigError` there rather than surfacing at render time. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
8
+
9
+ `init()` runs once however many times it is called — StrictMode mounts an effect twice — and loses to the user: a `setLocale` made while the persisted read was in flight is no longer overwritten by the stored value. Setting the locale that is already active changes nothing and re-renders nothing.
10
+
11
+ `i18n.useDeviceLocale()` forgets the stored choice and detects again, which is the "System language" option a settings screen offers; the storage adapter's `removeItem`, required by the contract and called by nothing, is what it uses.
12
+
13
+ Plural rules cover a compact CLDR subset instead of three languages: English and its neighbours, Korean, Chinese, Thai, Indonesian and Malay (no singular), French and Portuguese (0 is singular), Russian, Ukrainian, Polish, Czech and Slovak (`few`/`many`), and Arabic (every category). A language outside the table still falls back to "1 is singular", but now reports a `plural_rule_default` diagnostic once, rather than shipping wrong plurals in silence.
14
+
15
+ `formatDate` **merges** its options over the locale's default date format, as `formatNumber` always has: `{ month: 'long' }` keeps the year and the day. Pass `formatDate(value, options, { replaceDefaults: true })` for the old behaviour of replacing them. `formatCurrency` deliberately does not apply the locale's default number options, because a currency's own minor units must win.
16
+
17
+ The generated keys file escapes a key holding an apostrophe or a backslash, which used to produce a file that did not parse and failed `tsc` for the whole app.
18
+
19
+ ## 0.2.0
20
+
21
+ ### Minor Changes
22
+
23
+ - One diagnostic stream: install a sink with `installSdkSink` from
24
+ `@chipmobilesdk/rn-core` and it receives what every package observes (events,
25
+ failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
26
+ rn-core, so a listener that throws no longer stops the others nor fails the call that
27
+ notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
28
+
29
+ - rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
30
+ events are no longer deduplicated away; the dedupe memory is bounded. A throwing
31
+ status, consent or placement listener is reported as an `internal_error` event.
32
+ - rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
33
+ with a stable `code`); without it, warnings go to `console.warn` in development
34
+ builds only. The warn-once memory is bounded.
35
+ - rn-chart: now depends on rn-core for its diagnostics.
36
+ - rn-payment: status, entitlement and pending listeners report a throw to the logger
37
+ instead of swallowing it.
38
+
39
+ - The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
40
+ dependency: `isSdkError` recognises them alongside every other package's, and each
41
+ carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
42
+
43
+ - rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
44
+ deprecated alias.
45
+ - rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
46
+ e-mail addresses and UUIDs.
47
+ - rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
48
+ to its code.
49
+ - rn-ai: an error message quoted from the gateway is pattern-redacted.
50
+ - rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
51
+ - rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
52
+ `code: 'contrast'` (new `ThemeErrorCode` type).
53
+ - rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
54
+ `code: 'unit_conversion'` (new `I18nErrorCode` type).
55
+
56
+ - Hooks read through `useSyncExternalStore` (rn-core's `useExternalStore`) and
57
+ re-render only when what they return changes. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
58
+
59
+ - rn-auth: `useProfile` and `useAccessTier` no longer re-render for a status change,
60
+ and the provider's context no longer changes on every transition. New
61
+ `useAuthSelector`. `auth.getState()` returns the same frozen object until the state
62
+ changes, instead of a copy per call.
63
+ - rn-payment: `useEntitlement` re-renders only when its own entitlement changes; the
64
+ engine keeps unchanged entitlement objects across syncs and does not notify for a
65
+ sync that changed nothing.
66
+ - rn-ads: `usePlacementState` and `useAdsStatus` read tear-free; `useAdsEvents` keeps
67
+ one subscription when given an inline listener.
68
+ - rn-i18n: `useTranslation` returns the same `t` until the locale changes, and
69
+ `useLocale`'s `setLocale` is stable.
70
+ - rn-ai: new `useAiBehaviorState`, with `client.subscribe` and a stable
71
+ `client.getBehaviorState()`.
72
+
73
+ - Every package's own copy is an rn-core message catalog that an app can translate. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
74
+
75
+ - rn-i18n: new `runtimeEntriesFromCatalog(catalog)` registers any SDK catalog in one
76
+ line, converting `{name}` variables to `{{name}}`.
77
+ - rn-ai: new `messageKeyForError(error)` gives the copy for every error code, and
78
+ `aiMessageSet`. The input placeholder default is neutral ("Type a message…"), and
79
+ two keys were added for errors that had none (`ai.error.signIn`, `ai.error.generic`).
80
+ - rn-auth: new `authMessages`, the catalog split into user and developer copy.
81
+ - rn-payment: new `paymentMessages`.
82
+ - rn-chart: summaries, announcements, adjustable-action labels, built-in state text
83
+ and legend hints come from `chartStrings`; pass `strings` to translate them. The
84
+ `accessibility.summaryFormatter` prop, declared before, now replaces the summary.
85
+
86
+ - Formatters come from rn-core's cache instead of being built per call. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
87
+
88
+ - rn-notification: resolving a time zone builds one `Intl.DateTimeFormat` per zone
89
+ instead of one per occurrence; expanding 32 series of 32 occurrences went from
90
+ about 230 ms to under 30 ms on Node. Zone validity is memoized.
91
+ - rn-i18n: number, currency and date formatters are cached. A bad option (an unknown
92
+ currency such as `usd1`) goes through the invalid-format policy instead of
93
+ throwing out of a render, and a locale's `defaultCurrency` must be a three-letter
94
+ ISO 4217 code at startup.
95
+
96
+ - One app-lifecycle source, and a startup that needs no extra frame. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
97
+
98
+ - rn-core: `appLifecycle()`, a shared, reference-counted `AppState` subscription that
99
+ reports changes only (`active`, `inactive`, `background`), with `onForeground` for a
100
+ return from the background and `createManualLifecycle` in `./testing`.
101
+ - rn-camera, rn-ads and rn-notification hear the lifecycle through it instead of
102
+ subscribing to `AppState` each, so an app holds one native listener. The camera
103
+ controller's core layer no longer imports `react-native`.
104
+ - rn-payment: `syncOnForeground(engine)` and `<PaymentProvider syncOnForeground>` call
105
+ `handleForeground()` on each return from the background. Off by default; calling
106
+ `handleForeground()` yourself works as before.
107
+ - rn-i18n: `i18n.initSync()` resolves the locale before the first render when the
108
+ storage adapter has the new optional `getItemSync`, and returns false otherwise.
109
+ `localeStorageFromKeyValue(port)` builds such an adapter from rn-core's
110
+ `KeyValuePort` (MMKV).
111
+
112
+ ### Patch Changes
113
+
114
+ - Updated dependencies ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17)):
115
+ - @chipmobilesdk/rn-core@0.1.0
116
+
3
117
  ## 0.1.3
4
118
 
5
119
  ### Patch Changes
package/README.md CHANGED
@@ -75,6 +75,13 @@ export const i18n = createI18n<AppI18nKey>({
75
75
  await i18n.init(); // persisted -> device -> primary fallback
76
76
  ```
77
77
 
78
+ With a storage adapter that reads synchronously (see below), resolve the locale before
79
+ the first render instead, so the first frame is already in the user's language:
80
+
81
+ ```ts
82
+ if (!i18n.initSync()) await i18n.init(); // initSync is false only for an async-only store
83
+ ```
84
+
78
85
  Wrap the app and use the hooks:
79
86
 
80
87
  ```tsx
@@ -95,15 +102,15 @@ t('cart.items', { count }, { count }); // plural variant by count
95
102
 
96
103
  | Capability | Member |
97
104
  |------------|--------|
98
- | Setup + validation | `createI18n(config)`, `I18nConfigError` |
105
+ | Setup + validation | `createI18n(config)`, `I18nConfigError` (an `SdkError` from `@chipmobilesdk/rn-core`, `code: 'invalid_config'`; `UnitConversionError` has `code: 'unit_conversion'`) |
99
106
  | Translation lookup | `i18n.t`, `useTranslation` |
100
107
  | Locale state + switching | `i18n.getLocale`, `i18n.setLocale`, `useLocale` |
101
108
  | Locale metadata (RTL/LTR, defaults) | `i18n.getSupportedLocales` |
102
- | Device-locale init | `i18n.init`, `defaultDeviceLocaleProvider` |
109
+ | Device-locale init | `i18n.init`, `i18n.initSync`, `defaultDeviceLocaleProvider` |
103
110
  | Formatting | `i18n.formatNumber/formatDate/formatCurrency/formatUnit` |
104
111
  | Unit conversion | `i18n.convertUnit`, `convertUnit`, `getUnitSymbol`, `SUPPORTED_UNITS`, `UnitConversionError` |
105
112
  | Runtime-only declarations | `i18n.registerRuntimeEntries` |
106
- | Persistence key | `LOCALE_STORAGE_KEY` |
113
+ | Persistence | `LOCALE_STORAGE_KEY`, `localeStorageFromKeyValue` |
107
114
  | React | `I18nProvider` |
108
115
 
109
116
  Public contract types also include the configuration, locale, translation,
@@ -126,9 +133,20 @@ export const storageAdapter: LocaleStorageAdapter = {
126
133
  };
127
134
  ```
128
135
 
129
- An MMKV-backed adapter (wrapping its synchronous API in `Promise.resolve`)
130
- works equally well. If persistence is enabled without an adapter, `createI18n`
131
- throws before any localized content is served.
136
+ An adapter may also have `getItemSync(key)`. With it, `i18n.initSync()` reads the
137
+ saved locale synchronously and the app skips the frame it would otherwise render in
138
+ the fallback language. MMKV through rn-core's `KeyValuePort` is one line:
139
+
140
+ ```ts
141
+ import { openDurable } from '@chipmobilesdk/rn-core';
142
+ import { localeStorageFromKeyValue } from '@chipmobilesdk/rn-i18n';
143
+ import { createMMKV } from 'react-native-mmkv';
144
+
145
+ const storage = localeStorageFromKeyValue(openDurable(() => createMMKV({ id: 'app' })));
146
+ ```
147
+
148
+ If persistence is enabled without an adapter, `createI18n` throws before any localized
149
+ content is served.
132
150
 
133
151
  ### Device-locale provider (optional)
134
152
 
@@ -194,6 +212,67 @@ lookup, fallback, interpolation, and missing-key behavior, and are never
194
212
  removed by synchronization. Policy `add` writes the default text to missing
195
213
  locales; `report` only lists them.
196
214
 
215
+ ### SDK copy
216
+
217
+ Every `@chipmobilesdk` package with user-facing copy exports it as a catalog
218
+ (`key -> English default`). `runtimeEntriesFromCatalog(catalog, { policy? })`
219
+ turns one into runtime entries, converting `{name}` variables to `{{name}}`, so an
220
+ app translates SDK copy like its own:
221
+
222
+ ```ts
223
+ createI18n({
224
+ ...config,
225
+ runtimeEntries: [
226
+ ...runtimeEntriesFromCatalog(aiMessageSet.user),
227
+ ...runtimeEntriesFromCatalog(paymentMessages.user),
228
+ ],
229
+ });
230
+ ```
231
+
232
+ ## Formatting
233
+
234
+ Options **merge** over the active locale's defaults, for every formatter:
235
+ `formatDate(value, { month: 'long' })` keeps the locale's year and day, exactly as
236
+ `formatNumber(value, { maximumFractionDigits: 0 })` keeps the rest of the locale's number
237
+ options. Pass `formatDate(value, options, { replaceDefaults: true })` to use only what you
238
+ give it. `formatCurrency` deliberately does not take the locale's default number options:
239
+ a currency carries its own minor units (yen has none), and generic number defaults would
240
+ override them.
241
+
242
+ `formatNumber`, `formatCurrency`, `formatDate` and `formatUnit` reuse one cached
243
+ `Intl` formatter per locale and options. A value or option they cannot format (a
244
+ `NaN`, an unknown currency code) is handled by the `invalidFormat` policy: the
245
+ placeholder text, or a thrown error in `error` mode, and an `invalid_format`
246
+ diagnostic either way. It never throws from inside `Intl` during a render. A
247
+ locale's `defaultCurrency` must be a three-letter ISO 4217 code, checked at startup.
248
+
249
+ ## Switching, and following the device
250
+
251
+ ```ts
252
+ await i18n.setLocale('vi-VN'); // the user chose a language
253
+ await i18n.useDeviceLocale(); // "System language": forget the choice, detect again
254
+ ```
255
+
256
+ `init()` resolves the locale once — persisted, then device, then the primary fallback —
257
+ and is safe to call again (it returns the same promise). A `setLocale` during that read
258
+ wins: the stored value never overwrites a choice the user has just made. Setting the
259
+ locale already active changes nothing and re-renders nothing.
260
+
261
+ ## Diagnostics
262
+
263
+ Missing translations and variables, fallbacks, invalid formats, persistence
264
+ failures and a throwing locale listener are each reported once per lookup path as an
265
+ `I18nDiagnostic` (`code`, `message`, and the `key` and `locale` involved):
266
+
267
+ - to `onDiagnostic` in the config, when given;
268
+ - otherwise to `console.warn`, in development builds only;
269
+ - and always to the app's SDK sinks (`installSdkSink` from `@chipmobilesdk/rn-core`),
270
+ as `code` with `key` and `locale`.
271
+
272
+ ```ts
273
+ const i18n = createI18n({ ...config, onDiagnostic: d => crashlytics.log(`${d.code} ${d.key ?? ''}`) });
274
+ ```
275
+
197
276
  ## Compatibility
198
277
 
199
278
  | Surface | Baseline |
@@ -207,7 +286,7 @@ locales; `report` only lists them.
207
286
 
208
287
  ```bash
209
288
  npm test # package + demo suites
210
- npm run typecheck --workspace @chipmobilesdk/rn-i18n # tsc -p packages/rn-i18n/tsconfig.json --noEmit
289
+ npm run typecheck --workspace @chipmobilesdk/rn-i18n # source, tests and the Node CLI
211
290
  npm run pack:check --workspace @chipmobilesdk/rn-i18n # npm pack --workspace @chipmobilesdk/rn-i18n --dry-run
212
291
  npm run i18n:sync # extraction + synchronization
213
292
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-i18n",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -32,9 +32,12 @@
32
32
  ],
33
33
  "sideEffects": false,
34
34
  "scripts": {
35
- "typecheck": "tsc -p tsconfig.json --noEmit",
35
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json && tsc -p tsconfig.tooling.json",
36
36
  "pack:check": "npm pack --dry-run"
37
37
  },
38
+ "dependencies": {
39
+ "@chipmobilesdk/rn-core": "^0.1.0"
40
+ },
38
41
  "peerDependencies": {
39
42
  "react": ">=19",
40
43
  "react-native": ">=0.85"
@@ -40,11 +40,42 @@ Runtime:
40
40
  - `useLocale()` → `UseLocaleResult`
41
41
  - `defaultDeviceLocaleProvider` — device locale detection
42
42
  - `LOCALE_STORAGE_KEY` — where the chosen locale persists
43
- - `I18nConfigError` — thrown on invalid configuration at startup
43
+ - `i18n.initSync()` — resolve the locale before the first render when storage reads
44
+ synchronously; `localeStorageFromKeyValue(port)` makes such storage from an rn-core
45
+ key-value port (MMKV)
46
+ - `I18nConfigError` — thrown on invalid configuration at startup; an rn-core SDK error with an
47
+ `I18nErrorCode`
48
+
49
+ Formatting, on the instance (all locale-aware, all through Hermes's Intl):
50
+
51
+ - `i18n.formatNumber(value, options?)`, `i18n.formatCurrency(value, currency?, options?)`,
52
+ `i18n.formatUnit(value, options?)` — options merge over the locale's defaults
53
+ - `i18n.formatDate(value, options?, { replaceDefaults? })` — options merge too;
54
+ `replaceDefaults: true` uses only what is passed
55
+ - `i18n.convertUnit(value, from, to)`, `SUPPORTED_UNITS`, `UnitConversionError`
56
+
57
+ Locale, on the instance: `init()` (idempotent), `initSync()`, `setLocale(code)`,
58
+ `useDeviceLocale()` (forget the stored choice and follow the device again),
59
+ `getLocale()`, `getSupportedLocales()`, `registerRuntimeEntries(entries)`,
60
+ `subscribe(listener)`, `getVersion()`
44
61
 
45
62
  Types: `I18n`, `UnitFormatOptions`, `I18nProviderProps`, `UseLocaleResult`,
46
63
  `UseTranslationResult`
47
64
 
65
+ ## Plural rules are declared, not inferred
66
+
67
+ Hermes has no `Intl.PluralRules`, so this package carries a compact CLDR subset
68
+ (en/de/es/it/nl/sv/da/nb/fi/el/he, ja/vi/ko/zh/th/id/ms, fr/pt, ru/uk/pl/cs/sk, ar). A
69
+ language outside it falls back to "1 is singular" **and reports a
70
+ `plural_rule_default` diagnostic** — declare `pluralRule` on the locale rather than
71
+ shipping wrong plurals.
72
+
73
+ ## Entries registered at runtime re-render
74
+
75
+ `registerRuntimeEntries` notifies subscribers, so a lazily loaded feature's copy appears
76
+ without another render pass, and a malformed entry throws `I18nConfigError` there and then
77
+ instead of rendering the key.
78
+
48
79
  `I18nConfigError` throws at startup by design — a misconfigured i18n setup should fail
49
80
  loudly, not silently render keys.
50
81
 
@@ -0,0 +1,25 @@
1
+ import type { RuntimeTranslationEntry } from './types';
2
+
3
+ /**
4
+ * Runtime entries for a package's message catalog (I18N-13), so an app registers
5
+ * an SDK's copy in one line and translates it like its own:
6
+ *
7
+ * ```ts
8
+ * createI18n({ ...config, runtimeEntries: runtimeEntriesFromCatalog(paymentMessages.user) });
9
+ * ```
10
+ *
11
+ * Catalogs mark variables `{name}`; rn-i18n's are `{{name}}`, so they are
12
+ * converted. `policy: 'add'` lets the sync CLI write missing keys into each locale
13
+ * file with the default text.
14
+ */
15
+ export function runtimeEntriesFromCatalog(
16
+ catalog: Readonly<Record<string, string>>,
17
+ options: { readonly policy?: 'add' | 'report'; readonly description?: string } = {},
18
+ ): RuntimeTranslationEntry[] {
19
+ return Object.entries(catalog).map(([key, text]) => ({
20
+ key,
21
+ defaultText: text.replace(/(?<!\{)\{(\w+)\}(?!\})/g, '{{$1}}'),
22
+ policy: options.policy ?? 'report',
23
+ ...(options.description === undefined ? {} : { description: options.description }),
24
+ }));
25
+ }
@@ -0,0 +1,70 @@
1
+ import {
2
+ createDiagnosticChannel,
3
+ redactPatterns,
4
+ type DiagnosticSeverity,
5
+ } from '@chipmobilesdk/rn-core';
6
+
7
+ /** What rn-i18n reports. Stable: an app may branch on these. */
8
+ export type I18nDiagnosticCode =
9
+ | 'missing_translation'
10
+ | 'fallback_used'
11
+ | 'missing_variable'
12
+ | 'invalid_value'
13
+ | 'invalid_format'
14
+ | 'locale_unsupported'
15
+ | 'plural_rule_default'
16
+ | 'locale_read_failed'
17
+ | 'locale_write_failed'
18
+ | 'listener_threw';
19
+
20
+ export interface I18nDiagnostic {
21
+ readonly code: I18nDiagnosticCode;
22
+ /** For people: names the key, locale or variable involved. */
23
+ readonly message: string;
24
+ readonly key?: string;
25
+ readonly locale?: string;
26
+ }
27
+
28
+ export type I18nDiagnosticHandler = (diagnostic: I18nDiagnostic) => void;
29
+
30
+ const SEVERITY: Record<I18nDiagnosticCode, DiagnosticSeverity> = {
31
+ missing_translation: 'warn',
32
+ fallback_used: 'info',
33
+ missing_variable: 'warn',
34
+ invalid_value: 'warn',
35
+ invalid_format: 'warn',
36
+ locale_unsupported: 'warn',
37
+ plural_rule_default: 'warn',
38
+ locale_read_failed: 'warn',
39
+ locale_write_failed: 'warn',
40
+ listener_threw: 'error',
41
+ };
42
+
43
+ const channel = createDiagnosticChannel<I18nDiagnosticCode>('rn-i18n');
44
+
45
+ /**
46
+ * One place every diagnostic goes through (I18N-11): the app's SDK sinks always
47
+ * (`installSdkSink` from rn-core), then `onDiagnostic` when the app gave one,
48
+ * otherwise `console.warn` in development builds and tests only.
49
+ */
50
+ export function createReporter(onDiagnostic?: I18nDiagnosticHandler): I18nDiagnosticHandler {
51
+ return diagnostic => {
52
+ channel.emit(diagnostic.code, SEVERITY[diagnostic.code], {
53
+ ...(diagnostic.key === undefined ? {} : { key: diagnostic.key }),
54
+ ...(diagnostic.locale === undefined ? {} : { locale: diagnostic.locale }),
55
+ });
56
+ if (onDiagnostic) {
57
+ onDiagnostic(diagnostic);
58
+ } else if (typeof __DEV__ === 'undefined' || __DEV__) {
59
+ console.warn(`[rn-i18n] ${diagnostic.message}`);
60
+ }
61
+ };
62
+ }
63
+
64
+ /** For the few call sites that run before or outside an i18n instance. */
65
+ export const defaultReporter = createReporter();
66
+
67
+ /** Foreign error text, shaped before it reaches a message. */
68
+ export function describeError(error: unknown): string {
69
+ return redactPatterns(error instanceof Error ? error.message : String(error));
70
+ }
@@ -0,0 +1,28 @@
1
+ import { SdkError } from '@chipmobilesdk/rn-core';
2
+
3
+ /** The package's error codes. */
4
+ export type I18nErrorCode = 'invalid_config' | 'unit_conversion';
5
+
6
+ /**
7
+ * Configuration error listing every detected violation. An `SdkError`
8
+ * (`@chipmobilesdk/rn-core`) with `pkg: 'rn-i18n'`.
9
+ */
10
+ export class I18nConfigError extends SdkError<I18nErrorCode> {
11
+ readonly issues: readonly string[];
12
+
13
+ constructor(issues: readonly string[]) {
14
+ super('rn-i18n', 'invalid_config', {
15
+ message: `Invalid i18n configuration:\n- ${issues.join('\n- ')}`,
16
+ });
17
+ this.name = 'I18nConfigError';
18
+ this.issues = issues;
19
+ }
20
+ }
21
+
22
+ /** A unit conversion the package cannot perform, such as across dimensions. */
23
+ export class UnitConversionError extends SdkError<I18nErrorCode> {
24
+ constructor(message: string) {
25
+ super('rn-i18n', 'unit_conversion', { message });
26
+ this.name = 'UnitConversionError';
27
+ }
28
+ }
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Public configuration and contract types for @chipmobilesdk/rn-i18n.
3
3
  *
4
- * The package has zero runtime dependencies: formatting uses the Intl APIs
4
+ * The package has one runtime dependency, rn-core; formatting uses the Intl APIs
5
5
  * built into Hermes (Intl.NumberFormat, Intl.DateTimeFormat), while plural
6
6
  * rules and unit conversion are package-owned because Intl.PluralRules and
7
7
  * Intl unit-style formatting are not reliably available in Hermes.
8
8
  */
9
9
 
10
+ import type { I18nDiagnosticHandler } from './diagnostics';
11
+
10
12
  export type LocaleDirection = 'ltr' | 'rtl';
11
13
 
12
14
  export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
@@ -93,6 +95,12 @@ export interface LocaleStorageAdapter {
93
95
  getItem(key: string): Promise<string | null>;
94
96
  setItem(key: string, value: string): Promise<void>;
95
97
  removeItem(key: string): Promise<void>;
98
+ /**
99
+ * A synchronous read, for a store that has one (MMKV, rn-core's `KeyValuePort`).
100
+ * With it, `initSync()` resolves the locale before the first render, so the app
101
+ * never paints a frame in the wrong language (PERF-08).
102
+ */
103
+ getItemSync?(key: string): string | null;
96
104
  }
97
105
 
98
106
  /** Returns the device locale, or undefined when detection is unavailable. */
@@ -108,6 +116,12 @@ export interface I18nConfig {
108
116
  persistLocale?: boolean;
109
117
  /** Required when `persistLocale` is not disabled. */
110
118
  storage?: LocaleStorageAdapter;
119
+ /**
120
+ * Receives every diagnostic: missing translations and variables, fallbacks,
121
+ * invalid formats, persistence failures. Defaults to `console.warn` in
122
+ * development builds only. Diagnostics also reach rn-core's SDK sinks.
123
+ */
124
+ onDiagnostic?: I18nDiagnosticHandler;
111
125
  deviceLocale?: DeviceLocaleProvider;
112
126
  /** Default: `{ mode: 'key' }`. */
113
127
  missingTranslation?: MissingTranslationPolicy;
@@ -132,13 +146,5 @@ export interface TranslateOptions {
132
146
  count?: number;
133
147
  }
134
148
 
135
- /** Configuration error listing every detected violation. */
136
- export class I18nConfigError extends Error {
137
- readonly issues: readonly string[];
138
-
139
- constructor(issues: readonly string[]) {
140
- super(`Invalid i18n configuration:\n- ${issues.join('\n- ')}`);
141
- this.name = 'I18nConfigError';
142
- this.issues = issues;
143
- }
144
- }
149
+ // Kept importable from here for compatibility; the errors live in config/errors.ts (I18N-14).
150
+ export { I18nConfigError } from './errors';
@@ -85,6 +85,11 @@ function validateLocale(
85
85
  }
86
86
  if (!locale.defaultCurrency) {
87
87
  issues.push(`Locale "${code}" must declare a default currency.`);
88
+ } else if (!/^[A-Z]{3}$/.test(locale.defaultCurrency)) {
89
+ // An ISO 4217 code; anything else would fail every currency format (I18N-08).
90
+ issues.push(
91
+ `Locale "${code}" default currency "${locale.defaultCurrency}" is not a three-letter ISO 4217 code.`,
92
+ );
88
93
  }
89
94
  if (!knownUnits.has(locale.defaultUnit)) {
90
95
  issues.push(`Locale "${code}" declares unknown default unit "${locale.defaultUnit}".`);
@@ -1,9 +1,15 @@
1
+ import { getNumberFormat } from '@chipmobilesdk/rn-core';
1
2
  import type { InvalidFormatPolicy } from '../config/types';
2
3
  import { applyInvalidFormatPolicy } from './invalid';
3
4
 
4
5
  /**
5
6
  * Locale-aware currency formatting. The locale's configured default currency
6
7
  * applies unless the caller passes an explicit currency override (FR-021).
8
+ *
9
+ * `locale.defaultNumberFormat` is deliberately **not** applied here: a currency
10
+ * carries its own minor units (yen has none, dinar has three), and the locale's
11
+ * generic number options would override them — ¥1,234.56 instead of ¥1,235.
12
+ * Pass the options you want for a currency explicitly (I18N-09).
7
13
  */
8
14
  export function formatCurrencyValue(
9
15
  value: number,
@@ -19,9 +25,15 @@ export function formatCurrencyValue(
19
25
  `Cannot format invalid currency value "${String(value)}" for locale "${localeCode}".`,
20
26
  );
21
27
  }
22
- return new Intl.NumberFormat(localeCode, {
23
- ...options,
24
- style: 'currency',
25
- currency: currencyOverride ?? defaultCurrency,
26
- }).format(value);
28
+ const currency = currencyOverride ?? defaultCurrency;
29
+ // rn-core's cached formatter (I18N-03). An unknown currency code (`usd1`) used
30
+ // to throw out of render; it is a format problem now (I18N-08).
31
+ try {
32
+ return getNumberFormat(localeCode, { ...options, style: 'currency', currency }).format(value);
33
+ } catch (error) {
34
+ return applyInvalidFormatPolicy(
35
+ invalidFormat,
36
+ `Cannot format currency "${currency}" for locale "${localeCode}": ${String(error)}`,
37
+ );
38
+ }
27
39
  }
@@ -1,11 +1,17 @@
1
+ import { getDateTimeFormat } from '@chipmobilesdk/rn-core';
1
2
  import type { InvalidFormatPolicy } from '../config/types';
2
3
  import { applyInvalidFormatPolicy } from './invalid';
3
4
 
4
5
  /**
5
6
  * Locale-aware date formatting through the Hermes built-in
6
- * Intl.DateTimeFormat. The locale's configured default date format applies
7
- * unless the caller passes explicit overrides (FR-020). Options are limited
8
- * to component options supported by Hermes on both platforms.
7
+ * Intl.DateTimeFormat.
8
+ *
9
+ * Options are **merged** over the locale's configured default, the way
10
+ * `formatNumber` has always worked: `{ month: 'long' }` changes the month and
11
+ * keeps the year and day. Replacing the default wholesale, which this used to
12
+ * do, meant one option silently dropped every other component — and the two
13
+ * formatters disagreed about what an option meant (I18N-09). Pass
14
+ * `{ replaceDefaults: true }` for the old behaviour.
9
15
  */
10
16
  export function formatDateValue(
11
17
  value: Date | number,
@@ -13,6 +19,7 @@ export function formatDateValue(
13
19
  defaults: Intl.DateTimeFormatOptions,
14
20
  override: Intl.DateTimeFormatOptions | undefined,
15
21
  invalidFormat: InvalidFormatPolicy,
22
+ replaceDefaults = false,
16
23
  ): string {
17
24
  const date = value instanceof Date ? value : new Date(value);
18
25
  if (Number.isNaN(date.getTime())) {
@@ -21,6 +28,11 @@ export function formatDateValue(
21
28
  `Cannot format invalid date value "${String(value)}" for locale "${localeCode}".`,
22
29
  );
23
30
  }
24
- const options = override ?? defaults;
25
- return new Intl.DateTimeFormat(localeCode, options).format(date);
31
+ const options = replaceDefaults ? (override ?? defaults) : { ...defaults, ...override };
32
+ // rn-core's cached formatter (I18N-03); a bad option is a format problem (I18N-08).
33
+ try {
34
+ return getDateTimeFormat(localeCode, options).format(date);
35
+ } catch (error) {
36
+ return applyInvalidFormatPolicy(invalidFormat, `Cannot format a date: ${String(error)}`);
37
+ }
26
38
  }
@@ -1,11 +1,20 @@
1
+ import { defaultReporter, type I18nDiagnosticHandler } from '../config/diagnostics';
1
2
  import type { InvalidFormatPolicy } from '../config/types';
2
3
 
4
+ /** The policy as an i18n instance resolves it: with the instance's reporter. */
5
+ export type ResolvedInvalidFormatPolicy = InvalidFormatPolicy & {
6
+ readonly report?: I18nDiagnosticHandler | undefined;
7
+ };
8
+
3
9
  /**
4
10
  * Applies the configured invalid-format behavior: return the placeholder, or
5
11
  * throw a clear formatting error in `error` mode (FR-037, FR-045).
6
12
  */
7
- export function applyInvalidFormatPolicy(policy: InvalidFormatPolicy, message: string): string {
8
- console.warn(`[rn-i18n] ${message}`);
13
+ export function applyInvalidFormatPolicy(
14
+ policy: ResolvedInvalidFormatPolicy,
15
+ message: string,
16
+ ): string {
17
+ (policy.report ?? defaultReporter)({ code: 'invalid_format', message });
9
18
  if (policy.mode === 'error') {
10
19
  throw new RangeError(`[rn-i18n] ${message}`);
11
20
  }
@@ -1,3 +1,4 @@
1
+ import { getNumberFormat } from '@chipmobilesdk/rn-core';
1
2
  import type { InvalidFormatPolicy } from '../config/types';
2
3
  import { applyInvalidFormatPolicy } from './invalid';
3
4
 
@@ -19,5 +20,11 @@ export function formatNumberValue(
19
20
  `Cannot format invalid number value "${String(value)}" for locale "${localeCode}".`,
20
21
  );
21
22
  }
22
- return new Intl.NumberFormat(localeCode, { ...defaults, ...override }).format(value);
23
+ // rn-core's cached formatter (I18N-03); a bad option is a format problem, not a
24
+ // crash in render (I18N-08).
25
+ try {
26
+ return getNumberFormat(localeCode, { ...defaults, ...override }).format(value);
27
+ } catch (error) {
28
+ return applyInvalidFormatPolicy(invalidFormat, `Cannot format a number: ${String(error)}`);
29
+ }
23
30
  }
@@ -39,12 +39,10 @@ export const SUPPORTED_UNITS = Object.keys(UNIT_REGISTRY) as readonly UnitId[];
39
39
 
40
40
  export const KNOWN_UNIT_IDS: ReadonlySet<string> = new Set(SUPPORTED_UNITS);
41
41
 
42
- export class UnitConversionError extends Error {
43
- constructor(message: string) {
44
- super(message);
45
- this.name = 'UnitConversionError';
46
- }
47
- }
42
+ // Kept importable from here for compatibility; the errors live in config/errors.ts.
43
+ import { UnitConversionError } from '../config/errors';
44
+
45
+ export { UnitConversionError };
48
46
 
49
47
  export function getUnitSymbol(unit: UnitId): string {
50
48
  return UNIT_REGISTRY[unit].symbol;