@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.
@@ -0,0 +1,198 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type {
4
+ LocaleSyncResult,
5
+ RuntimeEntryConfig,
6
+ SyncConfig,
7
+ TranslationTemplate,
8
+ } from './types.ts';
9
+
10
+ type JsonValue = string | { [key: string]: JsonValue };
11
+
12
+ const PLURAL_CATEGORIES = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
13
+
14
+ /**
15
+ * Mirrors the runtime plural-group leaf rule (translation/plurals.ts) so a
16
+ * plural variant object is synchronized as one key, not per category.
17
+ */
18
+ function isPluralGroupValue(value: JsonValue): boolean {
19
+ if (typeof value !== 'object' || value === null) {
20
+ return false;
21
+ }
22
+ const keys = Object.keys(value);
23
+ return (
24
+ keys.length > 0 &&
25
+ 'other' in value &&
26
+ keys.every(
27
+ key => PLURAL_CATEGORIES.has(key) && typeof value[key] === 'string',
28
+ )
29
+ );
30
+ }
31
+
32
+ /** Flattens nested locale JSON into dot-path keys with leaf values. */
33
+ export function flattenTranslations(
34
+ node: JsonValue,
35
+ prefix = '',
36
+ into: Map<string, JsonValue> = new Map(),
37
+ ): Map<string, JsonValue> {
38
+ if (typeof node === 'string' || isPluralGroupValue(node)) {
39
+ into.set(prefix, node);
40
+ return into;
41
+ }
42
+ for (const [key, value] of Object.entries(node)) {
43
+ flattenTranslations(value, prefix ? `${prefix}.${key}` : key, into);
44
+ }
45
+ return into;
46
+ }
47
+
48
+ function sortValue(value: JsonValue): JsonValue {
49
+ if (typeof value === 'string') {
50
+ return value;
51
+ }
52
+ const sorted: { [key: string]: JsonValue } = {};
53
+ for (const key of Object.keys(value).sort((a, b) => a.localeCompare(b, 'en'))) {
54
+ sorted[key] = sortValue(value[key]);
55
+ }
56
+ return sorted;
57
+ }
58
+
59
+ /** Rebuilds the nested JSON object with deterministically sorted keys. */
60
+ export function nestTranslations(flat: Map<string, JsonValue>): JsonValue {
61
+ const root: { [key: string]: JsonValue } = {};
62
+ const sortedKeys = [...flat.keys()].sort((a, b) => a.localeCompare(b, 'en'));
63
+ for (const flatKey of sortedKeys) {
64
+ const segments = flatKey.split('.');
65
+ let node = root;
66
+ for (let index = 0; index < segments.length - 1; index += 1) {
67
+ const segment = segments[index];
68
+ const next = node[segment];
69
+ if (typeof next !== 'object' || next === null || isPluralGroupValue(next)) {
70
+ node[segment] = {};
71
+ }
72
+ node = node[segment] as { [key: string]: JsonValue };
73
+ }
74
+ node[segments[segments.length - 1]] = sortValue(flat.get(flatKey)!);
75
+ }
76
+ return root;
77
+ }
78
+
79
+ function placeholderValue(
80
+ config: SyncConfig,
81
+ locale: string,
82
+ key: string,
83
+ defaultText: string | undefined,
84
+ ): string {
85
+ const placeholder = config.placeholder ?? 'template';
86
+ if (placeholder === 'template') {
87
+ return defaultText ?? key;
88
+ }
89
+ if (typeof placeholder === 'string') {
90
+ return placeholder;
91
+ }
92
+ return placeholder[locale] ?? defaultText ?? key;
93
+ }
94
+
95
+ function writeLocaleFile(filePath: string, content: JsonValue): void {
96
+ // Atomic per-file write: a failed run never leaves a partial locale file.
97
+ const json = `${JSON.stringify(content, null, '\t')}\n`;
98
+ const temporaryPath = `${filePath}.tmp`;
99
+ fs.writeFileSync(temporaryPath, json, 'utf8');
100
+ fs.renameSync(temporaryPath, filePath);
101
+ }
102
+
103
+ /**
104
+ * Synchronizes every configured locale JSON file against the scanned
105
+ * template: creates missing files, adds missing keys with the configured
106
+ * placeholder, preserves existing translations verbatim, removes stale keys,
107
+ * and protects declared runtime-only entries (FR-027…FR-030, FR-034/FR-035).
108
+ * Conflicted keys are never added or overwritten while the conflict is open.
109
+ */
110
+ export function syncLocaleFiles(
111
+ rootDir: string,
112
+ config: SyncConfig,
113
+ template: TranslationTemplate,
114
+ runtimeEntries: readonly RuntimeEntryConfig[],
115
+ ): LocaleSyncResult[] {
116
+ const templateByKey = new Map(template.keys.map(key => [key.key, key]));
117
+ const conflictedKeys = new Set(template.conflicts.map(conflict => conflict.key));
118
+ const runtimeByKey = new Map(runtimeEntries.map(entry => [entry.key, entry]));
119
+ const results: LocaleSyncResult[] = [];
120
+
121
+ for (const locale of config.locales) {
122
+ const filePath = path.join(rootDir, config.localesDir, `${locale}.json`);
123
+ const result: LocaleSyncResult = {
124
+ locale,
125
+ filePath,
126
+ created: false,
127
+ addedKeys: [],
128
+ preservedKeys: [],
129
+ removedKeys: [],
130
+ runtimePreserved: [],
131
+ runtimeAdded: [],
132
+ runtimeReported: [],
133
+ };
134
+ results.push(result);
135
+
136
+ let existing = new Map<string, JsonValue>();
137
+ if (fs.existsSync(filePath)) {
138
+ try {
139
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as JsonValue;
140
+ existing = flattenTranslations(parsed);
141
+ } catch (error) {
142
+ result.error = `Malformed locale file ${locale}.json: ${String(error)}`;
143
+ continue;
144
+ }
145
+ } else {
146
+ result.created = true;
147
+ }
148
+
149
+ const next = new Map<string, JsonValue>();
150
+
151
+ for (const [key, templateKey] of templateByKey) {
152
+ const current = existing.get(key);
153
+ if (current !== undefined) {
154
+ next.set(key, current);
155
+ result.preservedKeys.push(key);
156
+ continue;
157
+ }
158
+ if (conflictedKeys.has(key)) {
159
+ // Reported for manual review; never silently added (FR-031).
160
+ continue;
161
+ }
162
+ next.set(
163
+ key,
164
+ placeholderValue(config, locale, key, templateKey.defaultText),
165
+ );
166
+ result.addedKeys.push(key);
167
+ }
168
+
169
+ for (const [key, entry] of runtimeByKey) {
170
+ const current = existing.get(key);
171
+ if (current !== undefined) {
172
+ next.set(key, current);
173
+ result.runtimePreserved.push(key);
174
+ continue;
175
+ }
176
+ if ((entry.policy ?? 'report') === 'add') {
177
+ next.set(key, placeholderValue(config, locale, key, entry.defaultText));
178
+ result.runtimeAdded.push(key);
179
+ } else {
180
+ result.runtimeReported.push(key);
181
+ }
182
+ }
183
+
184
+ for (const key of existing.keys()) {
185
+ if (!next.has(key) && !runtimeByKey.has(key) && !conflictedKeys.has(key)) {
186
+ result.removedKeys.push(key);
187
+ }
188
+ }
189
+
190
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
191
+ writeLocaleFile(filePath, nestTranslations(next));
192
+ result.addedKeys.sort((a, b) => a.localeCompare(b, 'en'));
193
+ result.preservedKeys.sort((a, b) => a.localeCompare(b, 'en'));
194
+ result.removedKeys.sort((a, b) => a.localeCompare(b, 'en'));
195
+ }
196
+
197
+ return results;
198
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Shared contracts for the Node-only extraction and synchronization tooling.
3
+ * This module must stay free of React Native imports; it is consumed through
4
+ * the `@chipmobilesdk/rn-i18n/tooling` entrypoint and the CLI.
5
+ */
6
+
7
+ export interface PatternConfig {
8
+ /**
9
+ * Regex source with a named `key` capture group for statically resolvable
10
+ * keys, and an optional named `defaultText` group for template values.
11
+ */
12
+ regex: string;
13
+ /** Extra regex flags; `g` is always applied. */
14
+ flags?: string;
15
+ /**
16
+ * Optional regex matching dynamic usages of the same call style. Matches
17
+ * that do not overlap a static key match are reported as ambiguous.
18
+ */
19
+ ambiguousRegex?: string;
20
+ }
21
+
22
+ export interface SyncConfig {
23
+ /** Globs relative to the project root, e.g. `src/**\/*.{ts,tsx}`. */
24
+ sourceGlobs: string[];
25
+ patterns: PatternConfig[];
26
+ /** Directory containing `<locale>.json` files. */
27
+ localesDir: string;
28
+ locales: string[];
29
+ /** Module exporting the declared runtime-only entries. */
30
+ runtimeEntriesFile?: string;
31
+ /**
32
+ * Value written for newly added keys: `template` uses the discovered
33
+ * default text (falling back to the key), a string is used verbatim, and
34
+ * a map selects per locale. Default: `template`.
35
+ */
36
+ placeholder?: 'template' | string | Record<string, string>;
37
+ /** Optional JSON report output path. */
38
+ reportPath?: string;
39
+ /** Generated typed key artifact path, e.g. `src/i18n/keys.generated.ts`. */
40
+ generateKeysFile?: string;
41
+ /** Exported key type name. Default: `AppI18nKey`. */
42
+ keysTypeName?: string;
43
+ }
44
+
45
+ export interface RuntimeEntryConfig {
46
+ key: string;
47
+ defaultText: string;
48
+ policy?: 'add' | 'report';
49
+ description?: string;
50
+ }
51
+
52
+ export interface SourceLocation {
53
+ file: string;
54
+ line: number;
55
+ }
56
+
57
+ export interface TemplateKey {
58
+ key: string;
59
+ defaultText?: string;
60
+ locations: SourceLocation[];
61
+ }
62
+
63
+ export interface AmbiguousUsage {
64
+ file: string;
65
+ line: number;
66
+ snippet: string;
67
+ }
68
+
69
+ export interface KeyConflict {
70
+ key: string;
71
+ values: { text: string; location: SourceLocation }[];
72
+ }
73
+
74
+ export interface TranslationTemplate {
75
+ /** Discovered keys sorted by key name. */
76
+ keys: TemplateKey[];
77
+ ambiguous: AmbiguousUsage[];
78
+ conflicts: KeyConflict[];
79
+ scannedFiles: number;
80
+ }
81
+
82
+ export interface LocaleSyncResult {
83
+ locale: string;
84
+ filePath: string;
85
+ created: boolean;
86
+ addedKeys: string[];
87
+ preservedKeys: string[];
88
+ removedKeys: string[];
89
+ runtimePreserved: string[];
90
+ runtimeAdded: string[];
91
+ runtimeReported: string[];
92
+ /** Set when the locale file is malformed; no write happened. */
93
+ error?: string;
94
+ }
95
+
96
+ export type ReportOutcome = 'success' | 'completedWithWarnings' | 'failed';
97
+
98
+ export interface ExtractionReport {
99
+ outcome: ReportOutcome;
100
+ discovered: {
101
+ key: string;
102
+ defaultText?: string;
103
+ locations: string[];
104
+ }[];
105
+ createdLocaleFiles: { locale: string; filePath: string }[];
106
+ addedKeys: Record<string, string[]>;
107
+ preservedKeys: Record<string, string[]>;
108
+ removedStaleKeys: Record<string, string[]>;
109
+ missingPerLocale: Record<string, string[]>;
110
+ runtimeOnly: {
111
+ declared: string[];
112
+ preserved: Record<string, string[]>;
113
+ added: Record<string, string[]>;
114
+ reported: Record<string, string[]>;
115
+ };
116
+ ambiguous: AmbiguousUsage[];
117
+ conflicts: KeyConflict[];
118
+ errors: string[];
119
+ }
@@ -0,0 +1,23 @@
1
+ import type { TranslateVars } from '../config/types';
2
+
3
+ const VARIABLE_PATTERN = /\{\{\s*([\w.]+)\s*\}\}/g;
4
+
5
+ /**
6
+ * Replaces `{{name}}` placeholders with named values. Missing variables keep
7
+ * their placeholder text so the result stays deterministic, and each missing
8
+ * name is reported through `onMissingVariable`.
9
+ */
10
+ export function interpolate(
11
+ text: string,
12
+ vars?: TranslateVars,
13
+ onMissingVariable?: (name: string) => void,
14
+ ): string {
15
+ return text.replace(VARIABLE_PATTERN, (placeholder, name: string) => {
16
+ const value = vars?.[name];
17
+ if (value === undefined || value === null) {
18
+ onMissingVariable?.(name);
19
+ return placeholder;
20
+ }
21
+ return String(value);
22
+ });
23
+ }
@@ -0,0 +1,69 @@
1
+ import type {
2
+ LocaleConfig,
3
+ PluralCategory,
4
+ PluralRule,
5
+ TranslationNode,
6
+ } from '../config/types';
7
+
8
+ export const PLURAL_CATEGORIES: readonly PluralCategory[] = [
9
+ 'zero',
10
+ 'one',
11
+ 'two',
12
+ 'few',
13
+ 'many',
14
+ 'other',
15
+ ];
16
+
17
+ const oneOtherRule: PluralRule = count => (count === 1 ? 'one' : 'other');
18
+ const otherOnlyRule: PluralRule = () => 'other';
19
+
20
+ /**
21
+ * Package-owned plural rules. Intl.PluralRules is not implemented by Hermes,
22
+ * so rules are resolved from configuration to keep behavior identical across
23
+ * devices and test environments. Languages without a built-in rule use the
24
+ * documented default (`count === 1` selects `one`, everything else `other`)
25
+ * unless the locale configuration supplies a custom rule.
26
+ */
27
+ const BUILT_IN_RULES: Record<string, PluralRule> = {
28
+ en: oneOtherRule,
29
+ ja: otherOnlyRule,
30
+ vi: otherOnlyRule,
31
+ };
32
+
33
+ export const DEFAULT_PLURAL_RULE: PluralRule = oneOtherRule;
34
+
35
+ export function resolvePluralRule(locale: LocaleConfig): PluralRule {
36
+ if (locale.pluralRule) {
37
+ return locale.pluralRule;
38
+ }
39
+ const language = locale.code.split('-')[0]?.toLowerCase() ?? '';
40
+ return BUILT_IN_RULES[language] ?? DEFAULT_PLURAL_RULE;
41
+ }
42
+
43
+ /**
44
+ * A plural group is an object whose keys are all plural categories, whose
45
+ * values are all strings, and which provides the required `other` variant.
46
+ */
47
+ export function isPluralGroup(
48
+ node: TranslationNode,
49
+ ): node is Record<string, string> {
50
+ if (typeof node !== 'object' || node === null) {
51
+ return false;
52
+ }
53
+ const keys = Object.keys(node);
54
+ if (keys.length === 0 || !('other' in node)) {
55
+ return false;
56
+ }
57
+ return keys.every(
58
+ key =>
59
+ (PLURAL_CATEGORIES as readonly string[]).includes(key) &&
60
+ typeof (node as Record<string, unknown>)[key] === 'string',
61
+ );
62
+ }
63
+
64
+ export function selectPluralVariant(
65
+ group: Record<string, string>,
66
+ category: PluralCategory,
67
+ ): string {
68
+ return group[category] ?? group.other;
69
+ }
@@ -0,0 +1,146 @@
1
+ import type {
2
+ LocaleConfig,
3
+ MissingTranslationPolicy,
4
+ TranslateOptions,
5
+ TranslateVars,
6
+ TranslationNamespace,
7
+ TranslationNode,
8
+ TranslationResources,
9
+ } from '../config/types';
10
+ import { interpolate } from './interpolate';
11
+ import { isPluralGroup, resolvePluralRule, selectPluralVariant } from './plurals';
12
+ import type { RuntimeEntryRegistry } from './runtimeKeys';
13
+
14
+ export interface ResolverContext {
15
+ localesByCode: ReadonlyMap<string, LocaleConfig>;
16
+ resources: TranslationResources;
17
+ runtimeEntries: RuntimeEntryRegistry;
18
+ missingTranslation: MissingTranslationPolicy;
19
+ /** Dedupe set so each failed lookup path warns exactly once. */
20
+ warned: Set<string>;
21
+ }
22
+
23
+ export function warnOnce(
24
+ warned: Set<string>,
25
+ dedupeKey: string,
26
+ message: string,
27
+ ): void {
28
+ if (warned.has(dedupeKey)) {
29
+ return;
30
+ }
31
+ warned.add(dedupeKey);
32
+ console.warn(message);
33
+ }
34
+
35
+ /** Walks a dot-path key through a nested translation namespace. */
36
+ export function getTranslationNode(
37
+ namespace: TranslationNamespace | undefined,
38
+ key: string,
39
+ ): TranslationNode | undefined {
40
+ if (!namespace) {
41
+ return undefined;
42
+ }
43
+ let node: TranslationNode = namespace;
44
+ for (const segment of key.split('.')) {
45
+ if (typeof node !== 'object' || node === null) {
46
+ return undefined;
47
+ }
48
+ const next: TranslationNode | undefined = node[segment];
49
+ if (next === undefined) {
50
+ return undefined;
51
+ }
52
+ node = next;
53
+ }
54
+ return node;
55
+ }
56
+
57
+ function pickValue(
58
+ node: TranslationNode,
59
+ locale: LocaleConfig,
60
+ options: TranslateOptions | undefined,
61
+ warned: Set<string>,
62
+ key: string,
63
+ ): string | undefined {
64
+ if (typeof node === 'string') {
65
+ return node;
66
+ }
67
+ if (isPluralGroup(node)) {
68
+ const count = options?.count;
69
+ if (count === undefined) {
70
+ return node.other;
71
+ }
72
+ return selectPluralVariant(node, resolvePluralRule(locale)(count));
73
+ }
74
+ warnOnce(
75
+ warned,
76
+ `invalid-value|${locale.code}|${key}`,
77
+ `[rn-i18n] Translation value for "${key}" in locale "${locale.code}" is not text or a plural group; treating it as missing.`,
78
+ );
79
+ return undefined;
80
+ }
81
+
82
+ /**
83
+ * Resolves a key through the active locale, its ordered fallback chain, and
84
+ * declared runtime-only default text, applying plural selection and named
85
+ * variable interpolation. Misses are reported with one console warning per
86
+ * lookup path; a full-chain miss applies the configured missing-translation
87
+ * policy (FR-008…FR-011, FR-033).
88
+ */
89
+ export function translate(
90
+ context: ResolverContext,
91
+ activeCode: string,
92
+ key: string,
93
+ vars?: TranslateVars,
94
+ options?: TranslateOptions,
95
+ ): string {
96
+ const active = context.localesByCode.get(activeCode);
97
+ const chain = [activeCode, ...(active?.fallbackChain ?? [])];
98
+ const mergedVars: TranslateVars | undefined =
99
+ options?.count === undefined ? vars : { count: options.count, ...vars };
100
+
101
+ const interpolateWith = (text: string, sourceLocale: string) =>
102
+ interpolate(text, mergedVars, name =>
103
+ warnOnce(
104
+ context.warned,
105
+ `missing-var|${sourceLocale}|${key}|${name}`,
106
+ `[rn-i18n] Missing interpolation variable "${name}" for key "${key}" in locale "${sourceLocale}".`,
107
+ ),
108
+ );
109
+
110
+ for (const code of chain) {
111
+ const locale = context.localesByCode.get(code);
112
+ if (!locale) {
113
+ continue;
114
+ }
115
+ const node = getTranslationNode(context.resources[code], key);
116
+ if (node === undefined) {
117
+ continue;
118
+ }
119
+ const value = pickValue(node, locale, options, context.warned, key);
120
+ if (value === undefined) {
121
+ continue;
122
+ }
123
+ if (code !== activeCode) {
124
+ warnOnce(
125
+ context.warned,
126
+ `fallback|${activeCode}|${key}`,
127
+ `[rn-i18n] Key "${key}" is missing in locale "${activeCode}"; served from fallback locale "${code}".`,
128
+ );
129
+ }
130
+ return interpolateWith(value, code);
131
+ }
132
+
133
+ const runtimeEntry = context.runtimeEntries.get(key);
134
+ if (runtimeEntry) {
135
+ return interpolateWith(runtimeEntry.defaultText, activeCode);
136
+ }
137
+
138
+ warnOnce(
139
+ context.warned,
140
+ `missing|${activeCode}|${key}`,
141
+ `[rn-i18n] Missing translation for key "${key}" in locale "${activeCode}" and its entire fallback chain.`,
142
+ );
143
+ return context.missingTranslation.mode === 'placeholder'
144
+ ? context.missingTranslation.text
145
+ : key;
146
+ }
@@ -0,0 +1,27 @@
1
+ import type { RuntimeTranslationEntry } from '../config/types';
2
+
3
+ /**
4
+ * Registry of declared runtime-only translation entries. Entries participate
5
+ * in the same lookup pipeline as scanned template keys and are protected from
6
+ * removal during locale JSON synchronization.
7
+ */
8
+ export type RuntimeEntryRegistry = Map<string, RuntimeTranslationEntry>;
9
+
10
+ export function createRuntimeRegistry(
11
+ entries?: readonly RuntimeTranslationEntry[],
12
+ ): RuntimeEntryRegistry {
13
+ const registry: RuntimeEntryRegistry = new Map();
14
+ if (entries) {
15
+ registerRuntimeEntries(registry, entries);
16
+ }
17
+ return registry;
18
+ }
19
+
20
+ export function registerRuntimeEntries(
21
+ registry: RuntimeEntryRegistry,
22
+ entries: readonly RuntimeTranslationEntry[],
23
+ ): void {
24
+ for (const entry of entries) {
25
+ registry.set(entry.key, entry);
26
+ }
27
+ }