@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,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(
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
+ );
@@ -0,0 +1,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
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
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Programmatic surface of the Node-only extraction and synchronization
3
+ * tooling, exposed as `@chipmobilesdk/rn-i18n/tooling`. This entrypoint must
4
+ * never be imported from React Native code; the runtime API lives at the
5
+ * package root (LIB-003).
6
+ */
7
+
8
+ export { globToRegExp, listSourceFiles, scanSource } from './scan.ts';
9
+ export { flattenTranslations, nestTranslations, syncLocaleFiles } from './sync.ts';
10
+ export { buildReport, renderReport } from './report.ts';
11
+ export { generateKeysFile, renderKeysModule } from './generateKeys.ts';
12
+ export type { GenerateKeysResult } from './generateKeys.ts';
13
+ export {
14
+ SyncConfigError,
15
+ loadRuntimeEntries,
16
+ runSync,
17
+ validateSyncConfig,
18
+ } from './runner.ts';
19
+ export type { SyncRunResult } from './runner.ts';
20
+ export type {
21
+ AmbiguousUsage,
22
+ ExtractionReport,
23
+ KeyConflict,
24
+ LocaleSyncResult,
25
+ PatternConfig,
26
+ ReportOutcome,
27
+ RuntimeEntryConfig,
28
+ SourceLocation,
29
+ SyncConfig,
30
+ TemplateKey,
31
+ TranslationTemplate,
32
+ } from './types.ts';
@@ -0,0 +1,117 @@
1
+ import type {
2
+ ExtractionReport,
3
+ LocaleSyncResult,
4
+ ReportOutcome,
5
+ RuntimeEntryConfig,
6
+ TranslationTemplate,
7
+ } from './types.ts';
8
+
9
+ function perLocale(
10
+ results: readonly LocaleSyncResult[],
11
+ select: (result: LocaleSyncResult) => string[],
12
+ ): Record<string, string[]> {
13
+ const record: Record<string, string[]> = {};
14
+ for (const result of results) {
15
+ record[result.locale] = select(result);
16
+ }
17
+ return record;
18
+ }
19
+
20
+ /**
21
+ * Assembles the extraction/synchronization report distinguishing scanned
22
+ * template keys, runtime-only keys, stale removals, ambiguous usages, and
23
+ * conflicts (FR-026, FR-036).
24
+ */
25
+ export function buildReport(
26
+ template: TranslationTemplate,
27
+ syncResults: readonly LocaleSyncResult[],
28
+ runtimeEntries: readonly RuntimeEntryConfig[],
29
+ ): ExtractionReport {
30
+ const errors = syncResults
31
+ .filter(result => result.error)
32
+ .map(result => result.error!);
33
+
34
+ let outcome: ReportOutcome = 'success';
35
+ if (
36
+ template.ambiguous.length > 0 ||
37
+ syncResults.some(
38
+ result => result.addedKeys.length > 0 || result.runtimeReported.length > 0,
39
+ )
40
+ ) {
41
+ outcome = 'completedWithWarnings';
42
+ }
43
+ if (errors.length > 0 || template.conflicts.length > 0) {
44
+ outcome = 'failed';
45
+ }
46
+
47
+ return {
48
+ outcome,
49
+ discovered: template.keys.map(key => ({
50
+ key: key.key,
51
+ defaultText: key.defaultText,
52
+ locations: key.locations.map(
53
+ location => `${location.file}:${location.line}`,
54
+ ),
55
+ })),
56
+ createdLocaleFiles: syncResults
57
+ .filter(result => result.created && !result.error)
58
+ .map(result => ({ locale: result.locale, filePath: result.filePath })),
59
+ addedKeys: perLocale(syncResults, result => result.addedKeys),
60
+ preservedKeys: perLocale(syncResults, result => result.preservedKeys),
61
+ removedStaleKeys: perLocale(syncResults, result => result.removedKeys),
62
+ missingPerLocale: perLocale(syncResults, result =>
63
+ [...result.addedKeys, ...result.runtimeAdded, ...result.runtimeReported].sort(
64
+ (a, b) => a.localeCompare(b, 'en'),
65
+ ),
66
+ ),
67
+ runtimeOnly: {
68
+ declared: runtimeEntries.map(entry => entry.key),
69
+ preserved: perLocale(syncResults, result => result.runtimePreserved),
70
+ added: perLocale(syncResults, result => result.runtimeAdded),
71
+ reported: perLocale(syncResults, result => result.runtimeReported),
72
+ },
73
+ ambiguous: template.ambiguous,
74
+ conflicts: template.conflicts,
75
+ errors,
76
+ };
77
+ }
78
+
79
+ /** Renders the report as readable text for CLI output. */
80
+ export function renderReport(report: ExtractionReport): string {
81
+ const lines: string[] = [];
82
+ lines.push(`i18n sync outcome: ${report.outcome}`);
83
+ lines.push(`Discovered keys: ${report.discovered.length}`);
84
+ for (const created of report.createdLocaleFiles) {
85
+ lines.push(`Created locale file: ${created.locale} -> ${created.filePath}`);
86
+ }
87
+ for (const [locale, keys] of Object.entries(report.addedKeys)) {
88
+ if (keys.length > 0) {
89
+ lines.push(`Added [${locale}]: ${keys.join(', ')}`);
90
+ }
91
+ }
92
+ for (const [locale, keys] of Object.entries(report.removedStaleKeys)) {
93
+ if (keys.length > 0) {
94
+ lines.push(`Removed stale [${locale}]: ${keys.join(', ')}`);
95
+ }
96
+ }
97
+ for (const [locale, keys] of Object.entries(report.runtimeOnly.reported)) {
98
+ if (keys.length > 0) {
99
+ lines.push(`Runtime-only missing [${locale}]: ${keys.join(', ')}`);
100
+ }
101
+ }
102
+ for (const usage of report.ambiguous) {
103
+ lines.push(
104
+ `Ambiguous usage at ${usage.file}:${usage.line}: ${usage.snippet}`,
105
+ );
106
+ }
107
+ for (const conflict of report.conflicts) {
108
+ const sources = conflict.values
109
+ .map(value => `"${value.text}" (${value.location.file}:${value.location.line})`)
110
+ .join(' vs ');
111
+ lines.push(`Conflict for key "${conflict.key}": ${sources}`);
112
+ }
113
+ for (const error of report.errors) {
114
+ lines.push(`Error: ${error}`);
115
+ }
116
+ return lines.join('\n');
117
+ }
@@ -0,0 +1,126 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { scanSource } from './scan.ts';
5
+ import { syncLocaleFiles } from './sync.ts';
6
+ import { buildReport } from './report.ts';
7
+ import { generateKeysFile } from './generateKeys.ts';
8
+ import type {
9
+ ExtractionReport,
10
+ RuntimeEntryConfig,
11
+ SyncConfig,
12
+ } from './types.ts';
13
+
14
+ export class SyncConfigError extends Error {
15
+ constructor(message: string) {
16
+ super(message);
17
+ this.name = 'SyncConfigError';
18
+ }
19
+ }
20
+
21
+ export function validateSyncConfig(config: SyncConfig): void {
22
+ const issues: string[] = [];
23
+ if (!Array.isArray(config.sourceGlobs) || config.sourceGlobs.length === 0) {
24
+ issues.push('`sourceGlobs` must list at least one glob.');
25
+ }
26
+ if (!Array.isArray(config.patterns) || config.patterns.length === 0) {
27
+ issues.push('`patterns` must list at least one regex pattern.');
28
+ } else {
29
+ for (const pattern of config.patterns) {
30
+ if (!pattern.regex?.includes('(?<key>')) {
31
+ issues.push(
32
+ `Pattern "${pattern.regex}" must contain a named (?<key>...) capture group.`,
33
+ );
34
+ }
35
+ }
36
+ }
37
+ if (!config.localesDir) {
38
+ issues.push('`localesDir` is required.');
39
+ }
40
+ if (!Array.isArray(config.locales) || config.locales.length === 0) {
41
+ issues.push('`locales` must list every supported locale.');
42
+ }
43
+ if (issues.length > 0) {
44
+ throw new SyncConfigError(
45
+ `Invalid i18n sync configuration:\n- ${issues.join('\n- ')}`,
46
+ );
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Loads declared runtime-only entries from the configured module. Accepts a
52
+ * default export or a named `runtimeEntries` export. The module must use
53
+ * type-only imports so it can run under Node's TypeScript type stripping.
54
+ */
55
+ export async function loadRuntimeEntries(
56
+ rootDir: string,
57
+ runtimeEntriesFile: string | undefined,
58
+ ): Promise<RuntimeEntryConfig[]> {
59
+ if (!runtimeEntriesFile) {
60
+ return [];
61
+ }
62
+ const absolutePath = path.resolve(rootDir, runtimeEntriesFile);
63
+ if (!fs.existsSync(absolutePath)) {
64
+ throw new SyncConfigError(
65
+ `Runtime entries file not found: ${runtimeEntriesFile}`,
66
+ );
67
+ }
68
+ const module = (await import(pathToFileURL(absolutePath).href)) as {
69
+ default?: RuntimeEntryConfig[];
70
+ runtimeEntries?: RuntimeEntryConfig[];
71
+ };
72
+ const entries = module.default ?? module.runtimeEntries;
73
+ if (!Array.isArray(entries)) {
74
+ throw new SyncConfigError(
75
+ `Runtime entries file must export an entry array (default or \`runtimeEntries\`): ${runtimeEntriesFile}`,
76
+ );
77
+ }
78
+ return entries;
79
+ }
80
+
81
+ export interface SyncRunResult {
82
+ report: ExtractionReport;
83
+ generatedKeysChanged: boolean;
84
+ }
85
+
86
+ /**
87
+ * Full pipeline: scan the source tree, synchronize every locale JSON file,
88
+ * regenerate the typed key artifact, and assemble the report.
89
+ */
90
+ export async function runSync(
91
+ rootDir: string,
92
+ config: SyncConfig,
93
+ runtimeEntriesOverride?: readonly RuntimeEntryConfig[],
94
+ ): Promise<SyncRunResult> {
95
+ validateSyncConfig(config);
96
+ const runtimeEntries =
97
+ runtimeEntriesOverride ??
98
+ (await loadRuntimeEntries(rootDir, config.runtimeEntriesFile));
99
+
100
+ const template = scanSource(rootDir, config.sourceGlobs, config.patterns);
101
+ const syncResults = syncLocaleFiles(rootDir, config, template, runtimeEntries);
102
+ const report = buildReport(template, syncResults, runtimeEntries);
103
+
104
+ let generatedKeysChanged = false;
105
+ if (config.generateKeysFile) {
106
+ generatedKeysChanged = generateKeysFile(
107
+ rootDir,
108
+ config.generateKeysFile,
109
+ template,
110
+ runtimeEntries,
111
+ config.keysTypeName,
112
+ ).changed;
113
+ }
114
+
115
+ if (config.reportPath) {
116
+ const reportPath = path.resolve(rootDir, config.reportPath);
117
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true });
118
+ fs.writeFileSync(
119
+ reportPath,
120
+ `${JSON.stringify(report, null, '\t')}\n`,
121
+ 'utf8',
122
+ );
123
+ }
124
+
125
+ return { report, generatedKeysChanged };
126
+ }
@@ -0,0 +1,210 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type {
4
+ AmbiguousUsage,
5
+ KeyConflict,
6
+ PatternConfig,
7
+ SourceLocation,
8
+ TemplateKey,
9
+ TranslationTemplate,
10
+ } from './types.ts';
11
+
12
+ const IGNORED_DIRECTORIES = new Set([
13
+ 'node_modules',
14
+ '.git',
15
+ 'build',
16
+ 'dist',
17
+ 'Pods',
18
+ ]);
19
+
20
+ /** Converts a glob (`**`, `*`, `?`, `{a,b}`) to an anchored RegExp. */
21
+ export function globToRegExp(glob: string): RegExp {
22
+ let source = '';
23
+ let index = 0;
24
+ while (index < glob.length) {
25
+ const char = glob[index];
26
+ if (char === '*') {
27
+ if (glob[index + 1] === '*') {
28
+ // `**/` matches zero or more directories; trailing `**` matches all.
29
+ if (glob[index + 2] === '/') {
30
+ source += '(?:.*/)?';
31
+ index += 3;
32
+ } else {
33
+ source += '.*';
34
+ index += 2;
35
+ }
36
+ continue;
37
+ }
38
+ source += '[^/]*';
39
+ } else if (char === '?') {
40
+ source += '[^/]';
41
+ } else if (char === '{') {
42
+ const end = glob.indexOf('}', index);
43
+ if (end === -1) {
44
+ source += '\\{';
45
+ } else {
46
+ const alternatives = glob
47
+ .slice(index + 1, end)
48
+ .split(',')
49
+ .map(alternative =>
50
+ alternative.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
51
+ );
52
+ source += `(?:${alternatives.join('|')})`;
53
+ index = end;
54
+ }
55
+ } else {
56
+ source += char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
57
+ }
58
+ index += 1;
59
+ }
60
+ return new RegExp(`^${source}$`);
61
+ }
62
+
63
+ function walkDirectory(rootDir: string, current: string, files: string[]): void {
64
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
65
+ if (entry.isDirectory()) {
66
+ if (!IGNORED_DIRECTORIES.has(entry.name)) {
67
+ walkDirectory(rootDir, path.join(current, entry.name), files);
68
+ }
69
+ continue;
70
+ }
71
+ if (entry.isFile()) {
72
+ files.push(
73
+ path.relative(rootDir, path.join(current, entry.name)).replace(/\\/g, '/'),
74
+ );
75
+ }
76
+ }
77
+ }
78
+
79
+ /** Lists project-relative source files matching the configured globs. */
80
+ export function listSourceFiles(
81
+ rootDir: string,
82
+ sourceGlobs: readonly string[],
83
+ ): string[] {
84
+ const allFiles: string[] = [];
85
+ walkDirectory(rootDir, rootDir, allFiles);
86
+ const matchers = sourceGlobs.map(globToRegExp);
87
+ return allFiles
88
+ .filter(file => matchers.some(matcher => matcher.test(file)))
89
+ .sort();
90
+ }
91
+
92
+ function lineOfIndex(text: string, index: number): number {
93
+ let line = 1;
94
+ for (let position = 0; position < index && position < text.length; position += 1) {
95
+ if (text[position] === '\n') {
96
+ line += 1;
97
+ }
98
+ }
99
+ return line;
100
+ }
101
+
102
+ interface CompiledPattern {
103
+ keyRegex: RegExp;
104
+ ambiguousRegex?: RegExp;
105
+ }
106
+
107
+ function compilePatterns(patterns: readonly PatternConfig[]): CompiledPattern[] {
108
+ return patterns.map(pattern => {
109
+ const flags = [...new Set(`g${pattern.flags ?? ''}`)].join('');
110
+ return {
111
+ keyRegex: new RegExp(pattern.regex, flags),
112
+ ambiguousRegex: pattern.ambiguousRegex
113
+ ? new RegExp(pattern.ambiguousRegex, flags)
114
+ : undefined,
115
+ };
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Scans the source tree with the app-configured i18n usage regex patterns and
121
+ * builds the source-of-truth translation template: discovered keys with
122
+ * default text and `file:line` locations, ambiguous dynamic usages, and
123
+ * conflicting duplicate keys (FR-024…FR-026, FR-031).
124
+ */
125
+ export function scanSource(
126
+ rootDir: string,
127
+ sourceGlobs: readonly string[],
128
+ patterns: readonly PatternConfig[],
129
+ ): TranslationTemplate {
130
+ const compiled = compilePatterns(patterns);
131
+ const files = listSourceFiles(rootDir, sourceGlobs);
132
+ const keys = new Map<string, TemplateKey>();
133
+ const valuesByKey = new Map<string, { text: string; location: SourceLocation }[]>();
134
+ const ambiguous: AmbiguousUsage[] = [];
135
+
136
+ for (const file of files) {
137
+ const text = fs.readFileSync(path.join(rootDir, file), 'utf8');
138
+ const matchedSpans: [number, number][] = [];
139
+
140
+ for (const pattern of compiled) {
141
+ pattern.keyRegex.lastIndex = 0;
142
+ for (const match of text.matchAll(pattern.keyRegex)) {
143
+ const key = match.groups?.key;
144
+ if (!key) {
145
+ continue;
146
+ }
147
+ const startIndex = match.index ?? 0;
148
+ matchedSpans.push([startIndex, startIndex + match[0].length]);
149
+ const location: SourceLocation = {
150
+ file,
151
+ line: lineOfIndex(text, startIndex),
152
+ };
153
+ const existing = keys.get(key);
154
+ if (existing) {
155
+ existing.locations.push(location);
156
+ } else {
157
+ keys.set(key, { key, locations: [location] });
158
+ }
159
+ const defaultText = match.groups?.defaultText;
160
+ if (defaultText !== undefined) {
161
+ const values = valuesByKey.get(key) ?? [];
162
+ values.push({ text: defaultText, location });
163
+ valuesByKey.set(key, values);
164
+ }
165
+ }
166
+ }
167
+
168
+ for (const pattern of compiled) {
169
+ if (!pattern.ambiguousRegex) {
170
+ continue;
171
+ }
172
+ pattern.ambiguousRegex.lastIndex = 0;
173
+ for (const match of text.matchAll(pattern.ambiguousRegex)) {
174
+ const start = match.index ?? 0;
175
+ const overlapsKeyMatch = matchedSpans.some(
176
+ ([from, to]) => start >= from && start < to,
177
+ );
178
+ if (overlapsKeyMatch) {
179
+ continue;
180
+ }
181
+ const lineNumber = lineOfIndex(text, start);
182
+ ambiguous.push({
183
+ file,
184
+ line: lineNumber,
185
+ snippet: text.split('\n')[lineNumber - 1]?.trim() ?? match[0],
186
+ });
187
+ }
188
+ }
189
+ }
190
+
191
+ const conflicts: KeyConflict[] = [];
192
+ for (const [key, values] of valuesByKey) {
193
+ const distinct = [...new Set(values.map(value => value.text))];
194
+ if (distinct.length > 1) {
195
+ conflicts.push({ key, values });
196
+ } else {
197
+ const templateKey = keys.get(key);
198
+ if (templateKey) {
199
+ templateKey.defaultText = values[0].text;
200
+ }
201
+ }
202
+ }
203
+
204
+ return {
205
+ keys: [...keys.values()].sort((a, b) => a.key.localeCompare(b.key)),
206
+ ambiguous,
207
+ conflicts: conflicts.sort((a, b) => a.key.localeCompare(b.key)),
208
+ scannedFiles: files.length,
209
+ };
210
+ }