@chipmobilesdk/rn-i18n 0.1.1 → 0.1.3

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,196 @@
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 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 { defaultDeviceLocaleProvider, matchSupportedLocale } from './locale/deviceLocale';
15
+ import { readPersistedLocale, writePersistedLocale } from './locale/persistence';
16
+ import { translate } from './translation/resolver';
17
+ import type { ResolverContext } from './translation/resolver';
18
+ import { createRuntimeRegistry, registerRuntimeEntries } from './translation/runtimeKeys';
19
+ import { formatCurrencyValue } from './format/currency';
20
+ import { formatDateValue } from './format/date';
21
+ import { formatNumberValue } from './format/number';
22
+ import { KNOWN_UNIT_IDS, convertUnitValue, formatUnitValue } from './format/units';
23
+ import type { UnitFormatOptions } from './format/units';
24
+
25
+ export interface I18n<TKey extends string = string> {
26
+ /** Resolves the initial locale: persisted → device → primary fallback. */
27
+ init(): Promise<void>;
28
+ t(key: TKey, vars?: TranslateVars, options?: TranslateOptions): string;
29
+ getLocale(): ActiveLocalePreference;
30
+ /** Switches at runtime without restart and persists best-effort. */
31
+ setLocale(code: string): Promise<void>;
32
+ getSupportedLocales(): readonly LocaleConfig[];
33
+ formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
34
+ formatDate(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
35
+ formatCurrency(value: number, currency?: string, options?: Intl.NumberFormatOptions): string;
36
+ formatUnit(value: number, options?: UnitFormatOptions): string;
37
+ convertUnit(value: number, from: UnitId, to: UnitId): number;
38
+ registerRuntimeEntries(entries: readonly RuntimeTranslationEntry[]): void;
39
+ subscribe(listener: () => void): () => void;
40
+ getVersion(): number;
41
+ }
42
+
43
+ const DEFAULT_MISSING: MissingTranslationPolicy = { mode: 'key' };
44
+ const DEFAULT_INVALID: InvalidFormatPolicy = { mode: 'placeholder', text: '—' };
45
+
46
+ /**
47
+ * Creates an I18n instance from an app-owned configuration. Validation runs
48
+ * eagerly and throws an I18nConfigError before any localized content can be
49
+ * served (FR-003). Pass the app's generated key type for typed lookups:
50
+ * `createI18n<AppI18nKey>(config)`.
51
+ */
52
+ export function createI18n<TKey extends string = string>(config: I18nConfig): I18n<TKey> {
53
+ validateConfig(config, KNOWN_UNIT_IDS);
54
+
55
+ const locales: readonly LocaleConfig[] = [...config.locales];
56
+ const localesByCode = new Map(locales.map(locale => [locale.code, locale]));
57
+ const missingTranslation = config.missingTranslation ?? DEFAULT_MISSING;
58
+ const invalidFormat = config.invalidFormat ?? DEFAULT_INVALID;
59
+ const persistenceEnabled = config.persistLocale !== false && !!config.storage;
60
+ const runtimeEntries = createRuntimeRegistry(config.runtimeEntries);
61
+
62
+ const store = createActiveLocaleStore({
63
+ code: config.primaryLocale,
64
+ source: 'primaryFallback',
65
+ recovered: false,
66
+ });
67
+
68
+ const resolverContext: ResolverContext = {
69
+ localesByCode,
70
+ resources: config.resources,
71
+ runtimeEntries,
72
+ missingTranslation,
73
+ warned: new Set(),
74
+ };
75
+
76
+ const activeLocale = (): LocaleConfig => {
77
+ const locale = localesByCode.get(store.get().code);
78
+ if (!locale) {
79
+ // Unreachable after validation; guards against external mutation.
80
+ throw new Error(`[rn-i18n] Active locale "${store.get().code}" is not configured.`);
81
+ }
82
+ return locale;
83
+ };
84
+
85
+ return {
86
+ async init() {
87
+ if (persistenceEnabled && config.storage) {
88
+ const persisted = await readPersistedLocale(config.storage);
89
+ if (persisted) {
90
+ if (localesByCode.has(persisted)) {
91
+ store.set({ code: persisted, source: 'persisted', recovered: false });
92
+ return;
93
+ }
94
+ console.warn(
95
+ `[rn-i18n] Persisted locale "${persisted}" is no longer supported; recovering to "${config.primaryLocale}".`,
96
+ );
97
+ store.set({
98
+ code: config.primaryLocale,
99
+ source: 'primaryFallback',
100
+ recovered: true,
101
+ });
102
+ return;
103
+ }
104
+ }
105
+ const provider = config.deviceLocale ?? defaultDeviceLocaleProvider;
106
+ const match = matchSupportedLocale(provider(), locales);
107
+ store.set(
108
+ match
109
+ ? { code: match.code, source: match.source, recovered: false }
110
+ : {
111
+ code: config.primaryLocale,
112
+ source: 'primaryFallback',
113
+ recovered: false,
114
+ },
115
+ );
116
+ },
117
+
118
+ t(key, vars, options) {
119
+ return translate(resolverContext, store.get().code, key, vars, options);
120
+ },
121
+
122
+ getLocale() {
123
+ return store.get();
124
+ },
125
+
126
+ async setLocale(code) {
127
+ if (!localesByCode.has(code)) {
128
+ throw new Error(`[rn-i18n] Cannot switch to unsupported locale "${code}".`);
129
+ }
130
+ store.set({ code, source: 'runtimeSwitch', recovered: false });
131
+ if (persistenceEnabled && config.storage) {
132
+ await writePersistedLocale(config.storage, code);
133
+ }
134
+ },
135
+
136
+ getSupportedLocales() {
137
+ return locales;
138
+ },
139
+
140
+ formatNumber(value, options) {
141
+ const locale = activeLocale();
142
+ return formatNumberValue(
143
+ value,
144
+ locale.code,
145
+ locale.defaultNumberFormat,
146
+ options,
147
+ invalidFormat,
148
+ );
149
+ },
150
+
151
+ formatDate(value, options) {
152
+ const locale = activeLocale();
153
+ return formatDateValue(value, locale.code, locale.defaultDateFormat, options, invalidFormat);
154
+ },
155
+
156
+ formatCurrency(value, currency, options) {
157
+ const locale = activeLocale();
158
+ return formatCurrencyValue(
159
+ value,
160
+ locale.code,
161
+ locale.defaultCurrency,
162
+ currency,
163
+ options,
164
+ invalidFormat,
165
+ );
166
+ },
167
+
168
+ formatUnit(value, options) {
169
+ const locale = activeLocale();
170
+ return formatUnitValue(
171
+ value,
172
+ locale.code,
173
+ locale.defaultUnit,
174
+ locale.defaultNumberFormat,
175
+ options,
176
+ invalidFormat,
177
+ );
178
+ },
179
+
180
+ convertUnit(value, from, to) {
181
+ return convertUnitValue(value, from, to);
182
+ },
183
+
184
+ registerRuntimeEntries(entries) {
185
+ registerRuntimeEntries(runtimeEntries, entries);
186
+ },
187
+
188
+ subscribe(listener) {
189
+ return store.subscribe(listener);
190
+ },
191
+
192
+ getVersion() {
193
+ return store.getVersion();
194
+ },
195
+ };
196
+ }
package/src/index.ts CHANGED
@@ -1,50 +1,50 @@
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 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,35 +1,46 @@
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
- for (const listener of [...listeners]) {
24
- listener();
25
- }
26
- },
27
- subscribe(listener) {
28
- listeners.add(listener);
29
- return () => {
30
- listeners.delete(listener);
31
- };
32
- },
33
- getVersion: () => version,
34
- };
35
- }
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(initial: ActiveLocalePreference): ActiveLocaleStore {
12
+ let preference = initial;
13
+ let version = 0;
14
+ const listeners = new Set<() => void>();
15
+
16
+ return {
17
+ get: () => preference,
18
+ set(next) {
19
+ preference = next;
20
+ version += 1;
21
+ // Snapshot, and isolate each listener (CORE-03). One that threw used to
22
+ // skip every listener after it — useSyncExternalStore subscribers
23
+ // included — and made setLocale reject after the switch but before the
24
+ // choice was persisted, so the app showed one locale and stored another.
25
+ for (const listener of [...listeners]) {
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
+ };
43
+ },
44
+ getVersion: () => version,
45
+ };
46
+ }