- 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
195 lines
6.0 KiB
JavaScript
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);
|
|
});
|