Files
site-portfolio/scripts/translate.mjs
T
root 4b52cec00d feat(i18n): translate all case studies and resume to English
- add .en.html overrides for 16 case pages + resume via apinex translator
- translate remaining partials (projects-related, mini-projects-*, fab-stack-home-contact,
  fab-stack-resume, content-edit-fab-stack, cookie-notice)
- enhance translate.mjs to protect <script>/<style> from translation
- second pass: translate attribute values (aria-label/alt/title/placeholder/content/
  data-hint-default) and visible text DeepL skipped in html mode
- make logo language-aware (NM / НМ) via i18n
2026-09-15 23:18:01 +02:00

195 lines
6.0 KiB
JavaScript

#!/usr/bin/env node
/**
* translate.mjs — CLI-обёртка над apinex.bond /v1/tools/translator
*
* Использование:
* node scripts/translate.mjs --target EN --text "Привет"
* node scripts/translate.mjs --target EN --source RU --file page.html --out page.en.html
* cat strings.txt | node scripts/translate.mjs --target EN --batch
* node scripts/translate.mjs --list-languages
*
* Ключ берётся из HERMES_CUSTOM_APINEX_API_KEY / APINEX_API_KEY, иначе из /root/.hermes/.env.
*
* Для HTML (--html или авто) шаблонные токены {{...}} и содержимое <code> защищаются
* HTML-комментариями, чтобы переводчик их не трогал (они восстанавливаются после).
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const API_URL = "https://api.apinex.bond/v1/tools/translator";
const LANG_URL = "https://api.apinex.bond/v1/tools/translator/languages";
const ENV_PATH = "/root/.hermes/.env";
// ---- аргументы ----
function parseArgs(argv) {
const a = {
target: null,
source: null,
html: null,
batch: false,
file: null,
out: null,
text: null,
list: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const next = () => argv[++i];
switch (arg) {
case "--target":
case "-t":
a.target = next();
break;
case "--source":
case "-s":
a.source = next();
break;
case "--html":
a.html = true;
break;
case "--no-html":
a.html = false;
break;
case "--batch":
a.batch = true;
break;
case "--file":
case "-f":
a.file = next();
break;
case "--out":
case "-o":
a.out = next();
break;
case "--text":
a.text = next();
break;
case "--list-languages":
case "--list":
a.list = true;
break;
default:
if (!a.text && !arg.startsWith("-")) a.text = arg;
}
}
return a;
}
// ---- ключ ----
function getKey() {
const fromEnv = process.env.HERMES_CUSTOM_APINEX_API_KEY || process.env.APINEX_API_KEY;
if (fromEnv) return fromEnv;
try {
const txt = readFileSync(ENV_PATH, "utf8");
const m = txt.match(/^\s*HERMES_CUSTOM_APINEX_API_KEY\s*=\s*"?([^"\n]+)"?/m)
|| txt.match(/^\s*APINEX_API_KEY\s*=\s*"?([^"\n]+)"?/m);
if (m) return m[1].trim();
} catch {}
throw new Error("API key not found (HERMES_CUSTOM_APINEX_API_KEY / APINEX_API_KEY / " + ENV_PATH + ")");
}
// ---- защита токенов, <script>, <style> и <code> для HTML ----
function protect(html) {
const store = [];
let out = html
// <script>/<style> целиком: переводчик не должен трогать JS/CSS
.replace(/<script[\s\S]*?<\/script>/gi, (m) => { store.push(m); return `<!--SCP${store.length - 1}-->`; })
.replace(/<style[\s\S]*?<\/style>/gi, (m) => { store.push(m); return `<!--SCP${store.length - 1}-->`; })
.replace(/\{\{[\s\S]*?\}\}/g, (m) => { store.push(m); return `<!--TPL${store.length - 1}-->`; })
.replace(/<code>([\s\S]*?)<\/code>/g, (_m, inner) => { store.push(inner); return `<code><!--CPL${store.length - 1}--></code>`; });
return { out, store };
}
function restore(html, store) {
return html
.replace(/<!--SCP(\d+)-->/g, (_m, i) => store[+i])
.replace(/<!--TPL(\d+)-->/g, (_m, i) => store[+i])
.replace(/<code><!--CPL(\d+)--><\/code>/g, (_m, i) => `<code>${store[+i]}</code>`);
}
// ---- сам перевод ----
async function translateTexts(texts, { target, source, html }) {
const body = {
text: texts,
target_lang: target,
preserve_formatting: true,
};
if (source) body.source_lang = source;
if (html) body.tag_handling = "html";
const res = await fetch(API_URL, {
method: "POST",
headers: {
Authorization: "Bearer " + getKey(),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Translator HTTP ${res.status}: ${err}`);
}
const data = await res.json();
return data.translations.map((t) => t.text);
}
async function listLanguages() {
const res = await fetch(LANG_URL, { headers: { Authorization: "Bearer " + getKey() } });
if (!res.ok) throw new Error(`Languages HTTP ${res.status}`);
return res.json();
}
// ---- main ----
async function main() {
const a = parseArgs(process.argv.slice(2));
if (a.list) {
const langs = await listLanguages();
console.log(JSON.stringify(langs, null, 2));
return;
}
if (!a.target) {
console.error("Usage: translate.mjs --target EN [--source RU] [--html] [--file|-f path] [--text|-t str] [--batch] [--out|-o path]");
process.exit(2);
}
let raw;
if (a.file) raw = readFileSync(resolve(__dirname, "..", a.file), "utf8");
else if (a.text) raw = a.text;
else raw = readFileSync(0, "utf8"); // stdin
const useHtml = a.html ?? (raw.includes("<") && raw.includes(">"));
let texts;
if (a.batch) {
texts = raw.split(/\r?\n/).filter((l) => l.trim().length);
} else {
texts = [raw];
}
let results;
if (useHtml) {
const protectedTexts = texts.map((t) => protect(t));
const translated = await translateTexts(protectedTexts.map((p) => p.out), {
target: a.target,
source: a.source,
html: true,
});
results = translated.map((t, i) => restore(t, protectedTexts[i].store));
} else {
results = await translateTexts(texts, { target: a.target, source: a.source, html: false });
}
const output = a.batch ? results.join("\n") : results[0];
if (a.out) writeFileSync(resolve(__dirname, "..", a.out), output, "utf8");
else process.stdout.write(output + (a.batch ? "" : ""));
if (!a.out) process.stdout.write("\n");
}
main().catch((e) => {
console.error("ERROR:", e.message);
process.exit(1);
});