@chipmobilesdk/rn-i18n 0.1.3 → 0.2.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 +98 -0
- package/README.md +66 -7
- package/package.json +5 -2
- package/skills/sdk-rn-i18n/SKILL.md +5 -1
- package/src/config/catalog.ts +25 -0
- package/src/config/diagnostics.ts +68 -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 +12 -5
- package/src/format/date.ts +7 -1
- 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 +70 -35
- package/src/index.ts +8 -0
- package/src/locale/activeLocale.ts +20 -24
- package/src/locale/keyValueStorage.ts +27 -0
- package/src/locale/persistence.ts +31 -3
- package/src/react/hooks.ts +18 -9
- package/src/translation/resolver.ts +45 -24
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,103 @@
|
|
|
1
1
|
# @chipmobilesdk/rn-i18n
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- One diagnostic stream: install a sink with `installSdkSink` from
|
|
8
|
+
`@chipmobilesdk/rn-core` and it receives what every package observes (events,
|
|
9
|
+
failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
|
|
10
|
+
rn-core, so a listener that throws no longer stops the others nor fails the call that
|
|
11
|
+
notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
12
|
+
|
|
13
|
+
- rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
|
|
14
|
+
events are no longer deduplicated away; the dedupe memory is bounded. A throwing
|
|
15
|
+
status, consent or placement listener is reported as an `internal_error` event.
|
|
16
|
+
- rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
|
|
17
|
+
with a stable `code`); without it, warnings go to `console.warn` in development
|
|
18
|
+
builds only. The warn-once memory is bounded.
|
|
19
|
+
- rn-chart: now depends on rn-core for its diagnostics.
|
|
20
|
+
- rn-payment: status, entitlement and pending listeners report a throw to the logger
|
|
21
|
+
instead of swallowing it.
|
|
22
|
+
|
|
23
|
+
- The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
|
|
24
|
+
dependency: `isSdkError` recognises them alongside every other package's, and each
|
|
25
|
+
carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
26
|
+
|
|
27
|
+
- rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
|
|
28
|
+
deprecated alias.
|
|
29
|
+
- rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
|
|
30
|
+
e-mail addresses and UUIDs.
|
|
31
|
+
- rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
|
|
32
|
+
to its code.
|
|
33
|
+
- rn-ai: an error message quoted from the gateway is pattern-redacted.
|
|
34
|
+
- rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
|
|
35
|
+
- rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
|
|
36
|
+
`code: 'contrast'` (new `ThemeErrorCode` type).
|
|
37
|
+
- rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
|
|
38
|
+
`code: 'unit_conversion'` (new `I18nErrorCode` type).
|
|
39
|
+
|
|
40
|
+
- Hooks read through `useSyncExternalStore` (rn-core's `useExternalStore`) and
|
|
41
|
+
re-render only when what they return changes. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
42
|
+
|
|
43
|
+
- rn-auth: `useProfile` and `useAccessTier` no longer re-render for a status change,
|
|
44
|
+
and the provider's context no longer changes on every transition. New
|
|
45
|
+
`useAuthSelector`. `auth.getState()` returns the same frozen object until the state
|
|
46
|
+
changes, instead of a copy per call.
|
|
47
|
+
- rn-payment: `useEntitlement` re-renders only when its own entitlement changes; the
|
|
48
|
+
engine keeps unchanged entitlement objects across syncs and does not notify for a
|
|
49
|
+
sync that changed nothing.
|
|
50
|
+
- rn-ads: `usePlacementState` and `useAdsStatus` read tear-free; `useAdsEvents` keeps
|
|
51
|
+
one subscription when given an inline listener.
|
|
52
|
+
- rn-i18n: `useTranslation` returns the same `t` until the locale changes, and
|
|
53
|
+
`useLocale`'s `setLocale` is stable.
|
|
54
|
+
- rn-ai: new `useAiBehaviorState`, with `client.subscribe` and a stable
|
|
55
|
+
`client.getBehaviorState()`.
|
|
56
|
+
|
|
57
|
+
- 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))
|
|
58
|
+
|
|
59
|
+
- rn-i18n: new `runtimeEntriesFromCatalog(catalog)` registers any SDK catalog in one
|
|
60
|
+
line, converting `{name}` variables to `{{name}}`.
|
|
61
|
+
- rn-ai: new `messageKeyForError(error)` gives the copy for every error code, and
|
|
62
|
+
`aiMessageSet`. The input placeholder default is neutral ("Type a message…"), and
|
|
63
|
+
two keys were added for errors that had none (`ai.error.signIn`, `ai.error.generic`).
|
|
64
|
+
- rn-auth: new `authMessages`, the catalog split into user and developer copy.
|
|
65
|
+
- rn-payment: new `paymentMessages`.
|
|
66
|
+
- rn-chart: summaries, announcements, adjustable-action labels, built-in state text
|
|
67
|
+
and legend hints come from `chartStrings`; pass `strings` to translate them. The
|
|
68
|
+
`accessibility.summaryFormatter` prop, declared before, now replaces the summary.
|
|
69
|
+
|
|
70
|
+
- Formatters come from rn-core's cache instead of being built per call. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
71
|
+
|
|
72
|
+
- rn-notification: resolving a time zone builds one `Intl.DateTimeFormat` per zone
|
|
73
|
+
instead of one per occurrence; expanding 32 series of 32 occurrences went from
|
|
74
|
+
about 230 ms to under 30 ms on Node. Zone validity is memoized.
|
|
75
|
+
- rn-i18n: number, currency and date formatters are cached. A bad option (an unknown
|
|
76
|
+
currency such as `usd1`) goes through the invalid-format policy instead of
|
|
77
|
+
throwing out of a render, and a locale's `defaultCurrency` must be a three-letter
|
|
78
|
+
ISO 4217 code at startup.
|
|
79
|
+
|
|
80
|
+
- One app-lifecycle source, and a startup that needs no extra frame. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
81
|
+
|
|
82
|
+
- rn-core: `appLifecycle()`, a shared, reference-counted `AppState` subscription that
|
|
83
|
+
reports changes only (`active`, `inactive`, `background`), with `onForeground` for a
|
|
84
|
+
return from the background and `createManualLifecycle` in `./testing`.
|
|
85
|
+
- rn-camera, rn-ads and rn-notification hear the lifecycle through it instead of
|
|
86
|
+
subscribing to `AppState` each, so an app holds one native listener. The camera
|
|
87
|
+
controller's core layer no longer imports `react-native`.
|
|
88
|
+
- rn-payment: `syncOnForeground(engine)` and `<PaymentProvider syncOnForeground>` call
|
|
89
|
+
`handleForeground()` on each return from the background. Off by default; calling
|
|
90
|
+
`handleForeground()` yourself works as before.
|
|
91
|
+
- rn-i18n: `i18n.initSync()` resolves the locale before the first render when the
|
|
92
|
+
storage adapter has the new optional `getItemSync`, and returns false otherwise.
|
|
93
|
+
`localeStorageFromKeyValue(port)` builds such an adapter from rn-core's
|
|
94
|
+
`KeyValuePort` (MMKV).
|
|
95
|
+
|
|
96
|
+
### Patch Changes
|
|
97
|
+
|
|
98
|
+
- 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)):
|
|
99
|
+
- @chipmobilesdk/rn-core@0.1.0
|
|
100
|
+
|
|
3
101
|
## 0.1.3
|
|
4
102
|
|
|
5
103
|
### 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,47 @@ 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
|
+
`formatNumber`, `formatCurrency`, `formatDate` and `formatUnit` reuse one cached
|
|
235
|
+
`Intl` formatter per locale and options. A value or option they cannot format (a
|
|
236
|
+
`NaN`, an unknown currency code) is handled by the `invalidFormat` policy: the
|
|
237
|
+
placeholder text, or a thrown error in `error` mode, and an `invalid_format`
|
|
238
|
+
diagnostic either way. It never throws from inside `Intl` during a render. A
|
|
239
|
+
locale's `defaultCurrency` must be a three-letter ISO 4217 code, checked at startup.
|
|
240
|
+
|
|
241
|
+
## Diagnostics
|
|
242
|
+
|
|
243
|
+
Missing translations and variables, fallbacks, invalid formats, persistence
|
|
244
|
+
failures and a throwing locale listener are each reported once per lookup path as an
|
|
245
|
+
`I18nDiagnostic` (`code`, `message`, and the `key` and `locale` involved):
|
|
246
|
+
|
|
247
|
+
- to `onDiagnostic` in the config, when given;
|
|
248
|
+
- otherwise to `console.warn`, in development builds only;
|
|
249
|
+
- and always to the app's SDK sinks (`installSdkSink` from `@chipmobilesdk/rn-core`),
|
|
250
|
+
as `code` with `key` and `locale`.
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
const i18n = createI18n({ ...config, onDiagnostic: d => crashlytics.log(`${d.code} ${d.key ?? ''}`) });
|
|
254
|
+
```
|
|
255
|
+
|
|
197
256
|
## Compatibility
|
|
198
257
|
|
|
199
258
|
| Surface | Baseline |
|
|
@@ -207,7 +266,7 @@ locales; `report` only lists them.
|
|
|
207
266
|
|
|
208
267
|
```bash
|
|
209
268
|
npm test # package + demo suites
|
|
210
|
-
npm run typecheck --workspace @chipmobilesdk/rn-i18n #
|
|
269
|
+
npm run typecheck --workspace @chipmobilesdk/rn-i18n # source, tests and the Node CLI
|
|
211
270
|
npm run pack:check --workspace @chipmobilesdk/rn-i18n # npm pack --workspace @chipmobilesdk/rn-i18n --dry-run
|
|
212
271
|
npm run i18n:sync # extraction + synchronization
|
|
213
272
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipmobilesdk/rn-i18n",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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,7 +40,11 @@ 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`
|
|
44
48
|
|
|
45
49
|
Types: `I18n`, `UnitFormatOptions`, `I18nProviderProps`, `UseLocaleResult`,
|
|
46
50
|
`UseTranslationResult`
|
|
@@ -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,68 @@
|
|
|
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
|
+
| 'locale_read_failed'
|
|
16
|
+
| 'locale_write_failed'
|
|
17
|
+
| 'listener_threw';
|
|
18
|
+
|
|
19
|
+
export interface I18nDiagnostic {
|
|
20
|
+
readonly code: I18nDiagnosticCode;
|
|
21
|
+
/** For people: names the key, locale or variable involved. */
|
|
22
|
+
readonly message: string;
|
|
23
|
+
readonly key?: string;
|
|
24
|
+
readonly locale?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type I18nDiagnosticHandler = (diagnostic: I18nDiagnostic) => void;
|
|
28
|
+
|
|
29
|
+
const SEVERITY: Record<I18nDiagnosticCode, DiagnosticSeverity> = {
|
|
30
|
+
missing_translation: 'warn',
|
|
31
|
+
fallback_used: 'info',
|
|
32
|
+
missing_variable: 'warn',
|
|
33
|
+
invalid_value: 'warn',
|
|
34
|
+
invalid_format: 'warn',
|
|
35
|
+
locale_unsupported: 'warn',
|
|
36
|
+
locale_read_failed: 'warn',
|
|
37
|
+
locale_write_failed: 'warn',
|
|
38
|
+
listener_threw: 'error',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const channel = createDiagnosticChannel<I18nDiagnosticCode>('rn-i18n');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One place every diagnostic goes through (I18N-11): the app's SDK sinks always
|
|
45
|
+
* (`installSdkSink` from rn-core), then `onDiagnostic` when the app gave one,
|
|
46
|
+
* otherwise `console.warn` in development builds and tests only.
|
|
47
|
+
*/
|
|
48
|
+
export function createReporter(onDiagnostic?: I18nDiagnosticHandler): I18nDiagnosticHandler {
|
|
49
|
+
return diagnostic => {
|
|
50
|
+
channel.emit(diagnostic.code, SEVERITY[diagnostic.code], {
|
|
51
|
+
...(diagnostic.key === undefined ? {} : { key: diagnostic.key }),
|
|
52
|
+
...(diagnostic.locale === undefined ? {} : { locale: diagnostic.locale }),
|
|
53
|
+
});
|
|
54
|
+
if (onDiagnostic) {
|
|
55
|
+
onDiagnostic(diagnostic);
|
|
56
|
+
} else if (typeof __DEV__ === 'undefined' || __DEV__) {
|
|
57
|
+
console.warn(`[rn-i18n] ${diagnostic.message}`);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** For the few call sites that run before or outside an i18n instance. */
|
|
63
|
+
export const defaultReporter = createReporter();
|
|
64
|
+
|
|
65
|
+
/** Foreign error text, shaped before it reaches a message. */
|
|
66
|
+
export function describeError(error: unknown): string {
|
|
67
|
+
return redactPatterns(error instanceof Error ? error.message : String(error));
|
|
68
|
+
}
|
|
@@ -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,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,9 +20,15 @@ export function formatCurrencyValue(
|
|
|
19
20
|
`Cannot format invalid currency value "${String(value)}" for locale "${localeCode}".`,
|
|
20
21
|
);
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const currency = currencyOverride ?? defaultCurrency;
|
|
24
|
+
// rn-core's cached formatter (I18N-03). An unknown currency code (`usd1`) used
|
|
25
|
+
// to throw out of render; it is a format problem now (I18N-08).
|
|
26
|
+
try {
|
|
27
|
+
return getNumberFormat(localeCode, { ...options, style: 'currency', currency }).format(value);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return applyInvalidFormatPolicy(
|
|
30
|
+
invalidFormat,
|
|
31
|
+
`Cannot format currency "${currency}" for locale "${localeCode}": ${String(error)}`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
27
34
|
}
|
package/src/format/date.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getDateTimeFormat } from '@chipmobilesdk/rn-core';
|
|
1
2
|
import type { InvalidFormatPolicy } from '../config/types';
|
|
2
3
|
import { applyInvalidFormatPolicy } from './invalid';
|
|
3
4
|
|
|
@@ -22,5 +23,10 @@ export function formatDateValue(
|
|
|
22
23
|
);
|
|
23
24
|
}
|
|
24
25
|
const options = override ?? defaults;
|
|
25
|
-
|
|
26
|
+
// rn-core's cached formatter (I18N-03); a bad option is a format problem (I18N-08).
|
|
27
|
+
try {
|
|
28
|
+
return getDateTimeFormat(localeCode, options).format(date);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return applyInvalidFormatPolicy(invalidFormat, `Cannot format a date: ${String(error)}`);
|
|
31
|
+
}
|
|
26
32
|
}
|
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;
|
package/src/i18n.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createReporter } from './config/diagnostics';
|
|
2
|
+
import type { ResolvedInvalidFormatPolicy } from './format/invalid';
|
|
1
3
|
import type {
|
|
2
4
|
ActiveLocalePreference,
|
|
3
5
|
I18nConfig,
|
|
@@ -12,7 +14,11 @@ import type {
|
|
|
12
14
|
import { validateConfig } from './config/validateConfig';
|
|
13
15
|
import { createActiveLocaleStore } from './locale/activeLocale';
|
|
14
16
|
import { defaultDeviceLocaleProvider, matchSupportedLocale } from './locale/deviceLocale';
|
|
15
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
readPersistedLocale,
|
|
19
|
+
readPersistedLocaleSync,
|
|
20
|
+
writePersistedLocale,
|
|
21
|
+
} from './locale/persistence';
|
|
16
22
|
import { translate } from './translation/resolver';
|
|
17
23
|
import type { ResolverContext } from './translation/resolver';
|
|
18
24
|
import { createRuntimeRegistry, registerRuntimeEntries } from './translation/runtimeKeys';
|
|
@@ -25,6 +31,13 @@ import type { UnitFormatOptions } from './format/units';
|
|
|
25
31
|
export interface I18n<TKey extends string = string> {
|
|
26
32
|
/** Resolves the initial locale: persisted → device → primary fallback. */
|
|
27
33
|
init(): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* `init()` without waiting, before the first render (PERF-08). Resolves the locale
|
|
36
|
+
* and returns true when it can do so synchronously: persistence is off, or the
|
|
37
|
+
* storage adapter has `getItemSync`. Returns false, changing nothing, when the
|
|
38
|
+
* adapter can only read asynchronously; call `init()` then.
|
|
39
|
+
*/
|
|
40
|
+
initSync(): boolean;
|
|
28
41
|
t(key: TKey, vars?: TranslateVars, options?: TranslateOptions): string;
|
|
29
42
|
getLocale(): ActiveLocalePreference;
|
|
30
43
|
/** Switches at runtime without restart and persists best-effort. */
|
|
@@ -55,15 +68,18 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
55
68
|
const locales: readonly LocaleConfig[] = [...config.locales];
|
|
56
69
|
const localesByCode = new Map(locales.map(locale => [locale.code, locale]));
|
|
57
70
|
const missingTranslation = config.missingTranslation ?? DEFAULT_MISSING;
|
|
58
|
-
const
|
|
71
|
+
const report = createReporter(config.onDiagnostic);
|
|
72
|
+
const invalidFormat: ResolvedInvalidFormatPolicy = {
|
|
73
|
+
...(config.invalidFormat ?? DEFAULT_INVALID),
|
|
74
|
+
report,
|
|
75
|
+
};
|
|
59
76
|
const persistenceEnabled = config.persistLocale !== false && !!config.storage;
|
|
60
77
|
const runtimeEntries = createRuntimeRegistry(config.runtimeEntries);
|
|
61
78
|
|
|
62
|
-
const store = createActiveLocaleStore(
|
|
63
|
-
code: config.primaryLocale,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
});
|
|
79
|
+
const store = createActiveLocaleStore(
|
|
80
|
+
{ code: config.primaryLocale, source: 'primaryFallback', recovered: false },
|
|
81
|
+
report,
|
|
82
|
+
);
|
|
67
83
|
|
|
68
84
|
const resolverContext: ResolverContext = {
|
|
69
85
|
localesByCode,
|
|
@@ -71,6 +87,7 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
71
87
|
runtimeEntries,
|
|
72
88
|
missingTranslation,
|
|
73
89
|
warned: new Set(),
|
|
90
|
+
report,
|
|
74
91
|
};
|
|
75
92
|
|
|
76
93
|
const activeLocale = (): LocaleConfig => {
|
|
@@ -82,37 +99,55 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
82
99
|
return locale;
|
|
83
100
|
};
|
|
84
101
|
|
|
102
|
+
/** persisted → device → primary fallback; shared by `init` and `initSync`. */
|
|
103
|
+
const resolveInitialLocale = (persisted: string | null): void => {
|
|
104
|
+
if (persisted) {
|
|
105
|
+
if (localesByCode.has(persisted)) {
|
|
106
|
+
store.set({ code: persisted, source: 'persisted', recovered: false });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
report({
|
|
110
|
+
code: 'locale_unsupported',
|
|
111
|
+
locale: persisted,
|
|
112
|
+
message: `Persisted locale "${persisted}" is no longer supported; recovering to "${config.primaryLocale}".`,
|
|
113
|
+
});
|
|
114
|
+
store.set({ code: config.primaryLocale, source: 'primaryFallback', recovered: true });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const provider = config.deviceLocale ?? defaultDeviceLocaleProvider;
|
|
118
|
+
const match = matchSupportedLocale(provider(), locales);
|
|
119
|
+
store.set(
|
|
120
|
+
match
|
|
121
|
+
? { code: match.code, source: match.source, recovered: false }
|
|
122
|
+
: { code: config.primaryLocale, source: 'primaryFallback', recovered: false },
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
85
126
|
return {
|
|
86
127
|
async init() {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
128
|
+
resolveInitialLocale(
|
|
129
|
+
persistenceEnabled && config.storage
|
|
130
|
+
? await readPersistedLocale(config.storage, report)
|
|
131
|
+
: null,
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
initSync() {
|
|
136
|
+
if (!persistenceEnabled || !config.storage) {
|
|
137
|
+
resolveInitialLocale(null);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
const { storage } = config;
|
|
141
|
+
if (typeof storage.getItemSync !== 'function') {
|
|
142
|
+
return false;
|
|
104
143
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
: {
|
|
111
|
-
code: config.primaryLocale,
|
|
112
|
-
source: 'primaryFallback',
|
|
113
|
-
recovered: false,
|
|
114
|
-
},
|
|
144
|
+
resolveInitialLocale(
|
|
145
|
+
readPersistedLocaleSync(
|
|
146
|
+
storage as typeof storage & { getItemSync(key: string): string | null },
|
|
147
|
+
report,
|
|
148
|
+
),
|
|
115
149
|
);
|
|
150
|
+
return true;
|
|
116
151
|
},
|
|
117
152
|
|
|
118
153
|
t(key, vars, options) {
|
|
@@ -129,7 +164,7 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
129
164
|
}
|
|
130
165
|
store.set({ code, source: 'runtimeSwitch', recovered: false });
|
|
131
166
|
if (persistenceEnabled && config.storage) {
|
|
132
|
-
await writePersistedLocale(config.storage, code);
|
|
167
|
+
await writePersistedLocale(config.storage, code, report);
|
|
133
168
|
}
|
|
134
169
|
},
|
|
135
170
|
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,14 @@ export { createI18n } from './i18n';
|
|
|
11
11
|
export type { I18n } from './i18n';
|
|
12
12
|
|
|
13
13
|
export { I18nConfigError } from './config/types';
|
|
14
|
+
export { runtimeEntriesFromCatalog } from './config/catalog';
|
|
15
|
+
export { localeStorageFromKeyValue } from './locale/keyValueStorage';
|
|
16
|
+
export type { I18nErrorCode } from './config/errors';
|
|
17
|
+
export type {
|
|
18
|
+
I18nDiagnostic,
|
|
19
|
+
I18nDiagnosticCode,
|
|
20
|
+
I18nDiagnosticHandler,
|
|
21
|
+
} from './config/diagnostics';
|
|
14
22
|
export type {
|
|
15
23
|
ActiveLocalePreference,
|
|
16
24
|
DeviceLocaleProvider,
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { createListenerSet } from '@chipmobilesdk/rn-core';
|
|
2
|
+
|
|
3
|
+
import { defaultReporter, describeError, type I18nDiagnosticHandler } from '../config/diagnostics';
|
|
1
4
|
import type { ActiveLocalePreference } from '../config/types';
|
|
2
5
|
|
|
3
6
|
export interface ActiveLocaleStore {
|
|
@@ -8,39 +11,32 @@ export interface ActiveLocaleStore {
|
|
|
8
11
|
getVersion(): number;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
export function createActiveLocaleStore(
|
|
14
|
+
export function createActiveLocaleStore(
|
|
15
|
+
initial: ActiveLocalePreference,
|
|
16
|
+
report: I18nDiagnosticHandler = defaultReporter,
|
|
17
|
+
): ActiveLocaleStore {
|
|
12
18
|
let preference = initial;
|
|
13
19
|
let version = 0;
|
|
14
|
-
const listeners =
|
|
20
|
+
const listeners = createListenerSet<[]>({
|
|
21
|
+
onListenerError: error =>
|
|
22
|
+
report({
|
|
23
|
+
code: 'listener_threw',
|
|
24
|
+
message: `A locale listener threw; the locale change still applied: ${describeError(error)}`,
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
15
27
|
|
|
16
28
|
return {
|
|
17
29
|
get: () => preference,
|
|
18
30
|
set(next) {
|
|
19
31
|
preference = next;
|
|
20
32
|
version += 1;
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
// Unsubscribed by an earlier listener during this notification.
|
|
27
|
-
if (!listeners.has(listener)) continue;
|
|
28
|
-
try {
|
|
29
|
-
listener();
|
|
30
|
-
} catch (error) {
|
|
31
|
-
console.warn(
|
|
32
|
-
'[rn-i18n] A locale listener threw; the locale change still applied.',
|
|
33
|
-
error,
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
},
|
|
38
|
-
subscribe(listener) {
|
|
39
|
-
listeners.add(listener);
|
|
40
|
-
return () => {
|
|
41
|
-
listeners.delete(listener);
|
|
42
|
-
};
|
|
33
|
+
// Isolated (CORE-03). One that threw used to skip every listener after
|
|
34
|
+
// it — useSyncExternalStore subscribers included — and made setLocale
|
|
35
|
+
// reject after the switch but before the choice was persisted, so the
|
|
36
|
+
// app showed one locale and stored another.
|
|
37
|
+
listeners.notify();
|
|
43
38
|
},
|
|
39
|
+
subscribe: listener => listeners.add(listener),
|
|
44
40
|
getVersion: () => version,
|
|
45
41
|
};
|
|
46
42
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `LocaleStorageAdapter` over rn-core's synchronous `KeyValuePort` (PERF-08).
|
|
3
|
+
*
|
|
4
|
+
* The adapter carries `getItemSync`, so `i18n.initSync()` resolves the persisted
|
|
5
|
+
* locale before the first render. Open the port the way the app opens its other
|
|
6
|
+
* durable state, for example `openDurable(() => createMMKV({ id: 'app' }))`.
|
|
7
|
+
*/
|
|
8
|
+
import type { KeyValuePort } from '@chipmobilesdk/rn-core';
|
|
9
|
+
|
|
10
|
+
import type { LocaleStorageAdapter } from '../config/types';
|
|
11
|
+
|
|
12
|
+
export function localeStorageFromKeyValue(
|
|
13
|
+
port: KeyValuePort,
|
|
14
|
+
): LocaleStorageAdapter & { getItemSync(key: string): string | null } {
|
|
15
|
+
return {
|
|
16
|
+
getItemSync: key => port.getString(key) ?? null,
|
|
17
|
+
async getItem(key) {
|
|
18
|
+
return port.getString(key) ?? null;
|
|
19
|
+
},
|
|
20
|
+
async setItem(key, value) {
|
|
21
|
+
port.set(key, value);
|
|
22
|
+
},
|
|
23
|
+
async removeItem(key) {
|
|
24
|
+
port.remove(key);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultReporter, describeError, type I18nDiagnosticHandler } from '../config/diagnostics';
|
|
1
2
|
import type { LocaleStorageAdapter } from '../config/types';
|
|
2
3
|
|
|
3
4
|
export const LOCALE_STORAGE_KEY = '@chipmobilesdk/rn-i18n:locale';
|
|
@@ -6,11 +7,33 @@ export const LOCALE_STORAGE_KEY = '@chipmobilesdk/rn-i18n:locale';
|
|
|
6
7
|
* Reads the persisted locale preference. Adapter failures are reported and
|
|
7
8
|
* treated as "no persisted value" so locale selection never crashes (FR-015).
|
|
8
9
|
*/
|
|
9
|
-
export async function readPersistedLocale(
|
|
10
|
+
export async function readPersistedLocale(
|
|
11
|
+
storage: LocaleStorageAdapter,
|
|
12
|
+
report: I18nDiagnosticHandler = defaultReporter,
|
|
13
|
+
): Promise<string | null> {
|
|
10
14
|
try {
|
|
11
15
|
return await storage.getItem(LOCALE_STORAGE_KEY);
|
|
12
16
|
} catch (error) {
|
|
13
|
-
|
|
17
|
+
report({
|
|
18
|
+
code: 'locale_read_failed',
|
|
19
|
+
message: `Failed to read the persisted locale preference: ${describeError(error)}`,
|
|
20
|
+
});
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `readPersistedLocale` for a store with a synchronous read; same failure handling. */
|
|
26
|
+
export function readPersistedLocaleSync(
|
|
27
|
+
storage: LocaleStorageAdapter & { getItemSync(key: string): string | null },
|
|
28
|
+
report: I18nDiagnosticHandler = defaultReporter,
|
|
29
|
+
): string | null {
|
|
30
|
+
try {
|
|
31
|
+
return storage.getItemSync(LOCALE_STORAGE_KEY);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
report({
|
|
34
|
+
code: 'locale_read_failed',
|
|
35
|
+
message: `Failed to read the persisted locale preference: ${describeError(error)}`,
|
|
36
|
+
});
|
|
14
37
|
return null;
|
|
15
38
|
}
|
|
16
39
|
}
|
|
@@ -22,10 +45,15 @@ export async function readPersistedLocale(storage: LocaleStorageAdapter): Promis
|
|
|
22
45
|
export async function writePersistedLocale(
|
|
23
46
|
storage: LocaleStorageAdapter,
|
|
24
47
|
code: string,
|
|
48
|
+
report: I18nDiagnosticHandler = defaultReporter,
|
|
25
49
|
): Promise<void> {
|
|
26
50
|
try {
|
|
27
51
|
await storage.setItem(LOCALE_STORAGE_KEY, code);
|
|
28
52
|
} catch (error) {
|
|
29
|
-
|
|
53
|
+
report({
|
|
54
|
+
code: 'locale_write_failed',
|
|
55
|
+
locale: code,
|
|
56
|
+
message: `Failed to persist locale "${code}": ${describeError(error)}`,
|
|
57
|
+
});
|
|
30
58
|
}
|
|
31
59
|
}
|
package/src/react/hooks.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useSyncExternalStore } from 'react';
|
|
1
|
+
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
|
2
2
|
import type {
|
|
3
3
|
ActiveLocalePreference,
|
|
4
4
|
LocaleConfig,
|
|
@@ -9,8 +9,8 @@ import type {
|
|
|
9
9
|
import type { I18n } from '../i18n';
|
|
10
10
|
import { useI18nContext } from './I18nProvider';
|
|
11
11
|
|
|
12
|
-
function useLocaleVersion(i18n: I18n<string>):
|
|
13
|
-
useSyncExternalStore(i18n.subscribe, i18n.getVersion, i18n.getVersion);
|
|
12
|
+
function useLocaleVersion(i18n: I18n<string>): number {
|
|
13
|
+
return useSyncExternalStore(i18n.subscribe, i18n.getVersion, i18n.getVersion);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export interface UseTranslationResult<TKey extends string> {
|
|
@@ -25,11 +25,19 @@ export interface UseTranslationResult<TKey extends string> {
|
|
|
25
25
|
*/
|
|
26
26
|
export function useTranslation<TKey extends string = string>(): UseTranslationResult<TKey> {
|
|
27
27
|
const i18n = useI18nContext();
|
|
28
|
-
useLocaleVersion(i18n);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
const version = useLocaleVersion(i18n);
|
|
29
|
+
// One `t` and one result per locale version (I18N-05): a memoized child that
|
|
30
|
+
// takes `t` re-renders when the locale changes, not on every parent render.
|
|
31
|
+
return useMemo(
|
|
32
|
+
() => ({
|
|
33
|
+
t: (key: TKey, vars?: TranslateVars, options?: TranslateOptions) =>
|
|
34
|
+
i18n.t(key, vars, options),
|
|
35
|
+
locale: i18n.getLocale().code,
|
|
36
|
+
}),
|
|
37
|
+
// `version` is the dependency that matters: it changes with the locale.
|
|
38
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `version` is read to invalidate, not used
|
|
39
|
+
[i18n, version],
|
|
40
|
+
);
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export interface UseLocaleResult {
|
|
@@ -44,6 +52,7 @@ export interface UseLocaleResult {
|
|
|
44
52
|
export function useLocale(): UseLocaleResult {
|
|
45
53
|
const i18n = useI18nContext();
|
|
46
54
|
useLocaleVersion(i18n);
|
|
55
|
+
const setLocale = useCallback((code: string) => i18n.setLocale(code), [i18n]);
|
|
47
56
|
const preference = i18n.getLocale();
|
|
48
57
|
const supportedLocales = i18n.getSupportedLocales();
|
|
49
58
|
const active = supportedLocales.find(locale => locale.code === preference.code);
|
|
@@ -52,6 +61,6 @@ export function useLocale(): UseLocaleResult {
|
|
|
52
61
|
direction: active?.direction ?? 'ltr',
|
|
53
62
|
preference,
|
|
54
63
|
supportedLocales,
|
|
55
|
-
setLocale
|
|
64
|
+
setLocale,
|
|
56
65
|
};
|
|
57
66
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { I18nDiagnostic, I18nDiagnosticHandler } from '../config/diagnostics';
|
|
1
2
|
import type {
|
|
2
3
|
LocaleConfig,
|
|
3
4
|
MissingTranslationPolicy,
|
|
@@ -18,14 +19,30 @@ export interface ResolverContext {
|
|
|
18
19
|
missingTranslation: MissingTranslationPolicy;
|
|
19
20
|
/** Dedupe set so each failed lookup path warns exactly once. */
|
|
20
21
|
warned: Set<string>;
|
|
22
|
+
report: I18nDiagnosticHandler;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
/**
|
|
26
|
+
* How many lookup paths are remembered. An app that misses many distinct keys
|
|
27
|
+
* forgets the oldest first, so memory stays bounded (I18N-11).
|
|
28
|
+
*/
|
|
29
|
+
const WARNED_CAPACITY = 500;
|
|
30
|
+
|
|
31
|
+
export function warnOnce(
|
|
32
|
+
context: Pick<ResolverContext, 'warned' | 'report'>,
|
|
33
|
+
dedupeKey: string,
|
|
34
|
+
diagnostic: I18nDiagnostic,
|
|
35
|
+
): void {
|
|
36
|
+
const { warned } = context;
|
|
24
37
|
if (warned.has(dedupeKey)) {
|
|
25
38
|
return;
|
|
26
39
|
}
|
|
27
40
|
warned.add(dedupeKey);
|
|
28
|
-
|
|
41
|
+
if (warned.size > WARNED_CAPACITY) {
|
|
42
|
+
const oldest = warned.values().next().value;
|
|
43
|
+
if (oldest !== undefined) warned.delete(oldest);
|
|
44
|
+
}
|
|
45
|
+
context.report(diagnostic);
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
/** Walks a dot-path key through a nested translation namespace. */
|
|
@@ -54,7 +71,7 @@ function pickValue(
|
|
|
54
71
|
node: TranslationNode,
|
|
55
72
|
locale: LocaleConfig,
|
|
56
73
|
options: TranslateOptions | undefined,
|
|
57
|
-
|
|
74
|
+
warnings: Pick<ResolverContext, 'warned' | 'report'>,
|
|
58
75
|
key: string,
|
|
59
76
|
): string | undefined {
|
|
60
77
|
if (typeof node === 'string') {
|
|
@@ -67,11 +84,12 @@ function pickValue(
|
|
|
67
84
|
}
|
|
68
85
|
return selectPluralVariant(node, resolvePluralRule(locale)(count));
|
|
69
86
|
}
|
|
70
|
-
warnOnce(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
87
|
+
warnOnce(warnings, `invalid-value|${locale.code}|${key}`, {
|
|
88
|
+
code: 'invalid_value',
|
|
89
|
+
key,
|
|
90
|
+
locale: locale.code,
|
|
91
|
+
message: `Translation value for "${key}" in locale "${locale.code}" is not text or a plural group; treating it as missing.`,
|
|
92
|
+
});
|
|
75
93
|
return undefined;
|
|
76
94
|
}
|
|
77
95
|
|
|
@@ -96,11 +114,12 @@ export function translate(
|
|
|
96
114
|
|
|
97
115
|
const interpolateWith = (text: string, sourceLocale: string) =>
|
|
98
116
|
interpolate(text, mergedVars, name =>
|
|
99
|
-
warnOnce(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
117
|
+
warnOnce(context, `missing-var|${sourceLocale}|${key}|${name}`, {
|
|
118
|
+
code: 'missing_variable',
|
|
119
|
+
key,
|
|
120
|
+
locale: sourceLocale,
|
|
121
|
+
message: `Missing interpolation variable "${name}" for key "${key}" in locale "${sourceLocale}".`,
|
|
122
|
+
}),
|
|
104
123
|
);
|
|
105
124
|
|
|
106
125
|
for (const code of chain) {
|
|
@@ -112,16 +131,17 @@ export function translate(
|
|
|
112
131
|
if (node === undefined) {
|
|
113
132
|
continue;
|
|
114
133
|
}
|
|
115
|
-
const value = pickValue(node, locale, options, context
|
|
134
|
+
const value = pickValue(node, locale, options, context, key);
|
|
116
135
|
if (value === undefined) {
|
|
117
136
|
continue;
|
|
118
137
|
}
|
|
119
138
|
if (code !== activeCode) {
|
|
120
|
-
warnOnce(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
139
|
+
warnOnce(context, `fallback|${activeCode}|${key}`, {
|
|
140
|
+
code: 'fallback_used',
|
|
141
|
+
key,
|
|
142
|
+
locale: activeCode,
|
|
143
|
+
message: `Key "${key}" is missing in locale "${activeCode}"; served from fallback locale "${code}".`,
|
|
144
|
+
});
|
|
125
145
|
}
|
|
126
146
|
return interpolateWith(value, code);
|
|
127
147
|
}
|
|
@@ -131,10 +151,11 @@ export function translate(
|
|
|
131
151
|
return interpolateWith(runtimeEntry.defaultText, activeCode);
|
|
132
152
|
}
|
|
133
153
|
|
|
134
|
-
warnOnce(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
154
|
+
warnOnce(context, `missing|${activeCode}|${key}`, {
|
|
155
|
+
code: 'missing_translation',
|
|
156
|
+
key,
|
|
157
|
+
locale: activeCode,
|
|
158
|
+
message: `Missing translation for key "${key}" in locale "${activeCode}" and its entire fallback chain.`,
|
|
159
|
+
});
|
|
139
160
|
return context.missingTranslation.mode === 'placeholder' ? context.missingTranslation.text : key;
|
|
140
161
|
}
|