chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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)}`);
|
||||
@@ -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)`);
|
||||
Executable
+161
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Executable
+173
@@ -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()
|
||||
@@ -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`);
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
Executable
+161
@@ -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()
|
||||
@@ -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)`);
|
||||
Executable
+628
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Executable
+496
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Executable
+635
@@ -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())
|
||||
Reference in New Issue
Block a user