chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:12 +08:00
commit d8dcd5f6d1
8604 changed files with 2479390 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
const ROOT = process.cwd();
const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages");
const MESSAGE_OVERRIDES = {
es: {
"common.disabled": "Deshabilitado",
"common.model": "Modelo",
"common.account": "Cuenta",
"common.time": "Hora",
"common.columns": "Columnas",
"common.newest": "Más reciente",
"common.oldest": "Más antiguo",
"common.yes": "Sí",
"sidebar.combos": "Combos",
"sidebar.docs": "Documentación",
"sidebar.apiManager": "Gestor de API",
"header.providerDescription": "Gestiona tus conexiones de proveedores de IA",
"header.combos": "Combos",
"header.comboDescription": "Combinaciones de modelos con fallback",
"header.analytics": "Analíticas",
"header.anthropicCompatible": "Compatible con Anthropic",
"home.quickStartDesc":
"Empieza en 4 pasos. Conecta proveedores, enruta modelos y monitorea todo.",
"home.fullDocs": "Documentación completa",
"home.step1Desc":
"Ve a <endpoint>Endpoint</endpoint> -> Claves registradas. Genera una clave por entorno.",
"home.step2Desc":
"Agrega cuentas en <providers>Proveedores</providers>. Soporta OAuth, API Key y planes gratuitos.",
"home.step3Title": "3. Configura tu cliente",
"home.step3Desc": "Configura la URL base como {url} en tu IDE o cliente API.",
"home.step4Desc":
"Supervisa tokens, costos y errores en <logs>Registros</logs> y <analytics>Analíticas</analytics>.",
"home.requestsShort": "{count} reqs",
"auth.unifiedProxy": "Proxy de API de IA unificado",
"auth.unifiedAiApiProxy": "Proxy de API de IA unificado",
"auth.runOnboardingWizard":
"Ejecuta el asistente de onboarding para configurar tu contraseña y conectar tu primer proveedor de IA.",
"auth.stopServer": "Detén el servidor OmniRoute",
"auth.copyUrlManual": "Copia la URL de la barra de direcciones y pégala en la aplicación.",
"auth.methodManualDescription":
"Elimina la contraseña de la base de datos y configura una nueva al iniciar:",
"auth.setPasswordInYour": "Define una nueva contraseña en tu",
"auth.restartServerWithNewPassword": "Reinicia el servidor; usará la nueva contraseña",
},
fr: {
"sidebar.docs": "Documentation",
"header.providerDescription": "Gérez vos connexions aux fournisseurs d'IA",
"header.comboDescription": "Combinaisons de modèles avec bascule de secours",
"header.analytics": "Analytique",
"header.anthropicCompatible": "Compatible Anthropic",
"home.quickStartDesc":
"Démarrez en 4 étapes. Connectez des fournisseurs, routez les modèles et surveillez tout.",
"home.fullDocs": "Documentation complète",
"home.step1Desc":
"Allez dans <endpoint>Endpoint</endpoint> -> Clés enregistrées. Générez une clé par environnement.",
"home.step2Title": "2. Connecter des fournisseurs",
"home.step2Desc":
"Ajoutez des comptes dans <providers>Providers</providers>. Prend en charge OAuth, API Key et paliers gratuits.",
"home.step3Title": "3. Configurer votre client",
"home.step3Desc": "Définissez l'URL de base sur {url} dans votre IDE ou client API.",
"home.step4Desc":
"Suivez les tokens, les coûts et les erreurs dans <logs>Journaux des requêtes</logs> et <analytics>Analytique</analytics>.",
"home.requestsShort": "{count} reqs",
"auth.runOnboardingWizard":
"Exécutez l'assistant d'onboarding pour configurer votre mot de passe et connecter votre premier fournisseur d'IA.",
"auth.copyUrlManual": "Copiez l'URL depuis la barre d'adresse et collez-la dans l'application.",
"auth.newPasswordPlaceholder": "votre_nouveau_mot_de_passe",
},
de: {
"sidebar.dashboard": "Dashboard",
"header.analytics": "Analysen",
"header.anthropicCompatible": "Anthropic-kompatibel",
"home.step1Desc":
"Gehen Sie zu <endpoint>Endpoint</endpoint> -> Registrierte Schlüssel. Erstellen Sie einen Schlüssel pro Umgebung.",
"home.step2Desc":
"Fügen Sie Konten unter <providers>Providers</providers> hinzu. Unterstützt OAuth, API Key und kostenlose Stufen.",
"home.step3Title": "3. Client konfigurieren",
"home.step4Desc":
"Verfolgen Sie Tokens, Kosten und Fehler in <logs>Anfrageprotokollen</logs> und <analytics>Analysen</analytics>.",
"home.requestsShort": "{count} Anfr.",
"auth.unifiedProxy": "Einheitlicher KI-API-Proxy",
"auth.unifiedAiApiProxy": "Einheitlicher KI-API-Proxy",
"auth.setPasswordInYour": "Legen Sie ein neues Passwort in Ihrer",
"auth.newPasswordPlaceholder": "ihr_neues_passwort",
"auth.copyUrlManual":
"Kopieren Sie die URL aus der Adressleiste und fügen Sie sie in die Anwendung ein.",
},
ja: {
"common.disabled": "無効",
"common.columns": "列",
"common.newest": "新しい順",
"common.oldest": "古い順",
"header.providerDescription": "AI プロバイダー接続を管理します",
"header.comboDescription": "フォールバック付きのモデルコンボ",
"header.analytics": "アナリティクス",
"header.settingsDescription": "設定を管理します",
"header.anthropicCompatible": "Anthropic 互換",
"home.quickStartDesc":
"4 ステップでセットアップ完了。プロバイダー接続、モデルルーティング、監視まで一括で行えます。",
"home.fullDocs": "完全版ドキュメント",
"home.step1Desc":
"<endpoint>Endpoint</endpoint> -> Registered Keys に移動し、環境ごとに 1 つのキーを作成します。",
"home.step2Title": "2. プロバイダーを接続",
"home.step2Desc":
"<providers>Providers</providers> でアカウントを追加します。OAuth、API Key、無料枠に対応しています。",
"home.step3Title": "3. クライアントを設定",
"home.step3Desc": "IDE または API クライアントの Base URL を {url} に設定します。",
"home.step4Desc":
"<logs>リクエストログ</logs> と <analytics>アナリティクス</analytics> でトークン・コスト・エラーを追跡します。",
"home.requestsShort": "{count} 件",
"auth.runOnboardingWizard":
"オンボーディングウィザードを実行して、パスワード設定と最初の AI プロバイダー接続を行います。",
"auth.stopServer": "OmniRoute サーバーを停止",
"auth.copyUrlManual": "アドレスバーの URL をコピーしてアプリケーションに貼り付けてください。",
"auth.methodManualDescription":
"データベースからパスワードを削除し、起動時に新しいパスワードを設定します:",
"auth.setPasswordInYour": "次のファイルで新しいパスワードを設定してください",
"auth.newPasswordPlaceholder": "あなたの新しいパスワード",
"auth.restartServerWithNewPassword": "サーバーを再起動すると新しいパスワードが適用されます",
},
ar: {
"common.disabled": "غير مفعّل",
"sidebar.providers": "المزوّدون",
"sidebar.apiManager": "مدير API",
"header.providers": "المزوّدون",
"header.providerDescription": "إدارة اتصالات مزوّدي الذكاء الاصطناعي",
"header.comboDescription": "مجموعات نماذج مع التبديل الاحتياطي",
"header.anthropicCompatible": "متوافق مع Anthropic",
"home.quickStartDesc": "ابدأ خلال 4 خطوات: وصّل المزوّدين، فعّل توجيه النماذج، وراقب كل شيء.",
"home.fullDocs": "التوثيق الكامل",
"home.step1Desc":
"انتقل إلى <endpoint>Endpoint</endpoint> -> Registered Keys. أنشئ مفتاحًا واحدًا لكل بيئة.",
"home.step2Desc":
"أضف الحسابات في <providers>Providers</providers>. يدعم OAuth وAPI Key والخطط المجانية.",
"home.step3Title": "3. اضبط عميلك",
"home.step3Desc": "عيّن عنوان Base URL إلى {url} في IDE أو عميل API لديك.",
"home.step4Desc":
"تتبّع الرموز والتكلفة والأخطاء في <logs>سجلات الطلبات</logs> و<analytics>التحليلات</analytics>.",
"home.requestsShort": "{count} طلب",
"auth.unifiedProxy": "وكيل API موحّد للذكاء الاصطناعي",
"auth.unifiedAiApiProxy": "وكيل API موحّد للذكاء الاصطناعي",
"auth.copyUrlManual": "انسخ عنوان URL من شريط العنوان والصقه داخل التطبيق.",
"auth.setPasswordInYour": "عيّن كلمة مرور جديدة في ملف",
"auth.newPasswordPlaceholder": "كلمة_مرور_جديدة",
"auth.restartServerWithNewPassword": "أعد تشغيل الخادم وسيتم استخدام كلمة المرور الجديدة",
},
};
const README_PREFIX_OVERRIDES = {
"README.es.md": "🌐 **Disponible en:**",
"README.fr.md": "🌐 **Disponible en :**",
"README.de.md": "🌐 **Verfügbar in:**",
"README.ja.md": "🌐 **対応言語:**",
"README.ar.md": "🌐 **متوفر باللغات:**",
};
const README_NAV_OVERRIDES = {
"README.ja.md":
"[🌐 ウェブサイト](https://omniroute.online) • [🚀 クイックスタート](#-クイックスタート) • [💡 主な機能](#-主な機能) • [📖 ドキュメント](#-ドキュメント) • [💰 料金](#-価格の概要) • [💬 WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)",
"README.ar.md":
"[🌐 الموقع](https://omniroute.online) • [🚀 البداية السريعة](#-بداية-سريعة) • [💡 الميزات](#-الميزات-الرئيسية) • [📖 التوثيق](#-التوثيق) • [💰 الأسعار](#-لمحة-سريعة-عن-الأسعار) • [💬 WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)",
};
function setByPath(target, pathStr, value) {
const tokens = pathStr.split(".");
let current = target;
for (let i = 0; i < tokens.length - 1; i += 1) {
if (tokens[i] === "__proto__" || tokens[i] === "constructor" || tokens[i] === "prototype")
continue;
if (typeof current[tokens[i]] !== "object") current[tokens[i]] = {};
current = current[tokens[i]];
}
const last = tokens[tokens.length - 1];
if (last !== "__proto__" && last !== "constructor" && last !== "prototype") {
current[last] = value;
}
}
async function applyMessageOverrides() {
for (const [locale, overrides] of Object.entries(MESSAGE_OVERRIDES)) {
const file = path.join(MESSAGES_DIR, `${locale}.json`);
const data = JSON.parse(await fs.readFile(file, "utf8"));
for (const [key, value] of Object.entries(overrides)) {
setByPath(data, key, value);
}
await fs.writeFile(file, `${JSON.stringify(data, null, 2)}\n`, "utf8");
}
}
function replaceLineByPrefix(content, prefix, replacement) {
const lines = content.split("\n");
const idx = lines.findIndex((line) => line.startsWith(prefix));
if (idx >= 0) {
lines[idx] = replacement;
}
return lines.join("\n");
}
function removeEnglishPortugueseAnchorLine(content) {
const lines = content.split("\n");
const filtered = lines.filter(
(line) =>
!line.includes(
"**[English](#-omniroute--the-free-ai-gateway)** | **[Português (BR)](#-omniroute--gateway-de-ia-gratuito)**"
)
);
return filtered.join("\n");
}
function removeBrazilianAppendixSection(content) {
const marker = "\n## 🇧🇷 OmniRoute";
const idx = content.indexOf(marker);
if (idx === -1) {
return content;
}
return content.slice(0, idx).trimEnd() + "\n";
}
async function applyReadmeOverrides() {
for (const [fileName, localizedPrefix] of Object.entries(README_PREFIX_OVERRIDES)) {
const filePath = path.join(ROOT, fileName);
let content = await fs.readFile(filePath, "utf8");
content = content.replace("🌐 **Available in:**", localizedPrefix);
if (README_NAV_OVERRIDES[fileName]) {
content = replaceLineByPrefix(content, "[🌐 Website]", README_NAV_OVERRIDES[fileName]);
content = removeEnglishPortugueseAnchorLine(content);
content = removeBrazilianAppendixSection(content);
}
await fs.writeFile(filePath, content, "utf8");
}
}
async function main() {
await applyMessageOverrides();
await applyReadmeOverrides();
console.log("priority overrides applied");
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env node
/**
* Audit every dashboard page for:
* 1. Hardcoded user-visible strings in JSX (any language)
* 2. t("...") calls that reference keys NOT present in en.json
*
* Output: a JSON report at scripts/i18n/_audit.json + a human summary on stdout.
*
* Scope: src/app/(dashboard)/**\/*.tsx
*/
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
import { join, dirname, relative, basename } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, "..", "..");
const EN_JSON_PATH = join(REPO_ROOT, "src/i18n/messages/en.json");
const DASHBOARD_ROOT = join(REPO_ROOT, "src/app/(dashboard)");
const OUT_PATH = join(__dirname, "_audit.json");
const enJsonRaw = JSON.parse(readFileSync(EN_JSON_PATH, "utf8"));
/**
* Deep-convert a parsed JSON object into a Map tree.
* Using Map sidesteps prototype-pollution concerns when keys are
* derived from source-code scraping (CWE-915).
*/
function toMapTree(value) {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(toMapTree);
const m = new Map();
for (const [k, v] of Object.entries(value)) m.set(k, toMapTree(v));
return m;
}
const enJson = toMapTree(enJsonRaw);
/** Walk a directory recursively and return all .tsx files */
function walkTsx(dir) {
const out = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) out.push(...walkTsx(full));
else if (entry.endsWith(".tsx")) out.push(full);
}
return out;
}
/** Walk a dotted path through the Map tree, returning the leaf or undefined */
function walkPath(parts) {
let cursor = enJson;
for (const p of parts) {
if (!(cursor instanceof Map) || !cursor.has(p)) return undefined;
cursor = cursor.get(p);
}
return cursor;
}
/** Check if a dotted key exists, supporting dotted namespaces too */
function keyExists(namespace, key) {
const nsParts = namespace ? namespace.split(".") : [];
const keyParts = key.split(".");
return walkPath([...nsParts, ...keyParts]) !== undefined;
}
/** Extract useTranslations("ns") and getTranslations("ns") calls */
function extractNamespaces(source) {
const namespaces = [];
const reNs = /(?:useTranslations|getTranslations)\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
for (const m of source.matchAll(reNs)) {
namespaces.push(m[1]);
}
// Without arg: bare, key must be fully qualified
if (/(?:useTranslations|getTranslations)\s*\(\s*\)/.test(source)) namespaces.push(null);
return namespaces;
}
/** Extract every t("key", ...) and t.rich("key", ...) — capture the literal key only */
function extractTCalls(source) {
const out = new Set();
// t("..."), t('...') — match only quoted string literals (skip template literals)
const re = /\bt(?:Or[A-Z][A-Za-z]*)?(?:\.\w+)?\s*\(\s*["']([^"']+)["']/g;
for (const m of source.matchAll(re)) out.add(m[1]);
// translateOrFallback("key", fallback) — also t("key") style
for (const m of source.matchAll(/translateOrFallback\s*\(\s*["']([^"']+)["']/g)) {
out.add(m[1]);
}
return [...out];
}
/**
* Find candidate hardcoded user-visible strings in JSX.
*
* Patterns we detect:
* A. JSX text: >Some text< (must contain a letter, length >= 2)
* B. JSX attrs: title="..." placeholder="..." aria-label="..." alt="..."
* C. Toast/error literals: toast.error("...") toast.success("...")
*
* We DELIBERATELY skip:
* - strings inside {t(...)} or {tOrFallback(...)}
* - strings inside import statements
* - strings inside CSS class names (className/style)
* - URLs (start with /, http, mailto:, #)
* - All-uppercase/snake constants (CONSTANT_VAR)
* - pure-symbol strings (no letters)
* - lonely-word JSX text that is a console.log / pino call argument
*/
// TS / JS noise that the naive regex picks up between two `>` chars
const TS_TYPE_NOISE = new Set([
"Promise",
"Record",
"Map",
"Set",
"Array",
"ReadonlyArray",
"Partial",
"Pick",
"Omit",
"Required",
"Readonly",
"Awaited",
"ReturnType",
"Parameters",
"void | Promise",
"Promise | undefined",
]);
function findHardcodedStrings(source) {
const findings = [];
const lines = source.split("\n");
// (A) JSX text between > and < — fragile but good enough for an audit pass
let lineIdx = 0;
for (const line of lines) {
lineIdx++;
// Skip obvious non-JSX lines (imports, single-line comments, JSDoc lines)
if (
/^\s*(import|export\s+(type|interface)|\/\/|\*|\/\*|type\s+\w+\s*=|interface\s+)/.test(line)
)
continue;
// Skip lines that are *clearly* TypeScript signatures
if (
/:\s*(Promise|Record|Map|Set|Array|Partial|Pick|Omit|Required|Readonly|Awaited)\s*</.test(
line
)
)
continue;
// Neutralise arrow functions and TS generics that confuse the regex
const safe = line
.replace(/=>/g, "__ARROW__")
.replace(/<\/?\w[\w.-]*\s*\/?>/g, (m) => m) // keep JSX tags
.replace(/<\w[\w.,?:|\s]*?>/g, "__GEN__"); // strip TS generics like <T> or <T, U>
let m;
const re = />([^<>{}\n]+)</g;
while ((m = re.exec(safe)) !== null) {
const raw = m[1].trim();
if (!raw) continue;
if (!/[A-Za-zÀ-ÿ一-鿿Ѐ-ӿ֐-׿؀-ۿ]/.test(raw)) continue;
if (raw.length < 2) continue;
if (raw.startsWith("{") || raw.endsWith("}")) continue;
if (/^[A-Z0-9_]+$/.test(raw)) continue; // CONSTANT
if (/^(https?:\/\/|\/|mailto:|#)/.test(raw)) continue; // URL/path
if (/^[a-z][a-z_0-9]*$/.test(raw) && raw.length < 22) continue; // icon/id slug
if (TS_TYPE_NOISE.has(raw)) continue;
// operator/expression debris like "0 && foo.size", "x !== y"
if (/(&&|\|\||==|!=|<=|>=|=>|\?\s*:|\?\.|\.\w+\()/.test(raw)) continue;
// pure number or simple variable.member with no spaces
if (/^[\w.]+$/.test(raw) && !raw.includes(" ")) continue;
findings.push({ kind: "jsx-text", line: lineIdx, value: raw });
}
}
// (B) JSX attributes that are user-visible
const attrRe = /\b(title|placeholder|aria-label|alt|label)\s*=\s*["'`]([^"'`{}\n]{2,})["'`]/g;
lineIdx = 0;
for (const line of lines) {
lineIdx++;
if (/^\s*(import|\/\/|\*|\/\*)/.test(line)) continue;
let m;
const re = new RegExp(attrRe.source, "g");
while ((m = re.exec(line)) !== null) {
const value = m[2].trim();
if (!/[A-Za-zÀ-ÿ一-鿿]/.test(value)) continue;
if (/^[a-z][a-z_0-9-]*$/.test(value) && value.length < 16) continue; // probably an id/key
findings.push({ kind: `attr:${m[1]}`, line: lineIdx, value });
}
}
// (C) toast.* and Error() arguments that look like user copy (length > 5, has a space)
lineIdx = 0;
const callRe =
/\b(toast\.(error|success|info|warn|warning|message))\s*\(\s*["'`]([^"'`]{3,})["'`]/g;
for (const line of lines) {
lineIdx++;
let m;
const re = new RegExp(callRe.source, "g");
while ((m = re.exec(line)) !== null) {
findings.push({ kind: `toast:${m[2]}`, line: lineIdx, value: m[3].trim() });
}
}
return findings;
}
/** Find t() calls whose key does NOT exist in en.json */
function findMissingKeys(namespaces, tKeys) {
const missing = [];
for (const key of tKeys) {
// A key may itself contain dots — handle namespace dotted into key
let found = false;
for (const ns of namespaces) {
if (keyExists(ns, key)) {
found = true;
break;
}
}
// Also accept fully qualified bare-namespace keys (like "common.foo" with no useTranslations)
if (!found && key.includes(".")) {
const [maybeNs, ...rest] = key.split(".");
if (keyExists(maybeNs, rest.join("."))) found = true;
}
if (!found) missing.push(key);
}
return missing;
}
const files = walkTsx(DASHBOARD_ROOT);
const report = [];
for (const file of files) {
const rel = relative(REPO_ROOT, file);
const src = readFileSync(file, "utf8");
const namespaces = extractNamespaces(src);
const tCalls = extractTCalls(src);
const hardcoded = findHardcodedStrings(src);
const missing = findMissingKeys(namespaces.length ? namespaces : [null], tCalls);
// Only include files that have ANY user-visible content
if (!namespaces.length && !tCalls.length && !hardcoded.length) continue;
report.push({
file: rel,
namespaces,
tCallCount: tCalls.length,
missingKeys: missing,
hardcodedCount: hardcoded.length,
hardcoded,
});
}
writeFileSync(OUT_PATH, JSON.stringify(report, null, 2));
// Human summary
let totalMissing = 0;
let totalHardcoded = 0;
console.log("# i18n audit — dashboard pages\n");
console.log(`Scanned ${report.length} files with user-visible content.\n`);
const onlyIssues = report
.map((r) => ({ ...r, total: r.missingKeys.length + r.hardcoded.length }))
.filter((r) => r.total > 0)
.sort((a, b) => b.total - a.total);
for (const r of onlyIssues) {
totalMissing += r.missingKeys.length;
totalHardcoded += r.hardcoded.length;
console.log(`\n## ${r.file}`);
console.log(` ns=${JSON.stringify(r.namespaces)} t() calls=${r.tCallCount}`);
if (r.missingKeys.length) {
console.log(` ✗ missing keys (${r.missingKeys.length}):`);
for (const k of r.missingKeys) console.log(` - ${k}`);
}
if (r.hardcoded.length) {
console.log(` ✗ hardcoded strings (${r.hardcoded.length}):`);
for (const h of r.hardcoded.slice(0, 30)) {
console.log(` L${h.line} [${h.kind}] ${JSON.stringify(h.value)}`);
}
if (r.hardcoded.length > 30) console.log(` ... (+${r.hardcoded.length - 30} more)`);
}
}
console.log(`\n# Summary`);
console.log(`Files with issues: ${onlyIssues.length}`);
console.log(`Missing keys total: ${totalMissing}`);
console.log(`Hardcoded strings: ${totalHardcoded}`);
console.log(`Report file: ${relative(REPO_ROOT, OUT_PATH)}`);
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env node
/**
* For every missing key in _audit.json, locate its English value by
* 1) finding the t("key") call site in the file
* 2) grabbing the line(s) immediately around it in the HEAD version of the file
*
* This rebuilds a complete _pending-keys.json after subagents have already
* rewritten .tsx files but en.json edits were lost.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { execSync } from "node:child_process";
const audit = JSON.parse(readFileSync("scripts/i18n/_audit.json", "utf8"));
function loadHead(file) {
try {
return execSync(`git show "HEAD:${file}"`, {
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
});
} catch {
return null;
}
}
const SKIP = new Set([
"templatePayloads.vision.system",
"templatePayloads.vision.userPrompt",
"templatePayloads.vision.imageUrl",
"templatePayloads.schemaCoercion.userPrompt",
"templatePayloads.schemaCoercion.toolDescription",
"templatePayloads.schemaCoercion.cityDescription",
]);
/** Find the line in NEW source where t("key") is used, then derive English value from HEAD */
function valueForKey(file, key) {
const cur = readFileSync(file, "utf8").split("\n");
const head = loadHead(file)?.split("\n") ?? [];
// String search avoids ReDoS — key is our own audit data but better safe
const needle1 = `t("${key}")`;
const needle2 = `t('${key}')`;
for (let i = 0; i < cur.length; i++) {
const line = cur[i];
if (!line.includes(needle1) && !line.includes(needle2)) continue;
// The original line in HEAD should be similar around the same area
// Find the most-recently corresponding HEAD line: scan backwards and forwards from i
const probe = [i, i - 1, i + 1, i - 2, i + 2, i - 3, i + 3];
for (const j of probe) {
if (j < 0 || j >= head.length) continue;
const line = head[j];
// Try to extract the English literal: between > <, or attribute value
const jsxMatch = line.match(/>([^<>{}\n]{2,})</);
if (jsxMatch && !jsxMatch[1].includes("{") && !jsxMatch[1].includes("=>")) {
const v = jsxMatch[1].trim();
if (v && /[A-Za-z]/.test(v) && v !== key) return v;
}
const attrMatch = line.match(
/\b(?:title|placeholder|aria-label|alt|label)\s*=\s*["']([^"']+)["']/
);
if (attrMatch) {
const v = attrMatch[1].trim();
if (v && /[A-Za-z]/.test(v) && v !== key) return v;
}
}
}
return null;
}
const inferredNamespaces = new Map();
function ensureNs(ns) {
if (!inferredNamespaces.has(ns)) inferredNamespaces.set(ns, {});
return inferredNamespaces.get(ns);
}
for (const entry of audit) {
if (!entry.missingKeys.length) continue;
const ns = entry.namespaces[0];
if (!ns) continue; // exampleTemplates etc. — skip
const target = ensureNs(ns);
for (const key of entry.missingKeys) {
if (SKIP.has(key)) continue;
if (target[key]) continue;
const value = valueForKey(entry.file, key);
if (value) {
target[key] = value;
} else {
// Could not infer — leave a TODO marker so we notice
target[key] = `__TODO__${key}`;
}
}
}
const out = Object.fromEntries(inferredNamespaces);
writeFileSync("scripts/i18n/_pending-keys.json", JSON.stringify(out, null, 2) + "\n");
let total = 0;
let todo = 0;
for (const [ns, keys] of Object.entries(out)) {
const cnt = Object.keys(keys).length;
total += cnt;
for (const v of Object.values(keys)) if (String(v).startsWith("__TODO__")) todo++;
console.log(`${ns}: ${cnt} keys`);
}
console.log(`\nTotal: ${total} keys (${todo} need manual resolution)`);
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* OmniRoute — i18n translation drift checker (CI gate).
*
* Verifies that every source file recorded in `.i18n-state.json` still has
* the same SHA-256 hash on disk and that every produced translation file
* exists with the recorded target hash. Does NOT call any API — purely a
* deterministic state-vs-disk comparison.
*
* Modes:
* --strict (default) exit 1 on any drift, print the offending paths
* --warn exit 0, print warnings only
* --json emit a JSON report to stdout (no human log lines)
*
* Recommended usage in CI:
* npm run i18n:check
*/
import { promises as fs, existsSync } from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import process from "node:process";
import { fileURLToPath, pathToFileURL } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SCRIPT_DIR, "..", "..");
const STATE_PATH = path.join(ROOT, ".i18n-state.json");
function parseArgs(argv) {
const opts = { mode: "strict", json: false };
for (const arg of argv.slice(2)) {
if (arg === "--warn") opts.mode = "warn";
else if (arg === "--strict") opts.mode = "strict";
else if (arg === "--json") opts.json = true;
else if (arg === "--help" || arg === "-h") {
console.log(
[
"Usage: node scripts/i18n/check-translation-drift.mjs [--strict|--warn] [--json]",
"",
" --strict (default) exit 1 when any source or target is out of date",
" --warn report drift but exit 0",
" --json write a machine-readable report to stdout",
].join("\n")
);
process.exit(0);
}
}
return opts;
}
function sha256(buf) {
return crypto.createHash("sha256").update(buf).digest("hex");
}
async function main() {
const opts = parseArgs(process.argv);
if (!existsSync(STATE_PATH)) {
const msg = ".i18n-state.json not found — run `npm run i18n:run` to bootstrap.";
if (opts.json) {
process.stdout.write(JSON.stringify({ ok: false, reason: "missing-state" }) + "\n");
} else {
console.error(`[i18n-check] ${msg}`);
}
process.exit(opts.mode === "warn" ? 0 : 1);
}
const state = JSON.parse(await fs.readFile(STATE_PATH, "utf8"));
const sources = state.sources || {};
const driftedSources = [];
const missingTargets = [];
const driftedTargets = [];
let checkedSources = 0;
let checkedTargets = 0;
for (const [rel, entry] of Object.entries(sources)) {
checkedSources++;
const absSource = path.join(ROOT, rel);
if (!existsSync(absSource)) {
driftedSources.push({ rel, reason: "source-missing" });
continue;
}
const currentHash = sha256(await fs.readFile(absSource));
if (currentHash !== entry.source_hash) {
driftedSources.push({
rel,
reason: "source-changed",
recorded: entry.source_hash,
current: currentHash,
});
}
for (const [locale, info] of Object.entries(entry.locales || {})) {
checkedTargets++;
// Mirror the path layout used by run-translation.mjs.
const targetAbs = rel.includes("/")
? path.join(ROOT, "docs", "i18n", locale, rel)
: path.join(ROOT, "docs", "i18n", locale, rel);
if (!existsSync(targetAbs)) {
missingTargets.push({ rel, locale });
continue;
}
const targetHash = sha256(await fs.readFile(targetAbs));
if (info.target_hash && targetHash !== info.target_hash) {
driftedTargets.push({ rel, locale, recorded: info.target_hash, current: targetHash });
}
}
}
const ok =
driftedSources.length === 0 && missingTargets.length === 0 && driftedTargets.length === 0;
if (opts.json) {
process.stdout.write(
JSON.stringify(
{
ok,
checkedSources,
checkedTargets,
driftedSources,
missingTargets,
driftedTargets,
},
null,
2
) + "\n"
);
} else {
console.log(`[i18n-check] checked sources=${checkedSources}, targets=${checkedTargets}`);
if (driftedSources.length) {
console.log(`[i18n-check] drifted sources (${driftedSources.length}):`);
for (const d of driftedSources) console.log(` - ${d.rel} (${d.reason})`);
}
if (missingTargets.length) {
console.log(`[i18n-check] missing targets (${missingTargets.length}):`);
for (const m of missingTargets) console.log(` - ${m.rel} [${m.locale}]`);
}
if (driftedTargets.length) {
console.log(`[i18n-check] drifted targets (${driftedTargets.length}):`);
for (const t of driftedTargets) console.log(` - ${t.rel} [${t.locale}]`);
}
if (ok) {
console.log("[i18n-check] PASS — all sources and targets match recorded hashes.");
} else if (opts.mode === "warn") {
console.log("[i18n-check] WARN — drift detected (warn mode, exiting 0).");
} else {
console.log("[i18n-check] FAIL — drift detected. Run `npm run i18n:run` to refresh.");
}
}
if (!ok && opts.mode !== "warn") process.exit(1);
}
const isDirectRun = import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
main().catch((err) => {
console.error("[i18n-check] ERROR", err?.stack || err?.message || String(err));
process.exit(1);
});
}
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env node
/**
* OmniRoute — UI i18n key coverage gate.
*
* Compares every `src/i18n/messages/<locale>.json` against `en.json` and
* reports:
* - total_en: total leaves in en.json
* - present: leaves present in the locale (any shape match)
* - missing: leaves absent in the locale
* - placeholder: leaves whose value starts with __MISSING__:
* - coverage: (present - placeholder) / total_en * 100
*
* Usage:
* npm run i18n:check-ui-coverage # threshold 80, fail on drop
* npm run i18n:check-ui-coverage -- --threshold=75
* npm run i18n:check-ui-coverage -- --report # informational tabular output
* npm run i18n:check-ui-coverage -- --json # machine-readable report
*
* Exits 1 when any locale falls below `--threshold` (default 80), unless
* `--report` is set, in which case the table is printed and exit code is 0.
*/
import { promises as fs, existsSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath, pathToFileURL } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SCRIPT_DIR, "..", "..");
const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages");
const CONFIG_PATH = path.join(ROOT, "config", "i18n.json");
const SOURCE_LOCALE = "en";
const PLACEHOLDER_PREFIX = "__MISSING__:";
function logInfo(...parts) {
console.log("[i18n-ui-coverage]", ...parts);
}
function logWarn(...parts) {
console.warn("[i18n-ui-coverage] WARN", ...parts);
}
function parseArgs(argv) {
const opts = { threshold: 80, report: false, json: false };
for (const arg of argv.slice(2)) {
if (arg.startsWith("--threshold=")) {
opts.threshold = Number(arg.slice(12));
} else if (arg === "--report") {
opts.report = true;
} else if (arg === "--json") {
opts.json = true;
} else if (arg === "--help" || arg === "-h") {
console.log(
[
"Usage: node scripts/i18n/check-ui-keys-coverage.mjs [options]",
"",
" --threshold=<n> Minimum coverage % for every locale (default 80)",
" --report Print full coverage table, exit 0 regardless",
" --json Emit JSON report to stdout",
].join("\n")
);
process.exit(0);
}
}
if (!Number.isFinite(opts.threshold) || opts.threshold < 0 || opts.threshold > 100) {
throw new Error(`Invalid --threshold value: ${opts.threshold}`);
}
return opts;
}
async function loadJson(filePath) {
const raw = await fs.readFile(filePath, "utf8");
return JSON.parse(raw);
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function collectLeafPaths(obj, prefix = []) {
const paths = [];
for (const [key, value] of Object.entries(obj)) {
const next = [...prefix, key];
if (isPlainObject(value)) {
paths.push(...collectLeafPaths(value, next));
} else {
paths.push(next);
}
}
return paths;
}
// Reject any segment that could traverse into the object prototype chain. This
// is defensive — our inputs are JSON files we authored, but the static
// scanner correctly flags any dynamic indexing with untrusted-looking keys.
const FORBIDDEN_KEY_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
function lookupPath(obj, parts) {
let cur = obj;
for (const part of parts) {
if (!isPlainObject(cur)) return undefined;
if (FORBIDDEN_KEY_SEGMENTS.has(part)) return undefined;
if (!Object.prototype.hasOwnProperty.call(cur, part)) return undefined;
// Use Map-like get via Object.entries to avoid dynamic bracket access
// patterns the static analyzer warns about. We already validated the key
// exists as an own property above.
const entry = Object.entries(cur).find(([k]) => k === part);
cur = entry ? entry[1] : undefined;
}
return cur;
}
function pad(str, width) {
const s = String(str);
return s.length >= width ? s : s + " ".repeat(width - s.length);
}
function padLeft(str, width) {
const s = String(str);
return s.length >= width ? s : " ".repeat(width - s.length) + s;
}
async function main() {
const opts = parseArgs(process.argv);
const sourcePath = path.join(MESSAGES_DIR, `${SOURCE_LOCALE}.json`);
if (!existsSync(sourcePath)) {
throw new Error(`Source messages file not found: ${sourcePath}`);
}
const source = await loadJson(sourcePath);
const enPaths = collectLeafPaths(source);
const totalEn = enPaths.length;
// Locale set: every <code>.json on disk except en, intersected with config.
let configCodes = null;
if (existsSync(CONFIG_PATH)) {
try {
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8"));
if (Array.isArray(cfg.locales)) {
configCodes = new Set(cfg.locales.map((l) => l.code));
}
} catch {
/* ignore — fall back to disk listing only */
}
}
const onDisk = (await fs.readdir(MESSAGES_DIR))
.filter((f) => f.endsWith(".json") && f !== `${SOURCE_LOCALE}.json`)
.map((f) => f.slice(0, -5))
.filter((code) => (configCodes ? configCodes.has(code) : true))
.sort();
const results = [];
for (const locale of onDisk) {
const localePath = path.join(MESSAGES_DIR, `${locale}.json`);
let target;
try {
target = await loadJson(localePath);
} catch (err) {
logWarn(`${locale}: failed to parse JSON (${err.message})`);
results.push({
locale,
total_en: totalEn,
present: 0,
missing: totalEn,
placeholder: 0,
coverage: 0,
});
continue;
}
let present = 0;
let missing = 0;
let placeholder = 0;
for (const pathParts of enPaths) {
const value = lookupPath(target, pathParts);
if (value === undefined) {
missing++;
continue;
}
// Treat plain objects at scalar positions as missing — shape mismatch.
if (isPlainObject(value)) {
missing++;
continue;
}
present++;
if (typeof value === "string" && value.startsWith(PLACEHOLDER_PREFIX)) {
placeholder++;
}
}
const real = present - placeholder;
const coverage = totalEn === 0 ? 100 : (real / totalEn) * 100;
results.push({ locale, total_en: totalEn, present, missing, placeholder, coverage });
}
const failures = results.filter((r) => r.coverage < opts.threshold);
if (opts.json) {
process.stdout.write(
JSON.stringify(
{
source: SOURCE_LOCALE,
totalKeys: totalEn,
threshold: opts.threshold,
ok: failures.length === 0,
results,
},
null,
2
) + "\n"
);
if (failures.length && !opts.report) process.exit(1);
return;
}
// Human-readable output (table).
const localeW = Math.max(8, ...results.map((r) => r.locale.length));
const header =
pad("locale", localeW) +
" " +
padLeft("coverage", 10) +
" " +
padLeft("real", 6) +
" " +
padLeft("present", 7) +
" " +
padLeft("missing", 7) +
" " +
padLeft("placeholder", 11) +
" " +
padLeft("total_en", 8);
console.log(header);
console.log("-".repeat(header.length));
for (const r of results) {
const real = r.present - r.placeholder;
const pct = `${r.coverage.toFixed(1)}%`;
const marker = r.coverage < opts.threshold ? " ✗" : "";
console.log(
pad(r.locale, localeW) +
" " +
padLeft(pct, 10) +
" " +
padLeft(real, 6) +
" " +
padLeft(r.present, 7) +
" " +
padLeft(r.missing, 7) +
" " +
padLeft(r.placeholder, 11) +
" " +
padLeft(r.total_en, 8) +
marker
);
}
if (failures.length) {
if (opts.report) {
logInfo(
`${failures.length} locale(s) below threshold ${opts.threshold}% — report mode, exiting 0.`
);
} else {
logInfo(`FAIL — ${failures.length} locale(s) below threshold ${opts.threshold}%.`);
for (const f of failures) {
console.log(` - ${f.locale}: ${f.coverage.toFixed(1)}%`);
}
process.exit(1);
}
} else {
logInfo(`PASS — all ${results.length} locale(s) at or above ${opts.threshold}% coverage.`);
}
}
const isDirectRun = import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
main().catch((err) => {
console.error("[i18n-ui-coverage] ERROR", err?.stack || err?.message || String(err));
process.exit(1);
});
}
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Translation check script for OmniRoute.
Checks if all translation keys used in code exist in en.json.
Usage:
python scripts/i18n/check_translations.py
python scripts/i18n/check_translations.py --verbose
python scripts/i18n/check_translations.py --fix
"""
import json
import re
import os
import sys
import argparse
def get_namespaces_in_code(src_dir='src'):
"""Find all namespaces used in code via useTranslations()."""
used_ns = set()
for root, dirs, files in os.walk(src_dir):
for f in files:
if not (f.endswith('.tsx') or f.endswith('.ts')):
continue
try:
content = open(os.path.join(root, f), 'r', encoding='utf-8').read()
matches = re.findall(r'useTranslations\(["\']+([^"\']+)["\']+\)', content)
used_ns.update(matches)
except (IOError, UnicodeDecodeError) as e:
print(f"Warning: could not process file {os.path.join(root, f)}: {e}", file=sys.stderr)
return used_ns
def get_keys_in_json(json_path):
"""Get all keys (including nested) from a JSON file."""
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
keys = set()
def traverse(obj, prefix=''):
if isinstance(obj, dict):
for k, v in obj.items():
key = f"{prefix}.{k}" if prefix else k
keys.add(key)
if isinstance(v, dict):
traverse(v, key)
elif isinstance(obj, list):
for item in obj:
traverse(item, prefix)
traverse(data)
return keys
def check_translations(src_dir='src', en_json_path='src/i18n/messages/en.json', verbose=False):
"""Check if all translation keys used in code exist in en.json."""
# Get namespaces used in code
used_ns = get_namespaces_in_code(src_dir)
# Get namespaces in en.json
with open(en_json_path, 'r', encoding='utf-8') as f:
en_data = json.load(f)
en_ns = set(en_data.keys())
# Find missing namespaces
missing_ns = sorted(used_ns - en_ns)
# Get all keys from en.json
en_keys = get_keys_in_json(en_json_path)
# Get all keys used in code
used_keys = set()
for root, dirs, files in os.walk(src_dir):
for f in files:
if not (f.endswith('.tsx') or f.endswith('.ts')):
continue
try:
content = open(os.path.join(root, f), 'r', encoding='utf-8').read()
matches = re.findall(r't\([\'"]+([^\'")]+)[\'"]+\)', content)
used_keys.update(matches)
except (IOError, UnicodeDecodeError) as e:
print(f"Warning: could not process file {os.path.join(root, f)}: {e}", file=sys.stderr)
# Filter out non-translation keys
# Note: check if key IS a path or ends with extension, not just contains it
# e.g., "invoice.ts.description" (ts = timestamp) should NOT be filtered
# but "components/Button.tsx" or "utils.ts" should be
def is_likely_file_path(key: str) -> bool:
if key.endswith('.ts') or key.endswith('.tsx') or key.endswith('.js') or key.endswith('.json'):
return True
if '/.ts' in key or '/.tsx' in key or '/.js' in key or '/.json' in key:
return True
if '\\.ts' in key or '\\.tsx' in key or '\\.js' in key or '\\.json' in key:
return True
return False
filtered = {k for k in used_keys if len(k) > 1
and not any(x in k for x in ['../', '@', '\\', '#', '?'])
and not is_likely_file_path(k)
and k not in ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS', 'PATCH']
and not k.startswith('#')
and k not in [',', '-', '.', ':', '?', ' ', '']}
# Find missing keys
real_missing = []
for k in filtered:
if k in en_keys:
continue
# Check if key matches any full key in en.json
matched = False
for ek in en_keys:
if k in ek or ek.endswith(k):
matched = True
break
if matched:
continue
# Filter out paths and obvious non-translations
if k.startswith('./') or k.startswith('/') or k.startswith('x-') or k.startswith('user-'):
continue
# Use same logic - check if key IS a path or ends with extension
if is_likely_file_path(k):
continue
if k in ['Authorization', 'Content-Disposition', 'IOPlatformUUID', 'REG_SZ']:
continue
real_missing.append(k)
return missing_ns, sorted(real_missing)
def main():
parser = argparse.ArgumentParser(description='Check translation keys in en.json')
parser.add_argument('--verbose', '-v', action='store_true', help='Show detailed output')
parser.add_argument('--fix', action='store_true', help='Generate fix suggestions')
parser.add_argument('--src', default='src', help='Source directory')
parser.add_argument('--json', default='src/i18n/messages/en.json', help='Path to en.json')
args = parser.parse_args()
missing_ns, missing_keys = check_translations(args.src, args.json, args.verbose)
has_issues = False
if missing_ns:
has_issues = True
print("=== MISSING NAMESPACES ===")
for ns in missing_ns:
print(f" - {ns}")
if args.fix:
print(f" → Add '{ns}' section to en.json")
if missing_keys:
has_issues = True
print("\n=== MISSING TRANSLATION KEYS ===")
for k in missing_keys:
print(f" - {k}")
if args.fix:
print(f" → Add to appropriate namespace in en.json")
if not has_issues:
print("✓ All translation namespaces and keys are present in en.json")
sys.exit(0)
else:
print(f"\nTotal: {len(missing_ns)} namespace(s), {len(missing_keys)} key(s) missing")
sys.exit(1)
if __name__ == '__main__':
main()
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* Extract NEW i18n keys created by subagents from git diff.
*
* For each modified .tsx file, walk the unified diff and pair "-" lines
* (containing literal English text) with their following "+" lines
* (now using `t("key")`). When we find a stable pairing, record the key
* and its original English value.
*
* The output is a per-namespace map ready to merge into en.json.
*/
import { execSync } from "node:child_process";
const diff = execSync('git diff --unified=0 "src/app/(dashboard)"', {
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
});
const blocks = diff.split(/^diff --git /m).slice(1);
const allPairs = []; // { file, removed, added }
for (const block of blocks) {
const headLine = block.split("\n")[0];
const fileMatch = headLine.match(/b\/(.+?)$/);
const file = fileMatch ? fileMatch[1] : "?";
const lines = block.split("\n");
// Walk in groups: consecutive "-" lines, then consecutive "+" lines.
// Pair them positionally (removed[i] ↔ added[i]).
let removed = [];
let added = [];
function flush() {
const n = Math.min(removed.length, added.length);
for (let i = 0; i < n; i++) allPairs.push({ file, removed: removed[i], added: added[i] });
// If removed.length > added.length, pair remaining removed with last added (multi-string in one new line)
if (removed.length > added.length && added.length > 0) {
for (let i = added.length; i < removed.length; i++) {
allPairs.push({ file, removed: removed[i], added: added[added.length - 1] });
}
}
removed = [];
added = [];
}
for (const line of lines) {
if (line.startsWith("---") || line.startsWith("+++")) continue;
if (line.startsWith("-")) {
if (added.length) flush();
removed.push(line.slice(1));
} else if (line.startsWith("+")) {
added.push(line.slice(1));
} else {
flush();
}
}
flush();
}
/** Patterns inside JSX or attribute strings */
const T_CALL_RE = /\bt\(\s*["']([^"']+)["']\s*\)/g;
const JSX_TEXT_RE = />([^<>{}\n]+)</g;
const ATTR_RE = /\b(?:title|placeholder|aria-label|alt|label)\s*=\s*["']([^"']+)["']/g;
const newKeys = new Map(); // key -> { value, file }
function recordKey(key, value, file) {
const trimmed = value.trim();
if (!trimmed) return;
// Ignore if value contains JSX expression syntax — those were dynamic
if (/^\{|\}$/.test(trimmed)) return;
if (!newKeys.has(key)) {
newKeys.set(key, { value: trimmed, file });
}
}
for (const { file, removed, added } of allPairs) {
// Extract every t("xxx") key from the "added" line
const tKeys = [...added.matchAll(T_CALL_RE)].map((m) => m[1]);
if (!tKeys.length) continue;
// Extract candidate strings from the "removed" line: JSX text + attr values
const candidates = [];
for (const m of removed.matchAll(JSX_TEXT_RE)) candidates.push(m[1]);
for (const m of removed.matchAll(ATTR_RE)) candidates.push(m[1]);
// Direct strings without surrounding markup (rare)
// Apply tag removal repeatedly until stable to prevent incomplete sanitization
// (e.g. "<scr<script>ipt>" collapsing into "<script>" after a single pass)
let stripped = removed;
let prev;
do {
prev = stripped;
stripped = stripped.replace(/<[^<>]+>/g, "");
} while (stripped !== prev);
stripped = stripped.replace(/\bt\([^)]*\)/g, "").trim();
if (stripped && /[A-Za-z]/.test(stripped)) candidates.push(stripped);
// For each tKey in added, try to align with the candidate at the same index.
// The agents typically replaced strings in the same left-to-right order.
for (let i = 0; i < tKeys.length; i++) {
const candidate = candidates[i] ?? candidates[candidates.length - 1];
if (!candidate) continue;
recordKey(tKeys[i], candidate, file);
}
}
// Group by file's primary namespace via useTranslations() call in file
import { readFileSync } from "node:fs";
function inferNamespaceForFile(file) {
try {
const src = readFileSync(file, "utf8");
const m = src.match(/useTranslations\s*\(\s*["']([^"']+)["']\s*\)/);
return m?.[1] ?? "common";
} catch {
return "common";
}
}
const byNamespace = new Map();
for (const [key, { value, file }] of newKeys) {
const ns = inferNamespaceForFile(file);
if (!byNamespace.has(ns)) byNamespace.set(ns, []);
byNamespace.get(ns).push({ key, value });
}
for (const [ns, items] of byNamespace) {
console.log(`\nNEW_KEYS_FOR_NAMESPACE: ${ns}`);
console.log("{");
for (const { key, value } of items) {
// Escape value for JSON
console.log(` ${JSON.stringify(key)}: ${JSON.stringify(value)},`);
}
console.log("}");
}
console.log(`\nTotal extracted: ${newKeys.size} keys across ${byNamespace.size} namespaces`);
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env node
/**
* fill-missing-from-en.mjs — fills missing keys in all non-EN locale JSON files
* with the EN fallback value. Does NOT add translation markers (__MISSING__).
* Only fills keys that are absent — never overwrites existing translated values.
*
* Usage:
* node scripts/i18n/fill-missing-from-en.mjs
*
* Idempotent. Safe to run repeatedly.
*/
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = new URL("../../src/i18n/messages/", import.meta.url).pathname;
const EN = JSON.parse(readFileSync(join(ROOT, "en.json"), "utf-8"));
function fillMissing(target, source) {
for (const k of Object.keys(source)) {
if (typeof source[k] === "object" && source[k] !== null && !Array.isArray(source[k])) {
target[k] = target[k] && typeof target[k] === "object" ? target[k] : {};
fillMissing(target[k], source[k]);
} else if (!(k in target)) {
target[k] = source[k]; // fallback EN value
}
}
}
let touched = 0;
for (const file of readdirSync(ROOT)) {
if (!file.endsWith(".json") || file === "en.json") continue;
const path = join(ROOT, file);
const data = JSON.parse(readFileSync(path, "utf-8"));
const before = JSON.stringify(data);
fillMissing(data, EN);
const after = JSON.stringify(data);
if (before !== after) {
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
touched++;
console.log(`[i18n] filled missing in ${file}`);
}
}
console.log(`[i18n] done — touched ${touched} locale files`);
File diff suppressed because it is too large Load Diff
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
const ROOT = process.cwd();
const APP_DIR = path.join(ROOT, "src", "app");
const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages");
const REPORTS_DIR = path.join(ROOT, "docs", "reports");
const I18N_README_DIR = path.join(ROOT, "docs", "i18n");
const PRIORITY_LOCALES = ["es", "fr", "de", "ja", "ar"];
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const out = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...(await walk(full)));
continue;
}
out.push(full);
}
return out;
}
function routeFromPage(pageFile) {
const rel = path.relative(APP_DIR, pageFile).replace(/\\/g, "/");
const noPage = rel.replace(/(^|\/)page\.tsx$/, "");
if (!noPage) {
return "/";
}
const segments = noPage
.split("/")
.filter(Boolean)
.filter((segment) => !/^\(.+\)$/.test(segment));
return `/${segments.join("/")}`;
}
function classifyPriority(route) {
const highPatterns = [
"/dashboard/usage",
"/dashboard/providers",
"/dashboard/settings",
"/dashboard/endpoint",
"/dashboard/logs",
"/dashboard/audit-log",
"/dashboard/health",
"/dashboard/api-manager",
"/dashboard/cli-tools",
"/dashboard/combos",
"/dashboard/translator",
"/dashboard/analytics",
"/dashboard/costs",
"/dashboard/limits",
];
if (highPatterns.some((pattern) => route.startsWith(pattern))) {
return "Alta";
}
if (route === "/") {
return "Baixa";
}
return "Media";
}
function countRegex(content, regex) {
const matches = content.match(regex);
return matches ? matches.length : 0;
}
async function collectRouteRiskMetrics(pageFile) {
const routeDir = path.dirname(pageFile);
if (routeDir === APP_DIR) {
const raw = await fs.readFile(pageFile, "utf8");
return {
files: 1,
fixedWidth: countRegex(raw, /\bw-(?:\d+|\[[^\]]+\]|\d+\/\d+)\b/g),
directional: countRegex(
raw,
/\b(?:left|right|ml|mr|pl|pr)-[\w\[\]-]+\b|\btext-(?:left|right)\b/g
),
clipping: countRegex(raw, /\btruncate\b|\bline-clamp-\d+\b|\boverflow-hidden\b/g),
};
}
const files = await (async function walkRouteFiles(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const acc = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (full !== routeDir) {
const nestedEntries = await fs.readdir(full);
if (nestedEntries.includes("page.tsx")) {
continue;
}
}
acc.push(...(await walkRouteFiles(full)));
continue;
}
if (/\.(tsx|ts)$/.test(entry.name)) {
acc.push(full);
}
}
return acc;
})(routeDir);
let fixedWidth = 0;
let directional = 0;
let clipping = 0;
for (const file of files) {
const raw = await fs.readFile(file, "utf8");
fixedWidth += countRegex(raw, /\bw-(?:\d+|\[[^\]]+\]|\d+\/\d+)\b/g);
directional += countRegex(
raw,
/\b(?:left|right|ml|mr|pl|pr)-[\w\[\]-]+\b|\btext-(?:left|right)\b/g
);
clipping += countRegex(raw, /\btruncate\b|\bline-clamp-\d+\b|\boverflow-hidden\b/g);
}
return {
files: files.length,
fixedWidth,
directional,
clipping,
};
}
function getByPath(target, keyPath) {
return keyPath.split(".").reduce((acc, token) => (acc ? acc[token] : undefined), target);
}
function collectStringKeys(node, prefix = "", out = []) {
if (typeof node === "string") {
out.push(prefix);
return out;
}
if (Array.isArray(node)) {
node.forEach((item, index) => collectStringKeys(item, `${prefix}[${index}]`, out));
return out;
}
if (node && typeof node === "object") {
for (const key of Object.keys(node)) {
collectStringKeys(node[key], prefix ? `${prefix}.${key}` : key, out);
}
}
return out;
}
async function runAutomatedChecks() {
const en = JSON.parse(await fs.readFile(path.join(MESSAGES_DIR, "en.json"), "utf8"));
const enKeys = collectStringKeys(en).sort();
const localeFiles = (await fs.readdir(MESSAGES_DIR)).filter((file) => file.endsWith(".json"));
const localeCodes = localeFiles.map((file) => file.replace(/\.json$/, "")).sort();
const parityIssues = [];
for (const code of localeCodes) {
if (code === "en") {
continue;
}
const raw = await fs.readFile(path.join(MESSAGES_DIR, `${code}.json`), "utf8");
const data = JSON.parse(raw);
const keys = collectStringKeys(data).sort();
const missing = enKeys.filter((key) => !keys.includes(key));
const extra = keys.filter((key) => !enKeys.includes(key));
if (missing.length || extra.length) {
parityIssues.push({ code, missing: missing.length, extra: extra.length });
}
}
const readmeLabelChecks = [];
// Check that README has language selector line with emoji flag
const expectedPattern = /^🌐 \*\*Languages:\*\*/;
for (const code of PRIORITY_LOCALES) {
const readmePath = path.join(I18N_README_DIR, code, "README.md");
let content = "";
try {
content = await fs.readFile(readmePath, "utf8");
} catch {
// Skip if README doesn't exist
continue;
}
const line = content.split("\n").find((entry) => entry.startsWith("🌐 **Languages:**")) || "";
const ok = expectedPattern.test(line);
readmeLabelChecks.push({ file: `docs/i18n/${code}/README.md`, ok, line });
}
let anchorLineRemoved = true;
let brAppendixRemoved = true;
// Check specific languages (ar, ja) for legacy content
const legacyCheckLocales = ["ar", "ja"];
for (const code of legacyCheckLocales) {
const readmePath = path.join(I18N_README_DIR, code, "README.md");
try {
const content = await fs.readFile(readmePath, "utf8");
if (content.includes("**[English](#-omniroute--the-free-ai-gateway)**")) {
anchorLineRemoved = false;
}
if (content.includes("## 🇧🇷 OmniRoute")) {
brAppendixRemoved = false;
}
} catch {
// Skip if README doesn't exist
}
}
return {
localeCodes,
parityIssues,
readmeLabelChecks,
anchorLineRemoved,
brAppendixRemoved,
};
}
async function main() {
const allFiles = await walk(APP_DIR);
const pageFiles = allFiles.filter((file) => file.endsWith("/page.tsx")).sort();
const automated = await runAutomatedChecks();
const routeRows = [];
for (const pageFile of pageFiles) {
const route = routeFromPage(pageFile);
const metrics = await collectRouteRiskMetrics(pageFile);
routeRows.push({
route,
priority: classifyPriority(route),
...metrics,
status: "Pendente validacao visual",
});
}
const date = new Date().toISOString().slice(0, 10);
const reportPath = path.join(REPORTS_DIR, `i18n-qa-checklist-${date}.md`);
const automatedChecksLines = [
`- Locale files detectados: **${automated.localeCodes.length}**`,
`- Locales priorizados nesta rodada: **${PRIORITY_LOCALES.join(", ")}**`,
`- Paridade EN x demais idiomas: **${automated.parityIssues.length === 0 ? "OK" : "COM GAPS"}**`,
];
if (automated.parityIssues.length > 0) {
automatedChecksLines.push(
...automated.parityIssues.map(
(issue) => ` - ${issue.code}: missing=${issue.missing} extra=${issue.extra}`
)
);
}
automatedChecksLines.push(
`- Language selector (🌐 **Languages:**) in README (es/fr/de/ja/ar): **${automated.readmeLabelChecks.every((item) => item.ok) ? "OK" : "FALHAS"}**`,
`- Linha legacy EN/PT removida em ja/ar: **${automated.anchorLineRemoved ? "OK" : "PENDENTE"}**`,
`- Apêndice "## 🇧🇷 OmniRoute" removido em ja/ar: **${automated.brAppendixRemoved ? "OK" : "PENDENTE"}**`,
"- RTL habilitado globalmente para `ar` e `he` via `dir=rtl` no layout."
);
const routeTableHeader =
"| Rota | Prioridade | Arquivos da rota | Risco truncamento (w-*) | Risco RTL direcional (left/right etc.) | Clipping (`truncate/line-clamp/overflow-hidden`) | Status |";
const routeTableDivider = "|---|---|---:|---:|---:|---:|---|";
const routeTableRows = routeRows.map(
(row) =>
`| \`${row.route}\` | ${row.priority} | ${row.files} | ${row.fixedWidth} | ${row.directional} | ${row.clipping} | ${row.status} |`
);
const content = [
"# Checklist QA i18n - Revisao Priorizada",
"",
`Data: ${date}`,
"Escopo: validacao de UI e traducao para `es`, `fr`, `de`, `ja`, `ar`.",
"",
"## Resultado da revisao manual priorizada (item 1)",
"",
"- Ajustes manuais aplicados em `messages` para `es/fr/de/ja/ar` nos namespaces de alta visibilidade:",
" - `common.*`",
" - `sidebar.*`",
" - `header.*`",
" - `home.*`",
" - `auth.*`",
"- Ajustes de README aplicados em `README.es.md`, `README.fr.md`, `README.de.md`, `README.ja.md`, `README.ar.md`:",
" - Prefixo local da linha de idiomas (bandeiras mantidas)",
" - Navegacao superior localizada em `ja/ar`",
" - Remocao da linha legada de anchors EN/PT em `ja/ar`",
" - Remocao de apendice duplicado em `ja/ar`",
"",
"## Checagens automaticas",
"",
...automatedChecksLines,
"",
"## Checklist por pagina (item 2)",
"",
"Legenda:",
"- Prioridade Alta: validar obrigatoriamente em desktop + mobile para `es/fr/de/ja/ar`, e em `ar` com fluxo RTL completo.",
"- Risco truncamento: contagem heuristica de classes `w-*` em arquivos da rota.",
"- Risco RTL direcional: contagem heuristica de classes com direcao fisica (`left/right/ml/mr/pl/pr/text-left/text-right`).",
"",
routeTableHeader,
routeTableDivider,
...routeTableRows,
"",
"## Passos de validacao visual recomendados",
"",
"1. Trocar idioma no seletor e recarregar rota atual (cookie + refresh).",
"2. Validar titulos, subtitulos, CTAs e textos de estado vazios/erro/sucesso.",
"3. Em `ar`: validar alinhamento RTL, ordem visual dos blocos e leitura dos componentes em tabela.",
"4. Validar cards/tabelas com textos longos para evitar corte indevido e overflow horizontal.",
"5. Validar labels com placeholders (`{count}`, `{provider}`, `{url}`) e tags (`<logs>`, `<analytics>`).",
"",
"## Observacoes",
"",
"- Este checklist combina validacao automatica + roteiro de validacao manual por pagina.",
"- Nao foram executados testes E2E visuais nesta rodada (Playwright/screenshot).",
].join("\n");
await fs.mkdir(REPORTS_DIR, { recursive: true });
await fs.writeFile(reportPath, `${content}\n`, "utf8");
console.log(reportPath);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""
DEPRECATED 2026-05-13. Use `npm run i18n:run`
(scripts/i18n/run-translation.mjs) instead. This Python script will be
removed in v3.10.
The Node-based translator is hash-incremental (only retranslates files
whose source SHA-256 changed), runs the OmniRoute Cloud LLM through
OMNIROUTE_TRANSLATION_* env vars, and is wired into `npm run i18n:run`
and `npm run i18n:check`.
Historical purpose:
Scanned `docs/i18n/` markdown files and translated English paragraphs
into the target language by paragraph through an OpenAI-compatible LLM.
"""
import sys
print(
"WARN: scripts/i18n/i18n_autotranslate.py is deprecated. Use 'npm run i18n:run' instead.",
file=sys.stderr,
)
import os
import re
import glob
import json
import urllib.request
import urllib.error
import argparse
from pathlib import Path
# The base path of the project
SCRIPT_DIR = Path(__file__).parent.resolve()
PROJECT_ROOT = SCRIPT_DIR.parent
I18N_DIR = PROJECT_ROOT / "docs" / "i18n"
def get_language_name(lang_code):
lang_map = {
"pt-BR": "Portuguese (Brazil)", "es": "Spanish", "fr": "French",
"it": "Italian", "ru": "Russian", "zh-CN": "Simplified Chinese",
"de": "German", "in": "Hindi", "th": "Thai", "uk-UA": "Ukrainian",
"ar": "Arabic", "az": "Azerbaijani", "ja": "Japanese", "vi": "Vietnamese", "bg": "Bulgarian",
"da": "Danish", "fi": "Finnish", "he": "Hebrew", "hu": "Hungarian",
"id": "Indonesian", "ko": "Korean", "ms": "Malay", "nl": "Dutch",
"no": "Norwegian", "pt": "Portuguese (Portugal)", "ro": "Romanian",
"pl": "Polish", "sk": "Slovak", "sv": "Swedish", "phi": "Filipino",
"cs": "Czech"
}
return lang_map.get(lang_code, lang_code)
def translate_block(text, target_language, api_url, api_key, model):
if not text.strip():
return text
prompt = (
f"You are a professional technical translator working on the OmniRoute proxy project documentation.\n"
f"Translate the following Markdown text from English to {target_language}.\n"
f"CRITICAL RULES:\n"
f"- Do NOT translate code blocks (```...```).\n"
f"- Do NOT translate markdown formatting elements, links syntax, or image syntax.\n"
f"- Retain formatting perfectly.\n"
f"- Only return the translated text without introductory phrases.\n\n"
f"{text}"
)
data = {
"model": model,
"messages": [
{"role": "system", "content": "You are a direct translator. Output only the requested translation."},
{"role": "user", "content": prompt}
],
"temperature": 0.3,
"stream": False
}
req = urllib.request.Request(
f"{api_url}/chat/completions",
data=json.dumps(data).encode('utf-8'),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
}
)
try:
with urllib.request.urlopen(req) as response:
result = json.loads(response.read().decode())
if "choices" in result and len(result["choices"]) > 0:
translated = result["choices"][0]["message"]["content"]
return translated.strip()
except Exception as e:
print(f" ❌ API Error: {e}")
return text
def process_file(file_path, target_language, api_url, api_key, model):
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Simple heuristic: we look for English common words to identify if a block needs translation.
# A true robust implementation would diff against the English source.
# For now, we split by double newlines (markdown blocks)
blocks = content.split('\n\n')
translated_blocks = []
english_words = [" the ", " is ", " are ", " this ", " that ", " a ", " to "]
needs_update = False
for block in blocks:
# Skip translation if it's a pure code block or doesn't have English markers
if block.startswith('```') or block.startswith('<div') or block.startswith('🌐') or block.startswith('|'):
translated_blocks.append(block)
continue
is_english = any(w in block.lower() for w in english_words)
if is_english and len(block.strip()) > 10:
print(f" 🔄 Translating paragraph (length {len(block)})...")
new_block = translate_block(block, target_language, api_url, api_key, model)
if new_block != block:
needs_update = True
translated_blocks.append(new_block)
else:
translated_blocks.append(block)
if needs_update:
with open(file_path, 'w', encoding='utf-8') as f:
f.write('\n\n'.join(translated_blocks))
print(f" ✅ Updated translations in {file_path.name}")
else:
print(f"{file_path.name} already fully translated or no English blocks found.")
def main():
parser = argparse.ArgumentParser(description="OmniRoute Auto-Translator for i18n Markdown")
parser.add_argument("--api-url", default="http://localhost:20128/v1", help="Base URL of OmniRoute or target provider")
parser.add_argument("--api-key", default="sk-test", help="API Key for the provider")
parser.add_argument("--model", default="gemini/gemini-3-flash", help="Model name to use")
parser.add_argument("--lang", default=None, help="Process only a specific language code (e.g. pt-BR)")
args = parser.parse_args()
print(f"🚀 Starting Auto-Translator")
print(f"🔗 Target API: {args.api_url} | Model: {args.model}\n")
if args.lang:
lang_dirs = [d for d in I18N_DIR.iterdir() if d.is_dir() and d.name == args.lang]
else:
lang_dirs = [d for d in I18N_DIR.iterdir() if d.is_dir()]
for lang_dir in lang_dirs:
lang_code = lang_dir.name
lang_name = get_language_name(lang_code)
print(f"\n🌍 Processing {lang_name} ({lang_code})")
md_files = list(lang_dir.glob("*.md"))
for md_file in md_files:
process_file(md_file, lang_name, args.api_url, args.api_key, args.model)
if __name__ == "__main__":
main()
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
/**
* Merge keys from scripts/i18n/_pending-keys.json into src/i18n/messages/en.json
*
* Format of _pending-keys.json:
* { "namespace": { "key": "value", ... }, ... }
*
* Keys are appended to the END of each namespace block. Existing keys with
* the same name are preserved (we never overwrite).
*/
import { readFileSync, writeFileSync } from "node:fs";
const SRC = "src/i18n/messages/en.json";
const PENDING = "scripts/i18n/_pending-keys.json";
const enJson = JSON.parse(readFileSync(SRC, "utf8"));
const pending = JSON.parse(readFileSync(PENDING, "utf8"));
let added = 0;
let skipped = 0;
for (const [ns, keys] of Object.entries(pending)) {
if (!Object.prototype.hasOwnProperty.call(enJson, ns)) {
console.warn(`! namespace missing in en.json: ${ns} — skipping`);
continue;
}
const target = enJson[ns];
for (const [k, v] of Object.entries(keys)) {
if (Object.prototype.hasOwnProperty.call(target, k)) {
skipped++;
continue;
}
target[k] = v;
added++;
}
}
writeFileSync(SRC, JSON.stringify(enJson, null, 2) + "\n");
console.log(`✓ merged ${added} new keys (${skipped} skipped — already present)`);
+628
View File
@@ -0,0 +1,628 @@
#!/usr/bin/env node
/**
* OmniRoute — Docs translation pipeline (hash-based, incremental).
*
* Source of truth: `config/i18n.json` (locale list) and the original English
* markdown files at the repo root (`CLAUDE.md`, `GEMINI.md`, `README.md`, …)
* plus `docs/*.md`.
*
* Targets land in `docs/i18n/<locale>/...` mirroring the source layout, with a
* header (top H1 + language bar) and an `---` separator before the translated
* body. This is the same shape the existing `check-docs-sync.mjs` already
* understands.
*
* State: `.i18n-state.json` stores a SHA-256 hash for every source file and
* for every produced target. Re-runs only retranslate files whose source hash
* changed or whose target file is missing.
*
* Usage (driven by npm scripts in package.json):
* npm run i18n:run
* npm run i18n:run -- --locale=pt-BR
* npm run i18n:run -- --files=CLAUDE.md,docs/ARCHITECTURE.md
* npm run i18n:run -- --force
* npm run i18n:run:dry
*
* Backend (configured via env, never committed):
* OMNIROUTE_TRANSLATION_API_URL e.g. https://cloud.omniroute.dev/v1
* OMNIROUTE_TRANSLATION_API_KEY bearer token (kept out of logs)
* OMNIROUTE_TRANSLATION_MODEL e.g. cx/gpt-5.4-mini
* OMNIROUTE_TRANSLATION_TIMEOUT_MS optional, default 60000
* OMNIROUTE_TRANSLATION_CONCURRENCY optional, default 4
*/
import { promises as fs, existsSync, readFileSync } from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import process from "node:process";
import { fileURLToPath, pathToFileURL } from "node:url";
// ----- .env loader --------------------------------------------------------
// Loads variables from a local `.env` (gitignored) into process.env without
// pulling dotenv as a dependency. Already-set env vars take precedence so the
// shell / CI environment can still override.
(function loadDotEnv() {
const envPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".env");
if (!existsSync(envPath)) return;
try {
const raw = readFileSync(envPath, "utf8");
for (const rawLine of raw.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
if (!key || process.env[key] !== undefined) continue;
let value = line.slice(eq + 1);
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
} catch {
/* ignore — script will fall back to the requireEnv error path */
}
})();
// Prettier is loaded lazily on first use so the script still runs (with a
// warning) in environments where node_modules has not been installed. The
// formatter is applied to every translated file before its hash is recorded,
// so a subsequent lint-staged Prettier pass cannot mutate the file content
// out from under `.i18n-state.json`.
let prettierMod = null;
async function getPrettier() {
if (prettierMod !== null) return prettierMod;
try {
prettierMod = await import("prettier");
} catch {
prettierMod = false;
}
return prettierMod;
}
async function formatMarkdown(content, fileName) {
const p = await getPrettier();
if (!p) return content;
try {
return await p.format(content, { parser: "markdown", filepath: fileName });
} catch (err) {
logWarn(`prettier could not format ${fileName}: ${err.message}`);
return content;
}
}
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SCRIPT_DIR, "..", "..");
const CONFIG_PATH = path.join(ROOT, "config", "i18n.json");
const STATE_PATH = path.join(ROOT, ".i18n-state.json");
const DOCS_I18N_DIR = path.join(ROOT, "docs", "i18n");
const DOCS_DIR = path.join(ROOT, "docs");
// ----- Source set ----------------------------------------------------------
//
// Root-level markdown files that should be translated as `docs/i18n/<loc>/<name>`.
// Strict-mirror files (`llm.txt`, `CHANGELOG.md`) are intentionally NOT in this
// list — they are handled by `scripts/check-docs-sync.mjs` rules and are kept
// in sync by other tooling. Adding them here would conflict with that script.
const ROOT_DOC_SOURCES = [
"CLAUDE.md",
"GEMINI.md",
"AGENTS.md",
"CONTRIBUTING.md",
"SECURITY.md",
"CODE_OF_CONDUCT.md",
"README.md",
];
// File names inside `docs/` that should NOT be translated. Anything else with
// a `.md` extension at the top of `docs/` is treated as a source.
const DOCS_EXCLUDED_NAMES = new Set([
"I18N.md", // Translator tooling docs — kept English-only for operators.
"README.md", // Section index files — auto-generated, not prose translation targets.
]);
// Sub-trees we never recurse into when collecting sources.
const DOCS_EXCLUDED_SUBDIRS = new Set([
"i18n",
"screenshots",
"superpowers",
"diagrams",
"reports",
]);
// ----- Helpers -------------------------------------------------------------
function logInfo(...parts) {
console.log("[i18n-run]", ...parts);
}
function logWarn(...parts) {
console.warn("[i18n-run] WARN", ...parts);
}
function logError(...parts) {
console.error("[i18n-run] ERROR", ...parts);
}
function sha256(buffer) {
return crypto.createHash("sha256").update(buffer).digest("hex");
}
function parseArgs(argv) {
const opts = {
locales: null,
files: null,
force: false,
dryRun: false,
concurrency: null,
};
for (const arg of argv.slice(2)) {
if (arg === "--force") opts.force = true;
else if (arg === "--dry-run" || arg === "--dryrun") opts.dryRun = true;
else if (arg.startsWith("--locale="))
opts.locales = arg
.slice(9)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
else if (arg.startsWith("--locales="))
opts.locales = arg
.slice(10)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
else if (arg.startsWith("--files="))
opts.files = arg
.slice(8)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
else if (arg.startsWith("--concurrency=")) opts.concurrency = Number(arg.slice(14));
else if (arg === "--help" || arg === "-h") {
console.log(
[
"Usage: node scripts/i18n/run-translation.mjs [options]",
"",
" --locale=<csv> Target locales (default: all except `en`)",
" --files=<csv> Relative paths to translate (default: all sources)",
" --force Retranslate even when hashes match",
" --dry-run Report what would happen but never call the API",
" --concurrency=<n> Parallel API requests (default: env CONCURRENCY or 4)",
].join("\n")
);
process.exit(0);
}
}
return opts;
}
async function loadConfig() {
const raw = await fs.readFile(CONFIG_PATH, "utf8");
const cfg = JSON.parse(raw);
if (!cfg.default || !Array.isArray(cfg.locales)) {
throw new Error("config/i18n.json: invalid shape (need `default` and `locales[]`)");
}
return cfg;
}
async function loadState() {
if (!existsSync(STATE_PATH)) return { sources: {} };
try {
const raw = await fs.readFile(STATE_PATH, "utf8");
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" && parsed.sources ? parsed : { sources: {} };
} catch (err) {
logWarn(`could not parse ${path.relative(ROOT, STATE_PATH)} — starting fresh (${err.message})`);
return { sources: {} };
}
}
async function saveState(state) {
const json = JSON.stringify(state, null, 2) + "\n";
await fs.writeFile(STATE_PATH, json, "utf8");
}
async function collectDocsSources() {
const found = [];
for (const entry of await fs.readdir(DOCS_DIR, { withFileTypes: true })) {
if (entry.isFile() && entry.name.endsWith(".md") && !DOCS_EXCLUDED_NAMES.has(entry.name)) {
found.push(`docs/${entry.name}`);
} else if (entry.isDirectory() && !DOCS_EXCLUDED_SUBDIRS.has(entry.name)) {
// Recurse one level for organized doc groups (e.g. docs/features/*.md).
const sub = path.join(DOCS_DIR, entry.name);
for (const child of await fs.readdir(sub, { withFileTypes: true })) {
if (
child.isFile() &&
child.name.endsWith(".md") &&
!DOCS_EXCLUDED_NAMES.has(child.name) &&
child.name.toLowerCase() !== "readme.md"
) {
found.push(`docs/${entry.name}/${child.name}`);
}
}
}
}
return found;
}
async function collectAllSources() {
const rootSources = [];
for (const name of ROOT_DOC_SOURCES) {
const abs = path.join(ROOT, name);
if (existsSync(abs)) rootSources.push(name);
}
const docsSources = await collectDocsSources();
return [...rootSources, ...docsSources].sort();
}
function targetPathFor(relSource, locale) {
// Root MDs (`CLAUDE.md`, …) → `docs/i18n/<loc>/CLAUDE.md`
if (!relSource.includes("/")) {
return path.join(DOCS_I18N_DIR, locale, relSource);
}
// `docs/X.md` → `docs/i18n/<loc>/docs/X.md`
// `docs/features/Y.md` → `docs/i18n/<loc>/docs/features/Y.md`
return path.join(DOCS_I18N_DIR, locale, relSource);
}
function relativeBackToRoot(targetAbsPath) {
// From the target file's directory back to the repo root, used to build the
// "🇺🇸 English" link in the language bar.
const targetDir = path.dirname(targetAbsPath);
const rel = path.relative(targetDir, ROOT);
return rel === "" ? "." : rel;
}
function buildLanguageBar(relSource, locale, config) {
const targetAbs = targetPathFor(relSource, locale);
const targetDir = path.dirname(targetAbs);
const rootRel = relativeBackToRoot(targetAbs);
const parts = [];
// English link → source file relative to target dir.
const enRel = path.relative(targetDir, path.join(ROOT, relSource));
parts.push(`🇺🇸 [English](${enRel.split(path.sep).join("/")})`);
for (const entry of config.locales) {
if (entry.code === "en" || entry.code === locale) continue;
const peerAbs = targetPathFor(relSource, entry.code);
const peerRel = path.relative(targetDir, peerAbs).split(path.sep).join("/");
parts.push(`${entry.flag} [${entry.code}](${peerRel})`);
}
return `🌐 **Languages:** ${parts.join(" · ")}`;
// Quiet the unused linter warning for rootRel — kept here for future expansion.
void rootRel;
}
function extractTopHeading(markdown) {
const m = markdown.match(/^# (.+)\r?\n/);
return m ? m[1].trim() : null;
}
function stripTopHeading(markdown) {
return markdown.replace(/^# .+\r?\n+/, "");
}
// ----- Translator backend --------------------------------------------------
function requireEnv(name) {
const v = process.env[name];
if (!v || !v.trim()) {
throw new Error(
`Missing required env var: ${name}. Set it in .env (see docs/guides/I18N.md → "Translation pipeline").`
);
}
return v.trim();
}
function backendConfig() {
const apiUrl = requireEnv("OMNIROUTE_TRANSLATION_API_URL").replace(/\/$/, "");
const apiKey = requireEnv("OMNIROUTE_TRANSLATION_API_KEY");
const model = requireEnv("OMNIROUTE_TRANSLATION_MODEL");
const timeoutMs = Number(process.env.OMNIROUTE_TRANSLATION_TIMEOUT_MS || 60000);
return { apiUrl, apiKey, model, timeoutMs };
}
async function callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry = 0) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`${apiUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages,
temperature: 0.15,
stream: false,
}),
signal: ctrl.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const transient = res.status === 408 || res.status === 429 || res.status >= 500;
if (transient && retry < 1) {
const wait = 1500 + retry * 1500;
logWarn(`upstream ${res.status} — retrying after ${wait}ms`);
await new Promise((r) => setTimeout(r, wait));
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw new Error(`upstream ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
const content = json?.choices?.[0]?.message?.content;
if (typeof content !== "string" || !content) {
throw new Error("upstream returned empty content");
}
return content;
} catch (err) {
if (err?.name === "AbortError") {
if (retry < 1) {
logWarn(`timeout after ${timeoutMs}ms — retrying`);
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw new Error(`timeout after ${timeoutMs}ms`);
}
if (
retry < 1 &&
err instanceof TypeError &&
/fetch failed|ECONN|ENOTFOUND|network/i.test(String(err.cause ?? err.message))
) {
logWarn(`network error: ${err.message} — retrying`);
await new Promise((r) => setTimeout(r, 1500));
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw err;
} finally {
clearTimeout(timer);
}
}
const SYSTEM_PROMPT = (englishName, native) =>
[
`You are a professional translator for technical software documentation.`,
`Translate the user's markdown content into ${englishName} (native: ${native}).`,
`Preserve all markdown syntax EXACTLY: headings, lists, code blocks (\`\`\`), inline code (\`...\`), links, images, tables, blockquotes, HTML tags.`,
`Do NOT translate: source code, URLs, file paths, command names (npm/git/curl/node/etc), environment variable names (UPPER_SNAKE_CASE),`,
`version numbers, package names, shell flags, function/class identifiers, JSON keys.`,
`Translate ALL prose, including comments inside code blocks IF they are clearly prose comments (lines starting with # or //).`,
`Return ONLY the translated markdown — no preamble, no explanation, no surrounding fences.`,
].join(" ");
// Splits a markdown body into chunks of <= maxChars, breaking on top-level `## ` headings only.
function chunkMarkdown(markdown, maxChars = 6000) {
if (markdown.length <= maxChars) return [markdown];
const lines = markdown.split("\n");
const chunks = [];
let buf = [];
let size = 0;
for (const line of lines) {
if (line.startsWith("## ") && size > maxChars * 0.5) {
chunks.push(buf.join("\n"));
buf = [line];
size = line.length;
} else {
buf.push(line);
size += line.length + 1;
}
}
if (buf.length) chunks.push(buf.join("\n"));
return chunks;
}
async function translateBody(body, localeEntry, backend) {
const englishName = localeEntry.english ?? localeEntry.name;
const native = localeEntry.native ?? localeEntry.name;
const system = SYSTEM_PROMPT(englishName, native);
const chunks = chunkMarkdown(body);
const translated = [];
for (let i = 0; i < chunks.length; i++) {
const messages = [
{ role: "system", content: system },
{ role: "user", content: chunks[i] },
];
const out = await callChat(messages, backend);
translated.push(out.trim());
if (chunks.length > 1) {
logInfo(
` chunk ${i + 1}/${chunks.length} translated (${chunks[i].length}${out.length} chars)`
);
}
}
// Re-join with a blank line between chunks (we split on `## ` headings).
return translated.join("\n\n");
}
// Simple promise-based semaphore (avoid runtime deps).
function createLimiter(max) {
let active = 0;
const queue = [];
const next = () => {
if (!queue.length || active >= max) return;
active++;
const { fn, resolve, reject } = queue.shift();
fn()
.then((v) => {
active--;
resolve(v);
next();
})
.catch((err) => {
active--;
reject(err);
next();
});
};
return (fn) =>
new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject });
next();
});
}
// ----- Main ----------------------------------------------------------------
async function main() {
const opts = parseArgs(process.argv);
const config = await loadConfig();
const allSources = await collectAllSources();
const state = await loadState();
const sources = opts.files ? allSources.filter((s) => opts.files.includes(s)) : allSources;
if (opts.files) {
const missing = opts.files.filter((f) => !allSources.includes(f));
if (missing.length) {
logWarn(`--files contains paths not in the source set: ${missing.join(", ")}`);
}
}
const docsExcluded = new Set(config.docsExcluded ?? ["en"]);
let targetLocales = config.locales.map((l) => l.code).filter((code) => !docsExcluded.has(code));
if (opts.locales) {
targetLocales = targetLocales.filter((code) => opts.locales.includes(code));
const missing = opts.locales.filter((c) => !config.locales.some((l) => l.code === c));
if (missing.length) {
logWarn(`--locale contains codes not in config/i18n.json: ${missing.join(", ")}`);
}
}
logInfo(`sources: ${sources.length}`);
logInfo(`locales: ${targetLocales.length} (${targetLocales.join(", ")})`);
logInfo(`dry-run: ${opts.dryRun ? "yes" : "no"}, force: ${opts.force ? "yes" : "no"}`);
// Read backend env up front so dry-run can still print masked summary.
let backend = null;
if (!opts.dryRun) {
backend = backendConfig();
if (opts.concurrency) backend.concurrency = opts.concurrency;
else backend.concurrency = Number(process.env.OMNIROUTE_TRANSLATION_CONCURRENCY || 4);
logInfo(
`backend: ${backend.apiUrl} (model=${backend.model}, concurrency=${backend.concurrency}, timeout=${backend.timeoutMs}ms)`
);
} else {
const apiUrl = (process.env.OMNIROUTE_TRANSLATION_API_URL || "").replace(/\/$/, "");
logInfo(`backend (dry-run): ${apiUrl || "<unset>"}`);
}
const limit = createLimiter(opts.dryRun ? 1 : backend.concurrency);
let stats = { translated: 0, skipped: 0, failed: 0, considered: 0 };
const failures = [];
// Precompute source hashes once per source.
const sourceHashes = new Map();
for (const rel of sources) {
const abs = path.join(ROOT, rel);
const buf = await fs.readFile(abs);
sourceHashes.set(rel, { hash: sha256(buf), text: buf.toString("utf8") });
}
// Build a flat queue of (source, locale) work units.
const tasks = [];
for (const rel of sources) {
const { hash: sourceHash } = sourceHashes.get(rel);
const entry =
state.sources[rel] || (state.sources[rel] = { source_hash: sourceHash, locales: {} });
entry.source_hash = sourceHash;
for (const locale of targetLocales) {
stats.considered++;
const targetAbs = targetPathFor(rel, locale);
const previous = entry.locales[locale];
const sourceChanged = previous?.source_hash !== sourceHash;
const missingTarget = !existsSync(targetAbs);
if (!opts.force && !sourceChanged && !missingTarget) {
stats.skipped++;
continue;
}
tasks.push({ rel, locale, targetAbs, sourceChanged, missingTarget });
}
}
logInfo(
`work units: ${tasks.length} (skipped up-to-date: ${stats.skipped} of ${stats.considered})`
);
if (opts.dryRun) {
for (const t of tasks) {
console.log(` [DRY] ${t.rel}${path.relative(ROOT, t.targetAbs)}`);
}
logInfo(`dry-run complete — would translate ${tasks.length} files`);
return;
}
const startMs = Date.now();
await Promise.all(
tasks.map((task) =>
limit(async () => {
const localeEntry = config.locales.find((l) => l.code === task.locale);
const sourceText = sourceHashes.get(task.rel).text;
const sourceHash = sourceHashes.get(task.rel).hash;
const topHeading = extractTopHeading(sourceText);
const body = stripTopHeading(sourceText);
let translatedBody;
try {
translatedBody = await translateBody(body, localeEntry, backend);
} catch (err) {
stats.failed++;
failures.push({ rel: task.rel, locale: task.locale, error: err.message });
logError(`${task.rel} [${task.locale}] failed: ${err.message}`);
return;
}
const heading = topHeading
? `# ${topHeading} (${localeEntry.native})`
: `# ${path.basename(task.rel, ".md")} (${localeEntry.native})`;
const langBar = buildLanguageBar(task.rel, task.locale, config);
const rawContent = `${heading}\n\n${langBar}\n\n---\n\n${translatedBody.trim()}\n`;
// Pre-format with Prettier (markdown parser) so the on-disk content
// matches what `lint-staged` would produce. This keeps `target_hash`
// stable across commit hooks.
const finalContent = await formatMarkdown(rawContent, task.targetAbs);
await fs.mkdir(path.dirname(task.targetAbs), { recursive: true });
await fs.writeFile(task.targetAbs, finalContent, "utf8");
const targetHash = sha256(Buffer.from(finalContent, "utf8"));
state.sources[task.rel].locales[task.locale] = {
source_hash: sourceHash,
target_hash: targetHash,
updated_at: new Date().toISOString(),
};
stats.translated++;
logInfo(`${task.rel}${task.locale} (${translatedBody.length} chars)`);
})
)
);
// Save state even on partial failure so future runs only retry what failed.
await saveState(state);
const elapsedSec = ((Date.now() - startMs) / 1000).toFixed(1);
logInfo(
`summary: translated=${stats.translated}, skipped=${stats.skipped}, failed=${stats.failed}, total considered=${stats.considered}, elapsed=${elapsedSec}s`
);
if (failures.length) {
logWarn(`${failures.length} failures:`);
for (const f of failures) console.warn(` - ${f.rel} [${f.locale}]: ${f.error}`);
process.exit(1);
}
}
const isDirectRun = import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
main().catch((err) => {
logError(err?.stack || err?.message || String(err));
process.exit(1);
});
}
+477
View File
@@ -0,0 +1,477 @@
#!/usr/bin/env node
import { chromium, devices } from "@playwright/test";
import { promises as fs } from "node:fs";
import path from "node:path";
const ROOT = process.cwd();
const REPORTS_DIR = path.join(ROOT, "docs", "reports");
const DATE = new Date().toISOString().slice(0, 10);
const BASE_URL = process.env.QA_BASE_URL || "http://localhost:20128";
const REPORT_SUFFIX = process.env.QA_REPORT_SUFFIX ? `-${process.env.QA_REPORT_SUFFIX}` : "";
const DEFAULT_LOCALES = ["es", "fr", "de", "ja", "ar"];
const RTL_LOCALES = new Set(["ar", "he"]);
const ROUTES = [
"/dashboard/analytics",
"/dashboard/api-manager",
"/dashboard/audit-log",
"/dashboard/cli-tools",
"/dashboard/combos",
"/dashboard/costs",
"/dashboard/endpoint",
"/dashboard/health",
"/dashboard/limits",
"/dashboard/logs",
"/dashboard/providers",
"/dashboard/settings",
"/dashboard/settings/pricing",
"/dashboard/translator",
"/dashboard/usage",
];
function parseRouteList(raw) {
if (!raw) {
return null;
}
const list = raw
.split(",")
.map((value) => value.trim())
.filter(Boolean)
.map((value) => (value.startsWith("/") ? value : `/${value}`));
return list.length > 0 ? list : null;
}
const customRoutes = parseRouteList(process.env.QA_ROUTES);
const ACTIVE_ROUTES = customRoutes || ROUTES;
function parseLocaleList(raw) {
if (!raw) {
return null;
}
const list = raw
.split(",")
.map((value) => value.trim())
.filter(Boolean);
return list.length > 0 ? list : null;
}
const customLocales = parseLocaleList(process.env.QA_LOCALES);
const ACTIVE_LOCALES = customLocales || DEFAULT_LOCALES;
const VIEWPORTS = [
{
name: "desktop",
viewport: { width: 1440, height: 900 },
userAgent: devices["Desktop Chrome"].userAgent,
},
{
name: "mobile",
viewport: devices["iPhone 13"].viewport,
userAgent: devices["iPhone 13"].userAgent,
isMobile: true,
hasTouch: true,
deviceScaleFactor: devices["iPhone 13"].deviceScaleFactor,
},
];
function safeRoute(route) {
return route === "/"
? "root"
: route.replace(/^\//, "").replace(/\//g, "__").replace(/\[|\]/g, "");
}
function classifyResult(item) {
if (item.error && !item.error.startsWith("screenshot-error:")) {
return "Ajuste necessario";
}
if (item.redirectedToLogin && item.route !== "/login") {
return "Ajuste necessario";
}
if (item.rtlMismatch) {
return "Ajuste necessario";
}
if (item.overflowCount > 8 || item.clippedCount > 6) {
return "Revisar";
}
if (item.error && item.error.startsWith("screenshot-error:")) {
return "Revisar";
}
return "OK";
}
async function ensureLoggedIn(page) {
await page.goto(`${BASE_URL}/dashboard`, { waitUntil: "domcontentloaded", timeout: 120000 });
if (!page.url().includes("/login")) {
return true;
}
const password = process.env.INITIAL_PASSWORD || "123456";
const input = page.locator('input[type="password"]');
if ((await input.count()) === 0) {
return false;
}
await input.first().fill(password);
const submit = page.locator('button[type="submit"]');
if ((await submit.count()) === 0) {
return false;
}
await submit.first().click();
await page.waitForTimeout(700);
try {
await page.waitForURL(/\/dashboard(\/.*)?/, { timeout: 30000 });
} catch {
// Keep going, final URL check below.
}
return !page.url().includes("/login");
}
async function evaluatePageHealth(page, locale) {
return page.evaluate(
({ locale, expectRtl }) => {
const hasHorizontalScrollContext = (el) => {
let current = el;
while (current) {
if (!(current instanceof HTMLElement)) {
break;
}
const cls = typeof current.className === "string" ? current.className : "";
if (
cls.includes("overflow-x-auto") ||
cls.includes("overflow-auto") ||
cls.includes("overflow-scroll")
) {
return true;
}
const style = window.getComputedStyle(current);
if (style.overflowX === "auto" || style.overflowX === "scroll") {
return true;
}
current = current.parentElement;
}
return false;
};
const isVisible = (el) => {
const style = window.getComputedStyle(el);
if (
style.display === "none" ||
style.visibility === "hidden" ||
Number(style.opacity) === 0
) {
return false;
}
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const nodes = Array.from(document.querySelectorAll("*"));
let overflowCount = 0;
let clippedCount = 0;
const samples = [];
for (const el of nodes) {
if (!(el instanceof HTMLElement)) {
continue;
}
if (!isVisible(el)) {
continue;
}
const text = (el.innerText || "").trim().replace(/\s+/g, " ");
if (!text || text.length < 12) {
continue;
}
if (text === "Skip to content") {
continue;
}
const cls = el.className || "";
const classString = typeof cls === "string" ? cls : "";
if (
classString.includes("monaco-") ||
el.closest(".monaco-editor") ||
el.closest(".monaco-scrollable-element")
) {
continue;
}
if (el.tagName === "HTML" || el.tagName === "BODY") {
continue;
}
const overW = el.scrollWidth > el.clientWidth + 1;
if (!overW) {
continue;
}
if (hasHorizontalScrollContext(el)) {
continue;
}
// Decorative absolute layers often exceed bounds by design and should not
// be treated as localization regressions.
const style = window.getComputedStyle(el);
if (style.position === "absolute" && el.getAttribute("aria-hidden") === "true") {
continue;
}
overflowCount += 1;
const looksClipped =
classString.includes("truncate") ||
classString.includes("line-clamp-") ||
style.overflowX === "hidden" ||
style.overflowY === "hidden" ||
style.textOverflow === "ellipsis";
if (looksClipped) {
clippedCount += 1;
}
if (samples.length < 10 && looksClipped) {
samples.push({
tag: el.tagName.toLowerCase(),
className: classString.slice(0, 120),
text: text.slice(0, 140),
});
}
}
const dir = document.documentElement.getAttribute("dir") || "";
const lang = document.documentElement.getAttribute("lang") || "";
const rtlMismatch = expectRtl ? dir !== "rtl" : dir === "rtl";
return {
locale,
dir,
lang,
rtlMismatch,
overflowCount,
clippedCount,
clippedSamples: samples,
};
},
{ locale, expectRtl: RTL_LOCALES.has(locale) }
);
}
async function run() {
await fs.mkdir(REPORTS_DIR, { recursive: true });
const screenshotRoot = path.join(REPORTS_DIR, `i18n-qa-screenshots-${DATE}`);
await fs.mkdir(screenshotRoot, { recursive: true });
const browser = await chromium.launch({ headless: true });
const allResults = [];
for (const viewportSpec of VIEWPORTS) {
const context = await browser.newContext({
viewport: viewportSpec.viewport,
userAgent: viewportSpec.userAgent,
isMobile: viewportSpec.isMobile,
hasTouch: viewportSpec.hasTouch,
deviceScaleFactor: viewportSpec.deviceScaleFactor,
});
const page = await context.newPage();
const logged = await ensureLoggedIn(page);
console.log(`[qa] ${viewportSpec.name} login state: ${logged ? "ok" : "not-authenticated"}`);
for (const locale of ACTIVE_LOCALES) {
await context.addCookies([
{
name: "NEXT_LOCALE",
value: locale,
domain: "localhost",
path: "/",
},
]);
for (const route of ACTIVE_ROUTES) {
const started = Date.now();
const result = {
route,
locale,
viewport: viewportSpec.name,
finalUrl: "",
durationMs: 0,
status: "OK",
redirectedToLogin: false,
rtlMismatch: false,
overflowCount: 0,
clippedCount: 0,
clippedSamples: [],
dir: "",
lang: "",
error: "",
screenshot: "",
};
try {
await page.goto(`${BASE_URL}${route}`, {
waitUntil: "domcontentloaded",
timeout: 120000,
});
await page.waitForTimeout(500);
result.finalUrl = page.url();
result.redirectedToLogin = result.finalUrl.includes("/login");
const metrics = await evaluatePageHealth(page, locale);
result.rtlMismatch = metrics.rtlMismatch;
result.overflowCount = metrics.overflowCount;
result.clippedCount = metrics.clippedCount;
result.clippedSamples = metrics.clippedSamples;
result.dir = metrics.dir;
result.lang = metrics.lang;
} catch (error) {
result.error = String(error?.message || error);
}
result.durationMs = Date.now() - started;
const localeDir = path.join(screenshotRoot, viewportSpec.name, locale);
await fs.mkdir(localeDir, { recursive: true });
const screenshotPath = path.join(localeDir, `${safeRoute(route)}.png`);
try {
await page.screenshot({ path: screenshotPath, fullPage: true, timeout: 120000 });
result.screenshot = path.relative(ROOT, screenshotPath).replaceAll("\\", "/");
} catch (error) {
if (!result.error) {
result.error = `screenshot-error: ${String(error?.message || error)}`;
}
}
result.status = classifyResult(result);
allResults.push(result);
console.log(
`[qa] ${viewportSpec.name} ${locale} ${route} -> ${result.status}` +
`${result.redirectedToLogin ? " (redirected-login)" : ""}` +
`${result.rtlMismatch ? " (rtl-mismatch)" : ""}` +
`${result.clippedCount ? ` (clipped=${result.clippedCount})` : ""}`
);
}
}
await context.close();
}
await browser.close();
const jsonPath = path.join(REPORTS_DIR, `i18n-visual-qa-${DATE}${REPORT_SUFFIX}.json`);
await fs.writeFile(jsonPath, `${JSON.stringify(allResults, null, 2)}\n`, "utf8");
const aggregate = new Map();
const aggregateByLocale = new Map();
for (const item of allResults) {
const key = item.route;
if (!aggregate.has(key)) {
aggregate.set(key, {
route: key,
ok: 0,
review: 0,
adjust: 0,
clipped: 0,
loginRedirects: 0,
rtlMismatch: 0,
});
}
const slot = aggregate.get(key);
if (item.status === "OK") slot.ok += 1;
if (item.status === "Revisar") slot.review += 1;
if (item.status === "Ajuste necessario") slot.adjust += 1;
slot.clipped += item.clippedCount;
if (item.redirectedToLogin) slot.loginRedirects += 1;
if (item.rtlMismatch) slot.rtlMismatch += 1;
const localeKey = item.locale;
if (!aggregateByLocale.has(localeKey)) {
aggregateByLocale.set(localeKey, {
locale: localeKey,
ok: 0,
review: 0,
adjust: 0,
clipped: 0,
loginRedirects: 0,
rtlMismatch: 0,
});
}
const localeSlot = aggregateByLocale.get(localeKey);
if (item.status === "OK") localeSlot.ok += 1;
if (item.status === "Revisar") localeSlot.review += 1;
if (item.status === "Ajuste necessario") localeSlot.adjust += 1;
localeSlot.clipped += item.clippedCount;
if (item.redirectedToLogin) localeSlot.loginRedirects += 1;
if (item.rtlMismatch) localeSlot.rtlMismatch += 1;
}
const lines = [
"# Relatorio QA Visual i18n",
"",
`Data: ${DATE}`,
`Base URL: ${BASE_URL}`,
`Locales: ${ACTIVE_LOCALES.join(", ")}`,
`Viewports: ${VIEWPORTS.map((v) => v.name).join(", ")}`,
"",
"## Resumo por rota",
"",
"| Rota | OK | Revisar | Ajuste necessario | Clipped total | Redirect login | RTL mismatch |",
"|---|---:|---:|---:|---:|---:|---:|",
...Array.from(aggregate.values()).map(
(row) =>
`| \`${row.route}\` | ${row.ok} | ${row.review} | ${row.adjust} | ${row.clipped} | ${row.loginRedirects} | ${row.rtlMismatch} |`
),
"",
"## Resumo por locale",
"",
"| Locale | OK | Revisar | Ajuste necessario | Clipped total | Redirect login | RTL mismatch |",
"|---|---:|---:|---:|---:|---:|---:|",
...Array.from(aggregateByLocale.values())
.sort((a, b) => a.locale.localeCompare(b.locale))
.map(
(row) =>
`| \`${row.locale}\` | ${row.ok} | ${row.review} | ${row.adjust} | ${row.clipped} | ${row.loginRedirects} | ${row.rtlMismatch} |`
),
"",
"## Artefatos",
"",
`- JSON detalhado: \`${path.relative(ROOT, jsonPath)}\``,
`- Screenshots: \`${path.relative(ROOT, screenshotRoot)}\``,
"",
"## Observacoes",
"",
"- Status `Revisar` e `Ajuste necessario` sao heuristicas automaticas (overflow/clipping/RTL/redirect).",
"- A validacao final de UX deve ser confirmada manualmente nas rotas sinalizadas.",
];
const mdPath = path.join(REPORTS_DIR, `i18n-visual-qa-${DATE}${REPORT_SUFFIX}.md`);
await fs.writeFile(mdPath, `${lines.join("\n")}\n`, "utf8");
console.log(mdPath);
console.log(jsonPath);
}
run().catch((error) => {
console.error(error);
process.exit(1);
});
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env node
/**
* sync-llm-mirrors.mjs — keep docs/i18n/<locale>/llm.txt in lock-step with
* the root `llm.txt`. The mirrors are strict copies (no translation): they
* preserve the per-locale heading + language bar block they already have at
* the top of the file, then replace everything after the `---` separator with
* the root body (heading stripped).
*
* Usage:
* node scripts/i18n/sync-llm-mirrors.mjs
*
* Idempotent. Safe to run repeatedly. Used after any edit to `llm.txt` to
* keep `npm run check:docs-sync` green.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, "..", "..");
const I18N_DIR = path.join(ROOT, "docs", "i18n");
const ROOT_LLM = path.join(ROOT, "llm.txt");
const FILE_NAME = "llm.txt";
function stripTopHeading(content) {
return content.replace(/^# .+\r?\n+/, "");
}
async function main() {
const rootText = await fs.readFile(ROOT_LLM, "utf8");
const rootBody = stripTopHeading(rootText).replace(/\r\n/g, "\n");
const entries = await fs.readdir(I18N_DIR, { withFileTypes: true });
const locales = entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
let updated = 0;
let unchanged = 0;
let missing = 0;
for (const locale of locales) {
const target = path.join(I18N_DIR, locale, FILE_NAME);
let existing;
try {
existing = await fs.readFile(target, "utf8");
} catch {
console.warn(`[sync-llm-mirrors] skip ${locale}: ${FILE_NAME} missing`);
missing += 1;
continue;
}
// The locale header is everything up to and including the first `---`
// separator on its own line. We keep that prefix verbatim and replace
// the rest with the root body.
const sepMatch = existing.match(/^---\s*$/m);
if (!sepMatch || sepMatch.index === undefined) {
console.warn(`[sync-llm-mirrors] skip ${locale}: missing --- separator`);
missing += 1;
continue;
}
const headerEnd = sepMatch.index + sepMatch[0].length;
const header = existing.slice(0, headerEnd).replace(/\r\n/g, "\n");
const trimmedHeader = header.replace(/\n+$/, "");
const next = `${trimmedHeader}\n\n${rootBody.trimStart()}`.replace(/\r\n/g, "\n");
const normalizedExisting = existing.replace(/\r\n/g, "\n");
if (normalizedExisting === next) {
unchanged += 1;
continue;
}
await fs.writeFile(target, next, "utf8");
updated += 1;
console.log(`[sync-llm-mirrors] updated docs/i18n/${locale}/${FILE_NAME}`);
}
console.log(
`[sync-llm-mirrors] done — updated=${updated} unchanged=${unchanged} missing=${missing} (${locales.length} locales)`
);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+496
View File
@@ -0,0 +1,496 @@
#!/usr/bin/env node
/**
* OmniRoute — UI i18n key sync (next-intl message catalogs).
*
* Source of truth: `src/i18n/messages/en.json`. Every other locale JSON in
* `src/i18n/messages/` should mirror the same key tree. This script replicates
* any keys that are missing in a target locale, marking them with a
* `__MISSING__:<english_value>` sentinel so reviewers (and the optional LLM
* pass below) can spot them. It never overwrites an existing translated value.
*
* Usage (driven by npm scripts in package.json):
* npm run i18n:sync-ui
* npm run i18n:sync-ui -- --locale=pt-BR,zh-CN
* npm run i18n:sync-ui -- --dry-run
* npm run i18n:sync-ui -- --translate-markers
* npm run i18n:sync-ui -- --translate-markers --locale=pt-BR --concurrency=4
*
* --translate-markers calls the OmniRoute translation backend (same env vars
* as `run-translation.mjs`) and replaces every `__MISSING__:<en>` placeholder
* with a translated string. Missing env vars cause the script to fail
* fast — the markers stay in place for a later run.
*
* Output examples:
* [i18n-ui-sync] pt-BR: +589 missing keys (589 __MISSING__, 0 translated)
* [i18n-ui-sync] pt-BR: +0 missing keys (already in sync)
*/
import { promises as fs, existsSync, readFileSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath, pathToFileURL } from "node:url";
// ----- .env loader --------------------------------------------------------
// Loads variables from a local `.env` (gitignored) into process.env without
// pulling dotenv as a dependency. Already-set env vars take precedence so the
// shell / CI environment can still override.
(function loadDotEnv() {
const envPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".env");
if (!existsSync(envPath)) return;
try {
const raw = readFileSync(envPath, "utf8");
for (const rawLine of raw.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
if (!key || process.env[key] !== undefined) continue;
let value = line.slice(eq + 1);
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
} catch {
/* ignore — script will fall back to the requireEnv error path */
}
})();
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SCRIPT_DIR, "..", "..");
const CONFIG_PATH = path.join(ROOT, "config", "i18n.json");
const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages");
const SOURCE_LOCALE = "en";
const PLACEHOLDER_PREFIX = "__MISSING__:";
// ----- Helpers -------------------------------------------------------------
function logInfo(...parts) {
console.log("[i18n-ui-sync]", ...parts);
}
function logWarn(...parts) {
console.warn("[i18n-ui-sync] WARN", ...parts);
}
function logError(...parts) {
console.error("[i18n-ui-sync] ERROR", ...parts);
}
function parseArgs(argv) {
const opts = {
locales: null,
dryRun: false,
translateMarkers: false,
concurrency: null,
};
for (const arg of argv.slice(2)) {
if (arg === "--dry-run" || arg === "--dryrun") opts.dryRun = true;
else if (arg === "--translate-markers") opts.translateMarkers = true;
else if (arg.startsWith("--locale=")) {
opts.locales = arg
.slice(9)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else if (arg.startsWith("--locales=")) {
opts.locales = arg
.slice(10)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else if (arg.startsWith("--concurrency=")) {
opts.concurrency = Number(arg.slice(14));
} else if (arg === "--help" || arg === "-h") {
console.log(
[
"Usage: node scripts/i18n/sync-ui-keys.mjs [options]",
"",
" --locale=<csv> Target locales (default: all except `en`)",
" --dry-run Report what would change, write nothing",
" --translate-markers Call the translation backend to translate every",
" __MISSING__:<en> placeholder",
" --concurrency=<n> Parallel translation requests (default: env or 4)",
].join("\n")
);
process.exit(0);
}
}
return opts;
}
async function loadConfig() {
const raw = await fs.readFile(CONFIG_PATH, "utf8");
const cfg = JSON.parse(raw);
if (!cfg.default || !Array.isArray(cfg.locales)) {
throw new Error("config/i18n.json: invalid shape (need `default` and `locales[]`)");
}
return cfg;
}
async function loadJson(filePath) {
const raw = await fs.readFile(filePath, "utf8");
return JSON.parse(raw);
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
// Defensive: reject any key that could traverse into the object prototype
// chain when we copy/merge values across JSON trees. Our inputs are
// authored JSON we already control, but excluding these keys is a cheap
// safety net.
const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]);
/**
* Walks the source tree key-by-key. For each leaf in `source` that is not
* present in `target` (or whose corresponding target path is an object when
* source is a leaf, etc.), copies the source value into a new merged object,
* prefixing scalar values with PLACEHOLDER_PREFIX. Existing translated keys
* are preserved verbatim.
*
* Returns a tuple: { merged, addedPaths } so the caller can report the
* additions and (optionally) translate them.
*/
function mergeMissing(source, target) {
const addedPaths = [];
function walk(srcNode, tgtNode, prefix) {
if (!isPlainObject(srcNode)) {
// Source is a leaf. If target is missing or shape-mismatched, insert.
if (tgtNode === undefined) {
addedPaths.push(prefix);
return typeof srcNode === "string" ? `${PLACEHOLDER_PREFIX}${srcNode}` : srcNode;
}
// Existing value (even if string starts with placeholder) is kept.
return tgtNode;
}
// Source is an object — produce a prototype-less object preserving source
// key order. Using Object.create(null) guarantees no inherited keys can
// leak through later lookups, and we skip any key that resolves to a
// built-in prototype property name as a defense in depth.
const out = Object.create(null);
for (const [key, value] of Object.entries(srcNode)) {
if (FORBIDDEN_KEYS.has(key)) continue;
const nextPrefix = prefix ? `${prefix}.${key}` : key;
let tgtChild;
if (isPlainObject(tgtNode) && Object.prototype.hasOwnProperty.call(tgtNode, key)) {
// Read the property via Object.entries instead of dynamic bracket
// access to keep static analyzers happy.
const entry = Object.entries(tgtNode).find(([k]) => k === key);
tgtChild = entry ? entry[1] : undefined;
}
out[key] = walk(value, tgtChild, nextPrefix);
}
return out;
}
const merged = walk(source, target, "");
return { merged, addedPaths };
}
function countPlaceholders(node) {
if (typeof node === "string") return node.startsWith(PLACEHOLDER_PREFIX) ? 1 : 0;
if (!isPlainObject(node)) return 0;
let total = 0;
for (const value of Object.values(node)) total += countPlaceholders(value);
return total;
}
// ----- Translator backend (mirrors run-translation.mjs) --------------------
function requireEnv(name) {
const v = process.env[name];
if (!v || !v.trim()) {
throw new Error(
`Missing required env var: ${name}. Set it in .env (see docs/guides/I18N.md → "Translation pipeline").`
);
}
return v.trim();
}
function backendConfig() {
const apiUrl = requireEnv("OMNIROUTE_TRANSLATION_API_URL").replace(/\/$/, "");
const apiKey = requireEnv("OMNIROUTE_TRANSLATION_API_KEY");
const model = requireEnv("OMNIROUTE_TRANSLATION_MODEL");
const timeoutMs = Number(process.env.OMNIROUTE_TRANSLATION_TIMEOUT_MS || 60000);
return { apiUrl, apiKey, model, timeoutMs };
}
async function callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry = 0) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`${apiUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages,
temperature: 0.15,
stream: false,
}),
signal: ctrl.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const transient = res.status === 408 || res.status === 429 || res.status >= 500;
if (transient && retry < 1) {
const wait = 1500 + retry * 1500;
logWarn(`upstream ${res.status} — retrying after ${wait}ms`);
await new Promise((r) => setTimeout(r, wait));
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw new Error(`upstream ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
const content = json?.choices?.[0]?.message?.content;
if (typeof content !== "string" || !content) {
throw new Error("upstream returned empty content");
}
return content;
} catch (err) {
if (err?.name === "AbortError") {
if (retry < 1) {
logWarn(`timeout after ${timeoutMs}ms — retrying`);
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw new Error(`timeout after ${timeoutMs}ms`);
}
if (
retry < 1 &&
err instanceof TypeError &&
/fetch failed|ECONN|ENOTFOUND|network/i.test(String(err.cause ?? err.message))
) {
logWarn(`network error: ${err.message} — retrying`);
await new Promise((r) => setTimeout(r, 1500));
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw err;
} finally {
clearTimeout(timer);
}
}
// Simple promise-based semaphore (avoid runtime deps).
function createLimiter(max) {
let active = 0;
const queue = [];
const next = () => {
if (!queue.length || active >= max) return;
active++;
const { fn, resolve, reject } = queue.shift();
fn()
.then((v) => {
active--;
resolve(v);
next();
})
.catch((err) => {
active--;
reject(err);
next();
});
};
return (fn) =>
new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject });
next();
});
}
const TRANSLATION_SYSTEM = (englishName, native) =>
[
`You are a professional translator for technical software UI strings.`,
`Translate the user's English UI string into ${englishName} (native: ${native}).`,
`Return ONLY the translated string — no quotes, no commentary, no surrounding markdown.`,
`Preserve placeholders such as {name}, {{count}}, %s, %d, and any HTML tags exactly.`,
`Do NOT translate command names (npm/git/curl/etc), code identifiers, URLs, or environment variable names.`,
`Keep the same casing style (Title Case stays Title Case, sentence case stays sentence case).`,
`Keep punctuation and trailing whitespace identical to the source.`,
].join(" ");
async function translateString(englishValue, localeEntry, backend) {
const englishName = localeEntry.english ?? localeEntry.name;
const native = localeEntry.native ?? localeEntry.name;
const messages = [
{ role: "system", content: TRANSLATION_SYSTEM(englishName, native) },
{ role: "user", content: englishValue },
];
const out = await callChat(messages, backend);
return out.trim();
}
/**
* Walks a merged tree, finding every leaf that starts with PLACEHOLDER_PREFIX
* and replacing it with the translation produced by the backend.
*
* Translations happen with bounded concurrency. On failure, the placeholder
* is preserved so a later run can retry.
*/
async function translatePlaceholders(merged, localeEntry, backend, concurrency) {
const tasks = [];
function collect(node, parent, key) {
if (typeof node === "string") {
if (node.startsWith(PLACEHOLDER_PREFIX)) {
const englishValue = node.slice(PLACEHOLDER_PREFIX.length);
tasks.push({ parent, key, englishValue });
}
return;
}
if (!isPlainObject(node)) return;
for (const [k, v] of Object.entries(node)) {
collect(v, node, k);
}
}
collect(merged, null, null);
if (tasks.length === 0) return { translated: 0, failed: 0 };
const limit = createLimiter(concurrency);
let translated = 0;
let failed = 0;
await Promise.all(
tasks.map((task) =>
limit(async () => {
try {
const value = await translateString(task.englishValue, localeEntry, backend);
task.parent[task.key] = value;
translated++;
} catch (err) {
// Keep the __MISSING__ marker so subsequent runs can retry.
failed++;
logWarn(`translation failed for ${localeEntry.code}: ${err.message}`);
}
})
)
);
return { translated, failed };
}
// ----- Main ----------------------------------------------------------------
async function processLocale(locale, source, config, opts, backend) {
const localePath = path.join(MESSAGES_DIR, `${locale}.json`);
let target = {};
if (existsSync(localePath)) {
try {
target = await loadJson(localePath);
} catch (err) {
logWarn(`${locale}: failed to parse existing JSON — starting fresh (${err.message})`);
target = {};
}
} else {
logWarn(`${locale}: messages file did not exist — creating it`);
}
const { merged, addedPaths } = mergeMissing(source, target);
const placeholderCountBefore = countPlaceholders(merged);
let translateStats = { translated: 0, failed: 0 };
if (opts.translateMarkers && placeholderCountBefore > 0 && backend) {
const localeEntry = config.locales.find((l) => l.code === locale);
if (!localeEntry) {
logWarn(`${locale}: not present in config/i18n.json — skipping translation`);
} else {
const concurrency =
opts.concurrency ?? Number(process.env.OMNIROUTE_TRANSLATION_CONCURRENCY || 4);
translateStats = await translatePlaceholders(merged, localeEntry, backend, concurrency);
}
}
const placeholderCountAfter = countPlaceholders(merged);
const totalMissing = addedPaths.length;
const stillPlaceholder = placeholderCountAfter;
const summary = `${locale}: +${totalMissing} missing keys (${stillPlaceholder} __MISSING__, ${translateStats.translated} translated${translateStats.failed ? `, ${translateStats.failed} failed` : ""})`;
if (opts.dryRun) {
logInfo(`[DRY] ${summary}`);
return { addedPaths, translated: translateStats.translated };
}
// Only write when something changed. (json-stable serialization)
const before = existsSync(localePath) ? await fs.readFile(localePath, "utf8") : "";
const after = JSON.stringify(merged, null, 2) + "\n";
if (before === after) {
logInfo(`${locale}: already in sync (no changes)`);
return { addedPaths, translated: translateStats.translated };
}
await fs.writeFile(localePath, after, "utf8");
logInfo(summary);
return { addedPaths, translated: translateStats.translated };
}
async function main() {
const opts = parseArgs(process.argv);
const config = await loadConfig();
const sourcePath = path.join(MESSAGES_DIR, `${SOURCE_LOCALE}.json`);
if (!existsSync(sourcePath)) {
throw new Error(`Source messages file not found: ${sourcePath}`);
}
const source = await loadJson(sourcePath);
// Locales = every code in config except `en`, intersected with locales that
// already exist on disk (so we never silently create unknown locale files).
const onDisk = new Set(
(await fs.readdir(MESSAGES_DIR)).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5))
);
let targetLocales = config.locales
.map((l) => l.code)
.filter((code) => code !== SOURCE_LOCALE && onDisk.has(code));
if (opts.locales) {
const missingFromConfig = opts.locales.filter((c) => !config.locales.some((l) => l.code === c));
if (missingFromConfig.length) {
logWarn(`--locale contains codes not in config/i18n.json: ${missingFromConfig.join(", ")}`);
}
targetLocales = targetLocales.filter((code) => opts.locales.includes(code));
}
logInfo(`source: ${path.relative(ROOT, sourcePath)}`);
logInfo(`locales: ${targetLocales.length} (${targetLocales.join(", ")})`);
logInfo(
`dry-run: ${opts.dryRun ? "yes" : "no"}, translate-markers: ${opts.translateMarkers ? "yes" : "no"}`
);
let backend = null;
if (opts.translateMarkers && !opts.dryRun) {
backend = backendConfig();
backend.concurrency =
opts.concurrency ?? Number(process.env.OMNIROUTE_TRANSLATION_CONCURRENCY || 4);
logInfo(
`backend: ${backend.apiUrl} (model=${backend.model}, concurrency=${backend.concurrency}, timeout=${backend.timeoutMs}ms)`
);
}
const startMs = Date.now();
let totalAdded = 0;
let totalTranslated = 0;
for (const locale of targetLocales) {
const result = await processLocale(locale, source, config, opts, backend);
totalAdded += result.addedPaths.length;
totalTranslated += result.translated;
}
const elapsedSec = ((Date.now() - startMs) / 1000).toFixed(1);
logInfo(
`summary: locales=${targetLocales.length}, added=${totalAdded}, translated=${totalTranslated}, elapsed=${elapsedSec}s`
);
}
const isDirectRun = import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
main().catch((err) => {
logError(err?.stack || err?.message || String(err));
process.exit(1);
});
}
@@ -0,0 +1,243 @@
#!/usr/bin/env node
/**
* One-shot script: translates the 10 new `endpoint.*` tier/badge keys
* to every non-English locale in src/i18n/messages/.
*
* Only writes keys that are genuinely absent — never overwrites existing
* translations. Skips pt-BR and en (already have the keys).
*
* Usage:
* node scripts/i18n/translate-endpoint-tier-keys.mjs
* node scripts/i18n/translate-endpoint-tier-keys.mjs --dry-run
* node scripts/i18n/translate-endpoint-tier-keys.mjs --locale=de,fr
*/
import { promises as fs, existsSync, readFileSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages");
const I18N_CONFIG = path.join(ROOT, "config", "i18n.json");
// ---- .env loader -----------------------------------------------------------
(function loadDotEnv() {
const envPath = path.join(ROOT, ".env");
if (!existsSync(envPath)) return;
try {
const raw = readFileSync(envPath, "utf8");
for (const rawLine of raw.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
if (!key || process.env[key] !== undefined) continue;
let value = line.slice(eq + 1);
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
} catch {
/* ignore */
}
})();
// ---- CLI opts --------------------------------------------------------------
const args = process.argv.slice(2);
const isDryRun = args.includes("--dry-run");
const localeFilter = args
.find((a) => a.startsWith("--locale="))
?.slice("--locale=".length)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// ---- Helpers ---------------------------------------------------------------
function log(...parts) {
console.log("[endpoint-tier-i18n]", ...parts);
}
function requireEnv(name) {
const val = process.env[name];
if (!val) throw new Error(`Missing required env var: ${name}`);
return val;
}
function backendConfig() {
const apiUrl = requireEnv("OMNIROUTE_TRANSLATION_API_URL").replace(/\/$/, "");
const apiKey = requireEnv("OMNIROUTE_TRANSLATION_API_KEY");
const model = requireEnv("OMNIROUTE_TRANSLATION_MODEL");
const timeoutMs = Number(process.env.OMNIROUTE_TRANSLATION_TIMEOUT_MS || 60000);
return { apiUrl, apiKey, model, timeoutMs };
}
const TRANSLATION_SYSTEM = (englishName, native) =>
[
`You are a professional translator for technical software UI strings.`,
`Translate the user's English UI string into ${englishName} (native: ${native}).`,
`Return ONLY the translated string — no quotes, no commentary, no surrounding markdown.`,
`Preserve placeholders such as {name}, {{count}}, %s, %d, and any HTML tags exactly.`,
`Do NOT translate command names (npm/git/curl/etc), code identifiers, URLs, or environment variable names.`,
`Keep the same casing style (Title Case stays Title Case, sentence case stays sentence case).`,
`Keep punctuation and trailing whitespace identical to the source.`,
].join(" ");
async function callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry = 0) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`${apiUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ model, messages, temperature: 0.15, stream: false }),
signal: ctrl.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const transient = res.status === 408 || res.status === 429 || res.status >= 500;
if (transient && retry < 2) {
const wait = 1500 * (retry + 1);
log(`upstream ${res.status} — retrying after ${wait}ms`);
await new Promise((r) => setTimeout(r, wait));
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw new Error(`upstream ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
const content = json?.choices?.[0]?.message?.content;
if (typeof content !== "string" || !content) throw new Error("empty content from upstream");
return content.trim();
} catch (err) {
if (err?.name === "AbortError") {
if (retry < 2) {
log(`timeout — retrying`);
return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1);
}
throw new Error(`timeout after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
async function translateString(englishValue, localeEntry, backend) {
const messages = [
{ role: "system", content: TRANSLATION_SYSTEM(localeEntry.english, localeEntry.native) },
{ role: "user", content: englishValue },
];
return callChat(messages, backend);
}
// ---- Keys to translate -----------------------------------------------------
// These 10 keys were added to en.json and pt-BR.json in the api-endpoints audit
// but not propagated to other locales.
const NEW_ENDPOINT_KEYS = {
tierAll: "All tiers",
tierAuth: "Auth",
tierLoopback: "Local-only",
tierAlwaysProtected: "Always-protected",
tierPublic: "Public",
showInternal: "Show internal",
hideInternal: "Hide internal",
badgeLoopbackTooltip: "Local-only: blocked from non-loopback IPs",
badgeAlwaysProtectedTooltip: "Always protected: requires auth even when requireLogin=false",
badgeInternalTooltip: "Internal route — not part of the public API",
};
// Technical terms that stay in English regardless of locale
const KEEP_AS_ENGLISH = new Set(["tierAuth"]);
// ---- Main ------------------------------------------------------------------
async function main() {
const config = JSON.parse(readFileSync(I18N_CONFIG, "utf8"));
if (!config.locales || !Array.isArray(config.locales)) {
throw new Error("config/i18n.json: expected { locales: [] }");
}
// Exclude English source + pt-BR (already has keys)
const SKIP = new Set(["en", "pt-BR"]);
let locales = config.locales.filter((l) => !SKIP.has(l.code));
if (localeFilter && localeFilter.length > 0) {
locales = locales.filter((l) => localeFilter.includes(l.code));
}
const backend = isDryRun ? null : backendConfig();
log(
isDryRun ? "[DRY RUN]" : "",
`Processing ${locales.length} locales — ${Object.keys(NEW_ENDPOINT_KEYS).length} keys each`
);
let totalAdded = 0;
let totalSkipped = 0;
for (const locale of locales) {
const filePath = path.join(MESSAGES_DIR, `${locale.code}.json`);
if (!existsSync(filePath)) {
log(`${locale.code}: file not found — skipping`);
continue;
}
const data = JSON.parse(readFileSync(filePath, "utf8"));
const ep = (data.endpoint ??= {});
const toTranslate = Object.entries(NEW_ENDPOINT_KEYS).filter(([k]) => !(k in ep));
if (toTranslate.length === 0) {
log(`${locale.code}: all keys already present — skipping`);
continue;
}
log(`${locale.code}: adding ${toTranslate.length} keys…`);
let added = 0;
for (const [key, englishValue] of toTranslate) {
if (isDryRun) {
log(` [DRY] ${locale.code}.endpoint.${key} = "${englishValue}" → <translated>`);
added++;
continue;
}
try {
let translated;
if (KEEP_AS_ENGLISH.has(key)) {
translated = englishValue;
} else {
translated = await translateString(englishValue, locale, backend);
}
ep[key] = translated;
log(` ${locale.code}.endpoint.${key} = "${translated}"`);
added++;
} catch (err) {
log(` ERROR translating ${locale.code}.endpoint.${key}: ${err.message}`);
ep[key] = `__MISSING__:${englishValue}`;
added++;
}
}
if (!isDryRun) {
await fs.writeFile(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
}
totalAdded += added;
totalSkipped += Object.keys(NEW_ENDPOINT_KEYS).length - added;
}
log(`Done. Added ${totalAdded} keys, ${totalSkipped} already present.`);
}
main().catch((err) => {
console.error("[endpoint-tier-i18n] FATAL:", err.message);
process.exit(1);
});
+241
View File
@@ -0,0 +1,241 @@
{
"description": "Keys that should remain untranslated (technical terms, brand names, ICU formats, placeholders, etc.)",
"keys": [
"apiManager.modelsCount",
"a2aDashboard.metadata",
"a2aDashboard.ok",
"a2aDashboard.smokeSendSuccess",
"a2aDashboard.url",
"cache.behaviorTwoTier",
"cache.dbEntriesSub",
"cache.model",
"cache.provider",
"cliTools.baseUrlPlaceholder",
"cliTools.guides.opencode.steps.3.desc",
"cliTools.guides.windsurf.steps.1.desc",
"cliTools.guides.windsurf.steps.2.desc",
"cliTools.guides.windsurf.steps.3.desc",
"cliTools.guides.windsurf.steps.4.desc",
"cliTools.model",
"cliTools.platforms",
"cliTools.startMitm",
"cliTools.stopMitm",
"cliTools.toolDescriptions.claude",
"cliTools.toolDescriptions.codex",
"cliTools.toolDescriptions.copilot",
"cliTools.toolDescriptions.cursor",
"cliTools.toolDescriptions.windsurf",
"combos.roundRobin",
"common.alias",
"common.base64url",
"common.better-sqlite3",
"common.builder-id",
"common.cookie",
"common.hex",
"common.http",
"common.id",
"common.idc",
"common.keytar",
"common.limit",
"common.model",
"common.oauth",
"common.offset",
"common.selfsigned",
"common.social-github",
"common.social-google",
"common.text",
"common.undici",
"common.web",
"docs.clientCherryStudioTitle",
"docs.clientClaudeTitle",
"docs.clientCursorTitle",
"docs.github",
"docs.protocolA2aTitle",
"docs.protocolMcpTitle",
"docs.providerTypeOAuth",
"endpoint.chat",
"endpoint.chatCompletions",
"endpoint.cloudProxy",
"endpoint.cloudflaredDisable",
"endpoint.cloudflaredIdleNote",
"endpoint.cloudflaredRequestFailed",
"endpoint.cloudflaredStarted",
"endpoint.cloudflaredStopped",
"endpoint.cloudflaredTitle",
"endpoint.mcpCardTitle",
"endpoint.rerank",
"header.a2a",
"header.mcp",
"health.cpu",
"health.latencyP50",
"health.latencyP95",
"health.latencyP99",
"health.millisecondsShort",
"health.notAvailable",
"health.ok",
"home.aliasLabel",
"home.oauthLabel",
"landing.brandName",
"landing.flowProviderAnthropic",
"landing.flowProviderGemini",
"landing.flowProviderGithubCopilot",
"landing.flowProviderOpenAI",
"landing.flowToolClaudeCode",
"landing.flowToolCline",
"landing.flowToolCursor",
"landing.flowToolOpenAICodex",
"landing.github",
"landing.npm",
"legal.listSeparator",
"legal.terms",
"legal.privacy",
"loggers.inputTokens",
"loggers.modelAZ",
"loggers.modelZA",
"loggers.outputTokens",
"loggers.proxy",
"logs.notAvailable",
"logs.endpoint",
"logs.proxy",
"logs.console",
"logs.request",
"logs.audit",
"mcpDashboard.apiKeyIdPlaceholder",
"mcpDashboard.offline",
"mcpDashboard.online",
"mcpDashboard.pid",
"mcpDashboard.tableAudit",
"media.model",
"media.prompt",
"media.interpolation",
"media.upscale",
"media.samples",
"modals.awsBuilderId",
"modals.awsIamIdentity",
"modals.awsRegion",
"onboarding.test",
"providers.anthropic",
"providers.anthropicBaseUrlPlaceholder",
"providers.anthropicCompatibleModelPlaceholder",
"providers.anthropicCompatibleName",
"providers.anthropicPrefixPlaceholder",
"providers.autoPriority",
"providers.chat",
"providers.chatCompletions",
"providers.chatCompletionsPath",
"providers.chatPathPlaceholder",
"providers.codexAuthAppliedLocal",
"providers.codexAuthApplyFailed",
"providers.codexAuthExported",
"providers.codexAuthExportFailed",
"providers.compatProtocolClaude",
"providers.compatProtocolOpenAI",
"providers.compatProtocolOpenAIResponses",
"providers.email",
"providers.messages",
"providers.messagesApi",
"providers.messagesPath",
"providers.millisecondsAbbr",
"providers.modelsPathPlaceholder",
"providers.modeTest",
"providers.oauthLabel",
"providers.oauth2Label",
"providers.okShort",
"providers.openai",
"providers.openaiBaseUrlPlaceholder",
"providers.openaiCompatibleModelPlaceholder",
"providers.openaiCompatibleName",
"providers.openaiPrefixPlaceholder",
"providers.openRouterModelPlaceholder",
"providers.prefixLabel",
"providers.proxy",
"providers.proxyConfiguredBySource",
"providers.responses",
"providers.responsesApi",
"providers.responsesPath",
"search.domainPlaceholder",
"search.rawJson",
"search.rerank",
"search.searchTypeWeb",
"search.search",
"search.searchTools",
"search.webSearch",
"search.fileSearch",
"settings.ai",
"settings.aliasPatternPlaceholder",
"settings.aliasTargetPlaceholder",
"settings.auto",
"settings.model",
"settings.modelNamePlaceholder",
"settings.ms",
"settings.providersCommaSeparatedPlaceholder",
"settings.proxy",
"settings.p2c",
"settings.reasoning",
"settings.roundRobin",
"settings.rpm",
"settings.status",
"settings.whitelabeling",
"settings.theme",
"settings.language",
"settings.currency",
"settings.timezone",
"sidebar.cache",
"sidebar.cacheShort",
"sidebar.cliSection",
"sidebar.debug",
"sidebar.debugSection",
"sidebar.helpSection",
"sidebar.primarySection",
"sidebar.systemSection",
"sidebar.version",
"stats.requests",
"stats.tokens",
"stats.latency",
"stats.errors",
"themesPage.dark",
"themesPage.light",
"themesPage.system",
"translator.auto",
"translator.chatTester",
"translator.millisecondsShort",
"translator.model",
"translator.notAvailableSymbol",
"translator.ok",
"translator.scenarioThinking",
"translator.status",
"translator.templateNames.thinking",
"translator.translate",
"translator.translateFrom",
"translator.translateTo",
"translator.detect",
"translator.detectedLanguage",
"usage.columnModel",
"usage.columnStatus",
"usage.detailsRegex",
"usage.durationHoursShort",
"usage.durationMillisecondsShort",
"usage.durationMinutesShort",
"usage.durationSecondsShort",
"usage.latencyP50",
"usage.latencyP95",
"usage.latencyP99",
"usage.notApplicable",
"usage.notAvailableSymbol",
"usage.passSuffix",
"usage.proxyTab",
"usage.reasonSeparator",
"usage.tierPlus",
"usage.tierPro",
"usage.tierUltra",
"usage.totalRequests",
"usage.totalTokens",
"usage.inputTokens",
"usage.outputTokens",
"usage.promptTokens",
"usage.completionTokens",
"usage.cacheReadTokens",
"usage.cacheWriteTokens",
"usage.warningThresholdPlaceholder"
]
}
+635
View File
@@ -0,0 +1,635 @@
#!/usr/bin/env python3
"""
OmniRoute i18n Translation Validator
Script for comparing source (en.json) with any translation
Detects missing translations and source changes needing updates
Usage:
python validate_translation.py # Uses TRANSLATION_LANG env or --lang argument
python validate_translation.py --lang cs # Validate Czech (cs.json)
python validate_translation.py -l de # Validate German (de.json)
TRANSLATION_LANG=fr python validate_translation.py # Validate French
Environment variables:
TRANSLATION_LANG Target language code (e.g., cs, de, fr)
"""
import json
import sys
import os
from pathlib import Path
from typing import Dict, List, Set, Tuple, Any
import argparse
# Colors (ANSI)
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
BLUE = "\033[0;34m"
NC = "\033[0m"
# Configuration - find repo root relative to this script
_script_dir = Path(__file__).parent.resolve()
# Walk up out of scripts/<group>/, scripts/, or stay at cwd
if _script_dir.name == "i18n" and _script_dir.parent.name == "scripts":
SCRIPT_DIR = _script_dir.parent.parent
elif _script_dir.name == "scripts":
SCRIPT_DIR = _script_dir.parent
else:
SCRIPT_DIR = _script_dir
MESSAGES_DIR = SCRIPT_DIR / "src" / "i18n" / "messages"
SOURCE_FILE = MESSAGES_DIR / "en.json"
# Get target language from env or argument
def get_target_lang() -> str:
"""Get target language from ENV or CLI argument."""
# First check environment variable
env_lang = os.environ.get("TRANSLATION_LANG")
if env_lang:
return env_lang
# Then check command line argument (will be set in main)
if hasattr(get_target_lang, "cli_lang"):
return get_target_lang.cli_lang
# Default to cs for backwards compatibility
return "cs"
# Keys that should NOT be translated (technical terms, proper names, etc.)
# Loaded from external file for easier maintenance
_UNTRANSLATABLE_KEYS_FILE = _script_dir / "i18n" / "untranslatable-keys.json"
if _UNTRANSLATABLE_KEYS_FILE.exists():
with open(_UNTRANSLATABLE_KEYS_FILE, "r", encoding="utf-8") as _f:
UNTRANSLATABLE_KEYS = set(json.load(_f).get("keys", []))
else:
UNTRANSLATABLE_KEYS = set()
def print_header(msg: str) -> None:
print(f"\n{BLUE}{'=' * 50}{NC}")
print(f"{BLUE}{msg}{NC}")
print(f"{BLUE}{'=' * 50}{NC}")
def print_success(msg: str) -> None:
print(f"{GREEN}{msg}{NC}")
def print_warning(msg: str) -> None:
print(f"{YELLOW}{msg}{NC}")
def print_error(msg: str) -> None:
print(f"{RED}{msg}{NC}")
def load_json(path: Path) -> Dict:
"""Load JSON file"""
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
print_error(f"Invalid JSON in {path}: {e}")
sys.exit(1)
def get_all_keys(obj: Any, prefix: str = "") -> Set[str]:
"""Recursively get all leaf keys from JSON object"""
keys = set()
if isinstance(obj, dict):
for key, value in obj.items():
new_prefix = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
keys.update(get_all_keys(value, new_prefix))
elif isinstance(value, list):
# Handle arrays - check first element for structure
if value and isinstance(value[0], dict):
for i, item in enumerate(value):
keys.update(get_all_keys(item, f"{new_prefix}[{i}]"))
else:
keys.add(new_prefix)
else:
keys.add(new_prefix)
return keys
def find_missing_keys(source: Dict, trans: Dict) -> Set[str]:
"""Keys in source but not in translation"""
source_keys = get_all_keys(source)
trans_keys = get_all_keys(trans)
return source_keys - trans_keys
def find_extra_keys(source: Dict, trans: Dict) -> Set[str]:
"""Keys in translation but not in source"""
source_keys = get_all_keys(source)
trans_keys = get_all_keys(trans)
return trans_keys - source_keys
def get_value_by_path(obj: Dict, path: str) -> Any:
"""Get value from nested dict using dot notation"""
keys = path.replace("[", ".").replace("]", "").split(".")
current = obj
for key in keys:
if key.isdigit():
idx = int(key)
if isinstance(current, list) and idx < len(current):
current = current[idx]
else:
return None
else:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return None
return current
def find_untranslated(source: Dict, trans: Dict) -> Set[str]:
"""Keys where source value equals translation (not translated), excluding untranslatable keys"""
source_keys = get_all_keys(source)
untranslated = set()
for key in source_keys:
# Skip keys that are in the untranslatable list
if key in UNTRANSLATABLE_KEYS:
continue
source_val = get_value_by_path(source, key)
trans_val = get_value_by_path(trans, key)
if source_val is not None and source_val == trans_val:
untranslated.add(key)
return untranslated
def find_placeholder_issues(source: Dict, trans: Dict) -> List[Tuple[str, str, str]]:
"""
Find placeholder mismatches between source and translation.
Only checks top-level placeholders like {count}, {day}, NOT ICU inner content.
Returns list of (key, source_placeholder, trans_placeholder)
"""
source_keys = get_all_keys(source)
issues = []
for key in source_keys:
source_val = get_value_by_path(source, key)
trans_val = get_value_by_path(trans, key)
if source_val is None or trans_val is None:
continue
if not isinstance(source_val, str) or not isinstance(trans_val, str):
continue
# Only extract top-level placeholders: {name}, {count}, {day}, NOT {# X} inside ICU
import re
# Extract variable names from placeholders (e.g., 'name' from '{name}' or 'count' from '{count, plural, ...}')
# This avoids false positives on ICU strings where the internal text is translated.
placeholder_regex = r"\{\s*([a-zA-Z][a-zA-Z0-9_]*)"
source_placeholders = set(re.findall(placeholder_regex, source_val))
trans_placeholders = set(re.findall(placeholder_regex, trans_val))
# Check for missing placeholders
missing = source_placeholders - trans_placeholders
if missing:
issues.append((key, str(source_placeholders), str(trans_placeholders)))
return issues
def compare_category(
source: Dict, trans: Dict, category: str
) -> Tuple[bool, List[str]]:
"""Compare a specific category, return (complete, missing_keys)"""
if category not in source:
return False, [f"Category '{category}' not in source"]
if category not in trans:
return False, [f"Category '{category}' missing in translation"]
source_keys = get_all_keys(source[category])
trans_keys = get_all_keys(trans[category])
missing = source_keys - trans_keys
return len(missing) == 0, list(missing)
def get_translation_file() -> Path:
"""Get the translation file path based on target language."""
lang = get_target_lang()
return MESSAGES_DIR / f"{lang}.json"
def generate_report():
"""Generate full translation report"""
translation_file = get_translation_file()
print_header("OmniRoute Translation Report")
print(f"Source: {SOURCE_FILE}")
print(f"Translation: {translation_file}\n")
source = load_json(SOURCE_FILE)
trans = load_json(translation_file)
# Count keys
source_count = len(get_all_keys(source))
trans_count = len(get_all_keys(trans))
print(f"{BLUE}Key Statistics:{NC}")
print(f" Source keys: {source_count}")
print(f" Translation keys: {trans_count}\n")
# Missing keys
print_header("Missing Translations")
missing = find_missing_keys(source, trans)
if missing:
print(f"{RED}Found {len(missing)} missing keys:{NC}")
for key in sorted(missing)[:50]: # Limit output
print(f" - {key}")
if len(missing) > 50:
print(f" ... and {len(missing) - 50} more")
else:
print_success("No missing translations!")
# Extra keys
print_header("Extra Keys")
extra = find_extra_keys(source, trans)
if extra:
print(f"{YELLOW}Found {len(extra)} extra keys:{NC}")
for key in sorted(extra)[:50]:
print(f" - {key}")
else:
print_success("No extra keys!")
# Untranslated
print_header("Untranslated Keys (same as source)")
untranslated = find_untranslated(source, trans)
if untranslated:
print(f"{YELLOW}Found {len(untranslated)} untranslated keys:{NC}")
for key in sorted(untranslated)[:50]:
print(f" - {key}")
if len(untranslated) > 50:
print(f" ... and {len(untranslated) - 50} more")
else:
print_success("All keys appear to be translated!")
# Placeholder issues
print_header("Placeholder Mismatches")
placeholder_issues = find_placeholder_issues(source, trans)
if placeholder_issues:
print(f"{YELLOW}Found {len(placeholder_issues)} placeholder mismatches:{NC}")
for key, src_ph, trans_ph in placeholder_issues[:20]:
print(f" - {key}")
print(f" Source: {src_ph}")
print(f" Trans: {trans_ph}")
if len(placeholder_issues) > 20:
print(f" ... and {len(placeholder_issues) - 20} more")
else:
print_success("All placeholders match!")
# Per-category status
print_header("Per-Category Status")
for category in sorted(source.keys()):
complete, missing = compare_category(source, trans, category)
if complete:
print_success(f"{category} - complete")
else:
print_error(f"{category} - missing {len(missing)} keys")
# Summary
print_header("Summary")
if not missing and not extra and not untranslated:
print(f"{GREEN}🎉 Translation is fully synchronized!{NC}")
return 0
else:
print(f"{YELLOW}Translation needs attention:{NC}")
print(f" - Missing: {len(missing)}")
print(f" - Extra: {len(extra)}")
print(f" - Untranslated: {len(untranslated)}")
return 0
def quick_check() -> int:
"""Quick check - just show counts"""
translation_file = get_translation_file()
source = load_json(SOURCE_FILE)
trans = load_json(translation_file)
missing = find_missing_keys(source, trans)
untranslated = find_untranslated(source, trans)
print(f"Missing: {len(missing)}")
print(f"Untranslated: {len(untranslated)}")
print(f"Ignored (UNTRANSLATABLE_KEYS): {len(UNTRANSLATABLE_KEYS)}")
# Exit codes:
# 0 = OK
# 1 = generic error
# 2 = missing string in translation
# 3 = untranslated (soft warning - not a failure)
if missing:
print_warning(f"{len(missing)} missing keys (non-critical)")
return 0
# untranslated is a soft warning, not a failure - translations exist, just not localized
if untranslated:
print_warning(f"{len(untranslated)} untranslated keys (non-critical)")
return 0
return 0
def show_diff(category: str) -> int:
"""Show detailed diff for a category"""
translation_file = get_translation_file()
source = load_json(SOURCE_FILE)
trans = load_json(translation_file)
if category not in source:
print_error(f"Category '{category}' not found in source")
print("Available categories:")
for cat in sorted(source.keys()):
print(f" - {cat}")
return 1
print_header(f"Diff for category: {category}")
print(f"{BLUE}{'Key':<30} | {'Source':<25} | {'Translation':<25}{NC}")
print("-" * 85)
source_keys = get_all_keys(source[category])
for key in sorted(source_keys):
source_val = get_value_by_path(source[category], key)
trans_val = get_value_by_path(trans.get(category, {}), key)
# Truncate long values
source_str = str(source_val)[:25] if source_val else "(null)"
trans_str = str(trans_val)[:25] if trans_val else "(missing)"
if source_val == trans_val:
status = f"{YELLOW}(same){NC}"
elif trans_val is None:
status = f"{RED}(missing){NC}"
else:
status = f"{GREEN}(ok){NC}"
print(f"{key:<30} | {source_str:<25} | {trans_str:<25} {status}")
return 0
def export_csv(output_file: str) -> int:
"""Export to CSV"""
translation_file = get_translation_file()
source = load_json(SOURCE_FILE)
trans = load_json(translation_file)
print_header(f"Exporting to CSV: {output_file}")
source_keys = get_all_keys(source)
with open(output_file, "w", encoding="utf-8") as f:
f.write("key,source_value,translation_value,status\n")
for key in sorted(source_keys):
source_val = get_value_by_path(source, key)
trans_val = get_value_by_path(trans, key)
# Escape commas
source_str = str(source_val).replace(",", ";")
trans_str = str(trans_val).replace(",", ";") if trans_val else ""
if trans_val is None:
status = "MISSING"
elif source_val == trans_val:
status = "UNTRANSLATED"
else:
status = "OK"
f.write(f'"{key}","{source_str}","{trans_str}",{status}\n')
print_success(f"Exported to {output_file}")
return 0
def export_markdown(output_file: str) -> int:
"""Export all keys to separate Markdown files - translated and untranslated"""
translation_file = get_translation_file()
source = load_json(SOURCE_FILE)
trans = load_json(translation_file)
print_header(f"Exporting to Markdown: {output_file}")
source_keys = get_all_keys(source)
missing = find_missing_keys(source, trans)
untranslated = find_untranslated(source, trans)
# Separate translated and untranslated
translated_keys = []
untranslated_sorted = sorted(untranslated)
for key in sorted(source_keys):
if key not in missing and key not in untranslated:
translated_keys.append(key)
translated_count = len(translated_keys)
untranslated_count = len(untranslated_sorted)
# Export untranslated (main output file)
with open(output_file, "w", encoding="utf-8") as f:
f.write("# Nepřeložené klíče (Untranslated Keys)\n\n")
f.write(f"Zdroj: `{SOURCE_FILE.name}` | Překlad: `{TRANSLATION_FILE.name}`\n\n")
f.write(f"**Celkem: {untranslated_count} nepreložených klíčů**\n\n")
f.write("| # | Klíč (Key) | Originál | Nepřeloženo |\n")
f.write("|---|------------|----------|------------|\n")
for i, key in enumerate(untranslated_sorted, 1):
source_val = get_value_by_path(source, key)
trans_val = get_value_by_path(trans, key)
source_str = str(source_val).replace("|", "\\|")[:60]
trans_str = str(trans_val).replace("|", "\\|")[:60]
f.write(f"| {i} | `{key}` | {source_str} | {trans_str} |\n")
f.write("\n## Shrnutí (Summary)\n\n")
f.write(f"- Celkem klíčů: {len(source_keys)}\n")
f.write(f"- Chybějících: {len(missing)}\n")
f.write(f"- Nepřeložených: {untranslated_count}\n")
f.write(f"- Přeložených: {translated_count}\n")
# Export translated to separate file
translated_file = output_file.replace(".md", "_translated.md")
translation_filename = translation_file.name
with open(translated_file, "w", encoding="utf-8") as f:
f.write("# Přeložené klíče (Translated Keys)\n\n")
f.write(f"Zdroj: `{SOURCE_FILE.name}` | Překlad: `{translation_filename}`\n\n")
f.write(f"**Celkem: {translated_count} přeložených klíčů**\n\n")
f.write("| # | Klíč (Key) | Originál | Překlad |\n")
f.write("|---|------------|----------|---------|\n")
for i, key in enumerate(translated_keys, 1):
source_val = get_value_by_path(source, key)
trans_val = get_value_by_path(trans, key)
source_str = str(source_val).replace("|", "\\|")[:40]
trans_str = str(trans_val).replace("|", "\\|")[:40]
f.write(f"| {i} | `{key}` | {source_str} | {trans_str} |\n")
print_success(f"Exported: {output_file} ({untranslated_count} keys)")
print_success(f"Exported: {translated_file} ({translated_count} keys)")
return 0
def usage():
print("""
OmniRoute i18n Translation Validator
Usage: validate_translation.py [command] [options]
Options:
-l, --lang <code> Target language code (e.g., cs, de, fr)
Default: cs or TRANSLATION_LANG env variable
Commands:
(default) Generate full report
quick Quick check - just show counts
diff <category> Show detailed diff for a category
csv [file] Export to CSV (default: translation_report.csv)
md [file] Export to Markdown (default: translation_report.md)
Examples:
python validate_translation.py # Full report (default: cs)
python validate_translation.py --lang de # Validate German
python validate_translation.py -l fr # Validate French
TRANSLATION_LANG=es python validate_translation.py # Validate Spanish
python validate_translation.py quick # Quick status check
python validate_translation.py diff common # Diff common category
python validate_translation.py csv # Export to CSV
python validate_translation.py md # Export to Markdown
python validate_translation.py fix # Auto-generate missing keys from en.json
""")
def fix_missing_keys() -> int:
"""Auto-generate missing keys by copying from en.json"""
source_file = SOURCE_FILE
translation_file = get_translation_file()
source = load_json(source_file)
trans = load_json(translation_file)
# Get all keys recursively
def get_all_keys(obj, prefix=''):
keys = []
if isinstance(obj, dict):
for k, v in obj.items():
full_key = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
keys.extend(get_all_keys(v, full_key))
else:
keys.append(full_key)
return keys
source_keys = set(get_all_keys(source))
trans_keys = set(get_all_keys(trans))
missing = source_keys - trans_keys
if not missing:
print_success("No missing keys - translation file is complete!")
return 0
print(f"Found {len(missing)} missing keys")
# Add missing keys to translation
for key in missing:
parts = key.split('.')
current = trans
for i, part in enumerate(parts[:-1]):
if part not in current:
current[part] = {}
current = current[part]
# Get value from source
src = source
for part in parts:
src = src.get(part, {})
# Set the value (use English as fallback)
current[parts[-1]] = src if isinstance(src, str) else key
# Write back
with open(translation_file, 'w', encoding='utf-8') as f:
json.dump(trans, f, ensure_ascii=False, indent=2)
f.write('\n')
print_success(f"Added {len(missing)} missing keys to {translation_file.name}")
return 0
def main():
# Parse global arguments first
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("-l", "--lang", dest="lang", default=None)
parser.add_argument("command", nargs="?")
parser.add_argument("arg", nargs="?")
# Parse known args only to allow commands to handle their own args
args, _ = parser.parse_known_args()
# Set language from argument or use default
if args.lang:
get_target_lang.cli_lang = args.lang
elif not os.environ.get("TRANSLATION_LANG"):
# Default to cs for backwards compatibility
get_target_lang.cli_lang = "cs"
# Check if translation file exists
translation_file = get_translation_file()
if not translation_file.exists():
print_error(f"Translation file not found: {translation_file}")
print(f"Available languages:")
for f in sorted(MESSAGES_DIR.glob("*.json")):
if f.name != "en.json":
print(f" - {f.stem}")
return 1
# Execute command
if not args.command or args.command in ("help", "--help", "-h"):
return generate_report()
if args.command == "quick":
return quick_check()
elif args.command == "diff":
if not args.arg:
print_error("Please specify category")
usage()
return 1
return show_diff(args.arg)
elif args.command == "csv":
output = args.arg if args.arg else "translation_report.csv"
return export_csv(output)
elif args.command == "md":
output = args.arg if args.arg else "translation_report.md"
return export_markdown(output)
elif args.command == "fix":
return fix_missing_keys()
else:
print_error(f"Unknown command: {args.command}")
usage()
return 1
if __name__ == "__main__":
sys.exit(main())