@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
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { InvalidFormatPolicy } from '../config/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Applies the configured invalid-format behavior: return the placeholder, or
|
|
5
|
+
* throw a clear formatting error in `error` mode (FR-037, FR-045).
|
|
6
|
+
*/
|
|
7
|
+
export function applyInvalidFormatPolicy(
|
|
8
|
+
policy: InvalidFormatPolicy,
|
|
9
|
+
message: string,
|
|
10
|
+
): string {
|
|
11
|
+
console.warn(`[rn-i18n] ${message}`);
|
|
12
|
+
if (policy.mode === 'error') {
|
|
13
|
+
throw new RangeError(`[rn-i18n] ${message}`);
|
|
14
|
+
}
|
|
15
|
+
return policy.text;
|
|
16
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { InvalidFormatPolicy } from '../config/types';
|
|
2
|
+
import { applyInvalidFormatPolicy } from './invalid';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Locale-aware number formatting through the Hermes built-in
|
|
6
|
+
* Intl.NumberFormat. The locale default options apply unless the caller
|
|
7
|
+
* passes explicit overrides (FR-019).
|
|
8
|
+
*/
|
|
9
|
+
export function formatNumberValue(
|
|
10
|
+
value: number,
|
|
11
|
+
localeCode: string,
|
|
12
|
+
defaults: Intl.NumberFormatOptions,
|
|
13
|
+
override: 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 number value "${String(value)}" for locale "${localeCode}".`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return new Intl.NumberFormat(localeCode, { ...defaults, ...override }).format(
|
|
23
|
+
value,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InvalidFormatPolicy,
|
|
3
|
+
UnitDimension,
|
|
4
|
+
UnitId,
|
|
5
|
+
} from '../config/types';
|
|
6
|
+
import { applyInvalidFormatPolicy } from './invalid';
|
|
7
|
+
import { formatNumberValue } from './number';
|
|
8
|
+
|
|
9
|
+
interface UnitDefinition {
|
|
10
|
+
dimension: UnitDimension;
|
|
11
|
+
/** Factor to the dimension's base unit; unused for temperature. */
|
|
12
|
+
factor: number;
|
|
13
|
+
symbol: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Package-owned unit registry and conversion tables. Intl.NumberFormat unit
|
|
18
|
+
* style is not consistently available under Hermes, so the numeric part is
|
|
19
|
+
* formatted through Intl and the unit symbol is appended from this registry
|
|
20
|
+
* (research R4). Base units: meter, kilogram, liter, celsius.
|
|
21
|
+
*/
|
|
22
|
+
const UNIT_REGISTRY: Record<UnitId, UnitDefinition> = {
|
|
23
|
+
millimeter: { dimension: 'length', factor: 0.001, symbol: 'mm' },
|
|
24
|
+
centimeter: { dimension: 'length', factor: 0.01, symbol: 'cm' },
|
|
25
|
+
meter: { dimension: 'length', factor: 1, symbol: 'm' },
|
|
26
|
+
kilometer: { dimension: 'length', factor: 1000, symbol: 'km' },
|
|
27
|
+
inch: { dimension: 'length', factor: 0.0254, symbol: 'in' },
|
|
28
|
+
foot: { dimension: 'length', factor: 0.3048, symbol: 'ft' },
|
|
29
|
+
mile: { dimension: 'length', factor: 1609.344, symbol: 'mi' },
|
|
30
|
+
gram: { dimension: 'mass', factor: 0.001, symbol: 'g' },
|
|
31
|
+
kilogram: { dimension: 'mass', factor: 1, symbol: 'kg' },
|
|
32
|
+
ounce: { dimension: 'mass', factor: 0.028349523125, symbol: 'oz' },
|
|
33
|
+
pound: { dimension: 'mass', factor: 0.45359237, symbol: 'lb' },
|
|
34
|
+
milliliter: { dimension: 'volume', factor: 0.001, symbol: 'ml' },
|
|
35
|
+
liter: { dimension: 'volume', factor: 1, symbol: 'l' },
|
|
36
|
+
gallon: { dimension: 'volume', factor: 3.785411784, symbol: 'gal' },
|
|
37
|
+
celsius: { dimension: 'temperature', factor: 1, symbol: '°C' },
|
|
38
|
+
fahrenheit: { dimension: 'temperature', factor: 1, symbol: '°F' },
|
|
39
|
+
kelvin: { dimension: 'temperature', factor: 1, symbol: 'K' },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const SUPPORTED_UNITS = Object.keys(UNIT_REGISTRY) as readonly UnitId[];
|
|
43
|
+
|
|
44
|
+
export const KNOWN_UNIT_IDS: ReadonlySet<string> = new Set(SUPPORTED_UNITS);
|
|
45
|
+
|
|
46
|
+
export class UnitConversionError extends Error {
|
|
47
|
+
constructor(message: string) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = 'UnitConversionError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getUnitSymbol(unit: UnitId): string {
|
|
54
|
+
return UNIT_REGISTRY[unit].symbol;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toCelsius(value: number, from: UnitId): number {
|
|
58
|
+
switch (from) {
|
|
59
|
+
case 'fahrenheit':
|
|
60
|
+
return ((value - 32) * 5) / 9;
|
|
61
|
+
case 'kelvin':
|
|
62
|
+
return value - 273.15;
|
|
63
|
+
default:
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fromCelsius(value: number, to: UnitId): number {
|
|
69
|
+
switch (to) {
|
|
70
|
+
case 'fahrenheit':
|
|
71
|
+
return (value * 9) / 5 + 32;
|
|
72
|
+
case 'kelvin':
|
|
73
|
+
return value + 273.15;
|
|
74
|
+
default:
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Converts between compatible supported units. Unknown units or conversions
|
|
81
|
+
* across dimensions throw a UnitConversionError (FR-044/FR-045).
|
|
82
|
+
*/
|
|
83
|
+
export function convertUnitValue(
|
|
84
|
+
value: number,
|
|
85
|
+
from: UnitId,
|
|
86
|
+
to: UnitId,
|
|
87
|
+
): number {
|
|
88
|
+
const fromDef = UNIT_REGISTRY[from];
|
|
89
|
+
const toDef = UNIT_REGISTRY[to];
|
|
90
|
+
if (!fromDef || !toDef) {
|
|
91
|
+
throw new UnitConversionError(
|
|
92
|
+
`Unknown unit in conversion "${from}" -> "${to}".`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (fromDef.dimension !== toDef.dimension) {
|
|
96
|
+
throw new UnitConversionError(
|
|
97
|
+
`Cannot convert between incompatible dimensions "${fromDef.dimension}" (${from}) and "${toDef.dimension}" (${to}).`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (!Number.isFinite(value)) {
|
|
101
|
+
throw new UnitConversionError(
|
|
102
|
+
`Cannot convert invalid value "${String(value)}" from "${from}" to "${to}".`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (from === to) {
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
if (fromDef.dimension === 'temperature') {
|
|
109
|
+
return fromCelsius(toCelsius(value, from), to);
|
|
110
|
+
}
|
|
111
|
+
return (value * fromDef.factor) / toDef.factor;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface UnitFormatOptions extends Intl.NumberFormatOptions {
|
|
115
|
+
/** Unit the value is expressed in. Default: the locale's default unit. */
|
|
116
|
+
from?: UnitId;
|
|
117
|
+
/** Unit to display. Default: `from` (no conversion). */
|
|
118
|
+
to?: UnitId;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Converts (when requested) and formats a measurement value: locale-aware
|
|
123
|
+
* numeric part via Intl.NumberFormat plus the package-owned unit symbol.
|
|
124
|
+
*/
|
|
125
|
+
export function formatUnitValue(
|
|
126
|
+
value: number,
|
|
127
|
+
localeCode: string,
|
|
128
|
+
localeDefaultUnit: UnitId,
|
|
129
|
+
numberDefaults: Intl.NumberFormatOptions,
|
|
130
|
+
options: UnitFormatOptions | undefined,
|
|
131
|
+
invalidFormat: InvalidFormatPolicy,
|
|
132
|
+
): string {
|
|
133
|
+
const { from = localeDefaultUnit, to = from, ...numberOptions } = options ?? {};
|
|
134
|
+
let converted: number;
|
|
135
|
+
try {
|
|
136
|
+
converted = convertUnitValue(value, from, to);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return applyInvalidFormatPolicy(
|
|
139
|
+
invalidFormat,
|
|
140
|
+
error instanceof Error ? error.message : String(error),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const formatted = formatNumberValue(
|
|
144
|
+
converted,
|
|
145
|
+
localeCode,
|
|
146
|
+
numberDefaults,
|
|
147
|
+
numberOptions,
|
|
148
|
+
invalidFormat,
|
|
149
|
+
);
|
|
150
|
+
return `${formatted} ${getUnitSymbol(to)}`;
|
|
151
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActiveLocalePreference,
|
|
3
|
+
I18nConfig,
|
|
4
|
+
InvalidFormatPolicy,
|
|
5
|
+
LocaleConfig,
|
|
6
|
+
MissingTranslationPolicy,
|
|
7
|
+
RuntimeTranslationEntry,
|
|
8
|
+
TranslateOptions,
|
|
9
|
+
TranslateVars,
|
|
10
|
+
UnitId,
|
|
11
|
+
} from './config/types';
|
|
12
|
+
import { validateConfig } from './config/validateConfig';
|
|
13
|
+
import { createActiveLocaleStore } from './locale/activeLocale';
|
|
14
|
+
import {
|
|
15
|
+
defaultDeviceLocaleProvider,
|
|
16
|
+
matchSupportedLocale,
|
|
17
|
+
} from './locale/deviceLocale';
|
|
18
|
+
import { readPersistedLocale, writePersistedLocale } from './locale/persistence';
|
|
19
|
+
import { translate } from './translation/resolver';
|
|
20
|
+
import type { ResolverContext } from './translation/resolver';
|
|
21
|
+
import {
|
|
22
|
+
createRuntimeRegistry,
|
|
23
|
+
registerRuntimeEntries,
|
|
24
|
+
} from './translation/runtimeKeys';
|
|
25
|
+
import { formatCurrencyValue } from './format/currency';
|
|
26
|
+
import { formatDateValue } from './format/date';
|
|
27
|
+
import { formatNumberValue } from './format/number';
|
|
28
|
+
import {
|
|
29
|
+
KNOWN_UNIT_IDS,
|
|
30
|
+
convertUnitValue,
|
|
31
|
+
formatUnitValue,
|
|
32
|
+
} from './format/units';
|
|
33
|
+
import type { UnitFormatOptions } from './format/units';
|
|
34
|
+
|
|
35
|
+
export interface I18n<TKey extends string = string> {
|
|
36
|
+
/** Resolves the initial locale: persisted → device → primary fallback. */
|
|
37
|
+
init(): Promise<void>;
|
|
38
|
+
t(key: TKey, vars?: TranslateVars, options?: TranslateOptions): string;
|
|
39
|
+
getLocale(): ActiveLocalePreference;
|
|
40
|
+
/** Switches at runtime without restart and persists best-effort. */
|
|
41
|
+
setLocale(code: string): Promise<void>;
|
|
42
|
+
getSupportedLocales(): readonly LocaleConfig[];
|
|
43
|
+
formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
|
|
44
|
+
formatDate(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
|
|
45
|
+
formatCurrency(
|
|
46
|
+
value: number,
|
|
47
|
+
currency?: string,
|
|
48
|
+
options?: Intl.NumberFormatOptions,
|
|
49
|
+
): string;
|
|
50
|
+
formatUnit(value: number, options?: UnitFormatOptions): string;
|
|
51
|
+
convertUnit(value: number, from: UnitId, to: UnitId): number;
|
|
52
|
+
registerRuntimeEntries(entries: readonly RuntimeTranslationEntry[]): void;
|
|
53
|
+
subscribe(listener: () => void): () => void;
|
|
54
|
+
getVersion(): number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DEFAULT_MISSING: MissingTranslationPolicy = { mode: 'key' };
|
|
58
|
+
const DEFAULT_INVALID: InvalidFormatPolicy = { mode: 'placeholder', text: '—' };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Creates an I18n instance from an app-owned configuration. Validation runs
|
|
62
|
+
* eagerly and throws an I18nConfigError before any localized content can be
|
|
63
|
+
* served (FR-003). Pass the app's generated key type for typed lookups:
|
|
64
|
+
* `createI18n<AppI18nKey>(config)`.
|
|
65
|
+
*/
|
|
66
|
+
export function createI18n<TKey extends string = string>(
|
|
67
|
+
config: I18nConfig,
|
|
68
|
+
): I18n<TKey> {
|
|
69
|
+
validateConfig(config, KNOWN_UNIT_IDS);
|
|
70
|
+
|
|
71
|
+
const locales: readonly LocaleConfig[] = [...config.locales];
|
|
72
|
+
const localesByCode = new Map(locales.map(locale => [locale.code, locale]));
|
|
73
|
+
const missingTranslation = config.missingTranslation ?? DEFAULT_MISSING;
|
|
74
|
+
const invalidFormat = config.invalidFormat ?? DEFAULT_INVALID;
|
|
75
|
+
const persistenceEnabled = config.persistLocale !== false && !!config.storage;
|
|
76
|
+
const runtimeEntries = createRuntimeRegistry(config.runtimeEntries);
|
|
77
|
+
|
|
78
|
+
const store = createActiveLocaleStore({
|
|
79
|
+
code: config.primaryLocale,
|
|
80
|
+
source: 'primaryFallback',
|
|
81
|
+
recovered: false,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const resolverContext: ResolverContext = {
|
|
85
|
+
localesByCode,
|
|
86
|
+
resources: config.resources,
|
|
87
|
+
runtimeEntries,
|
|
88
|
+
missingTranslation,
|
|
89
|
+
warned: new Set(),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const activeLocale = (): LocaleConfig => {
|
|
93
|
+
const locale = localesByCode.get(store.get().code);
|
|
94
|
+
if (!locale) {
|
|
95
|
+
// Unreachable after validation; guards against external mutation.
|
|
96
|
+
throw new Error(
|
|
97
|
+
`[rn-i18n] Active locale "${store.get().code}" is not configured.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return locale;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
async init() {
|
|
105
|
+
if (persistenceEnabled && config.storage) {
|
|
106
|
+
const persisted = await readPersistedLocale(config.storage);
|
|
107
|
+
if (persisted) {
|
|
108
|
+
if (localesByCode.has(persisted)) {
|
|
109
|
+
store.set({ code: persisted, source: 'persisted', recovered: false });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
console.warn(
|
|
113
|
+
`[rn-i18n] Persisted locale "${persisted}" is no longer supported; recovering to "${config.primaryLocale}".`,
|
|
114
|
+
);
|
|
115
|
+
store.set({
|
|
116
|
+
code: config.primaryLocale,
|
|
117
|
+
source: 'primaryFallback',
|
|
118
|
+
recovered: true,
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const provider = config.deviceLocale ?? defaultDeviceLocaleProvider;
|
|
124
|
+
const match = matchSupportedLocale(provider(), locales);
|
|
125
|
+
store.set(
|
|
126
|
+
match
|
|
127
|
+
? { code: match.code, source: match.source, recovered: false }
|
|
128
|
+
: {
|
|
129
|
+
code: config.primaryLocale,
|
|
130
|
+
source: 'primaryFallback',
|
|
131
|
+
recovered: false,
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
t(key, vars, options) {
|
|
137
|
+
return translate(resolverContext, store.get().code, key, vars, options);
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
getLocale() {
|
|
141
|
+
return store.get();
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async setLocale(code) {
|
|
145
|
+
if (!localesByCode.has(code)) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`[rn-i18n] Cannot switch to unsupported locale "${code}".`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
store.set({ code, source: 'runtimeSwitch', recovered: false });
|
|
151
|
+
if (persistenceEnabled && config.storage) {
|
|
152
|
+
await writePersistedLocale(config.storage, code);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
getSupportedLocales() {
|
|
157
|
+
return locales;
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
formatNumber(value, options) {
|
|
161
|
+
const locale = activeLocale();
|
|
162
|
+
return formatNumberValue(
|
|
163
|
+
value,
|
|
164
|
+
locale.code,
|
|
165
|
+
locale.defaultNumberFormat,
|
|
166
|
+
options,
|
|
167
|
+
invalidFormat,
|
|
168
|
+
);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
formatDate(value, options) {
|
|
172
|
+
const locale = activeLocale();
|
|
173
|
+
return formatDateValue(
|
|
174
|
+
value,
|
|
175
|
+
locale.code,
|
|
176
|
+
locale.defaultDateFormat,
|
|
177
|
+
options,
|
|
178
|
+
invalidFormat,
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
formatCurrency(value, currency, options) {
|
|
183
|
+
const locale = activeLocale();
|
|
184
|
+
return formatCurrencyValue(
|
|
185
|
+
value,
|
|
186
|
+
locale.code,
|
|
187
|
+
locale.defaultCurrency,
|
|
188
|
+
currency,
|
|
189
|
+
options,
|
|
190
|
+
invalidFormat,
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
formatUnit(value, options) {
|
|
195
|
+
const locale = activeLocale();
|
|
196
|
+
return formatUnitValue(
|
|
197
|
+
value,
|
|
198
|
+
locale.code,
|
|
199
|
+
locale.defaultUnit,
|
|
200
|
+
locale.defaultNumberFormat,
|
|
201
|
+
options,
|
|
202
|
+
invalidFormat,
|
|
203
|
+
);
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
convertUnit(value, from, to) {
|
|
207
|
+
return convertUnitValue(value, from, to);
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
registerRuntimeEntries(entries) {
|
|
211
|
+
registerRuntimeEntries(runtimeEntries, entries);
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
subscribe(listener) {
|
|
215
|
+
return store.subscribe(listener);
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
getVersion() {
|
|
219
|
+
return store.getVersion();
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @chipmobilesdk/rn-i18n — reusable localization for ChipMobileSdk-family
|
|
3
|
+
* React Native apps.
|
|
4
|
+
*
|
|
5
|
+
* This is the single React Native entrypoint. Node-only extraction and
|
|
6
|
+
* synchronization tooling is exposed separately via
|
|
7
|
+
* `@chipmobilesdk/rn-i18n/tooling` and must never be imported from here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { createI18n } from './i18n';
|
|
11
|
+
export type { I18n } from './i18n';
|
|
12
|
+
|
|
13
|
+
export { I18nConfigError } from './config/types';
|
|
14
|
+
export type {
|
|
15
|
+
ActiveLocalePreference,
|
|
16
|
+
DeviceLocaleProvider,
|
|
17
|
+
I18nConfig,
|
|
18
|
+
InvalidFormatPolicy,
|
|
19
|
+
LocaleConfig,
|
|
20
|
+
LocaleDirection,
|
|
21
|
+
LocaleSelectionSource,
|
|
22
|
+
LocaleStorageAdapter,
|
|
23
|
+
MissingTranslationPolicy,
|
|
24
|
+
PluralCategory,
|
|
25
|
+
PluralRule,
|
|
26
|
+
RuntimeTranslationEntry,
|
|
27
|
+
TranslateOptions,
|
|
28
|
+
TranslateVars,
|
|
29
|
+
TranslationNamespace,
|
|
30
|
+
TranslationNode,
|
|
31
|
+
TranslationResources,
|
|
32
|
+
UnitDimension,
|
|
33
|
+
UnitId,
|
|
34
|
+
} from './config/types';
|
|
35
|
+
|
|
36
|
+
export { LOCALE_STORAGE_KEY } from './locale/persistence';
|
|
37
|
+
export { defaultDeviceLocaleProvider } from './locale/deviceLocale';
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
SUPPORTED_UNITS,
|
|
41
|
+
UnitConversionError,
|
|
42
|
+
convertUnitValue as convertUnit,
|
|
43
|
+
getUnitSymbol,
|
|
44
|
+
} from './format/units';
|
|
45
|
+
export type { UnitFormatOptions } from './format/units';
|
|
46
|
+
|
|
47
|
+
export { I18nProvider } from './react/I18nProvider';
|
|
48
|
+
export type { I18nProviderProps } from './react/I18nProvider';
|
|
49
|
+
export { useLocale, useTranslation } from './react/hooks';
|
|
50
|
+
export type { UseLocaleResult, UseTranslationResult } from './react/hooks';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ActiveLocalePreference } from '../config/types';
|
|
2
|
+
|
|
3
|
+
export interface ActiveLocaleStore {
|
|
4
|
+
get(): ActiveLocalePreference;
|
|
5
|
+
set(preference: ActiveLocalePreference): void;
|
|
6
|
+
subscribe(listener: () => void): () => void;
|
|
7
|
+
/** Monotonic change counter consumed by useSyncExternalStore. */
|
|
8
|
+
getVersion(): number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createActiveLocaleStore(
|
|
12
|
+
initial: ActiveLocalePreference,
|
|
13
|
+
): ActiveLocaleStore {
|
|
14
|
+
let preference = initial;
|
|
15
|
+
let version = 0;
|
|
16
|
+
const listeners = new Set<() => void>();
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
get: () => preference,
|
|
20
|
+
set(next) {
|
|
21
|
+
preference = next;
|
|
22
|
+
version += 1;
|
|
23
|
+
for (const listener of [...listeners]) {
|
|
24
|
+
listener();
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
subscribe(listener) {
|
|
28
|
+
listeners.add(listener);
|
|
29
|
+
return () => {
|
|
30
|
+
listeners.delete(listener);
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
getVersion: () => version,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DeviceLocaleProvider,
|
|
3
|
+
LocaleConfig,
|
|
4
|
+
LocaleSelectionSource,
|
|
5
|
+
} from '../config/types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default device locale detection. Hermes resolves the Intl default locale
|
|
9
|
+
* from the device locale on both Android and iOS, so no native module is
|
|
10
|
+
* required. Apps may inject their own provider (e.g. react-native-localize)
|
|
11
|
+
* through `I18nConfig.deviceLocale`.
|
|
12
|
+
*/
|
|
13
|
+
export const defaultDeviceLocaleProvider: DeviceLocaleProvider = () => {
|
|
14
|
+
try {
|
|
15
|
+
return Intl.DateTimeFormat().resolvedOptions().locale;
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface DeviceLocaleMatch {
|
|
22
|
+
code: string;
|
|
23
|
+
source: Extract<LocaleSelectionSource, 'device' | 'languageMatch'>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Matches a detected device locale against the supported locales: an exact
|
|
28
|
+
* (case-insensitive) match wins; otherwise a device language that matches
|
|
29
|
+
* exactly one supported locale selects that locale (US4 scenario 3).
|
|
30
|
+
*/
|
|
31
|
+
export function matchSupportedLocale(
|
|
32
|
+
deviceLocale: string | undefined,
|
|
33
|
+
locales: readonly LocaleConfig[],
|
|
34
|
+
): DeviceLocaleMatch | undefined {
|
|
35
|
+
if (!deviceLocale) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const normalized = deviceLocale.toLowerCase();
|
|
39
|
+
const exact = locales.find(locale => locale.code.toLowerCase() === normalized);
|
|
40
|
+
if (exact) {
|
|
41
|
+
return { code: exact.code, source: 'device' };
|
|
42
|
+
}
|
|
43
|
+
const language = normalized.split('-')[0];
|
|
44
|
+
if (!language) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const languageMatches = locales.filter(
|
|
48
|
+
locale => locale.code.toLowerCase().split('-')[0] === language,
|
|
49
|
+
);
|
|
50
|
+
if (languageMatches.length === 1) {
|
|
51
|
+
return { code: languageMatches[0].code, source: 'languageMatch' };
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { LocaleStorageAdapter } from '../config/types';
|
|
2
|
+
|
|
3
|
+
export const LOCALE_STORAGE_KEY = '@chipmobilesdk/rn-i18n:locale';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads the persisted locale preference. Adapter failures are reported and
|
|
7
|
+
* treated as "no persisted value" so locale selection never crashes (FR-015).
|
|
8
|
+
*/
|
|
9
|
+
export async function readPersistedLocale(
|
|
10
|
+
storage: LocaleStorageAdapter,
|
|
11
|
+
): Promise<string | null> {
|
|
12
|
+
try {
|
|
13
|
+
return await storage.getItem(LOCALE_STORAGE_KEY);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.warn(
|
|
16
|
+
`[rn-i18n] Failed to read the persisted locale preference: ${String(error)}`,
|
|
17
|
+
);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Persists the selected locale. Persistence is best-effort: a write failure
|
|
24
|
+
* is reported but the in-memory locale switch stays authoritative.
|
|
25
|
+
*/
|
|
26
|
+
export async function writePersistedLocale(
|
|
27
|
+
storage: LocaleStorageAdapter,
|
|
28
|
+
code: string,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
try {
|
|
31
|
+
await storage.setItem(LOCALE_STORAGE_KEY, code);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.warn(
|
|
34
|
+
`[rn-i18n] Failed to persist locale "${code}": ${String(error)}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import type { I18n } from '../i18n';
|
|
4
|
+
|
|
5
|
+
// The context intentionally erases the app-specific key type; the typed
|
|
6
|
+
// surface is restored by the hooks' generic parameters.
|
|
7
|
+
export const I18nContext = createContext<I18n<string> | null>(null);
|
|
8
|
+
|
|
9
|
+
export interface I18nProviderProps {
|
|
10
|
+
i18n: I18n<never>;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Provides the I18n instance to the React tree. Locale changes re-render
|
|
16
|
+
* consumers through the hooks' useSyncExternalStore subscription, so a
|
|
17
|
+
* runtime switch updates UI within one interaction cycle (PERF-002).
|
|
18
|
+
*/
|
|
19
|
+
export function I18nProvider({ i18n, children }: I18nProviderProps) {
|
|
20
|
+
return (
|
|
21
|
+
<I18nContext.Provider value={i18n as unknown as I18n<string>}>
|
|
22
|
+
{children}
|
|
23
|
+
</I18nContext.Provider>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useI18nContext(): I18n<string> {
|
|
28
|
+
const i18n = useContext(I18nContext);
|
|
29
|
+
if (!i18n) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
'[rn-i18n] No I18n instance found. Wrap your app in <I18nProvider>.',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return i18n;
|
|
35
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
ActiveLocalePreference,
|
|
4
|
+
LocaleConfig,
|
|
5
|
+
LocaleDirection,
|
|
6
|
+
TranslateOptions,
|
|
7
|
+
TranslateVars,
|
|
8
|
+
} from '../config/types';
|
|
9
|
+
import type { I18n } from '../i18n';
|
|
10
|
+
import { useI18nContext } from './I18nProvider';
|
|
11
|
+
|
|
12
|
+
function useLocaleVersion(i18n: I18n<string>): void {
|
|
13
|
+
useSyncExternalStore(i18n.subscribe, i18n.getVersion, i18n.getVersion);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseTranslationResult<TKey extends string> {
|
|
17
|
+
t: (key: TKey, vars?: TranslateVars, options?: TranslateOptions) => string;
|
|
18
|
+
locale: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns the translation function bound to the active locale. Pass the
|
|
23
|
+
* app's generated key type for compile-time key validation (FR-041):
|
|
24
|
+
* `const { t } = useTranslation<AppI18nKey>();`
|
|
25
|
+
*/
|
|
26
|
+
export function useTranslation<
|
|
27
|
+
TKey extends string = string,
|
|
28
|
+
>(): UseTranslationResult<TKey> {
|
|
29
|
+
const i18n = useI18nContext();
|
|
30
|
+
useLocaleVersion(i18n);
|
|
31
|
+
return {
|
|
32
|
+
t: (key, vars, options) => i18n.t(key, vars, options),
|
|
33
|
+
locale: i18n.getLocale().code,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UseLocaleResult {
|
|
38
|
+
locale: string;
|
|
39
|
+
direction: LocaleDirection;
|
|
40
|
+
preference: ActiveLocalePreference;
|
|
41
|
+
supportedLocales: readonly LocaleConfig[];
|
|
42
|
+
setLocale: (code: string) => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Returns active locale state, direction metadata, and the locale switcher. */
|
|
46
|
+
export function useLocale(): UseLocaleResult {
|
|
47
|
+
const i18n = useI18nContext();
|
|
48
|
+
useLocaleVersion(i18n);
|
|
49
|
+
const preference = i18n.getLocale();
|
|
50
|
+
const supportedLocales = i18n.getSupportedLocales();
|
|
51
|
+
const active = supportedLocales.find(
|
|
52
|
+
locale => locale.code === preference.code,
|
|
53
|
+
);
|
|
54
|
+
return {
|
|
55
|
+
locale: preference.code,
|
|
56
|
+
direction: active?.direction ?? 'ltr',
|
|
57
|
+
preference,
|
|
58
|
+
supportedLocales,
|
|
59
|
+
setLocale: code => i18n.setLocale(code),
|
|
60
|
+
};
|
|
61
|
+
}
|