@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-i18n",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -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',
@@ -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,
@@ -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. The locale's configured default date format applies
8
- * unless the caller passes explicit overrides (FR-020). Options are limited
9
- * to component options supported by Hermes on both platforms.
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
- formatDate(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
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
- async init() {
128
- resolveInitialLocale(
129
- persistenceEnabled && config.storage
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(value, locale.code, locale.defaultDateFormat, options, invalidFormat);
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'${key}',`).join('\n')}\n]`;
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 implemented by Hermes,
17
- * so rules are resolved from configuration to keep behavior identical across
18
- * devices and test environments. Languages without a built-in rule use the
19
- * documented default (`count === 1` selects `one`, everything else `other`)
20
- * unless the locale configuration supplies a custom rule.
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 { RuntimeTranslationEntry } from '../config/types';
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
  }