@chipmobilesdk/rn-i18n 0.1.1 → 0.1.3

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
+ }
@@ -1,37 +1,31 @@
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 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(storage: LocaleStorageAdapter): Promise<string | null> {
10
+ try {
11
+ return await storage.getItem(LOCALE_STORAGE_KEY);
12
+ } catch (error) {
13
+ console.warn(`[rn-i18n] Failed to read the persisted locale preference: ${String(error)}`);
14
+ return null;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Persists the selected locale. Persistence is best-effort: a write failure
20
+ * is reported but the in-memory locale switch stays authoritative.
21
+ */
22
+ export async function writePersistedLocale(
23
+ storage: LocaleStorageAdapter,
24
+ code: string,
25
+ ): Promise<void> {
26
+ try {
27
+ await storage.setItem(LOCALE_STORAGE_KEY, code);
28
+ } catch (error) {
29
+ console.warn(`[rn-i18n] Failed to persist locale "${code}": ${String(error)}`);
30
+ }
31
+ }
@@ -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,57 @@
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 { 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<TKey extends string = string>(): UseTranslationResult<TKey> {
27
+ const i18n = useI18nContext();
28
+ useLocaleVersion(i18n);
29
+ return {
30
+ t: (key, vars, options) => i18n.t(key, vars, options),
31
+ locale: i18n.getLocale().code,
32
+ };
33
+ }
34
+
35
+ export interface UseLocaleResult {
36
+ locale: string;
37
+ direction: LocaleDirection;
38
+ preference: ActiveLocalePreference;
39
+ supportedLocales: readonly LocaleConfig[];
40
+ setLocale: (code: string) => Promise<void>;
41
+ }
42
+
43
+ /** Returns active locale state, direction metadata, and the locale switcher. */
44
+ export function useLocale(): UseLocaleResult {
45
+ const i18n = useI18nContext();
46
+ useLocaleVersion(i18n);
47
+ const preference = i18n.getLocale();
48
+ const supportedLocales = i18n.getSupportedLocales();
49
+ const active = supportedLocales.find(locale => locale.code === preference.code);
50
+ return {
51
+ locale: preference.code,
52
+ direction: active?.direction ?? 'ltr',
53
+ preference,
54
+ supportedLocales,
55
+ setLocale: code => i18n.setLocale(code),
56
+ };
57
+ }
@@ -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
+ );
@@ -1,68 +1,62 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import type { RuntimeEntryConfig, TranslationTemplate } from './types.ts';
4
-
5
- /**
6
- * Renders the typed key contract: the union of scanned template keys and
7
- * declared runtime-only keys (FR-041). Apps bind lookups to the generated
8
- * type so `tsc --noEmit` reports invalid or stale key usage before release.
9
- */
10
- export function renderKeysModule(
11
- template: TranslationTemplate,
12
- runtimeEntries: readonly RuntimeEntryConfig[],
13
- typeName = 'AppI18nKey',
14
- ): string {
15
- const templateKeys = template.keys
16
- .map(key => key.key)
17
- .sort((a, b) => a.localeCompare(b, 'en'));
18
- const runtimeKeys = runtimeEntries
19
- .map(entry => entry.key)
20
- .sort((a, b) => a.localeCompare(b, 'en'));
21
-
22
- const renderList = (keys: readonly string[]) =>
23
- keys.length === 0
24
- ? '[]'
25
- : `[\n${keys.map(key => `\t'${key}',`).join('\n')}\n]`;
26
-
27
- return [
28
- '// AUTO-GENERATED by @chipmobilesdk/rn-i18n tooling. Do not edit by hand.',
29
- '// Regenerate with: npm run i18n:sync',
30
- '',
31
- `export const I18N_TEMPLATE_KEYS = ${renderList(templateKeys)} as const;`,
32
- '',
33
- `export const I18N_RUNTIME_ONLY_KEYS = ${renderList(runtimeKeys)} as const;`,
34
- '',
35
- 'export const I18N_KEYS = [',
36
- '\t...I18N_TEMPLATE_KEYS,',
37
- '\t...I18N_RUNTIME_ONLY_KEYS,',
38
- '] as const;',
39
- '',
40
- `export type ${typeName} = (typeof I18N_KEYS)[number];`,
41
- '',
42
- ].join('\n');
43
- }
44
-
45
- export interface GenerateKeysResult {
46
- filePath: string;
47
- changed: boolean;
48
- }
49
-
50
- /** Writes the typed key artifact and reports whether it changed (staleness). */
51
- export function generateKeysFile(
52
- rootDir: string,
53
- relativePath: string,
54
- template: TranslationTemplate,
55
- runtimeEntries: readonly RuntimeEntryConfig[],
56
- typeName?: string,
57
- ): GenerateKeysResult {
58
- const filePath = path.join(rootDir, relativePath);
59
- const content = renderKeysModule(template, runtimeEntries, typeName);
60
- const previous = fs.existsSync(filePath)
61
- ? fs.readFileSync(filePath, 'utf8')
62
- : undefined;
63
- if (previous !== content) {
64
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
65
- fs.writeFileSync(filePath, content, 'utf8');
66
- }
67
- return { filePath, changed: previous !== content };
68
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { RuntimeEntryConfig, TranslationTemplate } from './types.ts';
4
+
5
+ /**
6
+ * Renders the typed key contract: the union of scanned template keys and
7
+ * declared runtime-only keys (FR-041). Apps bind lookups to the generated
8
+ * type so `tsc --noEmit` reports invalid or stale key usage before release.
9
+ */
10
+ export function renderKeysModule(
11
+ template: TranslationTemplate,
12
+ runtimeEntries: readonly RuntimeEntryConfig[],
13
+ typeName = 'AppI18nKey',
14
+ ): string {
15
+ const templateKeys = template.keys.map(key => key.key).sort((a, b) => a.localeCompare(b, 'en'));
16
+ const runtimeKeys = runtimeEntries
17
+ .map(entry => entry.key)
18
+ .sort((a, b) => a.localeCompare(b, 'en'));
19
+
20
+ const renderList = (keys: readonly string[]) =>
21
+ keys.length === 0 ? '[]' : `[\n${keys.map(key => `\t'${key}',`).join('\n')}\n]`;
22
+
23
+ return [
24
+ '// AUTO-GENERATED by @chipmobilesdk/rn-i18n tooling. Do not edit by hand.',
25
+ '// Regenerate with: npm run i18n:sync',
26
+ '',
27
+ `export const I18N_TEMPLATE_KEYS = ${renderList(templateKeys)} as const;`,
28
+ '',
29
+ `export const I18N_RUNTIME_ONLY_KEYS = ${renderList(runtimeKeys)} as const;`,
30
+ '',
31
+ 'export const I18N_KEYS = [',
32
+ '\t...I18N_TEMPLATE_KEYS,',
33
+ '\t...I18N_RUNTIME_ONLY_KEYS,',
34
+ '] as const;',
35
+ '',
36
+ `export type ${typeName} = (typeof I18N_KEYS)[number];`,
37
+ '',
38
+ ].join('\n');
39
+ }
40
+
41
+ export interface GenerateKeysResult {
42
+ filePath: string;
43
+ changed: boolean;
44
+ }
45
+
46
+ /** Writes the typed key artifact and reports whether it changed (staleness). */
47
+ export function generateKeysFile(
48
+ rootDir: string,
49
+ relativePath: string,
50
+ template: TranslationTemplate,
51
+ runtimeEntries: readonly RuntimeEntryConfig[],
52
+ typeName?: string,
53
+ ): GenerateKeysResult {
54
+ const filePath = path.join(rootDir, relativePath);
55
+ const content = renderKeysModule(template, runtimeEntries, typeName);
56
+ const previous = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : undefined;
57
+ if (previous !== content) {
58
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
59
+ fs.writeFileSync(filePath, content, 'utf8');
60
+ }
61
+ return { filePath, changed: previous !== content };
62
+ }