@chipmobilesdk/rn-i18n 0.2.0 → 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 +16 -0
- package/README.md +20 -0
- package/package.json +1 -1
- package/skills/sdk-rn-i18n/SKILL.md +27 -0
- package/src/config/diagnostics.ts +2 -0
- package/src/format/currency.ts +5 -0
- package/src/format/date.ts +10 -4
- package/src/i18n.ts +93 -9
- package/src/locale/activeLocale.ts +10 -0
- package/src/locale/persistence.ts +19 -0
- package/src/tooling/generateKeys.ts +4 -1
- package/src/translation/plurals.ts +74 -5
- package/src/translation/runtimeKeys.ts +28 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @chipmobilesdk/rn-i18n
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- `registerRuntimeEntries` notifies subscribers, so copy a lazily loaded feature registers after its screen mounted appears instead of leaving the key on screen until something else re-rendered. Entries registered at runtime get the same validation as configured ones: a missing key or a non-string default throws `I18nConfigError` there rather than surfacing at render time. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
|
|
8
|
+
|
|
9
|
+
`init()` runs once however many times it is called — StrictMode mounts an effect twice — and loses to the user: a `setLocale` made while the persisted read was in flight is no longer overwritten by the stored value. Setting the locale that is already active changes nothing and re-renders nothing.
|
|
10
|
+
|
|
11
|
+
`i18n.useDeviceLocale()` forgets the stored choice and detects again, which is the "System language" option a settings screen offers; the storage adapter's `removeItem`, required by the contract and called by nothing, is what it uses.
|
|
12
|
+
|
|
13
|
+
Plural rules cover a compact CLDR subset instead of three languages: English and its neighbours, Korean, Chinese, Thai, Indonesian and Malay (no singular), French and Portuguese (0 is singular), Russian, Ukrainian, Polish, Czech and Slovak (`few`/`many`), and Arabic (every category). A language outside the table still falls back to "1 is singular", but now reports a `plural_rule_default` diagnostic once, rather than shipping wrong plurals in silence.
|
|
14
|
+
|
|
15
|
+
`formatDate` **merges** its options over the locale's default date format, as `formatNumber` always has: `{ month: 'long' }` keeps the year and the day. Pass `formatDate(value, options, { replaceDefaults: true })` for the old behaviour of replacing them. `formatCurrency` deliberately does not apply the locale's default number options, because a currency's own minor units must win.
|
|
16
|
+
|
|
17
|
+
The generated keys file escapes a key holding an apostrophe or a backslash, which used to produce a file that did not parse and failed `tsc` for the whole app.
|
|
18
|
+
|
|
3
19
|
## 0.2.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -231,6 +231,14 @@ createI18n({
|
|
|
231
231
|
|
|
232
232
|
## Formatting
|
|
233
233
|
|
|
234
|
+
Options **merge** over the active locale's defaults, for every formatter:
|
|
235
|
+
`formatDate(value, { month: 'long' })` keeps the locale's year and day, exactly as
|
|
236
|
+
`formatNumber(value, { maximumFractionDigits: 0 })` keeps the rest of the locale's number
|
|
237
|
+
options. Pass `formatDate(value, options, { replaceDefaults: true })` to use only what you
|
|
238
|
+
give it. `formatCurrency` deliberately does not take the locale's default number options:
|
|
239
|
+
a currency carries its own minor units (yen has none), and generic number defaults would
|
|
240
|
+
override them.
|
|
241
|
+
|
|
234
242
|
`formatNumber`, `formatCurrency`, `formatDate` and `formatUnit` reuse one cached
|
|
235
243
|
`Intl` formatter per locale and options. A value or option they cannot format (a
|
|
236
244
|
`NaN`, an unknown currency code) is handled by the `invalidFormat` policy: the
|
|
@@ -238,6 +246,18 @@ placeholder text, or a thrown error in `error` mode, and an `invalid_format`
|
|
|
238
246
|
diagnostic either way. It never throws from inside `Intl` during a render. A
|
|
239
247
|
locale's `defaultCurrency` must be a three-letter ISO 4217 code, checked at startup.
|
|
240
248
|
|
|
249
|
+
## Switching, and following the device
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
await i18n.setLocale('vi-VN'); // the user chose a language
|
|
253
|
+
await i18n.useDeviceLocale(); // "System language": forget the choice, detect again
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`init()` resolves the locale once — persisted, then device, then the primary fallback —
|
|
257
|
+
and is safe to call again (it returns the same promise). A `setLocale` during that read
|
|
258
|
+
wins: the stored value never overwrites a choice the user has just made. Setting the
|
|
259
|
+
locale already active changes nothing and re-renders nothing.
|
|
260
|
+
|
|
241
261
|
## Diagnostics
|
|
242
262
|
|
|
243
263
|
Missing translations and variables, fallbacks, invalid formats, persistence
|
package/package.json
CHANGED
|
@@ -46,9 +46,36 @@ Runtime:
|
|
|
46
46
|
- `I18nConfigError` — thrown on invalid configuration at startup; an rn-core SDK error with an
|
|
47
47
|
`I18nErrorCode`
|
|
48
48
|
|
|
49
|
+
Formatting, on the instance (all locale-aware, all through Hermes's Intl):
|
|
50
|
+
|
|
51
|
+
- `i18n.formatNumber(value, options?)`, `i18n.formatCurrency(value, currency?, options?)`,
|
|
52
|
+
`i18n.formatUnit(value, options?)` — options merge over the locale's defaults
|
|
53
|
+
- `i18n.formatDate(value, options?, { replaceDefaults? })` — options merge too;
|
|
54
|
+
`replaceDefaults: true` uses only what is passed
|
|
55
|
+
- `i18n.convertUnit(value, from, to)`, `SUPPORTED_UNITS`, `UnitConversionError`
|
|
56
|
+
|
|
57
|
+
Locale, on the instance: `init()` (idempotent), `initSync()`, `setLocale(code)`,
|
|
58
|
+
`useDeviceLocale()` (forget the stored choice and follow the device again),
|
|
59
|
+
`getLocale()`, `getSupportedLocales()`, `registerRuntimeEntries(entries)`,
|
|
60
|
+
`subscribe(listener)`, `getVersion()`
|
|
61
|
+
|
|
49
62
|
Types: `I18n`, `UnitFormatOptions`, `I18nProviderProps`, `UseLocaleResult`,
|
|
50
63
|
`UseTranslationResult`
|
|
51
64
|
|
|
65
|
+
## Plural rules are declared, not inferred
|
|
66
|
+
|
|
67
|
+
Hermes has no `Intl.PluralRules`, so this package carries a compact CLDR subset
|
|
68
|
+
(en/de/es/it/nl/sv/da/nb/fi/el/he, ja/vi/ko/zh/th/id/ms, fr/pt, ru/uk/pl/cs/sk, ar). A
|
|
69
|
+
language outside it falls back to "1 is singular" **and reports a
|
|
70
|
+
`plural_rule_default` diagnostic** — declare `pluralRule` on the locale rather than
|
|
71
|
+
shipping wrong plurals.
|
|
72
|
+
|
|
73
|
+
## Entries registered at runtime re-render
|
|
74
|
+
|
|
75
|
+
`registerRuntimeEntries` notifies subscribers, so a lazily loaded feature's copy appears
|
|
76
|
+
without another render pass, and a malformed entry throws `I18nConfigError` there and then
|
|
77
|
+
instead of rendering the key.
|
|
78
|
+
|
|
52
79
|
`I18nConfigError` throws at startup by design — a misconfigured i18n setup should fail
|
|
53
80
|
loudly, not silently render keys.
|
|
54
81
|
|
|
@@ -12,6 +12,7 @@ export type I18nDiagnosticCode =
|
|
|
12
12
|
| 'invalid_value'
|
|
13
13
|
| 'invalid_format'
|
|
14
14
|
| 'locale_unsupported'
|
|
15
|
+
| 'plural_rule_default'
|
|
15
16
|
| 'locale_read_failed'
|
|
16
17
|
| 'locale_write_failed'
|
|
17
18
|
| 'listener_threw';
|
|
@@ -33,6 +34,7 @@ const SEVERITY: Record<I18nDiagnosticCode, DiagnosticSeverity> = {
|
|
|
33
34
|
invalid_value: 'warn',
|
|
34
35
|
invalid_format: 'warn',
|
|
35
36
|
locale_unsupported: 'warn',
|
|
37
|
+
plural_rule_default: 'warn',
|
|
36
38
|
locale_read_failed: 'warn',
|
|
37
39
|
locale_write_failed: 'warn',
|
|
38
40
|
listener_threw: 'error',
|
package/src/format/currency.ts
CHANGED
|
@@ -5,6 +5,11 @@ import { applyInvalidFormatPolicy } from './invalid';
|
|
|
5
5
|
/**
|
|
6
6
|
* Locale-aware currency formatting. The locale's configured default currency
|
|
7
7
|
* applies unless the caller passes an explicit currency override (FR-021).
|
|
8
|
+
*
|
|
9
|
+
* `locale.defaultNumberFormat` is deliberately **not** applied here: a currency
|
|
10
|
+
* carries its own minor units (yen has none, dinar has three), and the locale's
|
|
11
|
+
* generic number options would override them — ¥1,234.56 instead of ¥1,235.
|
|
12
|
+
* Pass the options you want for a currency explicitly (I18N-09).
|
|
8
13
|
*/
|
|
9
14
|
export function formatCurrencyValue(
|
|
10
15
|
value: number,
|
package/src/format/date.ts
CHANGED
|
@@ -4,9 +4,14 @@ import { applyInvalidFormatPolicy } from './invalid';
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Locale-aware date formatting through the Hermes built-in
|
|
7
|
-
* Intl.DateTimeFormat.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Intl.DateTimeFormat.
|
|
8
|
+
*
|
|
9
|
+
* Options are **merged** over the locale's configured default, the way
|
|
10
|
+
* `formatNumber` has always worked: `{ month: 'long' }` changes the month and
|
|
11
|
+
* keeps the year and day. Replacing the default wholesale, which this used to
|
|
12
|
+
* do, meant one option silently dropped every other component — and the two
|
|
13
|
+
* formatters disagreed about what an option meant (I18N-09). Pass
|
|
14
|
+
* `{ replaceDefaults: true }` for the old behaviour.
|
|
10
15
|
*/
|
|
11
16
|
export function formatDateValue(
|
|
12
17
|
value: Date | number,
|
|
@@ -14,6 +19,7 @@ export function formatDateValue(
|
|
|
14
19
|
defaults: Intl.DateTimeFormatOptions,
|
|
15
20
|
override: Intl.DateTimeFormatOptions | undefined,
|
|
16
21
|
invalidFormat: InvalidFormatPolicy,
|
|
22
|
+
replaceDefaults = false,
|
|
17
23
|
): string {
|
|
18
24
|
const date = value instanceof Date ? value : new Date(value);
|
|
19
25
|
if (Number.isNaN(date.getTime())) {
|
|
@@ -22,7 +28,7 @@ export function formatDateValue(
|
|
|
22
28
|
`Cannot format invalid date value "${String(value)}" for locale "${localeCode}".`,
|
|
23
29
|
);
|
|
24
30
|
}
|
|
25
|
-
const options = override ?? defaults;
|
|
31
|
+
const options = replaceDefaults ? (override ?? defaults) : { ...defaults, ...override };
|
|
26
32
|
// rn-core's cached formatter (I18N-03); a bad option is a format problem (I18N-08).
|
|
27
33
|
try {
|
|
28
34
|
return getDateTimeFormat(localeCode, options).format(date);
|
package/src/i18n.ts
CHANGED
|
@@ -15,10 +15,12 @@ import { validateConfig } from './config/validateConfig';
|
|
|
15
15
|
import { createActiveLocaleStore } from './locale/activeLocale';
|
|
16
16
|
import { defaultDeviceLocaleProvider, matchSupportedLocale } from './locale/deviceLocale';
|
|
17
17
|
import {
|
|
18
|
+
clearPersistedLocale,
|
|
18
19
|
readPersistedLocale,
|
|
19
20
|
readPersistedLocaleSync,
|
|
20
21
|
writePersistedLocale,
|
|
21
22
|
} from './locale/persistence';
|
|
23
|
+
import { hasPluralRule } from './translation/plurals';
|
|
22
24
|
import { translate } from './translation/resolver';
|
|
23
25
|
import type { ResolverContext } from './translation/resolver';
|
|
24
26
|
import { createRuntimeRegistry, registerRuntimeEntries } from './translation/runtimeKeys';
|
|
@@ -42,9 +44,23 @@ export interface I18n<TKey extends string = string> {
|
|
|
42
44
|
getLocale(): ActiveLocalePreference;
|
|
43
45
|
/** Switches at runtime without restart and persists best-effort. */
|
|
44
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>;
|
|
45
53
|
getSupportedLocales(): readonly LocaleConfig[];
|
|
46
54
|
formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
|
|
47
|
-
|
|
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;
|
|
48
64
|
formatCurrency(value: number, currency?: string, options?: Intl.NumberFormatOptions): string;
|
|
49
65
|
formatUnit(value: number, options?: UnitFormatOptions): string;
|
|
50
66
|
convertUnit(value: number, from: UnitId, to: UnitId): number;
|
|
@@ -81,6 +97,21 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
81
97
|
report,
|
|
82
98
|
);
|
|
83
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
|
+
}
|
|
114
|
+
|
|
84
115
|
const resolverContext: ResolverContext = {
|
|
85
116
|
localesByCode,
|
|
86
117
|
resources: config.resources,
|
|
@@ -123,13 +154,33 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
123
154
|
);
|
|
124
155
|
};
|
|
125
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
|
+
|
|
126
180
|
return {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
? await readPersistedLocale(config.storage, report)
|
|
131
|
-
: null,
|
|
132
|
-
);
|
|
181
|
+
init() {
|
|
182
|
+
initialized ??= runInit();
|
|
183
|
+
return initialized;
|
|
133
184
|
},
|
|
134
185
|
|
|
135
186
|
initSync() {
|
|
@@ -162,12 +213,34 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
162
213
|
if (!localesByCode.has(code)) {
|
|
163
214
|
throw new Error(`[rn-i18n] Cannot switch to unsupported locale "${code}".`);
|
|
164
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
|
+
}
|
|
165
223
|
store.set({ code, source: 'runtimeSwitch', recovered: false });
|
|
166
224
|
if (persistenceEnabled && config.storage) {
|
|
167
225
|
await writePersistedLocale(config.storage, code, report);
|
|
168
226
|
}
|
|
169
227
|
},
|
|
170
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
|
+
|
|
171
244
|
getSupportedLocales() {
|
|
172
245
|
return locales;
|
|
173
246
|
},
|
|
@@ -183,9 +256,16 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
183
256
|
);
|
|
184
257
|
},
|
|
185
258
|
|
|
186
|
-
formatDate(value, options) {
|
|
259
|
+
formatDate(value, options, formatting) {
|
|
187
260
|
const locale = activeLocale();
|
|
188
|
-
return formatDateValue(
|
|
261
|
+
return formatDateValue(
|
|
262
|
+
value,
|
|
263
|
+
locale.code,
|
|
264
|
+
locale.defaultDateFormat,
|
|
265
|
+
options,
|
|
266
|
+
invalidFormat,
|
|
267
|
+
formatting?.replaceDefaults === true,
|
|
268
|
+
);
|
|
189
269
|
},
|
|
190
270
|
|
|
191
271
|
formatCurrency(value, currency, options) {
|
|
@@ -218,6 +298,10 @@ export function createI18n<TKey extends string = string>(config: I18nConfig): I1
|
|
|
218
298
|
|
|
219
299
|
registerRuntimeEntries(entries) {
|
|
220
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();
|
|
221
305
|
},
|
|
222
306
|
|
|
223
307
|
subscribe(listener) {
|
|
@@ -7,6 +7,12 @@ export interface ActiveLocaleStore {
|
|
|
7
7
|
get(): ActiveLocalePreference;
|
|
8
8
|
set(preference: ActiveLocalePreference): void;
|
|
9
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;
|
|
10
16
|
/** Monotonic change counter consumed by useSyncExternalStore. */
|
|
11
17
|
getVersion(): number;
|
|
12
18
|
}
|
|
@@ -36,6 +42,10 @@ export function createActiveLocaleStore(
|
|
|
36
42
|
// app showed one locale and stored another.
|
|
37
43
|
listeners.notify();
|
|
38
44
|
},
|
|
45
|
+
bump() {
|
|
46
|
+
version += 1;
|
|
47
|
+
listeners.notify();
|
|
48
|
+
},
|
|
39
49
|
subscribe: listener => listeners.add(listener),
|
|
40
50
|
getVersion: () => version,
|
|
41
51
|
};
|
|
@@ -57,3 +57,22 @@ export async function writePersistedLocale(
|
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
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
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -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;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { I18nConfigError, type RuntimeTranslationEntry } from '../config/types';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Registry of declared runtime-only translation entries. Entries participate
|
|
@@ -17,10 +17,37 @@ export function createRuntimeRegistry(
|
|
|
17
17
|
return registry;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The same checks the configured entries get (I18N-06): an entry registered at
|
|
22
|
+
* runtime by a lazily loaded feature used to be written in unvalidated, so a
|
|
23
|
+
* missing key or a non-string default surfaced only when a screen rendered it.
|
|
24
|
+
*/
|
|
25
|
+
export function validateRuntimeEntries(entries: readonly RuntimeTranslationEntry[]): void {
|
|
26
|
+
const issues: string[] = [];
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (!entry?.key) {
|
|
30
|
+
issues.push('Runtime-only entries must declare a non-empty key.');
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (seen.has(entry.key)) {
|
|
34
|
+
issues.push(`Duplicate runtime-only entry "${entry.key}".`);
|
|
35
|
+
}
|
|
36
|
+
seen.add(entry.key);
|
|
37
|
+
if (typeof entry.defaultText !== 'string') {
|
|
38
|
+
issues.push(`Runtime-only entry "${entry.key}" must declare default text.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (issues.length > 0) {
|
|
42
|
+
throw new I18nConfigError(issues);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
20
46
|
export function registerRuntimeEntries(
|
|
21
47
|
registry: RuntimeEntryRegistry,
|
|
22
48
|
entries: readonly RuntimeTranslationEntry[],
|
|
23
49
|
): void {
|
|
50
|
+
validateRuntimeEntries(entries);
|
|
24
51
|
for (const entry of entries) {
|
|
25
52
|
registry.set(entry.key, entry);
|
|
26
53
|
}
|