@chipmobilesdk/rn-i18n 0.1.2 → 0.2.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.
@@ -1,54 +1,50 @@
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
- }
1
+ import type { DeviceLocaleProvider, LocaleConfig, LocaleSelectionSource } from '../config/types';
2
+
3
+ /**
4
+ * Default device locale detection. Hermes resolves the Intl default locale
5
+ * from the device locale on both Android and iOS, so no native module is
6
+ * required. Apps may inject their own provider (e.g. react-native-localize)
7
+ * through `I18nConfig.deviceLocale`.
8
+ */
9
+ export const defaultDeviceLocaleProvider: DeviceLocaleProvider = () => {
10
+ try {
11
+ return Intl.DateTimeFormat().resolvedOptions().locale;
12
+ } catch {
13
+ return undefined;
14
+ }
15
+ };
16
+
17
+ export interface DeviceLocaleMatch {
18
+ code: string;
19
+ source: Extract<LocaleSelectionSource, 'device' | 'languageMatch'>;
20
+ }
21
+
22
+ /**
23
+ * Matches a detected device locale against the supported locales: an exact
24
+ * (case-insensitive) match wins; otherwise a device language that matches
25
+ * exactly one supported locale selects that locale (US4 scenario 3).
26
+ */
27
+ export function matchSupportedLocale(
28
+ deviceLocale: string | undefined,
29
+ locales: readonly LocaleConfig[],
30
+ ): DeviceLocaleMatch | undefined {
31
+ if (!deviceLocale) {
32
+ return undefined;
33
+ }
34
+ const normalized = deviceLocale.toLowerCase();
35
+ const exact = locales.find(locale => locale.code.toLowerCase() === normalized);
36
+ if (exact) {
37
+ return { code: exact.code, source: 'device' };
38
+ }
39
+ const language = normalized.split('-')[0];
40
+ if (!language) {
41
+ return undefined;
42
+ }
43
+ const languageMatches = locales.filter(
44
+ locale => locale.code.toLowerCase().split('-')[0] === language,
45
+ );
46
+ if (languageMatches.length === 1) {
47
+ return { code: languageMatches[0].code, source: 'languageMatch' };
48
+ }
49
+ return undefined;
50
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A `LocaleStorageAdapter` over rn-core's synchronous `KeyValuePort` (PERF-08).
3
+ *
4
+ * The adapter carries `getItemSync`, so `i18n.initSync()` resolves the persisted
5
+ * locale before the first render. Open the port the way the app opens its other
6
+ * durable state, for example `openDurable(() => createMMKV({ id: 'app' }))`.
7
+ */
8
+ import type { KeyValuePort } from '@chipmobilesdk/rn-core';
9
+
10
+ import type { LocaleStorageAdapter } from '../config/types';
11
+
12
+ export function localeStorageFromKeyValue(
13
+ port: KeyValuePort,
14
+ ): LocaleStorageAdapter & { getItemSync(key: string): string | null } {
15
+ return {
16
+ getItemSync: key => port.getString(key) ?? null,
17
+ async getItem(key) {
18
+ return port.getString(key) ?? null;
19
+ },
20
+ async setItem(key, value) {
21
+ port.set(key, value);
22
+ },
23
+ async removeItem(key) {
24
+ port.remove(key);
25
+ },
26
+ };
27
+ }
@@ -1,37 +1,59 @@
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
- }
1
+ import { defaultReporter, describeError, type I18nDiagnosticHandler } from '../config/diagnostics';
2
+ import type { LocaleStorageAdapter } from '../config/types';
3
+
4
+ export const LOCALE_STORAGE_KEY = '@chipmobilesdk/rn-i18n:locale';
5
+
6
+ /**
7
+ * Reads the persisted locale preference. Adapter failures are reported and
8
+ * treated as "no persisted value" so locale selection never crashes (FR-015).
9
+ */
10
+ export async function readPersistedLocale(
11
+ storage: LocaleStorageAdapter,
12
+ report: I18nDiagnosticHandler = defaultReporter,
13
+ ): Promise<string | null> {
14
+ try {
15
+ return await storage.getItem(LOCALE_STORAGE_KEY);
16
+ } catch (error) {
17
+ report({
18
+ code: 'locale_read_failed',
19
+ message: `Failed to read the persisted locale preference: ${describeError(error)}`,
20
+ });
21
+ return null;
22
+ }
23
+ }
24
+
25
+ /** `readPersistedLocale` for a store with a synchronous read; same failure handling. */
26
+ export function readPersistedLocaleSync(
27
+ storage: LocaleStorageAdapter & { getItemSync(key: string): string | null },
28
+ report: I18nDiagnosticHandler = defaultReporter,
29
+ ): string | null {
30
+ try {
31
+ return storage.getItemSync(LOCALE_STORAGE_KEY);
32
+ } catch (error) {
33
+ report({
34
+ code: 'locale_read_failed',
35
+ message: `Failed to read the persisted locale preference: ${describeError(error)}`,
36
+ });
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Persists the selected locale. Persistence is best-effort: a write failure
43
+ * is reported but the in-memory locale switch stays authoritative.
44
+ */
45
+ export async function writePersistedLocale(
46
+ storage: LocaleStorageAdapter,
47
+ code: string,
48
+ report: I18nDiagnosticHandler = defaultReporter,
49
+ ): Promise<void> {
50
+ try {
51
+ await storage.setItem(LOCALE_STORAGE_KEY, code);
52
+ } catch (error) {
53
+ report({
54
+ code: 'locale_write_failed',
55
+ locale: code,
56
+ message: `Failed to persist locale "${code}": ${describeError(error)}`,
57
+ });
58
+ }
59
+ }
@@ -1,35 +1,31 @@
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
- }
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>}>{children}</I18nContext.Provider>
22
+ );
23
+ }
24
+
25
+ export function useI18nContext(): I18n<string> {
26
+ const i18n = useContext(I18nContext);
27
+ if (!i18n) {
28
+ throw new Error('[rn-i18n] No I18n instance found. Wrap your app in <I18nProvider>.');
29
+ }
30
+ return i18n;
31
+ }
@@ -1,61 +1,66 @@
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
- }
1
+ import { useCallback, useMemo, 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>): number {
13
+ return 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<TKey extends string = string>(): UseTranslationResult<TKey> {
27
+ const i18n = useI18nContext();
28
+ const version = useLocaleVersion(i18n);
29
+ // One `t` and one result per locale version (I18N-05): a memoized child that
30
+ // takes `t` re-renders when the locale changes, not on every parent render.
31
+ return useMemo(
32
+ () => ({
33
+ t: (key: TKey, vars?: TranslateVars, options?: TranslateOptions) =>
34
+ i18n.t(key, vars, options),
35
+ locale: i18n.getLocale().code,
36
+ }),
37
+ // `version` is the dependency that matters: it changes with the locale.
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `version` is read to invalidate, not used
39
+ [i18n, version],
40
+ );
41
+ }
42
+
43
+ export interface UseLocaleResult {
44
+ locale: string;
45
+ direction: LocaleDirection;
46
+ preference: ActiveLocalePreference;
47
+ supportedLocales: readonly LocaleConfig[];
48
+ setLocale: (code: string) => Promise<void>;
49
+ }
50
+
51
+ /** Returns active locale state, direction metadata, and the locale switcher. */
52
+ export function useLocale(): UseLocaleResult {
53
+ const i18n = useI18nContext();
54
+ useLocaleVersion(i18n);
55
+ const setLocale = useCallback((code: string) => i18n.setLocale(code), [i18n]);
56
+ const preference = i18n.getLocale();
57
+ const supportedLocales = i18n.getSupportedLocales();
58
+ const active = supportedLocales.find(locale => locale.code === preference.code);
59
+ return {
60
+ locale: preference.code,
61
+ direction: active?.direction ?? 'ltr',
62
+ preference,
63
+ supportedLocales,
64
+ setLocale,
65
+ };
66
+ }
@@ -1,78 +1,76 @@
1
- /**
2
- * Extraction/synchronization CLI. Node-only; run through the root npm
3
- * script under Node >= 22.11 with TypeScript type stripping:
4
- *
5
- * npm run i18n:sync
6
- * node --experimental-strip-types packages/rn-i18n/src/tooling/cli.ts --config i18n-sync.config.json
7
- *
8
- * Exit codes: 0 success or warnings, 1 blocking findings (conflicts or
9
- * malformed locale files), 2 invalid configuration or internal error.
10
- */
11
- import fs from 'node:fs';
12
- import path from 'node:path';
13
- import process from 'node:process';
14
- import { renderReport } from './report.ts';
15
- import { SyncConfigError, runSync } from './runner.ts';
16
- import type { SyncConfig } from './types.ts';
17
-
18
- function parseArgs(argv: readonly string[]): { configPath: string } {
19
- let configPath = 'i18n-sync.config.json';
20
- for (let index = 0; index < argv.length; index += 1) {
21
- if (argv[index] === '--config' && argv[index + 1]) {
22
- configPath = argv[index + 1];
23
- index += 1;
24
- }
25
- }
26
- return { configPath };
27
- }
28
-
29
- async function main(): Promise<number> {
30
- const rootDir = process.cwd();
31
- const { configPath } = parseArgs(process.argv.slice(2));
32
- const absoluteConfigPath = path.resolve(rootDir, configPath);
33
-
34
- if (!fs.existsSync(absoluteConfigPath)) {
35
- console.error(`[rn-i18n] Sync configuration not found: ${configPath}`);
36
- return 2;
37
- }
38
-
39
- let config: SyncConfig;
40
- try {
41
- config = JSON.parse(fs.readFileSync(absoluteConfigPath, 'utf8')) as SyncConfig;
42
- } catch (error) {
43
- console.error(
44
- `[rn-i18n] Sync configuration is not valid JSON: ${String(error)}`,
45
- );
46
- return 2;
47
- }
48
-
49
- try {
50
- const { report, generatedKeysChanged } = await runSync(rootDir, config);
51
- console.log(renderReport(report));
52
- if (config.generateKeysFile) {
53
- console.log(
54
- generatedKeysChanged
55
- ? `Typed keys regenerated: ${config.generateKeysFile}`
56
- : `Typed keys unchanged: ${config.generateKeysFile}`,
57
- );
58
- }
59
- return report.outcome === 'failed' ? 1 : 0;
60
- } catch (error) {
61
- if (error instanceof SyncConfigError) {
62
- console.error(`[rn-i18n] ${error.message}`);
63
- } else {
64
- console.error(`[rn-i18n] Sync failed: ${String(error)}`);
65
- }
66
- return 2;
67
- }
68
- }
69
-
70
- main().then(
71
- code => {
72
- process.exitCode = code;
73
- },
74
- error => {
75
- console.error(`[rn-i18n] Unexpected failure: ${String(error)}`);
76
- process.exitCode = 2;
77
- },
78
- );
1
+ /**
2
+ * Extraction/synchronization CLI. Node-only; run through the root npm
3
+ * script under Node >= 22.11 with TypeScript type stripping:
4
+ *
5
+ * npm run i18n:sync
6
+ * node --experimental-strip-types packages/rn-i18n/src/tooling/cli.ts --config i18n-sync.config.json
7
+ *
8
+ * Exit codes: 0 success or warnings, 1 blocking findings (conflicts or
9
+ * malformed locale files), 2 invalid configuration or internal error.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import process from 'node:process';
14
+ import { renderReport } from './report.ts';
15
+ import { SyncConfigError, runSync } from './runner.ts';
16
+ import type { SyncConfig } from './types.ts';
17
+
18
+ function parseArgs(argv: readonly string[]): { configPath: string } {
19
+ let configPath = 'i18n-sync.config.json';
20
+ for (let index = 0; index < argv.length; index += 1) {
21
+ if (argv[index] === '--config' && argv[index + 1]) {
22
+ configPath = argv[index + 1];
23
+ index += 1;
24
+ }
25
+ }
26
+ return { configPath };
27
+ }
28
+
29
+ async function main(): Promise<number> {
30
+ const rootDir = process.cwd();
31
+ const { configPath } = parseArgs(process.argv.slice(2));
32
+ const absoluteConfigPath = path.resolve(rootDir, configPath);
33
+
34
+ if (!fs.existsSync(absoluteConfigPath)) {
35
+ console.error(`[rn-i18n] Sync configuration not found: ${configPath}`);
36
+ return 2;
37
+ }
38
+
39
+ let config: SyncConfig;
40
+ try {
41
+ config = JSON.parse(fs.readFileSync(absoluteConfigPath, 'utf8')) as SyncConfig;
42
+ } catch (error) {
43
+ console.error(`[rn-i18n] Sync configuration is not valid JSON: ${String(error)}`);
44
+ return 2;
45
+ }
46
+
47
+ try {
48
+ const { report, generatedKeysChanged } = await runSync(rootDir, config);
49
+ console.log(renderReport(report));
50
+ if (config.generateKeysFile) {
51
+ console.log(
52
+ generatedKeysChanged
53
+ ? `Typed keys regenerated: ${config.generateKeysFile}`
54
+ : `Typed keys unchanged: ${config.generateKeysFile}`,
55
+ );
56
+ }
57
+ return report.outcome === 'failed' ? 1 : 0;
58
+ } catch (error) {
59
+ if (error instanceof SyncConfigError) {
60
+ console.error(`[rn-i18n] ${error.message}`);
61
+ } else {
62
+ console.error(`[rn-i18n] Sync failed: ${String(error)}`);
63
+ }
64
+ return 2;
65
+ }
66
+ }
67
+
68
+ main().then(
69
+ code => {
70
+ process.exitCode = code;
71
+ },
72
+ error => {
73
+ console.error(`[rn-i18n] Unexpected failure: ${String(error)}`);
74
+ process.exitCode = 2;
75
+ },
76
+ );