locai 2.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.
Files changed (72) hide show
  1. package/README.md +928 -0
  2. package/dist/cli.js +718 -0
  3. package/dist/commands/review.js +357 -0
  4. package/dist/commands/translator.js +29 -0
  5. package/dist/config/index.js +52 -0
  6. package/dist/core/adapters/base-adapter.js +1 -0
  7. package/dist/core/adapters/factory.js +44 -0
  8. package/dist/core/adapters/json-adapter.js +20 -0
  9. package/dist/core/adapters/po-adapter.js +50 -0
  10. package/dist/core/adapters/properties-adapter.js +31 -0
  11. package/dist/core/adapters/yaml-adapter.js +24 -0
  12. package/dist/core/context-processor.js +247 -0
  13. package/dist/core/detection/framework-detector.js +56 -0
  14. package/dist/core/detection/structure-detector.js +30 -0
  15. package/dist/core/fallback-provider.js +442 -0
  16. package/dist/core/orchestrator.js +438 -0
  17. package/dist/core/pipeline/Pipeline.js +48 -0
  18. package/dist/core/pipeline/PipelineStep.js +13 -0
  19. package/dist/core/pipeline/context.js +21 -0
  20. package/dist/core/pipeline/steps/CacheReadStep.js +80 -0
  21. package/dist/core/pipeline/steps/CacheWriteStep.js +32 -0
  22. package/dist/core/pipeline/steps/ConfidenceCheckStep.js +67 -0
  23. package/dist/core/pipeline/steps/GlossaryPostStep.js +17 -0
  24. package/dist/core/pipeline/steps/GlossaryPreStep.js +20 -0
  25. package/dist/core/pipeline/steps/InputValidationStep.js +26 -0
  26. package/dist/core/pipeline/steps/QualityCheckStep.js +48 -0
  27. package/dist/core/pipeline/steps/ToneCheckStep.js +58 -0
  28. package/dist/core/pipeline/steps/TranslationStep.js +65 -0
  29. package/dist/core/pipeline/steps/VectorCacheReadStep.js +84 -0
  30. package/dist/core/pipeline/steps/VectorCacheWriteStep.js +42 -0
  31. package/dist/core/pipeline/steps/__tests__/ToneCheckStep.test.js +100 -0
  32. package/dist/core/provider-factory.js +139 -0
  33. package/dist/providers/base-provider.js +230 -0
  34. package/dist/providers/dashscope.js +128 -0
  35. package/dist/providers/deepseek.js +111 -0
  36. package/dist/providers/gemini.js +135 -0
  37. package/dist/providers/openai.js +203 -0
  38. package/dist/providers/xai.js +111 -0
  39. package/dist/services/embedding-provider.js +50 -0
  40. package/dist/services/translation-service.js +609 -0
  41. package/dist/services/vector-store.js +79 -0
  42. package/dist/utils/ai-context-analyzer.js +460 -0
  43. package/dist/utils/confidence-scorer.js +277 -0
  44. package/dist/utils/error-helper.js +386 -0
  45. package/dist/utils/file-manager.js +357 -0
  46. package/dist/utils/glossary-manager.js +194 -0
  47. package/dist/utils/graceful-shutdown.js +118 -0
  48. package/dist/utils/input-validator.js +663 -0
  49. package/dist/utils/logger.js +374 -0
  50. package/dist/utils/object-transformer.js +177 -0
  51. package/dist/utils/progress-tracker.js +277 -0
  52. package/dist/utils/prompt-templates.js +415 -0
  53. package/dist/utils/quality/__tests__/tone-verifier.test.js +91 -0
  54. package/dist/utils/quality/base-checker.js +73 -0
  55. package/dist/utils/quality/code-block-checker.js +214 -0
  56. package/dist/utils/quality/html-tag-checker.js +64 -0
  57. package/dist/utils/quality/index.js +220 -0
  58. package/dist/utils/quality/length-checker.js +140 -0
  59. package/dist/utils/quality/markdown-checker.js +220 -0
  60. package/dist/utils/quality/placeholder-checker.js +142 -0
  61. package/dist/utils/quality/punctuation-checker.js +50 -0
  62. package/dist/utils/quality/quote-balance-checker.js +143 -0
  63. package/dist/utils/quality/special-characters-checker.js +229 -0
  64. package/dist/utils/quality/style-guide-checker.js +192 -0
  65. package/dist/utils/quality/text-sanitizer.js +120 -0
  66. package/dist/utils/quality/tone-verifier.js +91 -0
  67. package/dist/utils/rate-limiter.js +398 -0
  68. package/dist/utils/retry-helper.js +166 -0
  69. package/dist/utils/state-manager.js +162 -0
  70. package/dist/utils/statistics-manager.js +51 -0
  71. package/dist/utils/ui-manager.js +98 -0
  72. package/package.json +80 -0
@@ -0,0 +1,247 @@
1
+ import crypto from "crypto";
2
+ import AIContextAnalyzer from "../utils/ai-context-analyzer.js";
3
+ import { LRUCache } from "lru-cache";
4
+ import { log } from "../utils/logger.js";
5
+ /**
6
+ * Handles context analysis for translation keys.
7
+ * Uses keyword matching and optional AI analysis to determine the best context category.
8
+ */
9
+ class ContextProcessor {
10
+ config;
11
+ keywordCache;
12
+ aiAnalyzer;
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ resultCache;
15
+ /**
16
+ * Create a new ContextProcessor instance.
17
+ * @param config - Configuration object.
18
+ */
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ constructor(config) {
21
+ this.config = config;
22
+ this.keywordCache = new Map();
23
+ this.aiAnalyzer = new AIContextAnalyzer(config);
24
+ // Use analysisOptions.cacheAnalysis setting, default true
25
+ const enableCache = config.analysisOptions?.cacheAnalysis !== false;
26
+ this.resultCache = enableCache
27
+ ? new LRUCache({
28
+ max: 1000,
29
+ ttl: 1000 * 60 * 60 * 24,
30
+ })
31
+ : null;
32
+ this.initializeKeywords();
33
+ }
34
+ /**
35
+ * Initialize keyword matching patterns from configuration.
36
+ */
37
+ initializeKeywords() {
38
+ for (const [category, config] of Object.entries(this.config.categories)) {
39
+ const pattern = config.keywords
40
+ .map((keyword) => keyword.toLowerCase())
41
+ .map((keyword) => keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
42
+ .join("|");
43
+ this.keywordCache.set(category, {
44
+ regex: new RegExp(`\\b(${pattern})\\b`, "gi"),
45
+ weight: config.weight || 1.0,
46
+ prompt: config.prompt,
47
+ });
48
+ }
49
+ }
50
+ /**
51
+ * Perform context analysis on a batch of texts.
52
+ * @param texts - Array of texts to analyze.
53
+ * @returns - Array of context analysis results.
54
+ */
55
+ async analyzeBatch(texts) {
56
+ if (!texts || !Array.isArray(texts) || !this.config.enabled) {
57
+ return texts.map(() => this.getFallback());
58
+ }
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ const results = [];
61
+ const aiTexts = [];
62
+ const aiIndices = [];
63
+ // First pass: cache lookup and keyword matching
64
+ for (let i = 0; i < texts.length; i++) {
65
+ const text = texts[i];
66
+ if (!text) {
67
+ results[i] = this.getFallback();
68
+ continue;
69
+ }
70
+ const cacheKey = this.getCacheKey(text, {});
71
+ if (this.resultCache?.has(cacheKey)) {
72
+ results[i] = this.resultCache.get(cacheKey);
73
+ continue;
74
+ }
75
+ // Try keyword matching first
76
+ const keywordResult = this._performKeywordAnalysis(text);
77
+ // If keyword matching is confident enough, use it
78
+ if (keywordResult.confidence >= 0.8 ||
79
+ !this.config.useAI ||
80
+ (this.config.minTextLength && text.length < this.config.minTextLength)) {
81
+ results[i] = keywordResult;
82
+ if (this.resultCache) {
83
+ this.resultCache.set(cacheKey, keywordResult);
84
+ }
85
+ }
86
+ else {
87
+ // Need AI analysis
88
+ aiTexts.push(text);
89
+ aiIndices.push(i);
90
+ results[i] = keywordResult; // Fallback
91
+ }
92
+ }
93
+ // Batch AI analysis if needed
94
+ if (aiTexts.length > 0 && this.config.useAI) {
95
+ try {
96
+ const aiResults = await this.aiAnalyzer.analyzeBatch(aiTexts, this.config.aiProvider || "openai");
97
+ for (let i = 0; i < aiResults.length; i++) {
98
+ const originalIndex = aiIndices[i];
99
+ const aiResult = aiResults[i];
100
+ if (aiResult) {
101
+ const processedResult = {
102
+ category: aiResult.category,
103
+ confidence: aiResult.confidence,
104
+ prompt: aiResult.prompt,
105
+ matches: aiResult.keywords?.length || 0,
106
+ aiAnalyzed: true,
107
+ keywords: aiResult.keywords || [],
108
+ };
109
+ results[originalIndex] = processedResult;
110
+ if (this.resultCache) {
111
+ const cacheKey = this.getCacheKey(aiTexts[i], {});
112
+ this.resultCache.set(cacheKey, processedResult);
113
+ }
114
+ }
115
+ }
116
+ // Log batch AI analysis summary
117
+ const validAiResults = aiResults.filter((r) => r);
118
+ if (validAiResults.length > 0) {
119
+ log(`Batch AI Context Analysis: ${validAiResults.length}/${aiTexts.length} analyzed`, true);
120
+ }
121
+ }
122
+ catch (error) {
123
+ console.error("Batch AI context analysis failed:", error.message);
124
+ }
125
+ }
126
+ return results;
127
+ }
128
+ /**
129
+ * Perform context analysis on a single text.
130
+ * @param text - Text to analyze.
131
+ * @returns - Context analysis result.
132
+ */
133
+ async analyze(text) {
134
+ const results = await this.analyzeBatch([text]);
135
+ return results[0];
136
+ }
137
+ /**
138
+ * Perform keyword-based analysis on text.
139
+ * @param text - Text to analyze.
140
+ * @returns - Keyword analysis result.
141
+ */
142
+ _performKeywordAnalysis(text) {
143
+ const lowerText = text.toLowerCase();
144
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
145
+ const results = new Map();
146
+ let totalScore = 0;
147
+ const isShortText = text.length < 500;
148
+ for (const [category, config] of this.keywordCache.entries()) {
149
+ let matches;
150
+ if (isShortText) {
151
+ matches = lowerText.match(config.regex) || [];
152
+ }
153
+ else {
154
+ matches = [];
155
+ const keywords = this.config.categories[category].keywords;
156
+ for (const keyword of keywords) {
157
+ const keywordLower = keyword.toLowerCase();
158
+ let pos = lowerText.indexOf(keywordLower);
159
+ while (pos !== -1) {
160
+ const prevChar = lowerText[pos - 1];
161
+ const nextChar = lowerText[pos + keywordLower.length];
162
+ const isPrevBoundary = !prevChar || !/[a-z0-9_]/i.test(prevChar);
163
+ const isNextBoundary = !nextChar || !/[a-z0-9_]/i.test(nextChar);
164
+ if (isPrevBoundary && isNextBoundary) {
165
+ matches.push(keyword);
166
+ }
167
+ pos = lowerText.indexOf(keywordLower, pos + 1);
168
+ }
169
+ }
170
+ }
171
+ if (matches.length >= this.config.detection.threshold) {
172
+ const score = matches.length * config.weight;
173
+ results.set(category, {
174
+ score,
175
+ matches: matches.length,
176
+ prompt: config.prompt,
177
+ });
178
+ totalScore += score;
179
+ }
180
+ }
181
+ return this.getBestMatch(results, totalScore);
182
+ }
183
+ /**
184
+ * Determine the best category match from analysis results.
185
+ */
186
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
187
+ getBestMatch(results, totalScore) {
188
+ if (totalScore === 0)
189
+ return this.getFallback();
190
+ const bestMatches = Array.from(results.entries())
191
+ .map(([category, data]) => ({
192
+ category,
193
+ confidence: data.score / totalScore,
194
+ prompt: data.prompt,
195
+ matches: data.matches,
196
+ }))
197
+ .filter((match) => match.confidence >= this.config.detection.minConfidence)
198
+ .sort((a, b) => b.confidence - a.confidence);
199
+ return bestMatches[0] || this.getFallback();
200
+ }
201
+ /**
202
+ * Get fallback analysis result.
203
+ */
204
+ getFallback() {
205
+ return {
206
+ category: this.config.fallback.category,
207
+ confidence: 1.0,
208
+ prompt: this.config.fallback.prompt,
209
+ matches: 0,
210
+ };
211
+ }
212
+ /**
213
+ * Generate a collision-resistant cache key for text and context.
214
+ */
215
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
216
+ getCacheKey(text, context) {
217
+ let keyContent;
218
+ if (text.length <= 200) {
219
+ // Short text: use as-is
220
+ keyContent = text;
221
+ }
222
+ else if (text.length <= 1000) {
223
+ // Medium text: sample from beginning, middle, and end
224
+ const third = Math.floor(text.length / 3);
225
+ const start = text.substring(0, 50);
226
+ const middle = text.substring(third, third + 50);
227
+ const end = text.substring(text.length - 50);
228
+ keyContent = `${start}|MID:${middle}|${end}`;
229
+ }
230
+ else {
231
+ // Long text: smart sampling with length and position info
232
+ const quarter = Math.floor(text.length / 4);
233
+ const start = text.substring(0, 40);
234
+ const q1 = text.substring(quarter, quarter + 30);
235
+ const q2 = text.substring(quarter * 2, quarter * 2 + 30);
236
+ const q3 = text.substring(quarter * 3, quarter * 3 + 30);
237
+ const end = text.substring(text.length - 40);
238
+ // Include text length and character diversity info for uniqueness
239
+ const charSet = new Set(text.toLowerCase()).size;
240
+ keyContent = `LEN:${text.length}|CHARS:${charSet}|${start}|Q1:${q1}|Q2:${q2}|Q3:${q3}|${end}`;
241
+ }
242
+ const contextString = JSON.stringify(context);
243
+ const hashInput = `${keyContent}|CTX:${contextString}`;
244
+ return crypto.createHash("sha256").update(hashInput, "utf8").digest("hex").substring(0, 32); // Truncate for storage efficiency while maintaining uniqueness
245
+ }
246
+ }
247
+ export default ContextProcessor;
@@ -0,0 +1,56 @@
1
+ import { promises as fs } from "fs";
2
+ import path from "path";
3
+ export class FrameworkDetector {
4
+ static async detect(projectRoot) {
5
+ try {
6
+ // Check for package.json (Web projects)
7
+ const packageJsonPath = path.join(projectRoot, "package.json");
8
+ try {
9
+ const content = await fs.readFile(packageJsonPath, "utf8");
10
+ const pkg = JSON.parse(content);
11
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
12
+ if (deps.next) {
13
+ // Next.js
14
+ // Check for next-intl or next-i18next
15
+ if (deps["next-intl"]) {
16
+ return { framework: "next", localesDir: "messages", fileFormat: "json" };
17
+ }
18
+ if (deps["next-i18next"]) {
19
+ return {
20
+ framework: "next",
21
+ localesDir: "public/locales",
22
+ fileFormat: "json",
23
+ };
24
+ }
25
+ return { framework: "next", localesDir: "public/locales", fileFormat: "json" }; // Default Next.js
26
+ }
27
+ if (deps.vue || deps.nuxt) {
28
+ // Vue/Nuxt
29
+ if (deps["@nuxtjs/i18n"])
30
+ return { framework: "vue", localesDir: "locales", fileFormat: "json" };
31
+ return { framework: "vue", localesDir: "src/locales", fileFormat: "json" };
32
+ }
33
+ if (deps["react-i18next"] || deps.react) {
34
+ return { framework: "react", localesDir: "public/locales", fileFormat: "json" };
35
+ }
36
+ }
37
+ catch (e) {
38
+ // No package.json or invalid
39
+ }
40
+ // Check for pubspec.yaml (Flutter)
41
+ const pubspecPath = path.join(projectRoot, "pubspec.yaml");
42
+ try {
43
+ await fs.access(pubspecPath);
44
+ return { framework: "flutter", localesDir: "lib/l10n", fileFormat: "arb" };
45
+ }
46
+ catch {
47
+ // No pubspec.yaml
48
+ }
49
+ return { framework: "unknown" };
50
+ }
51
+ catch (error) {
52
+ console.warn("Framework detection failed:", error);
53
+ return null;
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,30 @@
1
+ import { promises as fs } from "fs";
2
+ export var LocaleStructure;
3
+ (function (LocaleStructure) {
4
+ LocaleStructure["FLAT"] = "flat";
5
+ LocaleStructure["NESTED"] = "nested";
6
+ LocaleStructure["NAMESPACED"] = "namespaced";
7
+ LocaleStructure["UNKNOWN"] = "unknown";
8
+ })(LocaleStructure || (LocaleStructure = {}));
9
+ export class StructureDetector {
10
+ static async detect(localesDir) {
11
+ try {
12
+ const entries = await fs.readdir(localesDir, { withFileTypes: true });
13
+ // Check for files (Flat)
14
+ const hasLocaleFiles = entries.some((entry) => entry.isFile() &&
15
+ /^[a-z]{2}(-[A-Z]{2})?\.(json|yaml|yml|po|arb|properties)$/.test(entry.name));
16
+ if (hasLocaleFiles) {
17
+ return LocaleStructure.FLAT;
18
+ }
19
+ // Check for directories (Nested)
20
+ const hasLocaleDirs = entries.some((entry) => entry.isDirectory() && /^[a-z]{2}(-[A-Z]{2})?$/.test(entry.name));
21
+ if (hasLocaleDirs) {
22
+ return LocaleStructure.NESTED;
23
+ }
24
+ return LocaleStructure.UNKNOWN;
25
+ }
26
+ catch (error) {
27
+ return LocaleStructure.UNKNOWN;
28
+ }
29
+ }
30
+ }