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.
- package/README.md +928 -0
- package/dist/cli.js +718 -0
- package/dist/commands/review.js +357 -0
- package/dist/commands/translator.js +29 -0
- package/dist/config/index.js +52 -0
- package/dist/core/adapters/base-adapter.js +1 -0
- package/dist/core/adapters/factory.js +44 -0
- package/dist/core/adapters/json-adapter.js +20 -0
- package/dist/core/adapters/po-adapter.js +50 -0
- package/dist/core/adapters/properties-adapter.js +31 -0
- package/dist/core/adapters/yaml-adapter.js +24 -0
- package/dist/core/context-processor.js +247 -0
- package/dist/core/detection/framework-detector.js +56 -0
- package/dist/core/detection/structure-detector.js +30 -0
- package/dist/core/fallback-provider.js +442 -0
- package/dist/core/orchestrator.js +438 -0
- package/dist/core/pipeline/Pipeline.js +48 -0
- package/dist/core/pipeline/PipelineStep.js +13 -0
- package/dist/core/pipeline/context.js +21 -0
- package/dist/core/pipeline/steps/CacheReadStep.js +80 -0
- package/dist/core/pipeline/steps/CacheWriteStep.js +32 -0
- package/dist/core/pipeline/steps/ConfidenceCheckStep.js +67 -0
- package/dist/core/pipeline/steps/GlossaryPostStep.js +17 -0
- package/dist/core/pipeline/steps/GlossaryPreStep.js +20 -0
- package/dist/core/pipeline/steps/InputValidationStep.js +26 -0
- package/dist/core/pipeline/steps/QualityCheckStep.js +48 -0
- package/dist/core/pipeline/steps/ToneCheckStep.js +58 -0
- package/dist/core/pipeline/steps/TranslationStep.js +65 -0
- package/dist/core/pipeline/steps/VectorCacheReadStep.js +84 -0
- package/dist/core/pipeline/steps/VectorCacheWriteStep.js +42 -0
- package/dist/core/pipeline/steps/__tests__/ToneCheckStep.test.js +100 -0
- package/dist/core/provider-factory.js +139 -0
- package/dist/providers/base-provider.js +230 -0
- package/dist/providers/dashscope.js +128 -0
- package/dist/providers/deepseek.js +111 -0
- package/dist/providers/gemini.js +135 -0
- package/dist/providers/openai.js +203 -0
- package/dist/providers/xai.js +111 -0
- package/dist/services/embedding-provider.js +50 -0
- package/dist/services/translation-service.js +609 -0
- package/dist/services/vector-store.js +79 -0
- package/dist/utils/ai-context-analyzer.js +460 -0
- package/dist/utils/confidence-scorer.js +277 -0
- package/dist/utils/error-helper.js +386 -0
- package/dist/utils/file-manager.js +357 -0
- package/dist/utils/glossary-manager.js +194 -0
- package/dist/utils/graceful-shutdown.js +118 -0
- package/dist/utils/input-validator.js +663 -0
- package/dist/utils/logger.js +374 -0
- package/dist/utils/object-transformer.js +177 -0
- package/dist/utils/progress-tracker.js +277 -0
- package/dist/utils/prompt-templates.js +415 -0
- package/dist/utils/quality/__tests__/tone-verifier.test.js +91 -0
- package/dist/utils/quality/base-checker.js +73 -0
- package/dist/utils/quality/code-block-checker.js +214 -0
- package/dist/utils/quality/html-tag-checker.js +64 -0
- package/dist/utils/quality/index.js +220 -0
- package/dist/utils/quality/length-checker.js +140 -0
- package/dist/utils/quality/markdown-checker.js +220 -0
- package/dist/utils/quality/placeholder-checker.js +142 -0
- package/dist/utils/quality/punctuation-checker.js +50 -0
- package/dist/utils/quality/quote-balance-checker.js +143 -0
- package/dist/utils/quality/special-characters-checker.js +229 -0
- package/dist/utils/quality/style-guide-checker.js +192 -0
- package/dist/utils/quality/text-sanitizer.js +120 -0
- package/dist/utils/quality/tone-verifier.js +91 -0
- package/dist/utils/rate-limiter.js +398 -0
- package/dist/utils/retry-helper.js +166 -0
- package/dist/utils/state-manager.js +162 -0
- package/dist/utils/statistics-manager.js +51 -0
- package/dist/utils/ui-manager.js +98 -0
- 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
|
+
}
|