@localheroai/cli 0.0.2 → 0.0.5

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,139 +1,381 @@
1
+ import chalk from 'chalk';
1
2
  import { glob } from 'glob';
2
3
  import { readFile } from 'fs/promises';
3
4
  import path from 'path';
4
5
  import yaml from 'yaml';
6
+ import { promises as fs } from 'fs';
5
7
 
6
- function parseFile(content, format) {
7
- try {
8
- if (format === 'json') {
9
- return JSON.parse(content);
10
- }
11
- return yaml.parse(content);
12
- } catch (error) {
13
- throw new Error(`Failed to parse ${format} file: ${error.message}`);
8
+ export function parseFile(content, format, filePath = '') {
9
+ try {
10
+ if (format === 'json') {
11
+ try {
12
+ return JSON.parse(content);
13
+ } catch (jsonError) {
14
+ const errorInfo = jsonError.message.match(/at position (\d+)/)
15
+ ? jsonError.message
16
+ : `${jsonError.message} (check for missing commas, quotes, or brackets)`;
17
+ throw new Error(errorInfo);
18
+ }
19
+ }
20
+ return yaml.parse(content);
21
+ } catch (error) {
22
+ const location = filePath ? ` in ${filePath}` : '';
23
+ throw new Error(`Failed to parse ${format} file${location}: ${error.message}`);
24
+ }
25
+ }
26
+
27
+ export function extractLocaleFromPath(filePath, localeRegex, knownLocales = []) {
28
+ if (knownLocales && knownLocales.length > 0) {
29
+ const basename = path.basename(filePath, path.extname(filePath));
30
+ const foundLocaleInFilename = knownLocales.find(locale =>
31
+ locale && basename.toLowerCase() === locale.toLowerCase()
32
+ );
33
+ if (foundLocaleInFilename) {
34
+ return foundLocaleInFilename.toLowerCase();
35
+ }
36
+
37
+ // Then try to match in the path
38
+ const pathParts = filePath.toLowerCase().split(path.sep);
39
+ const foundLocaleInPath = knownLocales.find(locale =>
40
+ locale && pathParts.includes(locale.toLowerCase())
41
+ );
42
+ if (foundLocaleInPath) {
43
+ return foundLocaleInPath.toLowerCase();
44
+ }
45
+ }
46
+
47
+ const dirName = path.basename(path.dirname(filePath));
48
+ if (dirName && isValidLocale(dirName)) {
49
+ return dirName.toLowerCase();
50
+ }
51
+
52
+ if (localeRegex) {
53
+ const filename = path.basename(filePath);
54
+ const regexPattern = new RegExp(localeRegex);
55
+ const regexMatch = filename.match(regexPattern);
56
+ if (regexMatch && regexMatch[1]) {
57
+ const locale = regexMatch[1].toLowerCase();
58
+ if (isValidLocale(locale)) {
59
+ return locale;
60
+ }
61
+ }
62
+ }
63
+
64
+ throw new Error(`Could not extract locale from path: ${filePath}`);
65
+ }
66
+
67
+ export function isValidLocale(locale) {
68
+ // Basic validation for language code (2 letters) or language-region code (e.g., en-US)
69
+ return /^[a-z]{2}(?:-[A-Z]{2})?$/.test(locale);
70
+ }
71
+
72
+ export function flattenTranslations(obj, parentKey = '') {
73
+ const result = {};
74
+
75
+ for (const [key, value] of Object.entries(obj)) {
76
+ const newKey = parentKey ? `${parentKey}.${key}` : key;
77
+
78
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
79
+ Object.assign(result, flattenTranslations(value, newKey));
80
+ } else {
81
+ result[newKey] = value;
14
82
  }
83
+ }
84
+
85
+ return result;
15
86
  }
16
87
 
17
- function extractKeysWithContext(obj, parentKeys = [], result = {}) {
18
- for (const [key, value] of Object.entries(obj)) {
19
- const currentPath = [...parentKeys, key];
20
- const fullKey = currentPath.join('.');
21
-
22
- if (typeof value === 'object' && value !== null) {
23
- extractKeysWithContext(value, currentPath, result);
24
- } else {
25
- const siblings = {};
26
- const parentObj = parentKeys.length ?
27
- parentKeys.reduce((acc, key) => acc[key], obj) :
28
- obj;
29
-
30
- Object.entries(parentObj)
31
- .filter(([k, v]) => k !== key && typeof v !== 'object')
32
- .forEach(([k, v]) => {
33
- siblings[`${parentKeys.join('.')}.${k}`] = v;
34
- });
35
-
36
- result[fullKey] = {
37
- value: value,
38
- context: {
39
- parent_keys: parentKeys,
40
- sibling_keys: siblings
41
- }
42
- };
88
+ function detectJsonFormat(obj) {
89
+ let hasNested = false;
90
+ let hasDotNotation = false;
91
+
92
+ for (const [key, value] of Object.entries(obj)) {
93
+ if (key.includes('.')) {
94
+ hasDotNotation = true;
95
+ }
96
+
97
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
98
+ hasNested = true;
99
+
100
+ for (const [, nestedValue] of Object.entries(value)) {
101
+ if (nestedValue && typeof nestedValue === 'object' && !Array.isArray(nestedValue)) {
102
+ return 'nested';
43
103
  }
104
+ }
44
105
  }
45
- return result;
106
+ }
107
+
108
+ if (hasNested && hasDotNotation) {
109
+ return 'mixed';
110
+ } else if (hasNested) {
111
+ return 'nested';
112
+ } else if (hasDotNotation) {
113
+ return 'flat';
114
+ }
115
+
116
+ return 'flat';
46
117
  }
47
118
 
48
- function extractLocaleFromPath(filePath, localeRegex) {
49
- const match = path.basename(filePath).match(new RegExp(localeRegex));
50
- if (!match || !match[1]) {
51
- throw new Error(`Could not extract locale from filename: ${filePath}`);
119
+ function unflattenTranslations(flatObj) {
120
+ const result = {};
121
+
122
+ for (const [key, value] of Object.entries(flatObj)) {
123
+ const keys = key.split('.');
124
+ let current = result;
125
+
126
+ for (let i = 0; i < keys.length - 1; i++) {
127
+ const k = keys[i];
128
+ current[k] = current[k] || {};
129
+ current = current[k];
52
130
  }
53
- return match[1].toLowerCase();
131
+
132
+ current[keys[keys.length - 1]] = value;
133
+ }
134
+
135
+ return result;
54
136
  }
55
137
 
56
- function flattenTranslations(obj, parentKey = '') {
57
- const result = {};
138
+ function preserveJsonStructure(originalObj, newTranslations, format) {
139
+ if (format === 'flat') {
140
+ return { ...originalObj, ...newTranslations };
141
+ }
58
142
 
59
- for (const [key, value] of Object.entries(obj)) {
60
- const newKey = parentKey ? `${parentKey}.${key}` : key;
143
+ if (format === 'nested') {
144
+ const merged = { ...originalObj };
145
+ const unflattenedNew = unflattenTranslations(newTranslations);
146
+ return deepMerge(merged, unflattenedNew);
147
+ }
148
+ const result = { ...originalObj };
61
149
 
62
- if (value && typeof value === 'object' && !Array.isArray(value)) {
63
- Object.assign(result, flattenTranslations(value, newKey));
64
- } else {
65
- result[newKey] = value;
150
+ for (const [key, value] of Object.entries(newTranslations)) {
151
+ if (key.includes('.')) {
152
+ const keys = key.split('.');
153
+ if (originalObj[key] !== undefined) {
154
+ result[key] = value;
155
+ continue;
156
+ }
157
+
158
+ let current = result;
159
+
160
+ for (let i = 0; i < keys.length - 1; i++) {
161
+ const k = keys[i];
162
+ current[k] = current[k] || {};
163
+ if (typeof current[k] !== 'object' || Array.isArray(current[k])) {
164
+ current[k] = {};
66
165
  }
166
+
167
+ current = current[k];
168
+ }
169
+
170
+ current[keys[keys.length - 1]] = value;
171
+ } else {
172
+ result[key] = value;
173
+ }
174
+ }
175
+
176
+ return result;
177
+ }
178
+
179
+ function deepMerge(target, source) {
180
+ const result = { ...target };
181
+
182
+ for (const [key, value] of Object.entries(source)) {
183
+ if (value && typeof value === 'object' &&
184
+ result[key] && typeof result[key] === 'object' &&
185
+ !Array.isArray(value) && !Array.isArray(result[key])) {
186
+ result[key] = deepMerge(result[key], value);
187
+ } else {
188
+ result[key] = value;
67
189
  }
190
+ }
68
191
 
69
- return result;
192
+ return result;
70
193
  }
71
194
 
72
- export async function findTranslationFiles(translationPath, localeRegex) {
73
- const pattern = path.join(translationPath, '**/*.{yml,yaml,json}');
74
- const files = await glob(pattern, { absolute: true });
75
-
76
- return Promise.all(files.map(async (filePath) => {
77
- try {
78
- const locale = extractLocaleFromPath(filePath, localeRegex);
79
- const content = await readFile(filePath, 'utf8');
80
- const format = path.extname(filePath).slice(1);
81
- const parsedContent = parseFile(content, format);
82
-
83
- // Extract translations, handling both nested and flat structures
84
- let translations;
85
- if (parsedContent[locale]) {
86
- // Nested under locale key (common in Rails/YAML)
87
- translations = flattenTranslations(parsedContent[locale]);
88
- } else {
89
- // Flat structure (common in JSON)
90
- translations = flattenTranslations(parsedContent);
91
- }
92
-
93
- // Extract keys with context
94
- const keys = {};
95
- for (const [key, value] of Object.entries(translations)) {
96
- const parts = key.split('.');
97
- const parentKeys = parts.slice(0, -1);
98
-
99
- // Get sibling translations
100
- const siblings = {};
101
- Object.entries(translations)
102
- .filter(([k, v]) => {
103
- const kParts = k.split('.');
104
- return k !== key &&
105
- kParts.length === parts.length &&
106
- kParts.slice(0, -1).join('.') === parentKeys.join('.');
107
- })
108
- .forEach(([k, v]) => {
109
- siblings[k] = v;
110
- });
111
-
112
- keys[key] = {
113
- value,
114
- context: {
115
- parent_keys: parentKeys,
116
- sibling_keys: siblings
117
- }
118
- };
119
- }
120
-
121
- const formattedContent = {
122
- keys,
123
- metadata: {
124
- source_language: locale
125
- }
126
- };
127
-
128
- return {
129
- path: filePath,
130
- locale,
131
- format,
132
- content: Buffer.from(JSON.stringify(formattedContent)).toString('base64')
133
- };
134
- } catch (error) {
135
- console.error(chalk.yellow(`⚠️ Skipping ${filePath}: ${error.message}`));
136
- return null;
195
+ function extractNamespace(filePath) {
196
+ const fileName = path.basename(filePath, path.extname(filePath));
197
+ const dirName = path.basename(path.dirname(filePath));
198
+
199
+ // Pattern 1: /path/to/en/common.json -> namespace = common
200
+ if (/^[a-z]{2}(-[A-Z]{2})?$/.test(dirName)) {
201
+ return fileName;
202
+ }
203
+
204
+ // Pattern 2: /path/to/messages.en.json -> namespace = messages
205
+ const dotMatch = fileName.match(/^(.+)\.([a-z]{2}(?:-[A-Z]{2})?)$/);
206
+ if (dotMatch) {
207
+ return dotMatch[1];
208
+ }
209
+
210
+ // Pattern 3: /path/to/common-en.json -> namespace = common
211
+ const dashMatch = fileName.match(/^(.+)-([a-z]{2}(?:-[A-Z]{2})?)$/);
212
+ if (dashMatch) {
213
+ return dashMatch[1];
214
+ }
215
+
216
+ return '';
217
+ }
218
+
219
+ export async function findTranslationFiles(config, options = {}) {
220
+ const {
221
+ parseContent = true,
222
+ includeContent = true,
223
+ extractKeys = true,
224
+ basePath = process.cwd(),
225
+ sourceLocale = config.sourceLocale,
226
+ targetLocales = config.outputLocales || [],
227
+ includeNamespace = false,
228
+ verbose = false,
229
+ returnFullResult = false
230
+ } = options;
231
+ const knownLocales = [sourceLocale, ...targetLocales];
232
+ const { translationFiles } = config;
233
+ const {
234
+ paths = [],
235
+ pattern = '**/*.{json,yml,yaml}',
236
+ ignore = [],
237
+ localeRegex = '.*?([a-z]{2}(?:-[A-Z]{2})?)\\.(?:yml|yaml|json)$'
238
+ } = translationFiles || {};
239
+
240
+ const processedFiles = [];
241
+
242
+ for (const translationPath of paths) {
243
+ const fullPath = path.join(basePath, translationPath);
244
+ const globPattern = path.join(fullPath, pattern);
245
+
246
+ if (verbose) {
247
+ console.log(chalk.blue(`Searching for translation files in ${globPattern}`));
248
+ }
249
+
250
+ let files;
251
+ try {
252
+ files = await glob(globPattern, {
253
+ ignore: ignore.map(i => path.join(basePath, i)),
254
+ absolute: false
255
+ });
256
+
257
+ if (verbose) {
258
+ console.log(chalk.blue(`Found ${files.length} files in ${translationPath}`));
259
+ }
260
+ } catch (error) {
261
+ if (verbose) {
262
+ console.error(chalk.red(`Error searching for files in ${translationPath}: ${error.message}`));
263
+ }
264
+ files = [];
265
+ }
266
+
267
+ for (const file of files) {
268
+ try {
269
+ const filePath = file;
270
+ const format = path.extname(file).slice(1);
271
+ const locale = extractLocaleFromPath(file, localeRegex, knownLocales);
272
+
273
+ const result = {
274
+ path: filePath,
275
+ format,
276
+ locale
277
+ };
278
+
279
+ if (parseContent) {
280
+ const content = await readFile(filePath, 'utf8');
281
+ const parsedContent = parseFile(content, format, filePath);
282
+
283
+ if (includeContent) {
284
+ result.content = Buffer.from(content).toString('base64');
285
+ }
286
+
287
+ if (extractKeys) {
288
+ const hasLanguageWrapper = parsedContent[locale] !== undefined;
289
+ result.hasLanguageWrapper = hasLanguageWrapper;
290
+ const translationData = hasLanguageWrapper ? parsedContent[locale] : parsedContent;
291
+ result.translations = translationData;
292
+ result.keys = flattenTranslations(translationData);
293
+ }
137
294
  }
138
- })).then(results => results.filter(Boolean));
139
- }
295
+
296
+ if (includeNamespace) {
297
+ result.namespace = extractNamespace(filePath);
298
+ }
299
+
300
+ processedFiles.push(result);
301
+ } catch (error) {
302
+ if (error.message.includes('Failed to parse') ||
303
+ error.message.includes('JSON') ||
304
+ error.message.includes('Unexpected token') ||
305
+ error.message.includes('Missing closing')) {
306
+ console.warn(chalk.yellow(`\nWarning: ${error.message}`));
307
+
308
+ const format = path.extname(file).slice(1);
309
+ if (format === 'json') {
310
+ console.warn(chalk.gray(' Tip: Check for missing commas, quotes, or brackets in your JSON file.'));
311
+ } else if (format === 'yml' || format === 'yaml') {
312
+ console.warn(chalk.gray(' Tip: Check for proper indentation and quote matching in your YAML file.'));
313
+ }
314
+ } else if (verbose) {
315
+ console.warn(chalk.yellow(`Warning: ${error.message}`));
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ if (!returnFullResult) {
322
+ return processedFiles;
323
+ }
324
+
325
+ const allFiles = processedFiles;
326
+ const sourceFiles = allFiles.filter(file => file.locale === sourceLocale);
327
+ const targetFilesByLocale = {};
328
+
329
+ for (const locale of targetLocales) {
330
+ targetFilesByLocale[locale] = allFiles.filter(file => file.locale === locale);
331
+ }
332
+
333
+ return {
334
+ allFiles,
335
+ sourceFiles,
336
+ targetFilesByLocale
337
+ };
338
+ }
339
+
340
+ export {
341
+ unflattenTranslations,
342
+ detectJsonFormat,
343
+ preserveJsonStructure,
344
+ directoryExists,
345
+ findFirstExistingPath,
346
+ getDirectoryContents
347
+ };
348
+
349
+ async function directoryExists(path, fsModule = fs) {
350
+ try {
351
+ const stats = await fsModule.stat(path);
352
+ return stats.isDirectory();
353
+ } catch (error) {
354
+ if (error.code === 'ENOENT') {
355
+ return false;
356
+ }
357
+ throw error;
358
+ }
359
+ }
360
+
361
+ async function findFirstExistingPath(paths, fsModule = fs) {
362
+ for (const path of paths) {
363
+ if (await directoryExists(path, fsModule)) {
364
+ return path;
365
+ }
366
+ }
367
+ return null;
368
+ }
369
+
370
+ async function getDirectoryContents(dir, fsModule = fs) {
371
+ try {
372
+ const files = await fsModule.readdir(dir);
373
+ return {
374
+ files,
375
+ jsonFiles: files.filter(f => f.endsWith('.json')),
376
+ yamlFiles: files.filter(f => f.endsWith('.yml') || f.endsWith('.yaml'))
377
+ };
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
package/src/utils/git.js CHANGED
@@ -1,16 +1,72 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
+ import { promisify } from 'util';
4
+ import { execFile } from 'child_process';
3
5
 
4
- export async function updateGitignore(basePath) {
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ const defaultDeps = {
9
+ fs,
10
+ path,
11
+ exec: execFileAsync,
12
+ };
13
+
14
+ export const gitService = {
15
+ deps: { ...defaultDeps },
16
+
17
+ setDependencies(customDeps = {}) {
18
+ this.deps = { ...defaultDeps, ...customDeps };
19
+ return this;
20
+ },
21
+
22
+ async updateGitignore(basePath) {
23
+ const { fs, path } = this.deps;
5
24
  const gitignorePath = path.join(basePath, '.gitignore');
25
+ let content = '';
26
+
6
27
  try {
7
- const content = await fs.readFile(gitignorePath, 'utf8').catch(() => '');
8
- if (!content.includes('.localhero_key')) {
9
- await fs.appendFile(gitignorePath, '\n.localhero_key\n');
10
- return true;
11
- }
12
- return false;
28
+ content = await fs.readFile(gitignorePath, 'utf8');
13
29
  } catch (error) {
30
+ if (error.code !== 'ENOENT') {
14
31
  return false;
32
+ }
15
33
  }
16
- }
34
+
35
+ if (content.includes('.localhero_key')) {
36
+ return false;
37
+ }
38
+
39
+ try {
40
+ await fs.appendFile(gitignorePath, '\n.localhero_key\n');
41
+ return true;
42
+ } catch (error) {
43
+ if (error.code === 'ENOENT') {
44
+ try {
45
+ await fs.writeFile(gitignorePath, '.localhero_key\n');
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+ return false;
52
+ }
53
+ },
54
+
55
+ async getCurrentBranch() {
56
+ try {
57
+ const { exec } = this.deps;
58
+ const { stdout } = await exec('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
59
+ return stdout.trim();
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+ };
65
+
66
+ export async function updateGitignore(basePath) {
67
+ return gitService.updateGitignore(basePath);
68
+ }
69
+
70
+ export async function getCurrentBranch() {
71
+ return gitService.getCurrentBranch();
72
+ }