@chipmobilesdk/rn-i18n 0.1.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 ADDED
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@chipmobilesdk/rn-i18n` are documented here. This project
4
+ follows [Semantic Versioning](https://semver.org/).
5
+
6
+ ## 0.1.0 — 2026-06-13
7
+
8
+ Initial release of the reusable mobile i18n and locale library.
9
+
10
+ ### Added
11
+
12
+ - `createI18n(config)` with eager configuration validation: duplicate locales,
13
+ unknown/duplicate/cyclic fallback chains, missing fallback resources, and
14
+ missing storage adapter are rejected before any localized content is served.
15
+ - Translation lookup with namespaced nested keys, ordered fallback-chain
16
+ resolution, `{{name}}` interpolation, package-owned pluralization, and a
17
+ deterministic missing-key policy (one console warning per failed lookup path).
18
+ - Runtime locale switching without restart, persisted through an app-injected
19
+ `LocaleStorageAdapter`, with recovery from unsupported persisted values.
20
+ - Device-locale initialization via an injectable provider (default backed by
21
+ Hermes `Intl`), with exact-match and single-language-match selection.
22
+ - Locale-aware formatting: `formatNumber`, `formatDate`, `formatCurrency` (via
23
+ Hermes `Intl`), and package-owned `formatUnit`/`convertUnit` across length,
24
+ mass, volume, and temperature dimensions.
25
+ - Declared runtime-only translation entries that resolve through the same
26
+ pipeline and are protected during synchronization.
27
+ - React bindings: `I18nProvider`, `useTranslation`, `useLocale`.
28
+ - Node-only extraction/synchronization CLI (`@chipmobilesdk/rn-i18n/tooling`):
29
+ regex-pattern source scanning, locale JSON create/add/preserve/remove,
30
+ ambiguous-usage and conflict reporting, malformed-file isolation, typed key
31
+ contract generation, and deterministic output.
32
+
33
+ ### Adoption notes
34
+
35
+ - App-owned translations live in the consuming app (commonly
36
+ `src/i18n/locales/<locale>.json`), never inside this package.
37
+ - Host frameworks (`react`, `react-native`) are peer dependencies; this package
38
+ has zero runtime dependencies.
39
+ - No native modules, permissions, network calls, analytics, or tracking are
40
+ introduced; the locale preference stays on-device via the injected adapter.
41
+
42
+ ### Compatibility
43
+
44
+ React `19.x`, React Native `0.85+` (Hermes), TypeScript `5.8+`, Node `>=22.11.0`
45
+ for tooling. Android and iOS, with no native code.
package/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # @chipmobilesdk/rn-i18n
2
+
3
+ Reusable localization for ChipMobileSdk-family React Native apps: per-locale
4
+ configuration, app-owned namespaced translations, typed keys, interpolation,
5
+ pluralization, fallback chains, runtime locale switching with app-injected
6
+ persistence, device-locale detection, locale-aware number/date/currency/unit
7
+ formatting, and a Node CLI that extracts and synchronizes translation keys.
8
+
9
+ > **License:** `UNLICENSED`. Published publicly for use by the owner's
10
+ > applications; no open-source license is granted.
11
+
12
+ - **Zero runtime dependencies.** Formatting uses the `Intl` APIs built into
13
+ Hermes; plural rules and unit conversion are package-owned.
14
+ - **No native code.** Storage and (optionally) device-locale detection are
15
+ injected by the consuming app.
16
+ - **Public package.** Published under the `@chipmobilesdk` npm organization.
17
+ - **Version**: `0.1.0`
18
+ - **Entrypoints**: `@chipmobilesdk/rn-i18n` and the Node-only
19
+ `@chipmobilesdk/rn-i18n/tooling`
20
+
21
+ ## Installation
22
+
23
+ This package ships TypeScript sources and is consumed through the workspace or
24
+ the public npm registry:
25
+
26
+ ```bash
27
+ npm install @chipmobilesdk/rn-i18n
28
+ ```
29
+
30
+ Host frameworks are peer dependencies:
31
+
32
+ ```jsonc
33
+ // peerDependencies
34
+ "react": ">=19",
35
+ "react-native": ">=0.85"
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ ```ts
41
+ import { createI18n } from '@chipmobilesdk/rn-i18n';
42
+ import enUS from './locales/en-US.json';
43
+ import viVN from './locales/vi-VN.json';
44
+ import { storageAdapter } from './storage';
45
+ import type { AppI18nKey } from './keys.generated';
46
+
47
+ export const i18n = createI18n<AppI18nKey>({
48
+ primaryLocale: 'en-US',
49
+ locales: [
50
+ {
51
+ code: 'en-US',
52
+ displayName: 'English (US)',
53
+ direction: 'ltr',
54
+ fallbackChain: [],
55
+ defaultCurrency: 'USD',
56
+ defaultUnit: 'mile',
57
+ defaultDateFormat: { year: 'numeric', month: 'short', day: 'numeric' },
58
+ defaultNumberFormat: { maximumFractionDigits: 2 },
59
+ },
60
+ {
61
+ code: 'vi-VN',
62
+ displayName: 'Tiếng Việt',
63
+ direction: 'ltr',
64
+ fallbackChain: ['en-US'],
65
+ defaultCurrency: 'VND',
66
+ defaultUnit: 'kilometer',
67
+ defaultDateFormat: { day: '2-digit', month: '2-digit', year: 'numeric' },
68
+ defaultNumberFormat: { maximumFractionDigits: 2 },
69
+ },
70
+ ],
71
+ resources: { 'en-US': enUS, 'vi-VN': viVN },
72
+ storage: storageAdapter,
73
+ });
74
+
75
+ await i18n.init(); // persisted -> device -> primary fallback
76
+ ```
77
+
78
+ Wrap the app and use the hooks:
79
+
80
+ ```tsx
81
+ import { I18nProvider, useTranslation, useLocale } from '@chipmobilesdk/rn-i18n';
82
+
83
+ <I18nProvider i18n={i18n}>
84
+ <App />
85
+ </I18nProvider>;
86
+
87
+ const { t } = useTranslation<AppI18nKey>();
88
+ const { locale, direction, supportedLocales, setLocale } = useLocale();
89
+
90
+ t('welcome.message', { name: 'Dung' });
91
+ t('cart.items', { count }, { count }); // plural variant by count
92
+ ```
93
+
94
+ ## Public API
95
+
96
+ | Capability | Member |
97
+ |------------|--------|
98
+ | Setup + validation | `createI18n(config)`, `I18nConfigError` |
99
+ | Translation lookup | `i18n.t`, `useTranslation` |
100
+ | Locale state + switching | `i18n.getLocale`, `i18n.setLocale`, `useLocale` |
101
+ | Locale metadata (RTL/LTR, defaults) | `i18n.getSupportedLocales` |
102
+ | Device-locale init | `i18n.init`, `defaultDeviceLocaleProvider` |
103
+ | Formatting | `i18n.formatNumber/formatDate/formatCurrency/formatUnit` |
104
+ | Unit conversion | `i18n.convertUnit`, `convertUnit`, `getUnitSymbol`, `SUPPORTED_UNITS`, `UnitConversionError` |
105
+ | Runtime-only declarations | `i18n.registerRuntimeEntries` |
106
+ | Persistence key | `LOCALE_STORAGE_KEY` |
107
+ | React | `I18nProvider` |
108
+
109
+ Public contract types also include the configuration, locale, translation,
110
+ formatting, unit, provider/adapter, and hook result types exported by
111
+ `src/index.ts`. Do not deep-import package internals. Node-only extraction and
112
+ synchronization APIs must be imported from `@chipmobilesdk/rn-i18n/tooling`.
113
+
114
+ ### Storage adapter (required for persistence)
115
+
116
+ The package never bundles a storage backend (FR-046/FR-047). Inject one:
117
+
118
+ ```ts
119
+ import type { LocaleStorageAdapter } from '@chipmobilesdk/rn-i18n';
120
+ import AsyncStorage from '@react-native-async-storage/async-storage';
121
+
122
+ export const storageAdapter: LocaleStorageAdapter = {
123
+ getItem: key => AsyncStorage.getItem(key),
124
+ setItem: (key, value) => AsyncStorage.setItem(key, value),
125
+ removeItem: key => AsyncStorage.removeItem(key),
126
+ };
127
+ ```
128
+
129
+ An MMKV-backed adapter (wrapping its synchronous API in `Promise.resolve`)
130
+ works equally well. If persistence is enabled without an adapter, `createI18n`
131
+ throws before any localized content is served.
132
+
133
+ ### Device-locale provider (optional)
134
+
135
+ The default reads `Intl.DateTimeFormat().resolvedOptions().locale`. Apps that
136
+ need richer detection can inject `deviceLocale` (e.g. backed by
137
+ `react-native-localize`); that dependency stays in the app, never the package.
138
+
139
+ ## Translations
140
+
141
+ App-owned JSON, organized by locale and namespace; nested dot-path keys,
142
+ `{{name}}` interpolation, and plural groups:
143
+
144
+ ```jsonc
145
+ {
146
+ "welcome": { "message": "Welcome, {{name}}!" },
147
+ "cart": { "items": { "one": "{{count}} item", "other": "{{count}} items" } }
148
+ }
149
+ ```
150
+
151
+ Plural categories deliberately come from package-owned rules rather than
152
+ depending on `Intl.PluralRules`, which keeps behavior consistent across the
153
+ supported Hermes environments. Built-ins cover `en` (one/other), `vi`/`ja`
154
+ (other-only), and a default `count === 1 -> one` rule; supply
155
+ `LocaleConfig.pluralRule` for richer languages.
156
+
157
+ ## Extraction & synchronization CLI
158
+
159
+ Node-only, exposed via `@chipmobilesdk/rn-i18n/tooling` and a CLI. Add an app
160
+ config (`i18n-sync.config.json`) and a script:
161
+
162
+ ```jsonc
163
+ {
164
+ "sourceGlobs": ["src/**/*.ts", "src/**/*.tsx"],
165
+ "patterns": [{ "regex": "[^\\w.]t\\(\\s*'(?<key>[A-Za-z0-9_.-]+)'" }],
166
+ "localesDir": "src/i18n/locales",
167
+ "locales": ["en-US", "vi-VN", "ja-JP"],
168
+ "runtimeEntriesFile": "src/i18n/runtime-keys.ts",
169
+ "generateKeysFile": "src/i18n/keys.generated.ts",
170
+ "keysTypeName": "AppI18nKey"
171
+ }
172
+ ```
173
+
174
+ ```jsonc
175
+ // package.json
176
+ "scripts": {
177
+ "i18n:sync": "node --experimental-strip-types node_modules/@chipmobilesdk/rn-i18n/src/tooling/cli.ts --config i18n-sync.config.json"
178
+ }
179
+ ```
180
+
181
+ Running it scans the source tree, then per locale: creates missing files, adds
182
+ missing keys (with the placeholder policy), preserves existing translations,
183
+ removes stale keys, protects declared runtime-only keys, regenerates the typed
184
+ key contract, and prints a report. Exit codes: `0` success/warnings, `1`
185
+ blocking findings (conflicts, malformed locale files), `2` invalid config.
186
+
187
+ Bind `t()` to the generated `AppI18nKey` so `tsc --noEmit` reports invalid or
188
+ stale key usage before release.
189
+
190
+ ### Runtime-only keys
191
+
192
+ Keys resolved only at runtime (declared in `runtimeEntriesFile`) use the same
193
+ lookup, fallback, interpolation, and missing-key behavior, and are never
194
+ removed by synchronization. Policy `add` writes the default text to missing
195
+ locales; `report` only lists them.
196
+
197
+ ## Compatibility
198
+
199
+ | Surface | Baseline |
200
+ |---------|----------|
201
+ | React / React Native | `19.x` / `0.85+` (Hermes) |
202
+ | TypeScript | `5.8+` |
203
+ | Node (tooling) | `>=22.11.0` |
204
+ | Platforms | Android, iOS (no native code) |
205
+
206
+ ## Validation
207
+
208
+ ```bash
209
+ npm test # package + demo suites
210
+ npm run typecheck:i18n # tsc -p packages/rn-i18n/tsconfig.json --noEmit
211
+ npm run pack:i18n # npm pack --workspace @chipmobilesdk/rn-i18n --dry-run
212
+ npm run i18n:sync # extraction + synchronization
213
+ ```
214
+
215
+ The demo integration lives in `src/screens/I18nDemoScreen.tsx` and uses
216
+ `src/i18n/` for setup, translations, persistence, and generated keys.
217
+
218
+ See [CHANGELOG.md](CHANGELOG.md) for release notes and adoption guidance.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@chipmobilesdk/rn-i18n",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "license": "UNLICENSED",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "react-native": "./src/index.ts",
12
+ "default": "./src/index.ts"
13
+ },
14
+ "./tooling": {
15
+ "types": "./src/tooling/index.ts",
16
+ "node": "./src/tooling/index.ts",
17
+ "default": "./src/tooling/index.ts"
18
+ }
19
+ },
20
+ "files": [
21
+ "src/index.ts",
22
+ "src/i18n.ts",
23
+ "src/config",
24
+ "src/translation",
25
+ "src/locale",
26
+ "src/format",
27
+ "src/react",
28
+ "src/tooling",
29
+ "README.md",
30
+ "CHANGELOG.md"
31
+ ],
32
+ "sideEffects": false,
33
+ "scripts": {
34
+ "typecheck": "tsc -p tsconfig.json --noEmit",
35
+ "pack:dry-run": "npm pack --dry-run"
36
+ },
37
+ "peerDependencies": {
38
+ "react": ">=19",
39
+ "react-native": ">=0.85"
40
+ },
41
+ "engines": {
42
+ "node": ">=22.11.0"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "registry": "https://registry.npmjs.org/"
47
+ }
48
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Public configuration and contract types for @chipmobilesdk/rn-i18n.
3
+ *
4
+ * The package has zero runtime dependencies: formatting uses the Intl APIs
5
+ * built into Hermes (Intl.NumberFormat, Intl.DateTimeFormat), while plural
6
+ * rules and unit conversion are package-owned because Intl.PluralRules and
7
+ * Intl unit-style formatting are not reliably available in Hermes.
8
+ */
9
+
10
+ export type LocaleDirection = 'ltr' | 'rtl';
11
+
12
+ export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
13
+
14
+ export type PluralRule = (count: number) => PluralCategory;
15
+
16
+ export type UnitDimension = 'length' | 'mass' | 'volume' | 'temperature';
17
+
18
+ export type UnitId =
19
+ | 'millimeter'
20
+ | 'centimeter'
21
+ | 'meter'
22
+ | 'kilometer'
23
+ | 'inch'
24
+ | 'foot'
25
+ | 'mile'
26
+ | 'gram'
27
+ | 'kilogram'
28
+ | 'ounce'
29
+ | 'pound'
30
+ | 'milliliter'
31
+ | 'liter'
32
+ | 'gallon'
33
+ | 'celsius'
34
+ | 'fahrenheit'
35
+ | 'kelvin';
36
+
37
+ export interface LocaleConfig {
38
+ /** BCP-47 language-region code, e.g. `vi-VN`, `en-US`, `ja-JP`. */
39
+ code: string;
40
+ displayName: string;
41
+ direction: LocaleDirection;
42
+ /**
43
+ * Ordered locale codes consulted when a key is missing from this locale.
44
+ * Every entry must be another supported locale; cycles, duplicates, and
45
+ * self-references are rejected during setup validation.
46
+ */
47
+ fallbackChain: string[];
48
+ /** ISO-4217 currency code used when no explicit currency is requested. */
49
+ defaultCurrency: string;
50
+ defaultUnit: UnitId;
51
+ /**
52
+ * Default Intl.DateTimeFormat options for this locale. Restricted to the
53
+ * component options supported by Hermes on both Android and iOS.
54
+ */
55
+ defaultDateFormat: Intl.DateTimeFormatOptions;
56
+ /** Default Intl.NumberFormat options for this locale. */
57
+ defaultNumberFormat: Intl.NumberFormatOptions;
58
+ /** Optional plural rule override; defaults to the built-in language rule. */
59
+ pluralRule?: PluralRule;
60
+ }
61
+
62
+ export type TranslationNode = string | { [key: string]: TranslationNode };
63
+
64
+ export type TranslationNamespace = { [key: string]: TranslationNode };
65
+
66
+ /** App-owned translation resources keyed by locale code. */
67
+ export type TranslationResources = { [localeCode: string]: TranslationNamespace };
68
+
69
+ /**
70
+ * A translation key intentionally resolved at runtime and therefore not
71
+ * discoverable by static source scanning. Declared entries are protected
72
+ * from removal during locale JSON synchronization.
73
+ */
74
+ export interface RuntimeTranslationEntry {
75
+ key: string;
76
+ /** Template text used when no locale resource provides a value. */
77
+ defaultText: string;
78
+ /**
79
+ * Synchronization policy when the key is missing from a locale file:
80
+ * `add` writes the default text, `report` only lists it. Default: `report`.
81
+ */
82
+ policy?: 'add' | 'report';
83
+ /** Owning feature note surfaced in the extraction report. */
84
+ description?: string;
85
+ }
86
+
87
+ export type MissingTranslationPolicy =
88
+ | { mode: 'key' }
89
+ | { mode: 'placeholder'; text: string };
90
+
91
+ export type InvalidFormatPolicy =
92
+ | { mode: 'placeholder'; text: string }
93
+ | { mode: 'error' };
94
+
95
+ /** App-injected persistence contract. The package never bundles a backend. */
96
+ export interface LocaleStorageAdapter {
97
+ getItem(key: string): Promise<string | null>;
98
+ setItem(key: string, value: string): Promise<void>;
99
+ removeItem(key: string): Promise<void>;
100
+ }
101
+
102
+ /** Returns the device locale, or undefined when detection is unavailable. */
103
+ export type DeviceLocaleProvider = () => string | undefined;
104
+
105
+ export interface I18nConfig {
106
+ locales: LocaleConfig[];
107
+ /** Recovery locale when persistence or device detection cannot decide. */
108
+ primaryLocale: string;
109
+ resources: TranslationResources;
110
+ runtimeEntries?: RuntimeTranslationEntry[];
111
+ /** Persist the selected locale across launches. Default: true. */
112
+ persistLocale?: boolean;
113
+ /** Required when `persistLocale` is not disabled. */
114
+ storage?: LocaleStorageAdapter;
115
+ deviceLocale?: DeviceLocaleProvider;
116
+ /** Default: `{ mode: 'key' }`. */
117
+ missingTranslation?: MissingTranslationPolicy;
118
+ /** Default: `{ mode: 'placeholder', text: '—' }`. */
119
+ invalidFormat?: InvalidFormatPolicy;
120
+ }
121
+
122
+ export type LocaleSelectionSource =
123
+ | 'persisted'
124
+ | 'device'
125
+ | 'languageMatch'
126
+ | 'primaryFallback'
127
+ | 'runtimeSwitch';
128
+
129
+ export interface ActiveLocalePreference {
130
+ code: string;
131
+ source: LocaleSelectionSource;
132
+ /** True when an unsupported persisted locale was discarded. */
133
+ recovered: boolean;
134
+ }
135
+
136
+ export type TranslateVars = Record<string, string | number>;
137
+
138
+ export interface TranslateOptions {
139
+ /** Selects the plural variant and is exposed as the `count` variable. */
140
+ count?: number;
141
+ }
142
+
143
+ /** Configuration error listing every detected violation. */
144
+ export class I18nConfigError extends Error {
145
+ readonly issues: readonly string[];
146
+
147
+ constructor(issues: readonly string[]) {
148
+ super(`Invalid i18n configuration:\n- ${issues.join('\n- ')}`);
149
+ this.name = 'I18nConfigError';
150
+ this.issues = issues;
151
+ }
152
+ }
@@ -0,0 +1,174 @@
1
+ import type { I18nConfig, LocaleConfig } from './types';
2
+ import { I18nConfigError } from './types';
3
+
4
+ /**
5
+ * Validates an I18nConfig before any localized content is served (FR-003,
6
+ * FR-046). Collects every violation and throws a single I18nConfigError so
7
+ * apps see the complete picture instead of fixing issues one at a time.
8
+ */
9
+ export function validateConfig(
10
+ config: I18nConfig,
11
+ knownUnits: ReadonlySet<string>,
12
+ ): void {
13
+ const issues: string[] = [];
14
+ const locales = Array.isArray(config.locales) ? config.locales : [];
15
+
16
+ if (locales.length === 0) {
17
+ issues.push('At least one supported locale must be declared.');
18
+ }
19
+
20
+ const codes = new Set<string>();
21
+ for (const locale of locales) {
22
+ if (codes.has(locale.code)) {
23
+ issues.push(`Duplicate locale code "${locale.code}".`);
24
+ }
25
+ codes.add(locale.code);
26
+ }
27
+
28
+ for (const locale of locales) {
29
+ validateLocale(locale, codes, knownUnits, issues);
30
+ }
31
+
32
+ detectFallbackCycles(locales, codes, issues);
33
+
34
+ if (locales.length > 0 && !codes.has(config.primaryLocale)) {
35
+ issues.push(
36
+ `Primary locale "${config.primaryLocale}" is not a supported locale.`,
37
+ );
38
+ }
39
+
40
+ for (const code of codes) {
41
+ const resource = config.resources?.[code];
42
+ if (resource === undefined || resource === null) {
43
+ issues.push(`Locale "${code}" has no translation resource.`);
44
+ }
45
+ }
46
+
47
+ if (config.persistLocale !== false && !config.storage) {
48
+ issues.push(
49
+ 'Locale persistence is enabled but no storage adapter was injected. ' +
50
+ 'Provide `storage` or set `persistLocale: false`.',
51
+ );
52
+ }
53
+
54
+ const runtimeKeys = new Set<string>();
55
+ for (const entry of config.runtimeEntries ?? []) {
56
+ if (!entry.key) {
57
+ issues.push('Runtime-only entries must declare a non-empty key.');
58
+ continue;
59
+ }
60
+ if (runtimeKeys.has(entry.key)) {
61
+ issues.push(`Duplicate runtime-only entry "${entry.key}".`);
62
+ }
63
+ runtimeKeys.add(entry.key);
64
+ if (typeof entry.defaultText !== 'string') {
65
+ issues.push(
66
+ `Runtime-only entry "${entry.key}" must declare default text.`,
67
+ );
68
+ }
69
+ }
70
+
71
+ if (issues.length > 0) {
72
+ throw new I18nConfigError(issues);
73
+ }
74
+ }
75
+
76
+ function validateLocale(
77
+ locale: LocaleConfig,
78
+ codes: ReadonlySet<string>,
79
+ knownUnits: ReadonlySet<string>,
80
+ issues: string[],
81
+ ): void {
82
+ const code = locale.code;
83
+ if (!code) {
84
+ issues.push('Every locale must declare a non-empty code.');
85
+ return;
86
+ }
87
+ if (!locale.displayName) {
88
+ issues.push(`Locale "${code}" must declare a display name.`);
89
+ }
90
+ if (locale.direction !== 'ltr' && locale.direction !== 'rtl') {
91
+ issues.push(`Locale "${code}" must declare direction "ltr" or "rtl".`);
92
+ }
93
+ if (!locale.defaultCurrency) {
94
+ issues.push(`Locale "${code}" must declare a default currency.`);
95
+ }
96
+ if (!knownUnits.has(locale.defaultUnit)) {
97
+ issues.push(
98
+ `Locale "${code}" declares unknown default unit "${locale.defaultUnit}".`,
99
+ );
100
+ }
101
+ if (typeof locale.defaultDateFormat !== 'object' || !locale.defaultDateFormat) {
102
+ issues.push(`Locale "${code}" must declare a default date format.`);
103
+ }
104
+ if (
105
+ typeof locale.defaultNumberFormat !== 'object' ||
106
+ !locale.defaultNumberFormat
107
+ ) {
108
+ issues.push(`Locale "${code}" must declare a default number format.`);
109
+ }
110
+ if (!Array.isArray(locale.fallbackChain)) {
111
+ issues.push(`Locale "${code}" must declare a fallback chain array.`);
112
+ return;
113
+ }
114
+ const seen = new Set<string>();
115
+ for (const entry of locale.fallbackChain) {
116
+ if (entry === code) {
117
+ issues.push(`Locale "${code}" must not include itself in its fallback chain.`);
118
+ }
119
+ if (seen.has(entry)) {
120
+ issues.push(
121
+ `Locale "${code}" lists duplicate fallback-chain entry "${entry}".`,
122
+ );
123
+ }
124
+ seen.add(entry);
125
+ if (!codes.has(entry)) {
126
+ issues.push(
127
+ `Locale "${code}" references unsupported fallback locale "${entry}".`,
128
+ );
129
+ }
130
+ }
131
+ }
132
+
133
+ function detectFallbackCycles(
134
+ locales: readonly LocaleConfig[],
135
+ codes: ReadonlySet<string>,
136
+ issues: string[],
137
+ ): void {
138
+ const chains = new Map(
139
+ locales.map(locale => [
140
+ locale.code,
141
+ (locale.fallbackChain ?? []).filter(entry => codes.has(entry)),
142
+ ]),
143
+ );
144
+ const states = new Map<string, 'visiting' | 'done'>();
145
+
146
+ const visit = (code: string, path: string[]): boolean => {
147
+ const state = states.get(code);
148
+ if (state === 'done') {
149
+ return false;
150
+ }
151
+ if (state === 'visiting') {
152
+ issues.push(
153
+ `Fallback cycle detected: ${[...path, code].join(' -> ')}.`,
154
+ );
155
+ return true;
156
+ }
157
+ states.set(code, 'visiting');
158
+ for (const next of chains.get(code) ?? []) {
159
+ if (visit(next, [...path, code])) {
160
+ states.set(code, 'done');
161
+ return true;
162
+ }
163
+ }
164
+ states.set(code, 'done');
165
+ return false;
166
+ };
167
+
168
+ for (const locale of locales) {
169
+ if (visit(locale.code, [])) {
170
+ // Report the first cycle only; one clear cycle message is actionable.
171
+ return;
172
+ }
173
+ }
174
+ }
@@ -0,0 +1,27 @@
1
+ import type { InvalidFormatPolicy } from '../config/types';
2
+ import { applyInvalidFormatPolicy } from './invalid';
3
+
4
+ /**
5
+ * Locale-aware currency formatting. The locale's configured default currency
6
+ * applies unless the caller passes an explicit currency override (FR-021).
7
+ */
8
+ export function formatCurrencyValue(
9
+ value: number,
10
+ localeCode: string,
11
+ defaultCurrency: string,
12
+ currencyOverride: string | undefined,
13
+ options: Intl.NumberFormatOptions | undefined,
14
+ invalidFormat: InvalidFormatPolicy,
15
+ ): string {
16
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
17
+ return applyInvalidFormatPolicy(
18
+ invalidFormat,
19
+ `Cannot format invalid currency value "${String(value)}" for locale "${localeCode}".`,
20
+ );
21
+ }
22
+ return new Intl.NumberFormat(localeCode, {
23
+ ...options,
24
+ style: 'currency',
25
+ currency: currencyOverride ?? defaultCurrency,
26
+ }).format(value);
27
+ }
@@ -0,0 +1,26 @@
1
+ import type { InvalidFormatPolicy } from '../config/types';
2
+ import { applyInvalidFormatPolicy } from './invalid';
3
+
4
+ /**
5
+ * Locale-aware date formatting through the Hermes built-in
6
+ * Intl.DateTimeFormat. The locale's configured default date format applies
7
+ * unless the caller passes explicit overrides (FR-020). Options are limited
8
+ * to component options supported by Hermes on both platforms.
9
+ */
10
+ export function formatDateValue(
11
+ value: Date | number,
12
+ localeCode: string,
13
+ defaults: Intl.DateTimeFormatOptions,
14
+ override: Intl.DateTimeFormatOptions | undefined,
15
+ invalidFormat: InvalidFormatPolicy,
16
+ ): string {
17
+ const date = value instanceof Date ? value : new Date(value);
18
+ if (Number.isNaN(date.getTime())) {
19
+ return applyInvalidFormatPolicy(
20
+ invalidFormat,
21
+ `Cannot format invalid date value "${String(value)}" for locale "${localeCode}".`,
22
+ );
23
+ }
24
+ const options = override ?? defaults;
25
+ return new Intl.DateTimeFormat(localeCode, options).format(date);
26
+ }