@chipmobilesdk/rn-i18n 0.1.2 → 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/src/i18n.ts CHANGED
@@ -1,222 +1,231 @@
1
- import type {
2
- ActiveLocalePreference,
3
- I18nConfig,
4
- InvalidFormatPolicy,
5
- LocaleConfig,
6
- MissingTranslationPolicy,
7
- RuntimeTranslationEntry,
8
- TranslateOptions,
9
- TranslateVars,
10
- UnitId,
11
- } from './config/types';
12
- import { validateConfig } from './config/validateConfig';
13
- import { createActiveLocaleStore } from './locale/activeLocale';
14
- import {
15
- defaultDeviceLocaleProvider,
16
- matchSupportedLocale,
17
- } from './locale/deviceLocale';
18
- import { readPersistedLocale, writePersistedLocale } from './locale/persistence';
19
- import { translate } from './translation/resolver';
20
- import type { ResolverContext } from './translation/resolver';
21
- import {
22
- createRuntimeRegistry,
23
- registerRuntimeEntries,
24
- } from './translation/runtimeKeys';
25
- import { formatCurrencyValue } from './format/currency';
26
- import { formatDateValue } from './format/date';
27
- import { formatNumberValue } from './format/number';
28
- import {
29
- KNOWN_UNIT_IDS,
30
- convertUnitValue,
31
- formatUnitValue,
32
- } from './format/units';
33
- import type { UnitFormatOptions } from './format/units';
34
-
35
- export interface I18n<TKey extends string = string> {
36
- /** Resolves the initial locale: persisted → device → primary fallback. */
37
- init(): Promise<void>;
38
- t(key: TKey, vars?: TranslateVars, options?: TranslateOptions): string;
39
- getLocale(): ActiveLocalePreference;
40
- /** Switches at runtime without restart and persists best-effort. */
41
- setLocale(code: string): Promise<void>;
42
- getSupportedLocales(): readonly LocaleConfig[];
43
- formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
44
- formatDate(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
45
- formatCurrency(
46
- value: number,
47
- currency?: string,
48
- options?: Intl.NumberFormatOptions,
49
- ): string;
50
- formatUnit(value: number, options?: UnitFormatOptions): string;
51
- convertUnit(value: number, from: UnitId, to: UnitId): number;
52
- registerRuntimeEntries(entries: readonly RuntimeTranslationEntry[]): void;
53
- subscribe(listener: () => void): () => void;
54
- getVersion(): number;
55
- }
56
-
57
- const DEFAULT_MISSING: MissingTranslationPolicy = { mode: 'key' };
58
- const DEFAULT_INVALID: InvalidFormatPolicy = { mode: 'placeholder', text: '—' };
59
-
60
- /**
61
- * Creates an I18n instance from an app-owned configuration. Validation runs
62
- * eagerly and throws an I18nConfigError before any localized content can be
63
- * served (FR-003). Pass the app's generated key type for typed lookups:
64
- * `createI18n<AppI18nKey>(config)`.
65
- */
66
- export function createI18n<TKey extends string = string>(
67
- config: I18nConfig,
68
- ): I18n<TKey> {
69
- validateConfig(config, KNOWN_UNIT_IDS);
70
-
71
- const locales: readonly LocaleConfig[] = [...config.locales];
72
- const localesByCode = new Map(locales.map(locale => [locale.code, locale]));
73
- const missingTranslation = config.missingTranslation ?? DEFAULT_MISSING;
74
- const invalidFormat = config.invalidFormat ?? DEFAULT_INVALID;
75
- const persistenceEnabled = config.persistLocale !== false && !!config.storage;
76
- const runtimeEntries = createRuntimeRegistry(config.runtimeEntries);
77
-
78
- const store = createActiveLocaleStore({
79
- code: config.primaryLocale,
80
- source: 'primaryFallback',
81
- recovered: false,
82
- });
83
-
84
- const resolverContext: ResolverContext = {
85
- localesByCode,
86
- resources: config.resources,
87
- runtimeEntries,
88
- missingTranslation,
89
- warned: new Set(),
90
- };
91
-
92
- const activeLocale = (): LocaleConfig => {
93
- const locale = localesByCode.get(store.get().code);
94
- if (!locale) {
95
- // Unreachable after validation; guards against external mutation.
96
- throw new Error(
97
- `[rn-i18n] Active locale "${store.get().code}" is not configured.`,
98
- );
99
- }
100
- return locale;
101
- };
102
-
103
- return {
104
- async init() {
105
- if (persistenceEnabled && config.storage) {
106
- const persisted = await readPersistedLocale(config.storage);
107
- if (persisted) {
108
- if (localesByCode.has(persisted)) {
109
- store.set({ code: persisted, source: 'persisted', recovered: false });
110
- return;
111
- }
112
- console.warn(
113
- `[rn-i18n] Persisted locale "${persisted}" is no longer supported; recovering to "${config.primaryLocale}".`,
114
- );
115
- store.set({
116
- code: config.primaryLocale,
117
- source: 'primaryFallback',
118
- recovered: true,
119
- });
120
- return;
121
- }
122
- }
123
- const provider = config.deviceLocale ?? defaultDeviceLocaleProvider;
124
- const match = matchSupportedLocale(provider(), locales);
125
- store.set(
126
- match
127
- ? { code: match.code, source: match.source, recovered: false }
128
- : {
129
- code: config.primaryLocale,
130
- source: 'primaryFallback',
131
- recovered: false,
132
- },
133
- );
134
- },
135
-
136
- t(key, vars, options) {
137
- return translate(resolverContext, store.get().code, key, vars, options);
138
- },
139
-
140
- getLocale() {
141
- return store.get();
142
- },
143
-
144
- async setLocale(code) {
145
- if (!localesByCode.has(code)) {
146
- throw new Error(
147
- `[rn-i18n] Cannot switch to unsupported locale "${code}".`,
148
- );
149
- }
150
- store.set({ code, source: 'runtimeSwitch', recovered: false });
151
- if (persistenceEnabled && config.storage) {
152
- await writePersistedLocale(config.storage, code);
153
- }
154
- },
155
-
156
- getSupportedLocales() {
157
- return locales;
158
- },
159
-
160
- formatNumber(value, options) {
161
- const locale = activeLocale();
162
- return formatNumberValue(
163
- value,
164
- locale.code,
165
- locale.defaultNumberFormat,
166
- options,
167
- invalidFormat,
168
- );
169
- },
170
-
171
- formatDate(value, options) {
172
- const locale = activeLocale();
173
- return formatDateValue(
174
- value,
175
- locale.code,
176
- locale.defaultDateFormat,
177
- options,
178
- invalidFormat,
179
- );
180
- },
181
-
182
- formatCurrency(value, currency, options) {
183
- const locale = activeLocale();
184
- return formatCurrencyValue(
185
- value,
186
- locale.code,
187
- locale.defaultCurrency,
188
- currency,
189
- options,
190
- invalidFormat,
191
- );
192
- },
193
-
194
- formatUnit(value, options) {
195
- const locale = activeLocale();
196
- return formatUnitValue(
197
- value,
198
- locale.code,
199
- locale.defaultUnit,
200
- locale.defaultNumberFormat,
201
- options,
202
- invalidFormat,
203
- );
204
- },
205
-
206
- convertUnit(value, from, to) {
207
- return convertUnitValue(value, from, to);
208
- },
209
-
210
- registerRuntimeEntries(entries) {
211
- registerRuntimeEntries(runtimeEntries, entries);
212
- },
213
-
214
- subscribe(listener) {
215
- return store.subscribe(listener);
216
- },
217
-
218
- getVersion() {
219
- return store.getVersion();
220
- },
221
- };
222
- }
1
+ import { createReporter } from './config/diagnostics';
2
+ import type { ResolvedInvalidFormatPolicy } from './format/invalid';
3
+ import type {
4
+ ActiveLocalePreference,
5
+ I18nConfig,
6
+ InvalidFormatPolicy,
7
+ LocaleConfig,
8
+ MissingTranslationPolicy,
9
+ RuntimeTranslationEntry,
10
+ TranslateOptions,
11
+ TranslateVars,
12
+ UnitId,
13
+ } from './config/types';
14
+ import { validateConfig } from './config/validateConfig';
15
+ import { createActiveLocaleStore } from './locale/activeLocale';
16
+ import { defaultDeviceLocaleProvider, matchSupportedLocale } from './locale/deviceLocale';
17
+ import {
18
+ readPersistedLocale,
19
+ readPersistedLocaleSync,
20
+ writePersistedLocale,
21
+ } from './locale/persistence';
22
+ import { translate } from './translation/resolver';
23
+ import type { ResolverContext } from './translation/resolver';
24
+ import { createRuntimeRegistry, registerRuntimeEntries } from './translation/runtimeKeys';
25
+ import { formatCurrencyValue } from './format/currency';
26
+ import { formatDateValue } from './format/date';
27
+ import { formatNumberValue } from './format/number';
28
+ import { KNOWN_UNIT_IDS, convertUnitValue, formatUnitValue } from './format/units';
29
+ import type { UnitFormatOptions } from './format/units';
30
+
31
+ export interface I18n<TKey extends string = string> {
32
+ /** Resolves the initial locale: persisted → device → primary fallback. */
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;
41
+ t(key: TKey, vars?: TranslateVars, options?: TranslateOptions): string;
42
+ getLocale(): ActiveLocalePreference;
43
+ /** Switches at runtime without restart and persists best-effort. */
44
+ setLocale(code: string): Promise<void>;
45
+ getSupportedLocales(): readonly LocaleConfig[];
46
+ formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
47
+ formatDate(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
48
+ formatCurrency(value: number, currency?: string, options?: Intl.NumberFormatOptions): string;
49
+ formatUnit(value: number, options?: UnitFormatOptions): string;
50
+ convertUnit(value: number, from: UnitId, to: UnitId): number;
51
+ registerRuntimeEntries(entries: readonly RuntimeTranslationEntry[]): void;
52
+ subscribe(listener: () => void): () => void;
53
+ getVersion(): number;
54
+ }
55
+
56
+ const DEFAULT_MISSING: MissingTranslationPolicy = { mode: 'key' };
57
+ const DEFAULT_INVALID: InvalidFormatPolicy = { mode: 'placeholder', text: '—' };
58
+
59
+ /**
60
+ * Creates an I18n instance from an app-owned configuration. Validation runs
61
+ * eagerly and throws an I18nConfigError before any localized content can be
62
+ * served (FR-003). Pass the app's generated key type for typed lookups:
63
+ * `createI18n<AppI18nKey>(config)`.
64
+ */
65
+ export function createI18n<TKey extends string = string>(config: I18nConfig): I18n<TKey> {
66
+ validateConfig(config, KNOWN_UNIT_IDS);
67
+
68
+ const locales: readonly LocaleConfig[] = [...config.locales];
69
+ const localesByCode = new Map(locales.map(locale => [locale.code, locale]));
70
+ const missingTranslation = config.missingTranslation ?? DEFAULT_MISSING;
71
+ const report = createReporter(config.onDiagnostic);
72
+ const invalidFormat: ResolvedInvalidFormatPolicy = {
73
+ ...(config.invalidFormat ?? DEFAULT_INVALID),
74
+ report,
75
+ };
76
+ const persistenceEnabled = config.persistLocale !== false && !!config.storage;
77
+ const runtimeEntries = createRuntimeRegistry(config.runtimeEntries);
78
+
79
+ const store = createActiveLocaleStore(
80
+ { code: config.primaryLocale, source: 'primaryFallback', recovered: false },
81
+ report,
82
+ );
83
+
84
+ const resolverContext: ResolverContext = {
85
+ localesByCode,
86
+ resources: config.resources,
87
+ runtimeEntries,
88
+ missingTranslation,
89
+ warned: new Set(),
90
+ report,
91
+ };
92
+
93
+ const activeLocale = (): LocaleConfig => {
94
+ const locale = localesByCode.get(store.get().code);
95
+ if (!locale) {
96
+ // Unreachable after validation; guards against external mutation.
97
+ throw new Error(`[rn-i18n] Active locale "${store.get().code}" is not configured.`);
98
+ }
99
+ return locale;
100
+ };
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
+
126
+ return {
127
+ async init() {
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;
143
+ }
144
+ resolveInitialLocale(
145
+ readPersistedLocaleSync(
146
+ storage as typeof storage & { getItemSync(key: string): string | null },
147
+ report,
148
+ ),
149
+ );
150
+ return true;
151
+ },
152
+
153
+ t(key, vars, options) {
154
+ return translate(resolverContext, store.get().code, key, vars, options);
155
+ },
156
+
157
+ getLocale() {
158
+ return store.get();
159
+ },
160
+
161
+ async setLocale(code) {
162
+ if (!localesByCode.has(code)) {
163
+ throw new Error(`[rn-i18n] Cannot switch to unsupported locale "${code}".`);
164
+ }
165
+ store.set({ code, source: 'runtimeSwitch', recovered: false });
166
+ if (persistenceEnabled && config.storage) {
167
+ await writePersistedLocale(config.storage, code, report);
168
+ }
169
+ },
170
+
171
+ getSupportedLocales() {
172
+ return locales;
173
+ },
174
+
175
+ formatNumber(value, options) {
176
+ const locale = activeLocale();
177
+ return formatNumberValue(
178
+ value,
179
+ locale.code,
180
+ locale.defaultNumberFormat,
181
+ options,
182
+ invalidFormat,
183
+ );
184
+ },
185
+
186
+ formatDate(value, options) {
187
+ const locale = activeLocale();
188
+ return formatDateValue(value, locale.code, locale.defaultDateFormat, options, invalidFormat);
189
+ },
190
+
191
+ formatCurrency(value, currency, options) {
192
+ const locale = activeLocale();
193
+ return formatCurrencyValue(
194
+ value,
195
+ locale.code,
196
+ locale.defaultCurrency,
197
+ currency,
198
+ options,
199
+ invalidFormat,
200
+ );
201
+ },
202
+
203
+ formatUnit(value, options) {
204
+ const locale = activeLocale();
205
+ return formatUnitValue(
206
+ value,
207
+ locale.code,
208
+ locale.defaultUnit,
209
+ locale.defaultNumberFormat,
210
+ options,
211
+ invalidFormat,
212
+ );
213
+ },
214
+
215
+ convertUnit(value, from, to) {
216
+ return convertUnitValue(value, from, to);
217
+ },
218
+
219
+ registerRuntimeEntries(entries) {
220
+ registerRuntimeEntries(runtimeEntries, entries);
221
+ },
222
+
223
+ subscribe(listener) {
224
+ return store.subscribe(listener);
225
+ },
226
+
227
+ getVersion() {
228
+ return store.getVersion();
229
+ },
230
+ };
231
+ }
package/src/index.ts CHANGED
@@ -1,50 +1,58 @@
1
- /**
2
- * @chipmobilesdk/rn-i18n — reusable localization for ChipMobileSdk-family
3
- * React Native apps.
4
- *
5
- * This is the single React Native entrypoint. Node-only extraction and
6
- * synchronization tooling is exposed separately via
7
- * `@chipmobilesdk/rn-i18n/tooling` and must never be imported from here.
8
- */
9
-
10
- export { createI18n } from './i18n';
11
- export type { I18n } from './i18n';
12
-
13
- export { I18nConfigError } from './config/types';
14
- export type {
15
- ActiveLocalePreference,
16
- DeviceLocaleProvider,
17
- I18nConfig,
18
- InvalidFormatPolicy,
19
- LocaleConfig,
20
- LocaleDirection,
21
- LocaleSelectionSource,
22
- LocaleStorageAdapter,
23
- MissingTranslationPolicy,
24
- PluralCategory,
25
- PluralRule,
26
- RuntimeTranslationEntry,
27
- TranslateOptions,
28
- TranslateVars,
29
- TranslationNamespace,
30
- TranslationNode,
31
- TranslationResources,
32
- UnitDimension,
33
- UnitId,
34
- } from './config/types';
35
-
36
- export { LOCALE_STORAGE_KEY } from './locale/persistence';
37
- export { defaultDeviceLocaleProvider } from './locale/deviceLocale';
38
-
39
- export {
40
- SUPPORTED_UNITS,
41
- UnitConversionError,
42
- convertUnitValue as convertUnit,
43
- getUnitSymbol,
44
- } from './format/units';
45
- export type { UnitFormatOptions } from './format/units';
46
-
47
- export { I18nProvider } from './react/I18nProvider';
48
- export type { I18nProviderProps } from './react/I18nProvider';
49
- export { useLocale, useTranslation } from './react/hooks';
50
- export type { UseLocaleResult, UseTranslationResult } from './react/hooks';
1
+ /**
2
+ * @chipmobilesdk/rn-i18n — reusable localization for ChipMobileSdk-family
3
+ * React Native apps.
4
+ *
5
+ * This is the single React Native entrypoint. Node-only extraction and
6
+ * synchronization tooling is exposed separately via
7
+ * `@chipmobilesdk/rn-i18n/tooling` and must never be imported from here.
8
+ */
9
+
10
+ export { createI18n } from './i18n';
11
+ export type { I18n } from './i18n';
12
+
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';
22
+ export type {
23
+ ActiveLocalePreference,
24
+ DeviceLocaleProvider,
25
+ I18nConfig,
26
+ InvalidFormatPolicy,
27
+ LocaleConfig,
28
+ LocaleDirection,
29
+ LocaleSelectionSource,
30
+ LocaleStorageAdapter,
31
+ MissingTranslationPolicy,
32
+ PluralCategory,
33
+ PluralRule,
34
+ RuntimeTranslationEntry,
35
+ TranslateOptions,
36
+ TranslateVars,
37
+ TranslationNamespace,
38
+ TranslationNode,
39
+ TranslationResources,
40
+ UnitDimension,
41
+ UnitId,
42
+ } from './config/types';
43
+
44
+ export { LOCALE_STORAGE_KEY } from './locale/persistence';
45
+ export { defaultDeviceLocaleProvider } from './locale/deviceLocale';
46
+
47
+ export {
48
+ SUPPORTED_UNITS,
49
+ UnitConversionError,
50
+ convertUnitValue as convertUnit,
51
+ getUnitSymbol,
52
+ } from './format/units';
53
+ export type { UnitFormatOptions } from './format/units';
54
+
55
+ export { I18nProvider } from './react/I18nProvider';
56
+ export type { I18nProviderProps } from './react/I18nProvider';
57
+ export { useLocale, useTranslation } from './react/hooks';
58
+ export type { UseLocaleResult, UseTranslationResult } from './react/hooks';
@@ -1,45 +1,42 @@
1
- import type { ActiveLocalePreference } from '../config/types';
2
-
3
- export interface ActiveLocaleStore {
4
- get(): ActiveLocalePreference;
5
- set(preference: ActiveLocalePreference): void;
6
- subscribe(listener: () => void): () => void;
7
- /** Monotonic change counter consumed by useSyncExternalStore. */
8
- getVersion(): number;
9
- }
10
-
11
- export function createActiveLocaleStore(
12
- initial: ActiveLocalePreference,
13
- ): ActiveLocaleStore {
14
- let preference = initial;
15
- let version = 0;
16
- const listeners = new Set<() => void>();
17
-
18
- return {
19
- get: () => preference,
20
- set(next) {
21
- preference = next;
22
- version += 1;
23
- // Snapshot, and isolate each listener (CORE-03). One that threw used to
24
- // skip every listener after it — useSyncExternalStore subscribers
25
- // included — and made setLocale reject after the switch but before the
26
- // choice was persisted, so the app showed one locale and stored another.
27
- for (const listener of [...listeners]) {
28
- // Unsubscribed by an earlier listener during this notification.
29
- if (!listeners.has(listener)) continue;
30
- try {
31
- listener();
32
- } catch (error) {
33
- console.warn('[rn-i18n] A locale listener threw; the locale change still applied.', error);
34
- }
35
- }
36
- },
37
- subscribe(listener) {
38
- listeners.add(listener);
39
- return () => {
40
- listeners.delete(listener);
41
- };
42
- },
43
- getVersion: () => version,
44
- };
45
- }
1
+ import { createListenerSet } from '@chipmobilesdk/rn-core';
2
+
3
+ import { defaultReporter, describeError, type I18nDiagnosticHandler } from '../config/diagnostics';
4
+ import type { ActiveLocalePreference } from '../config/types';
5
+
6
+ export interface ActiveLocaleStore {
7
+ get(): ActiveLocalePreference;
8
+ set(preference: ActiveLocalePreference): void;
9
+ subscribe(listener: () => void): () => void;
10
+ /** Monotonic change counter consumed by useSyncExternalStore. */
11
+ getVersion(): number;
12
+ }
13
+
14
+ export function createActiveLocaleStore(
15
+ initial: ActiveLocalePreference,
16
+ report: I18nDiagnosticHandler = defaultReporter,
17
+ ): ActiveLocaleStore {
18
+ let preference = initial;
19
+ let version = 0;
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
+ });
27
+
28
+ return {
29
+ get: () => preference,
30
+ set(next) {
31
+ preference = next;
32
+ version += 1;
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();
38
+ },
39
+ subscribe: listener => listeners.add(listener),
40
+ getVersion: () => version,
41
+ };
42
+ }