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