@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 +114 -0
- package/README.md +86 -7
- package/package.json +5 -2
- package/skills/sdk-rn-i18n/SKILL.md +32 -1
- package/src/config/catalog.ts +25 -0
- package/src/config/diagnostics.ts +70 -0
- package/src/config/errors.ts +28 -0
- package/src/config/types.ts +17 -11
- package/src/config/validateConfig.ts +5 -0
- package/src/format/currency.ts +17 -5
- package/src/format/date.ts +17 -5
- package/src/format/invalid.ts +11 -2
- package/src/format/number.ts +8 -1
- package/src/format/units.ts +4 -6
- package/src/i18n.ts +158 -39
- package/src/index.ts +8 -0
- package/src/locale/activeLocale.ts +29 -23
- package/src/locale/keyValueStorage.ts +27 -0
- package/src/locale/persistence.ts +50 -3
- package/src/react/hooks.ts +18 -9
- package/src/tooling/generateKeys.ts +4 -1
- package/src/translation/plurals.ts +74 -5
- package/src/translation/resolver.ts +45 -24
- package/src/translation/runtimeKeys.ts +28 -1
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
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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 #
|
|
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.
|
|
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
|
|
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
|
-
- `
|
|
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
|
+
}
|
package/src/config/types.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Public configuration and contract types for @chipmobilesdk/rn-i18n.
|
|
3
3
|
*
|
|
4
|
-
* The package has
|
|
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
|
-
|
|
136
|
-
export
|
|
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}".`);
|
package/src/format/currency.ts
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
}
|
package/src/format/date.ts
CHANGED
|
@@ -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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/src/format/invalid.ts
CHANGED
|
@@ -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(
|
|
8
|
-
|
|
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
|
}
|
package/src/format/number.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/format/units.ts
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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;
|