locai 2.5.1 → 2.6.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.
package/README.md CHANGED
@@ -59,6 +59,7 @@ Contributing to Locai? Learn how to build, test, and run the project locally.
59
59
  - **Framework Agnostic**: Works with Next.js, Flutter, Django, and more.
60
60
  - **Context Aware**: Knows the difference between "Home" (House) and "Home" (Page).
61
61
  - **Cost Efficient**: Smart caching and incremental updates save ~90% of AI costs.
62
+ - **Flexible Structure**: Supports both flat (`en.json`) and nested (`en/translation.json`) directory structures automatically.
62
63
  - **Developer Friendly**: TypeScript config, type safety, and great error messages.
63
64
 
64
65
  ## 🤝 Community & Contributing
@@ -20,7 +20,7 @@ class AnthropicProvider extends BaseProvider {
20
20
  "x-api-key": this.getApiKey(),
21
21
  "anthropic-version": "2023-06-01",
22
22
  },
23
- timeout: 30000,
23
+ timeout: config.timeoutMs || 60000,
24
24
  maxRedirects: 0,
25
25
  validateStatus: (status) => status < 500,
26
26
  });
@@ -20,7 +20,7 @@ class DashScopeProvider extends BaseProvider {
20
20
  ...this.commonHeaders,
21
21
  Authorization: `Bearer ${this.getApiKey()}`,
22
22
  },
23
- timeout: 30000,
23
+ timeout: config.timeoutMs || 60000,
24
24
  maxRedirects: 0,
25
25
  validateStatus: (status) => status < 500,
26
26
  });
@@ -31,7 +31,7 @@ class DashScopeProvider extends BaseProvider {
31
31
  ...this.commonHeaders,
32
32
  Authorization: `Bearer ${this.getApiKey()}`,
33
33
  },
34
- timeout: 30000,
34
+ timeout: config.timeoutMs || 60000,
35
35
  maxRedirects: 0,
36
36
  validateStatus: (status) => status < 500,
37
37
  });
@@ -19,7 +19,7 @@ class DeepSeekProvider extends BaseProvider {
19
19
  ...this.commonHeaders,
20
20
  Authorization: `Bearer ${this.getApiKey()}`,
21
21
  },
22
- timeout: 45000,
22
+ timeout: config.timeoutMs || 60000,
23
23
  maxRedirects: 0,
24
24
  validateStatus: (status) => status < 500,
25
25
  });
@@ -18,7 +18,7 @@ class GeminiProvider extends BaseProvider {
18
18
  headers: {
19
19
  ...this.commonHeaders,
20
20
  },
21
- timeout: 30000,
21
+ timeout: config.timeoutMs || 60000,
22
22
  maxRedirects: 0,
23
23
  validateStatus: (status) => status < 500,
24
24
  });
@@ -19,7 +19,7 @@ class OpenAIProvider extends BaseProvider {
19
19
  ...this.commonHeaders,
20
20
  Authorization: `Bearer ${this.getApiKey()}`,
21
21
  },
22
- timeout: 30000,
22
+ timeout: config.timeoutMs || 60000,
23
23
  maxRedirects: 0,
24
24
  validateStatus: (status) => status < 500,
25
25
  });
@@ -19,7 +19,7 @@ class XAIProvider extends BaseProvider {
19
19
  ...this.commonHeaders,
20
20
  Authorization: `Bearer ${this.getApiKey()}`,
21
21
  },
22
- timeout: 30000,
22
+ timeout: config.timeoutMs || 60000,
23
23
  maxRedirects: 0,
24
24
  validateStatus: (status) => status < 500,
25
25
  });
@@ -43,8 +43,24 @@ export class LanguageProcessor {
43
43
  let savedMessage = undefined;
44
44
  statisticsManager.initLanguageStats(safeTargetLang);
45
45
  const sourceDir = path.dirname(sourceFile);
46
- const safeTargetFilename = `${safeTargetLang}.json`;
47
- const targetPath = InputValidator.createSafeFilePath(sourceDir, safeTargetFilename);
46
+ // Detect structure based on source file name
47
+ const isNestedStructure = path.basename(sourceFile) === "translation.json";
48
+ let targetPath;
49
+ if (isNestedStructure) {
50
+ // Nested: locales/tr/translation.json
51
+ // We assume sourceDir ends with the language code (e.g. .../en)
52
+ // So we need to go up one level and then into target lang
53
+ const localesRoot = path.dirname(sourceDir);
54
+ const targetDir = path.join(localesRoot, safeTargetLang);
55
+ // Ensure target directory exists for nested structure
56
+ await FileManager.ensureDir(targetDir);
57
+ targetPath = path.join(targetDir, "translation.json");
58
+ }
59
+ else {
60
+ // Flat: locales/tr.json
61
+ const safeTargetFilename = `${safeTargetLang}.json`;
62
+ targetPath = InputValidator.createSafeFilePath(sourceDir, safeTargetFilename);
63
+ }
48
64
  let targetContent = {};
49
65
  try {
50
66
  targetContent = await FileManager.readJSON(targetPath);
@@ -248,16 +248,28 @@ class TranslationService {
248
248
  */
249
249
  async removeDeletedKeysFromTargets(sourceFile, deletedKeys, options) {
250
250
  const sourceDir = path.dirname(sourceFile);
251
+ // Detect structure based on source file name
252
+ const isNestedStructure = path.basename(sourceFile) === "translation.json";
251
253
  let totalRemoved = 0;
252
254
  let filesProcessed = 0;
253
255
  for (const targetLang of options.targets || []) {
254
256
  try {
255
- const safeTargetFilename = `${targetLang}.json`;
256
- const targetPath = InputValidator.createSafeFilePath(sourceDir, safeTargetFilename);
257
+ let targetPath;
258
+ if (isNestedStructure) {
259
+ // Nested: locales/tr/translation.json
260
+ const localesRoot = path.dirname(sourceDir);
261
+ const targetDir = path.join(localesRoot, targetLang);
262
+ targetPath = path.join(targetDir, "translation.json");
263
+ }
264
+ else {
265
+ // Flat: locales/tr.json
266
+ const safeTargetFilename = `${targetLang}.json`;
267
+ targetPath = InputValidator.createSafeFilePath(sourceDir, safeTargetFilename);
268
+ }
257
269
  // Check if target file exists
258
270
  const fileExists = await FileManager.exists(targetPath);
259
271
  if (!fileExists) {
260
- await uiManager.log(` Skipping ${targetLang}.json (file doesn't exist)`);
272
+ await uiManager.log(` Skipping ${targetLang} (file doesn't exist)`);
261
273
  continue;
262
274
  }
263
275
  // Read target file
@@ -279,12 +291,12 @@ class TranslationService {
279
291
  await uiManager.log(` ${targetLang}.json: Removed ${removedFromThisFile} keys`);
280
292
  }
281
293
  else {
282
- await uiManager.log(` No keys to remove from ${targetLang}.json`);
294
+ await uiManager.log(` No keys to remove from ${targetLang}`);
283
295
  }
284
296
  filesProcessed++;
285
297
  }
286
298
  catch (error) {
287
- await uiManager.log(` \u274c Error processing ${targetLang}.json: ${error.message}`);
299
+ await uiManager.log(` \u274c Error processing ${targetLang}: ${error.message}`);
288
300
  }
289
301
  }
290
302
  await uiManager.log(`Cleanup Summary: Removed ${totalRemoved} keys from ${filesProcessed} files\n`);
@@ -300,14 +312,23 @@ class TranslationService {
300
312
  const sourceContent = await FileManager.readJSON(file);
301
313
  const flattenedSource = ObjectTransformer.flatten(sourceContent);
302
314
  const orchestrator = new Orchestrator(options);
315
+ const sourceDir = path.dirname(file);
316
+ const isNestedStructure = path.basename(file) === "translation.json";
303
317
  const languageResults = await Promise.all((options.targets || []).map(async (targetLang) => {
304
318
  try {
305
- const targetPath = path.join(path.dirname(file), `${targetLang}.json`);
319
+ let targetPath;
320
+ if (isNestedStructure) {
321
+ const localesRoot = path.dirname(sourceDir);
322
+ targetPath = path.join(localesRoot, targetLang, "translation.json");
323
+ }
324
+ else {
325
+ targetPath = path.join(sourceDir, `${targetLang}.json`);
326
+ }
306
327
  const targetContent = await FileManager.readJSON(targetPath);
307
328
  return { targetLang, targetPath, content: targetContent, success: true };
308
329
  }
309
330
  catch (err) {
310
- await uiManager.log(`Could not read ${targetLang}.json: ${err.message}`);
331
+ await uiManager.log(`Could not read ${targetLang}: ${err.message}`);
311
332
  return {
312
333
  targetLang,
313
334
  targetPath: "",
@@ -40,6 +40,12 @@ declare class FileManager {
40
40
  * @returns Current configuration
41
41
  */
42
42
  static getConfig(): FileOptions;
43
+ /**
44
+ * Find locale files in the specified directory
45
+ * @param localesDir - Directory containing locale files
46
+ * @param sourceLang - Source language code
47
+ * @returns Array of file paths
48
+ */
43
49
  /**
44
50
  * Find locale files in the specified directory
45
51
  * @param localesDir - Directory containing locale files
@@ -37,6 +37,12 @@ class FileManager {
37
37
  static getConfig() {
38
38
  return this.options || this.defaultOptions;
39
39
  }
40
+ /**
41
+ * Find locale files in the specified directory
42
+ * @param localesDir - Directory containing locale files
43
+ * @param sourceLang - Source language code
44
+ * @returns Array of file paths
45
+ */
40
46
  /**
41
47
  * Find locale files in the specified directory
42
48
  * @param localesDir - Directory containing locale files
@@ -45,17 +51,45 @@ class FileManager {
45
51
  */
46
52
  static async findLocaleFiles(localesDir, sourceLang) {
47
53
  try {
48
- // Find any file with the source language name (ignoring extension)
54
+ // Priority 1: Check for nested structure: localesDir/sourceLang/translation.json
55
+ // This is the standard i18next-fs-backend pattern
56
+ const nestedPath = path.join(localesDir, sourceLang, "translation.json");
57
+ if (await this.exists(nestedPath)) {
58
+ return [nestedPath];
59
+ }
60
+ // Priority 2: Check for flat structure: localesDir/sourceLang.json
61
+ const flatPath = path.join(localesDir, `${sourceLang}.json`);
62
+ if (await this.exists(flatPath)) {
63
+ return [flatPath];
64
+ }
65
+ // Priority 3: Scan directory for any file starting with sourceLang
66
+ // This matches "en-US.json" or "en_GB.json" if sourceLang is "en"
49
67
  const files = await fs.readdir(localesDir);
50
68
  const sourceFiles = files
51
- .filter((file) => file.startsWith(`${sourceLang}.`))
69
+ .filter((file) => file.startsWith(`${sourceLang}.`) || file === sourceLang)
52
70
  .map((file) => path.join(localesDir, file));
53
71
  if (sourceFiles.length === 0) {
54
72
  throw new Error(`Source language file not found for '${sourceLang}' in ${localesDir}`);
55
73
  }
56
- // Return the first match, or maybe prioritize JSON?
57
- // For now, return all matches, but usually there's only one.
58
- return sourceFiles;
74
+ // Filter specifically for files or directories that look like the locale
75
+ const validFiles = [];
76
+ for (const file of sourceFiles) {
77
+ const stats = await fs.stat(file);
78
+ if (stats.isDirectory()) {
79
+ // If it's a directory, check for translation.json inside
80
+ const nestedTrial = path.join(file, "translation.json");
81
+ if (await this.exists(nestedTrial)) {
82
+ validFiles.push(nestedTrial);
83
+ }
84
+ }
85
+ else {
86
+ validFiles.push(file);
87
+ }
88
+ }
89
+ if (validFiles.length > 0) {
90
+ return validFiles;
91
+ }
92
+ throw new Error(`Source language file not found for '${sourceLang}' in ${localesDir}`);
59
93
  }
60
94
  catch (err) {
61
95
  if (err.code === "ENOENT") {
@@ -398,7 +398,7 @@ class RateLimiter {
398
398
  }
399
399
  const config = {
400
400
  queueStrategy: process.env.QUEUE_STRATEGY || "priority",
401
- queueTimeout: parseInt(process.env.QUEUE_TIMEOUT || "30000"),
401
+ queueTimeout: parseInt(process.env.QUEUE_TIMEOUT || "60000"),
402
402
  adaptiveThrottling: process.env.ADAPTIVE_THROTTLING !== "false",
403
403
  };
404
404
  export default new RateLimiter(config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locai",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "main": "dist/core/orchestrator.js",
6
6
  "types": "dist/core/orchestrator.d.ts",
@@ -75,32 +75,32 @@
75
75
  "axios": "1.13.2",
76
76
  "c12": "^3.3.3",
77
77
  "commander": "^14.0.2",
78
- "dot-properties": "^1.1.0",
78
+ "dot-properties": "^1.1.1",
79
79
  "dotenv": "^17.2.3",
80
80
  "fast-xml-parser": "^5.3.3",
81
- "gettext-parser": "^8.0.0",
81
+ "gettext-parser": "^9.0.0",
82
82
  "jiti": "^2.6.1",
83
83
  "js-yaml": "^4.1.1",
84
84
  "lru-cache": "^11.2.4",
85
85
  "p-limit": "^7.2.0",
86
86
  "prompts": "^2.4.2",
87
87
  "ts-morph": "^27.0.2",
88
- "vectra": "^0.11.1"
88
+ "vectra": "^0.12.3"
89
89
  },
90
90
  "devDependencies": {
91
91
  "@eslint/js": "^9.39.2",
92
92
  "@types/js-yaml": "^4.0.9",
93
- "@types/node": "^25.0.3",
93
+ "@types/node": "^25.0.7",
94
94
  "@types/prompts": "^2.4.9",
95
- "@vitest/coverage-v8": "^4.0.16",
95
+ "@vitest/coverage-v8": "^4.0.17",
96
96
  "eslint": "^9.39.2",
97
97
  "eslint-config-prettier": "^10.1.8",
98
- "globals": "^16.5.0",
98
+ "globals": "^17.0.0",
99
99
  "prettier": "^3.7.4",
100
100
  "std-mocks": "^2.0.0",
101
101
  "tsx": "^4.21.0",
102
102
  "typescript": "^5.9.3",
103
- "vitest": "^4.0.16"
103
+ "vitest": "^4.0.17"
104
104
  },
105
105
  "files": [
106
106
  "dist/"