@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/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,13 @@ 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
|
+
clearPersistedLocale,
|
|
19
|
+
readPersistedLocale,
|
|
20
|
+
readPersistedLocaleSync,
|
|
21
|
+
writePersistedLocale,
|
|
22
|
+
} from './locale/persistence';
|
|
23
|
+
import { hasPluralRule } from './translation/plurals';
|
|
16
24
|
import { translate } from './translation/resolver';
|
|
17
25
|
import type { ResolverContext } from './translation/resolver';
|
|
18
26
|
import { createRuntimeRegistry, registerRuntimeEntries } from './translation/runtimeKeys';
|
|
@@ -25,13 +33,34 @@ import type { UnitFormatOptions } from './format/units';
|
|
|
25
33
|
export interface I18n<TKey extends string = string> {
|
|
26
34
|
/** Resolves the initial locale: persisted → device → primary fallback. */
|
|
27
35
|
init(): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* `init()` without waiting, before the first render (PERF-08). Resolves the locale
|
|
38
|
+
* and returns true when it can do so synchronously: persistence is off, or the
|
|
39
|
+
* storage adapter has `getItemSync`. Returns false, changing nothing, when the
|
|
40
|
+
* adapter can only read asynchronously; call `init()` then.
|
|
41
|
+
*/
|
|
42
|
+
initSync(): boolean;
|
|
28
43
|
t(key: TKey, vars?: TranslateVars, options?: TranslateOptions): string;
|
|
29
44
|
getLocale(): ActiveLocalePreference;
|
|
30
45
|
/** Switches at runtime without restart and persists best-effort. */
|
|
31
46
|
setLocale(code: string): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Forgets the stored choice and follows the device again — the "System
|
|
49
|
+
* language" option a settings screen offers (I18N-17). Resolves to the
|
|
50
|
+
* locale now active.
|
|
51
|
+
*/
|
|
52
|
+
useDeviceLocale(): Promise<ActiveLocalePreference>;
|
|
32
53
|
getSupportedLocales(): readonly LocaleConfig[];
|
|
33
54
|
formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
|
|
34
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Options are merged over the locale's default (`{ month: 'long' }` keeps the
|
|
57
|
+
* year and day). `replaceDefaults` uses only what is passed.
|
|
58
|
+
*/
|
|
59
|
+
formatDate(
|
|
60
|
+
value: Date | number,
|
|
61
|
+
options?: Intl.DateTimeFormatOptions,
|
|
62
|
+
formatting?: { replaceDefaults?: boolean },
|
|
63
|
+
): string;
|
|
35
64
|
formatCurrency(value: number, currency?: string, options?: Intl.NumberFormatOptions): string;
|
|
36
65
|
formatUnit(value: number, options?: UnitFormatOptions): string;
|
|
37
66
|
convertUnit(value: number, from: UnitId, to: UnitId): number;
|
|
@@ -55,15 +84,33 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
55
84
|
const locales: readonly LocaleConfig[] = [...config.locales];
|
|
56
85
|
const localesByCode = new Map(locales.map(locale => [locale.code, locale]));
|
|
57
86
|
const missingTranslation = config.missingTranslation ?? DEFAULT_MISSING;
|
|
58
|
-
const
|
|
87
|
+
const report = createReporter(config.onDiagnostic);
|
|
88
|
+
const invalidFormat: ResolvedInvalidFormatPolicy = {
|
|
89
|
+
...(config.invalidFormat ?? DEFAULT_INVALID),
|
|
90
|
+
report,
|
|
91
|
+
};
|
|
59
92
|
const persistenceEnabled = config.persistLocale !== false && !!config.storage;
|
|
60
93
|
const runtimeEntries = createRuntimeRegistry(config.runtimeEntries);
|
|
61
94
|
|
|
62
|
-
const store = createActiveLocaleStore(
|
|
63
|
-
code: config.primaryLocale,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
95
|
+
const store = createActiveLocaleStore(
|
|
96
|
+
{ code: config.primaryLocale, source: 'primaryFallback', recovered: false },
|
|
97
|
+
report,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// A language this package has no plural rule for falls back to
|
|
101
|
+
// `count === 1 ? one : other`, which is wrong for most of the world. Said
|
|
102
|
+
// once per locale, at creation, rather than never (I18N-12).
|
|
103
|
+
for (const locale of locales) {
|
|
104
|
+
if (!hasPluralRule(locale)) {
|
|
105
|
+
report({
|
|
106
|
+
code: 'plural_rule_default',
|
|
107
|
+
locale: locale.code,
|
|
108
|
+
message:
|
|
109
|
+
`No plural rule for "${locale.code}"; falling back to "count === 1 selects one". ` +
|
|
110
|
+
'Declare `pluralRule` on the locale if that is not how the language works.',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
67
114
|
|
|
68
115
|
const resolverContext: ResolverContext = {
|
|
69
116
|
localesByCode,
|
|
@@ -71,6 +118,7 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
71
118
|
runtimeEntries,
|
|
72
119
|
missingTranslation,
|
|
73
120
|
warned: new Set(),
|
|
121
|
+
report,
|
|
74
122
|
};
|
|
75
123
|
|
|
76
124
|
const activeLocale = (): LocaleConfig => {
|
|
@@ -82,37 +130,75 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
82
130
|
return locale;
|
|
83
131
|
};
|
|
84
132
|
|
|
133
|
+
/** persisted → device → primary fallback; shared by `init` and `initSync`. */
|
|
134
|
+
const resolveInitialLocale = (persisted: string | null): void => {
|
|
135
|
+
if (persisted) {
|
|
136
|
+
if (localesByCode.has(persisted)) {
|
|
137
|
+
store.set({ code: persisted, source: 'persisted', recovered: false });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
report({
|
|
141
|
+
code: 'locale_unsupported',
|
|
142
|
+
locale: persisted,
|
|
143
|
+
message: `Persisted locale "${persisted}" is no longer supported; recovering to "${config.primaryLocale}".`,
|
|
144
|
+
});
|
|
145
|
+
store.set({ code: config.primaryLocale, source: 'primaryFallback', recovered: true });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const provider = config.deviceLocale ?? defaultDeviceLocaleProvider;
|
|
149
|
+
const match = matchSupportedLocale(provider(), locales);
|
|
150
|
+
store.set(
|
|
151
|
+
match
|
|
152
|
+
? { code: match.code, source: match.source, recovered: false }
|
|
153
|
+
: { code: config.primaryLocale, source: 'primaryFallback', recovered: false },
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `init()` runs once, and loses to the user.
|
|
159
|
+
*
|
|
160
|
+
* It used to be re-run by every caller (StrictMode mounts an effect twice),
|
|
161
|
+
* and the persisted value it had awaited was written in unconditionally — so
|
|
162
|
+
* a locale the user picked, or a deep link set, while the read was in flight
|
|
163
|
+
* was overwritten by the old one (I18N-07).
|
|
164
|
+
*/
|
|
165
|
+
let initialized: Promise<void> | undefined;
|
|
166
|
+
let switched = false;
|
|
167
|
+
|
|
168
|
+
const runInit = async (): Promise<void> => {
|
|
169
|
+
const persisted =
|
|
170
|
+
persistenceEnabled && config.storage
|
|
171
|
+
? await readPersistedLocale(config.storage, report)
|
|
172
|
+
: null;
|
|
173
|
+
if (switched) {
|
|
174
|
+
// The user chose while the read was in flight; their choice stands.
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
resolveInitialLocale(persisted);
|
|
178
|
+
};
|
|
179
|
+
|
|
85
180
|
return {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
}
|
|
181
|
+
init() {
|
|
182
|
+
initialized ??= runInit();
|
|
183
|
+
return initialized;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
initSync() {
|
|
187
|
+
if (!persistenceEnabled || !config.storage) {
|
|
188
|
+
resolveInitialLocale(null);
|
|
189
|
+
return true;
|
|
104
190
|
}
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
},
|
|
191
|
+
const { storage } = config;
|
|
192
|
+
if (typeof storage.getItemSync !== 'function') {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
resolveInitialLocale(
|
|
196
|
+
readPersistedLocaleSync(
|
|
197
|
+
storage as typeof storage & { getItemSync(key: string): string | null },
|
|
198
|
+
report,
|
|
199
|
+
),
|
|
115
200
|
);
|
|
201
|
+
return true;
|
|
116
202
|
},
|
|
117
203
|
|
|
118
204
|
t(key, vars, options) {
|
|
@@ -127,12 +213,34 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
127
213
|
if (!localesByCode.has(code)) {
|
|
128
214
|
throw new Error(`[rn-i18n] Cannot switch to unsupported locale "${code}".`);
|
|
129
215
|
}
|
|
216
|
+
switched = true;
|
|
217
|
+
const current = store.get();
|
|
218
|
+
if (current.code === code && current.source === 'runtimeSwitch') {
|
|
219
|
+
// Already this locale, by this route: re-rendering the whole tree and
|
|
220
|
+
// rewriting storage would change nothing (I18N-07).
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
130
223
|
store.set({ code, source: 'runtimeSwitch', recovered: false });
|
|
131
224
|
if (persistenceEnabled && config.storage) {
|
|
132
|
-
await writePersistedLocale(config.storage, code);
|
|
225
|
+
await writePersistedLocale(config.storage, code, report);
|
|
133
226
|
}
|
|
134
227
|
},
|
|
135
228
|
|
|
229
|
+
async useDeviceLocale() {
|
|
230
|
+
switched = true;
|
|
231
|
+
if (persistenceEnabled && config.storage) {
|
|
232
|
+
await clearPersistedLocale(config.storage, report);
|
|
233
|
+
}
|
|
234
|
+
const provider = config.deviceLocale ?? defaultDeviceLocaleProvider;
|
|
235
|
+
const match = matchSupportedLocale(provider(), locales);
|
|
236
|
+
store.set(
|
|
237
|
+
match
|
|
238
|
+
? { code: match.code, source: match.source, recovered: false }
|
|
239
|
+
: { code: config.primaryLocale, source: 'primaryFallback', recovered: false },
|
|
240
|
+
);
|
|
241
|
+
return store.get();
|
|
242
|
+
},
|
|
243
|
+
|
|
136
244
|
getSupportedLocales() {
|
|
137
245
|
return locales;
|
|
138
246
|
},
|
|
@@ -148,9 +256,16 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
148
256
|
);
|
|
149
257
|
},
|
|
150
258
|
|
|
151
|
-
formatDate(value, options) {
|
|
259
|
+
formatDate(value, options, formatting) {
|
|
152
260
|
const locale = activeLocale();
|
|
153
|
-
return formatDateValue(
|
|
261
|
+
return formatDateValue(
|
|
262
|
+
value,
|
|
263
|
+
locale.code,
|
|
264
|
+
locale.defaultDateFormat,
|
|
265
|
+
options,
|
|
266
|
+
invalidFormat,
|
|
267
|
+
formatting?.replaceDefaults === true,
|
|
268
|
+
);
|
|
154
269
|
},
|
|
155
270
|
|
|
156
271
|
formatCurrency(value, currency, options) {
|
|
@@ -183,6 +298,10 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
183
298
|
|
|
184
299
|
registerRuntimeEntries(entries) {
|
|
185
300
|
registerRuntimeEntries(runtimeEntries, entries);
|
|
301
|
+
// What `t()` answers has changed, so every subscriber has to read again:
|
|
302
|
+
// a feature that registered its entries after mounting used to leave the
|
|
303
|
+
// screen showing the key (I18N-06).
|
|
304
|
+
store.bump();
|
|
186
305
|
},
|
|
187
306
|
|
|
188
307
|
subscribe(listener) {
|
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,46 +1,52 @@
|
|
|
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 {
|
|
4
7
|
get(): ActiveLocalePreference;
|
|
5
8
|
set(preference: ActiveLocalePreference): void;
|
|
6
9
|
subscribe(listener: () => void): () => void;
|
|
10
|
+
/**
|
|
11
|
+
* Notifies subscribers without changing the locale: what reaches the screen
|
|
12
|
+
* changed, not which language it is in (a runtime entry registered by a
|
|
13
|
+
* lazily loaded feature, say).
|
|
14
|
+
*/
|
|
15
|
+
bump(): void;
|
|
7
16
|
/** Monotonic change counter consumed by useSyncExternalStore. */
|
|
8
17
|
getVersion(): number;
|
|
9
18
|
}
|
|
10
19
|
|
|
11
|
-
export function createActiveLocaleStore(
|
|
20
|
+
export function createActiveLocaleStore(
|
|
21
|
+
initial: ActiveLocalePreference,
|
|
22
|
+
report: I18nDiagnosticHandler = defaultReporter,
|
|
23
|
+
): ActiveLocaleStore {
|
|
12
24
|
let preference = initial;
|
|
13
25
|
let version = 0;
|
|
14
|
-
const listeners =
|
|
26
|
+
const listeners = createListenerSet<[]>({
|
|
27
|
+
onListenerError: error =>
|
|
28
|
+
report({
|
|
29
|
+
code: 'listener_threw',
|
|
30
|
+
message: `A locale listener threw; the locale change still applied: ${describeError(error)}`,
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
15
33
|
|
|
16
34
|
return {
|
|
17
35
|
get: () => preference,
|
|
18
36
|
set(next) {
|
|
19
37
|
preference = next;
|
|
20
38
|
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
|
-
}
|
|
39
|
+
// Isolated (CORE-03). One that threw used to skip every listener after
|
|
40
|
+
// it — useSyncExternalStore subscribers included — and made setLocale
|
|
41
|
+
// reject after the switch but before the choice was persisted, so the
|
|
42
|
+
// app showed one locale and stored another.
|
|
43
|
+
listeners.notify();
|
|
37
44
|
},
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
listeners.delete(listener);
|
|
42
|
-
};
|
|
45
|
+
bump() {
|
|
46
|
+
version += 1;
|
|
47
|
+
listeners.notify();
|
|
43
48
|
},
|
|
49
|
+
subscribe: listener => listeners.add(listener),
|
|
44
50
|
getVersion: () => version,
|
|
45
51
|
};
|
|
46
52
|
}
|
|
@@ -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,34 @@ 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
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Forgets the stored choice, so the next launch follows the device again. The
|
|
63
|
+
* adapter's `removeItem` was required by the contract and called by nothing
|
|
64
|
+
* until `useDeviceLocale()` (I18N-17). Best-effort, like the write.
|
|
65
|
+
*/
|
|
66
|
+
export async function clearPersistedLocale(
|
|
67
|
+
storage: LocaleStorageAdapter,
|
|
68
|
+
report: I18nDiagnosticHandler = defaultReporter,
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
try {
|
|
71
|
+
await storage.removeItem(LOCALE_STORAGE_KEY);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
report({
|
|
74
|
+
code: 'locale_write_failed',
|
|
75
|
+
message: `Failed to forget the persisted locale preference: ${describeError(error)}`,
|
|
76
|
+
});
|
|
30
77
|
}
|
|
31
78
|
}
|
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
|
}
|
|
@@ -17,8 +17,11 @@ export function renderKeysModule(
|
|
|
17
17
|
.map(entry => entry.key)
|
|
18
18
|
.sort((a, b) => a.localeCompare(b, 'en'));
|
|
19
19
|
|
|
20
|
+
// Escaped, not interpolated: a key holding an apostrophe or a backslash wrote
|
|
21
|
+
// a file that did not parse, and `tsc` then failed for the whole app (I18N-20).
|
|
22
|
+
const literal = (key: string) => `'${key.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
20
23
|
const renderList = (keys: readonly string[]) =>
|
|
21
|
-
keys.length === 0 ? '[]' : `[\n${keys.map(key => `\t
|
|
24
|
+
keys.length === 0 ? '[]' : `[\n${keys.map(key => `\t${literal(key)},`).join('\n')}\n]`;
|
|
22
25
|
|
|
23
26
|
return [
|
|
24
27
|
'// AUTO-GENERATED by @chipmobilesdk/rn-i18n tooling. Do not edit by hand.',
|
|
@@ -11,22 +11,91 @@ export const PLURAL_CATEGORIES: readonly PluralCategory[] = [
|
|
|
11
11
|
|
|
12
12
|
const oneOtherRule: PluralRule = count => (count === 1 ? 'one' : 'other');
|
|
13
13
|
const otherOnlyRule: PluralRule = () => 'other';
|
|
14
|
+
/** French and Brazilian Portuguese: 0 and 1 are both singular. */
|
|
15
|
+
const zeroOneRule: PluralRule = count => (count === 0 || count === 1 ? 'one' : 'other');
|
|
16
|
+
|
|
17
|
+
/** Russian, Ukrainian, Polish, Czech and their neighbours (CLDR). */
|
|
18
|
+
const slavicRule: PluralRule = count => {
|
|
19
|
+
if (!Number.isInteger(count)) {
|
|
20
|
+
return 'many';
|
|
21
|
+
}
|
|
22
|
+
const mod10 = Math.abs(count) % 10;
|
|
23
|
+
const mod100 = Math.abs(count) % 100;
|
|
24
|
+
if (mod10 === 1 && mod100 !== 11) {
|
|
25
|
+
return 'one';
|
|
26
|
+
}
|
|
27
|
+
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
|
|
28
|
+
return 'few';
|
|
29
|
+
}
|
|
30
|
+
return 'many';
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Arabic (CLDR), which uses every category. */
|
|
34
|
+
const arabicRule: PluralRule = count => {
|
|
35
|
+
if (count === 0) return 'zero';
|
|
36
|
+
if (count === 1) return 'one';
|
|
37
|
+
if (count === 2) return 'two';
|
|
38
|
+
const mod100 = Math.abs(count) % 100;
|
|
39
|
+
if (mod100 >= 3 && mod100 <= 10) return 'few';
|
|
40
|
+
if (mod100 >= 11 && mod100 <= 99) return 'many';
|
|
41
|
+
return 'other';
|
|
42
|
+
};
|
|
14
43
|
|
|
15
44
|
/**
|
|
16
|
-
* Package-owned plural rules. Intl.PluralRules is not
|
|
17
|
-
* so rules are resolved from configuration to keep
|
|
18
|
-
* devices and test environments.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
45
|
+
* Package-owned plural rules, a compact subset of CLDR. Intl.PluralRules is not
|
|
46
|
+
* implemented by Hermes, so rules are resolved from configuration to keep
|
|
47
|
+
* behavior identical across devices and test environments.
|
|
48
|
+
*
|
|
49
|
+
* A language that is not here falls back to `count === 1 ? one : other`, which
|
|
50
|
+
* is wrong for most of the world — Korean and Thai have no singular, French
|
|
51
|
+
* calls 0 singular, Russian has `few` and `many` — so the fallback now reports
|
|
52
|
+
* a diagnostic once per locale rather than being silently applied (I18N-12).
|
|
21
53
|
*/
|
|
22
54
|
const BUILT_IN_RULES: Record<string, PluralRule> = {
|
|
55
|
+
// one/other
|
|
23
56
|
en: oneOtherRule,
|
|
57
|
+
de: oneOtherRule,
|
|
58
|
+
es: oneOtherRule,
|
|
59
|
+
it: oneOtherRule,
|
|
60
|
+
nl: oneOtherRule,
|
|
61
|
+
sv: oneOtherRule,
|
|
62
|
+
da: oneOtherRule,
|
|
63
|
+
nb: oneOtherRule,
|
|
64
|
+
fi: oneOtherRule,
|
|
65
|
+
el: oneOtherRule,
|
|
66
|
+
he: oneOtherRule,
|
|
67
|
+
// other only
|
|
24
68
|
ja: otherOnlyRule,
|
|
25
69
|
vi: otherOnlyRule,
|
|
70
|
+
ko: otherOnlyRule,
|
|
71
|
+
zh: otherOnlyRule,
|
|
72
|
+
th: otherOnlyRule,
|
|
73
|
+
id: otherOnlyRule,
|
|
74
|
+
ms: otherOnlyRule,
|
|
75
|
+
// 0 and 1 are singular
|
|
76
|
+
fr: zeroOneRule,
|
|
77
|
+
pt: zeroOneRule,
|
|
78
|
+
// Slavic
|
|
79
|
+
ru: slavicRule,
|
|
80
|
+
uk: slavicRule,
|
|
81
|
+
pl: slavicRule,
|
|
82
|
+
cs: slavicRule,
|
|
83
|
+
sk: slavicRule,
|
|
84
|
+
// Every category
|
|
85
|
+
ar: arabicRule,
|
|
26
86
|
};
|
|
27
87
|
|
|
28
88
|
export const DEFAULT_PLURAL_RULE: PluralRule = oneOtherRule;
|
|
29
89
|
|
|
90
|
+
/** True when this package has a rule for the language, or the locale brought one. */
|
|
91
|
+
export function hasPluralRule(locale: LocaleConfig): boolean {
|
|
92
|
+
if (locale.pluralRule) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
const language = locale.code.split('-')[0]?.toLowerCase() ?? '';
|
|
96
|
+
return language in BUILT_IN_RULES;
|
|
97
|
+
}
|
|
98
|
+
|
|
30
99
|
export function resolvePluralRule(locale: LocaleConfig): PluralRule {
|
|
31
100
|
if (locale.pluralRule) {
|
|
32
101
|
return locale.pluralRule;
|