zlocalz
Version:
ZLocalz - TUI Locale Guardian for Flutter ARB l10n/i18n validation and translation with AI-powered fixes
178 lines (174 loc) • 7.22 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Translator = void 0;
const generative_ai_1 = require("@google/generative-ai");
const p_limit_1 = __importDefault(require("p-limit"));
class Translator {
genAI;
model;
config;
limit;
constructor(config, apiKey) {
this.config = config;
this.genAI = new generative_ai_1.GoogleGenerativeAI(apiKey);
this.model = this.genAI.getGenerativeModel({
model: config.geminiModel || 'gemini-2.5-pro'
});
this.limit = (0, p_limit_1.default)(3);
}
async translateMissing(sourceFile, targetFile, targetLocale, keysToTranslate) {
const translations = [];
const chunks = this.chunkKeys(keysToTranslate, 10);
for (const chunk of chunks) {
const chunkTranslations = await this.limit(() => this.translateChunk(sourceFile, targetFile, targetLocale, chunk));
translations.push(...chunkTranslations);
}
return translations;
}
async translateChunk(sourceFile, _targetFile, targetLocale, keys) {
const translations = [];
const entries = keys.map(key => ({
key,
value: sourceFile.entries[key]?.value || '',
metadata: sourceFile.entries[key]?.metadata
}));
const prompt = this.buildPrompt(sourceFile.locale, targetLocale, entries);
try {
const result = await this.model.generateContent(prompt);
const response = await result.response;
const text = response.text();
const parsed = this.parseTranslationResponse(text);
for (const key of keys) {
const sourceValue = sourceFile.entries[key]?.value || '';
const translatedValue = parsed[key] || '';
const validation = this.validateTranslation(sourceValue, translatedValue);
translations.push({
locale: targetLocale,
key,
sourceValue,
translatedValue,
placeholdersPreserved: validation.placeholdersPreserved,
glossaryApplied: this.checkGlossaryApplication(translatedValue)
});
}
}
catch (error) {
console.error('Translation error:', error);
for (const key of keys) {
translations.push({
locale: targetLocale,
key,
sourceValue: sourceFile.entries[key]?.value || '',
translatedValue: 'TRANSLATION_ERROR',
placeholdersPreserved: false
});
}
}
return translations;
}
buildPrompt(sourceLocale, targetLocale, entries) {
const glossarySection = this.config.domainGlossary
? `\nDomain Glossary:\n${JSON.stringify(this.config.domainGlossary, null, 2)}`
: '';
const doNotTranslateSection = this.config.doNotTranslate
? `\nDo NOT translate these tokens: ${this.config.doNotTranslate.join(', ')}`
: '';
const styleSection = this.config.styleGuidelines
? `\nStyle Guidelines: ${this.config.styleGuidelines}`
: '';
const entriesJson = JSON.stringify(entries.reduce((acc, entry) => {
acc[entry.key] = {
value: entry.value,
description: entry.metadata?.description
};
return acc;
}, {}), null, 2);
return `You are a professional translator for UI text. Translate the following ARB entries from ${sourceLocale} to ${targetLocale}.
CRITICAL RULES:
1. Preserve ALL placeholders exactly as they appear (e.g., {name}, {count})
2. Maintain ICU message format structure (plural, select, etc.)
3. Only translate the actual text content, not placeholders or ICU syntax
4. Keep translations concise and appropriate for UI elements
5. Return ONLY valid JSON with translated values
${glossarySection}
${doNotTranslateSection}
${styleSection}
Input entries:
${entriesJson}
Return a JSON object with the same keys, containing only the translated values.
Example: {"key1": "translated text", "key2": "another translation"}`;
}
parseTranslationResponse(response) {
try {
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No JSON found in response');
}
const parsed = JSON.parse(jsonMatch[0]);
const result = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === 'string') {
result[key] = value;
}
else if (typeof value === 'object' && value !== null && 'value' in value) {
result[key] = String(value.value);
}
}
return result;
}
catch (error) {
console.error('Failed to parse translation response:', error);
return {};
}
}
validateTranslation(sourceValue, translatedValue) {
const sourcePlaceholders = this.extractPlaceholders(sourceValue);
const translatedPlaceholders = this.extractPlaceholders(translatedValue);
const sourceSet = new Set(sourcePlaceholders);
const translatedSet = new Set(translatedPlaceholders);
const placeholdersPreserved = sourcePlaceholders.length === translatedPlaceholders.length &&
sourcePlaceholders.every(p => translatedSet.has(p));
const issues = [];
const missing = sourcePlaceholders.filter(p => !translatedSet.has(p));
if (missing.length > 0) {
issues.push(`Missing placeholders: ${missing.join(', ')}`);
}
const extra = translatedPlaceholders.filter(p => !sourceSet.has(p));
if (extra.length > 0) {
issues.push(`Extra placeholders: ${extra.join(', ')}`);
}
return { placeholdersPreserved, issues };
}
checkGlossaryApplication(translatedValue) {
if (!this.config.domainGlossary)
return undefined;
const applied = [];
for (const [term, translation] of Object.entries(this.config.domainGlossary)) {
if (translatedValue.includes(translation)) {
applied.push(term);
}
}
return applied.length > 0 ? applied : undefined;
}
chunkKeys(keys, chunkSize) {
const chunks = [];
for (let i = 0; i < keys.length; i += chunkSize) {
chunks.push(keys.slice(i, i + chunkSize));
}
return chunks;
}
extractPlaceholders(value) {
const placeholderRegex = /\{([^}]+)\}/g;
const placeholders = [];
let match;
while ((match = placeholderRegex.exec(value)) !== null) {
placeholders.push(match[1]);
}
return placeholders;
}
}
exports.Translator = Translator;
//# sourceMappingURL=translator.js.map