diff --git a/frontend/package.json b/frontend/package.json index c59727f5..cdb1fc13 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,9 @@ "type": "module", "description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4). Built with Vite into ../web/dist/ and embedded by the Go binary.", "scripts": { + "i18n:sync": "node scripts/sync-locales.mjs", + "predev": "node scripts/sync-locales.mjs", + "prebuild": "node scripts/sync-locales.mjs", "dev": "vite", "build": "vite build", "preview": "vite preview", diff --git a/frontend/scripts/sync-locales.mjs b/frontend/scripts/sync-locales.mjs new file mode 100644 index 00000000..1dc38986 --- /dev/null +++ b/frontend/scripts/sync-locales.mjs @@ -0,0 +1,97 @@ +// 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/.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)); + }); +} + +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)], 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}`); diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js new file mode 100644 index 00000000..4f50d3c1 --- /dev/null +++ b/frontend/src/i18n/index.js @@ -0,0 +1,94 @@ +// vue-i18n setup. Locale files are generated from web/translation/*.toml +// by `npm run i18n:sync` (run automatically as a pre-build step). +// +// Usage in a component: +// import { useI18n } from 'vue-i18n'; +// const { t } = useI18n(); +// ... +// {{ t('pages.inbounds.email') }} +// +// Or via the global helper exposed on the app: +// {{ $t('pages.inbounds.email') }} +// +// The locale follows the `lang` cookie that LanguageManager already +// reads/writes — switching language anywhere in the app continues to +// trigger a full page reload (matches legacy ergonomics), so we don't +// need a runtime locale switcher here. + +import { createI18n } from 'vue-i18n'; + +import { LanguageManager } from '@/utils'; + +// Lazy-loaded locales — Vite splits each one into its own chunk. We +// eager-load only the active language plus the en-US fallback so the +// initial page payload stays small (the inbounds bundle was sitting +// at ~700kB gzipped with all 13 locales eager; now ~480kB). +// +// LanguageManager.setLanguage() does a full reload on change, so +// "lazy" here effectively means "load only what this page needs for +// its lifetime." +const FALLBACK = 'en-US'; +const lazyModules = import.meta.glob('../locales/*.json'); +const eagerModules = import.meta.glob('../locales/*.json', { eager: true }); + +function moduleKeyFor(code) { + return `../locales/${code}.json`; +} + +// Resolve the active locale via LanguageManager so the cookie set on +// the legacy panel keeps working after a user upgrades. Falls back +// to en-US when the cookie names a language we don't have. +let active = LanguageManager.getLanguage(); +if (!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) { + active = FALLBACK; +} + +const messages = {}; +// Eagerly include the active locale + the fallback (when distinct) +// so the very first render has strings ready. Vite still emits these +// as their own chunks so the user pays for at most two locales. +for (const code of new Set([active, FALLBACK])) { + const mod = eagerModules[moduleKeyFor(code)]; + if (mod) messages[code] = mod.default || mod; +} + +export const i18n = createI18n({ + legacy: false, + // `composition` mode (legacy: false) so `useI18n()` works in + //