@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 +45 -0
- package/README.md +218 -0
- package/package.json +48 -0
- package/src/config/types.ts +152 -0
- package/src/config/validateConfig.ts +174 -0
- package/src/format/currency.ts +27 -0
- package/src/format/date.ts +26 -0
- package/src/format/invalid.ts +16 -0
- package/src/format/number.ts +25 -0
- package/src/format/units.ts +151 -0
- package/src/i18n.ts +222 -0
- package/src/index.ts +50 -0
- package/src/locale/activeLocale.ts +35 -0
- package/src/locale/deviceLocale.ts +54 -0
- package/src/locale/persistence.ts +37 -0
- package/src/react/I18nProvider.tsx +35 -0
- package/src/react/hooks.ts +61 -0
- package/src/tooling/cli.ts +78 -0
- package/src/tooling/generateKeys.ts +68 -0
- package/src/tooling/index.ts +32 -0
- package/src/tooling/report.ts +117 -0
- package/src/tooling/runner.ts +126 -0
- package/src/tooling/scan.ts +210 -0
- package/src/tooling/sync.ts +198 -0
- package/src/tooling/types.ts +119 -0
- package/src/translation/interpolate.ts +23 -0
- package/src/translation/plurals.ts +69 -0
- package/src/translation/resolver.ts +146 -0
- package/src/translation/runtimeKeys.ts +27 -0
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
|
+
}
|