mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
Continues the page-by-page translation pass started in cb37dd55 — runs
every user-visible string on settings (General/Security/Telegram/Sub),
inbounds (Client/QR/Info modals), and xray (Routing/Balancer/Rule/Warp/
Nord/Basics/Outbounds tabs) through useI18n. Updates the TOML→JSON sync
script to escape `@` (vue-i18n parses it as a linked-format prefix) and
refreshes all 13 locale files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
112 lines
4.2 KiB
JavaScript
112 lines
4.2 KiB
JavaScript
// Converts the panel's TOML translation files (web/translation/) into
|
|
// nested JSON that vue-i18n can consume. The Go side stays the source
|
|
// of truth — translators continue to edit the TOML files; this script
|
|
// snapshots them into frontend/src/locales/<code>.json on each run.
|
|
//
|
|
// Run via `npm run i18n:sync` (also kicked off automatically by
|
|
// `npm run prebuild` so production builds always include the latest
|
|
// strings).
|
|
//
|
|
// Format support is intentionally narrow — the project's TOML files
|
|
// are limited to:
|
|
// • blank lines and `# comment` lines
|
|
// • bare-string values: "key" = "value"
|
|
// • dotted section heads: [pages.inbounds.toasts]
|
|
// Multi-line strings, arrays, dates, and inline tables aren't used in
|
|
// the panel's translation set, so the parser rejects them rather than
|
|
// silently mis-parsing. If the format ever grows, swap this out for a
|
|
// proper TOML lib.
|
|
|
|
import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
import { resolve, dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const tomlDir = resolve(here, '..', '..', 'web', 'translation');
|
|
const outDir = resolve(here, '..', 'src', 'locales');
|
|
|
|
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
|
|
// Decode the small set of escapes TOML allows inside basic strings.
|
|
// Unicode `\uXXXX` escapes aren't used in the panel's files but are
|
|
// handled too just in case a translator adds one.
|
|
function unescape(value) {
|
|
return value.replace(/\\(["\\bfnrt]|u[0-9a-fA-F]{4})/g, (_m, what) => {
|
|
if (what === '"') return '"';
|
|
if (what === '\\') return '\\';
|
|
if (what === 'b') return '\b';
|
|
if (what === 'f') return '\f';
|
|
if (what === 'n') return '\n';
|
|
if (what === 'r') return '\r';
|
|
if (what === 't') return '\t';
|
|
return String.fromCharCode(parseInt(what.slice(1), 16));
|
|
});
|
|
}
|
|
|
|
// vue-i18n's message compiler treats `@` as the start of a linked
|
|
// reference (`@:key` or `@.modifier:key`). When the panel's strings
|
|
// contain a literal `@` (e.g. "@BotFather", "@userinfobot",
|
|
// "@every 1m"), the compiler aborts with "Invalid linked format".
|
|
// vue-i18n's escape syntax is `{'@'}` — that renders a literal `@`.
|
|
// We don't use linked references anywhere in the panel's locales,
|
|
// so a blanket escape is safe and keeps the TOML readable for
|
|
// translators (and for the Go-side template renderer that doesn't
|
|
// need this escape).
|
|
function escapeForVueI18n(value) {
|
|
// Keep the `{` and `}` characters that vue-i18n already uses for
|
|
// `{var}` named interpolation working — only `@` needs escaping.
|
|
return value.replace(/@/g, "{'@'}");
|
|
}
|
|
|
|
function setNested(target, path, value) {
|
|
let cursor = target;
|
|
for (let i = 0; i < path.length - 1; i++) {
|
|
const seg = path[i];
|
|
if (typeof cursor[seg] !== 'object' || cursor[seg] === null) {
|
|
cursor[seg] = {};
|
|
}
|
|
cursor = cursor[seg];
|
|
}
|
|
cursor[path[path.length - 1]] = value;
|
|
}
|
|
|
|
const SECTION_RE = /^\[([A-Za-z0-9_.-]+)\]$/;
|
|
const KV_RE = /^"([^"\\]*(?:\\.[^"\\]*)*)"\s*=\s*"((?:[^"\\]|\\.)*)"$/;
|
|
|
|
function parseToml(src) {
|
|
const tree = {};
|
|
let section = [];
|
|
let lineNo = 0;
|
|
for (const rawLine of src.split(/\r?\n/)) {
|
|
lineNo++;
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith('#')) continue;
|
|
|
|
const sectionMatch = SECTION_RE.exec(line);
|
|
if (sectionMatch) {
|
|
section = sectionMatch[1].split('.');
|
|
continue;
|
|
}
|
|
|
|
const kvMatch = KV_RE.exec(line);
|
|
if (!kvMatch) {
|
|
throw new Error(`Unsupported TOML construct at line ${lineNo}: ${rawLine}`);
|
|
}
|
|
const [, key, value] = kvMatch;
|
|
setNested(tree, [...section, unescape(key)], escapeForVueI18n(unescape(value)));
|
|
}
|
|
return tree;
|
|
}
|
|
|
|
const files = readdirSync(tomlDir).filter((f) => f.startsWith('translate.') && f.endsWith('.toml'));
|
|
let count = 0;
|
|
for (const file of files) {
|
|
const code = file.replace(/^translate\./, '').replace(/\.toml$/, '').replace('_', '-');
|
|
const tree = parseToml(readFileSync(join(tomlDir, file), 'utf8'));
|
|
const outPath = join(outDir, `${code}.json`);
|
|
writeFileSync(outPath, JSON.stringify(tree, null, 2) + '\n');
|
|
count++;
|
|
}
|
|
|
|
// eslint-disable-next-line no-console
|
|
console.log(`sync-locales: wrote ${count} locale file(s) to ${outDir}`);
|