locale-ai 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Taha KHABOUSS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,141 @@
1
- # Temporary Holding Version
1
+ # locale-ai
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Translate your i18n JSON locale files **locally, on CPU, for free**. No API keys, no Python, nothing leaves your machine.
4
+
5
+ ```bash
6
+ npx locale-ai translate --from en --to fr,es,de
7
+ ```
8
+
9
+ ```
10
+ locale-ai 0.2.0
11
+ Found 3 en files in locales/en/
12
+ Models to download (one time, then cached for every project):
13
+ en-fr 114 MB Xenova/opus-mt-en-fr
14
+ ...
15
+ Download 346 MB? (Y/n)
16
+ ✓ fr 43 strings translated, 3 files written in 2.7s, 1 plural forms added
17
+ ✓ es 43 strings translated, 3 files written in 2.5s, 1 plural forms added
18
+ ✓ de 43 strings translated, 3 files written in 2.1s
19
+ ```
20
+
21
+ ## What it handles
22
+
23
+ - **Layouts**: `locales/en/*.json` (nested folders too) → `locales/fr/...`, or `messages/en.json` → `messages/fr.json`. Common folders (`locales`, `src/locales`, `public/locales`, `src/i18n`, `messages`, `lang`, ...) are found automatically, or pass `--input`.
24
+ - **Placeholders are never translated or lost**: `{name}`, `{{count}}`, `%s`, `%1$s`, `%(name)s`, `%{name}`, `${name}`, `:attribute`, `$t(key)`, `@:key`, `<b>…</b>`, `<0>…</0>`, `&nbsp;`, URLs, e-mails.
25
+ - **ICU MessageFormat**: `{count, plural, one {# file} other {# files}}`, `select`, nesting. Only the text is translated.
26
+ - **i18next plurals**: adds the forms the target language needs (`_few`, `_many` for Arabic, Russian, Polish...).
27
+ - **Incremental**: only missing keys are translated, existing translations are kept, re-runs are no-ops. `--overwrite` re-translates everything.
28
+ - **Structure**: key order, numbers, booleans, arrays, indentation and trailing newline are preserved.
29
+
30
+ ## Commands
31
+
32
+ ```bash
33
+ locale-ai translate --to fr,pt-BR [--from en] [--input dir] [--dry-run] [--overwrite] [--fast] [--report file] [-y]
34
+ locale-ai models list [--from en] # supported language pairs
35
+ locale-ai models installed
36
+ locale-ai models install en-de # pre-download (e.g. in CI)
37
+ locale-ai models remove en-de
38
+ locale-ai models convert <hf-marian-model> --out <dir> # bring your own model (see below)
39
+ ```
40
+
41
+ `translate` uses beam search (4 beams) by default. `--fast` uses greedy decoding: 2–3x faster, about 0.5 chrF lower.
42
+
43
+ Models are cached once per machine in `~/.cache/locale-ai` (`LOCALE_AI_CACHE` to change). In CI, pass `--yes`.
44
+
45
+ ## Project config (optional)
46
+
47
+ `locale-ai.config.json` in the project root:
48
+
49
+ ```json
50
+ {
51
+ "doNotTranslate": ["Acme", "Acme Pro"],
52
+ "glossary": {
53
+ "fr": { "Workspace": "Espace de travail" },
54
+ "de": { "Sign in": "Einloggen" }
55
+ }
56
+ }
57
+ ```
58
+
59
+ `glossary` entries replace whole strings. `models` adds your own translation models (next section). A built-in glossary covers ~60 standard UI labels ("Sign in", "Settings", "Log out", ...) for fr, es, de, it, pt-BR, nl and ar, because one-to-three word labels are where sentence-level MT is weakest.
60
+
61
+ ## Add a language, or a better model
62
+
63
+ Any [MarianMT / OPUS-MT](https://huggingface.co/Helsinki-NLP) model on Hugging Face can be used. Convert it once (needs Python):
64
+
65
+ ```bash
66
+ pip install "optimum-onnx[onnxruntime]" accelerate sentencepiece
67
+ locale-ai models convert Helsinki-NLP/opus-mt-tc-big-en-tr --out models/en-tr --source en --target tr
68
+ ```
69
+
70
+ then declare it in `locale-ai.config.json`:
71
+
72
+ ```json
73
+ {
74
+ "models": [
75
+ { "source": "en", "target": "tr", "path": "models/en-tr" },
76
+ { "source": "en", "target": ["ru", "uk"], "path": "models/en-zle", "prefix": { "ru": ">>rus<<", "uk": ">>ukr<<" } },
77
+ { "source": "en", "target": "he", "repo": "your-org/opus-mt-en-he-onnx" }
78
+ ]
79
+ }
80
+ ```
81
+
82
+ - `path` is a local folder (relative to the config file); `repo` is a Hugging Face repo with the converted files (uploaded output folder), downloaded and cached like built-in models.
83
+ - `prefix` is required for multi-target models (`convert` lists the model's `>>xx<<` tokens).
84
+ - Project models take priority over built-ins for their language pairs and can be used for pivoting (e.g. `ja → en → tr`).
85
+
86
+ **Recommended for quality**: the `opus-mt-tc-big-en-*` models (fr, es, it, pt, ar, tr, and `zle` for ru/uk). They are ~3x larger (270–370 MB int8) and ~1.5x slower, and clearly better:
87
+
88
+ | Target | Built-in model, chrF / BLEU | tc-big model, chrF / BLEU |
89
+ |---|---|---|
90
+ | fr | 68.9 / 53.7 | **72.5 / 55.9** |
91
+ | es | 72.9 / 56.9 | **73.6 / 57.4** |
92
+ | it | 73.8 / 54.9 | **75.3 / 58.2** |
93
+ | pt | 68.3 / 44.4 | **70.6 / 47.7** (`>>por<<`, or `>>pob<<` for pt-BR) |
94
+ | ru | 65.7 / 46.0 | **68.2 / 48.1** (`>>rus<<`) |
95
+ | ar | 47.8 / 16.6 | **49.7 / 19.9** (`>>ara<<`, with `--fast`) |
96
+ | tr | — | **67.4 / 42.1** |
97
+
98
+ 300 Tatoeba test sentences per language, int8, beam 4 unless noted; reproduce with `bench/` (see `bench/README.md`). `opus-mt-tc-big-en-ko` is broken upstream (garbage output even in PyTorch) and is not recommended.
99
+
100
+ ## Languages
101
+
102
+ From English: af, ar, ca, cs, da, de, es, fi, fr, gl, hi, hu, id, it, nl, oc, pt (pt-BR, pt-PT), ro, ru, sv, uk, vi, xh, zh (zh-CN, zh-TW). Other source languages are supported directly or through English (`locale-ai models list --from fr`).
103
+
104
+ Measured on a sample app (see `test/fixtures`):
105
+
106
+ | Quality (built-in models) | Languages |
107
+ |---|---|
108
+ | Good — light review | fr, es, de, pt-BR, nl |
109
+ | Usable — review before shipping | it, ar, ru, zh-CN, zh-TW |
110
+ | Untested | the other pairs in `locale-ai models list` |
111
+
112
+ With the tc-big models above, it, ar and ru move up a tier and tr becomes available. Not available yet: ja, ko, pl and others; he works with a converted `opus-mt-en-he` (weaker). Moroccan Arabic (Darija): the tc-big en-ar model has a `>>ary<<` token, but its output is close to Modern Standard Arabic — not supported.
113
+
114
+ ## How it works
115
+
116
+ - **Models**: [OPUS-MT](https://huggingface.co/Helsinki-NLP) (MarianMT) in ONNX, int8-quantized (int8 vs fp32 measured equal: en-fr chrF 68.9 vs 69.0, en-ar 47.8 vs 47.8). Downloads are pinned to a commit and verified (sha256 / git blob hash).
117
+ - **Own runtime** on [onnxruntime](https://onnxruntime.ai) (`src/marian.ts`), no transformers.js:
118
+ - a SentencePiece Unigram tokenizer including the model's precompiled normalisation map, identical to Python `sentencepiece` on 3,577/3,577 test sentences across 5 models (`src/spm.ts`);
119
+ - real beam search with Hugging Face semantics, identical to `transformers` PyTorch beam search on 599/600 sentences; log-softmax + top-k run natively in a tiny embedded ONNX graph (`scripts/make-topk-graph.py`);
120
+ - KV cache with batch compaction: finished sentences leave the batch, so short and long strings share batches cheaply.
121
+ - **Sentence splitting**: OPUS-MT drops sentences from multi-sentence inputs, so each sentence is translated separately.
122
+ - **Placeholder masking** with three marker styles tried in order (`{0}` / `<0>…</0>`, then numbers `900`, then `_P0_`), validating every marker; numbers survive models that mangle braces (en-zh, en-ru). A last-resort fallback translates around placeholders, and anything that needed it is listed for review.
123
+ - **Speed** (20-core CPU, ~1 GB RAM): sample app (43 strings) in ~3 s per language with beam search, ~2 s with `--fast`; full sentences at ~18/s (built-in, beam 4), ~12/s (tc-big, beam 4), ~33/s (tc-big, `--fast`).
124
+
125
+ ## Development
126
+
127
+ ```bash
128
+ npm install
129
+ npm test # unit tests (no models)
130
+ npm run test:e2e # real models, downloads ~115 MB once
131
+ node bench/eval.ts --dir <model dir> --lang fr --beams 4 --out out.json # quality benchmark (see bench/README.md)
132
+ npm run dev -- translate --to fr # run from source (Node ≥ 22.18 strips types)
133
+ npm run build # → dist/
134
+ ```
135
+
136
+ ## Next steps
137
+
138
+ 1. **Host the tc-big models** (converted ONNX) on Hugging Face so they can become the built-in defaults instead of a manual conversion.
139
+ 2. **Smaller install.** `onnxruntime-node` is ~550 MB unpacked: it ships binaries for every platform plus a 260 MB CUDA provider we never use.
140
+ 3. **More languages**: ja, ko, pl have no usable OPUS-MT model yet; a different model family would be needed.
141
+ 4. **More formats**: YAML, `.po`, ARB, Apple `.strings`.
@@ -0,0 +1,121 @@
1
+ import path from "node:path";
2
+ import { parseLocale } from "./lang.js";
3
+ const pair = (src, tgt) => ({
4
+ id: `${src}-${tgt}`,
5
+ repo: `Xenova/opus-mt-${src}-${tgt}`,
6
+ sources: [src],
7
+ targets: [tgt],
8
+ });
9
+ const DIRECT = [
10
+ "af-en", "ar-en", "cs-en", "da-de", "da-en", "de-en", "de-es", "de-fr",
11
+ "en-af", "en-cs", "en-da", "en-de", "en-es", "en-fi", "en-fr", "en-hi",
12
+ "en-hu", "en-id", "en-it", "en-nl", "en-ro", "en-ru", "en-sv", "en-uk",
13
+ "en-xh", "es-de", "es-en", "es-fr", "es-it", "es-ru", "et-en", "fi-de",
14
+ "fi-en", "fr-de", "fr-en", "fr-es", "fr-ro", "fr-ru", "hi-en", "hu-en",
15
+ "id-en", "it-en", "it-es", "it-fr", "ja-en", "ko-en", "nl-en", "nl-fr",
16
+ "no-de", "pl-en", "ro-fr", "ru-en", "ru-es", "ru-fr", "ru-uk", "sv-en",
17
+ "th-en", "tr-en", "uk-en", "uk-ru", "vi-en", "xh-en", "zh-en",
18
+ ];
19
+ const ROMANCE = ["es", "pt", "ca", "gl", "oc", "it", "ro", "fr"];
20
+ export const CATALOG = [
21
+ ...DIRECT.map((p) => {
22
+ const [s, t] = p.split("-");
23
+ return pair(s, t);
24
+ }),
25
+ {
26
+ // Note: the model card lists Moroccan Arabic ("ary") but its vocabulary has no
27
+ // >>ary<< token, so Darija is not supported.
28
+ ...pair("en", "ar"),
29
+ prefix: () => ">>ara<<",
30
+ },
31
+ {
32
+ ...pair("en", "zh"),
33
+ prefix: (t) => t.script === "Hant" || ["TW", "HK", "MO"].includes(t.region ?? "") ? ">>cmn_Hant<<" : ">>cmn_Hans<<",
34
+ },
35
+ { ...pair("en", "vi"), prefix: () => ">>vie<<" },
36
+ {
37
+ id: "en-ROMANCE",
38
+ repo: "Xenova/opus-mt-en-ROMANCE",
39
+ sources: ["en"],
40
+ targets: ROMANCE,
41
+ prefix: (t) => {
42
+ if (t.lang === "pt" && t.region === "BR")
43
+ return ">>pt_BR<<";
44
+ if (t.lang === "pt" && t.region === "PT")
45
+ return ">>pt_PT<<";
46
+ return `>>${t.lang}<<`;
47
+ },
48
+ },
49
+ {
50
+ id: "ROMANCE-en",
51
+ repo: "Xenova/opus-mt-ROMANCE-en",
52
+ sources: ROMANCE,
53
+ targets: ["en"],
54
+ },
55
+ ];
56
+ const asList = (v) => (Array.isArray(v) ? v : [v]).map((x) => parseLocale(x).lang);
57
+ export function customEntries(models, configDir) {
58
+ return (models ?? []).map((c, i) => {
59
+ if (!c.repo === !c.path)
60
+ throw new Error(`models[${i}]: set exactly one of "repo" or "path".`);
61
+ if (!c.source || !c.target)
62
+ throw new Error(`models[${i}]: "source" and "target" are required.`);
63
+ const sources = asList(c.source);
64
+ const targets = asList(c.target);
65
+ const prefix = c.prefix;
66
+ return {
67
+ id: c.id ?? `custom:${sources.join("+")}-${targets.join("+")}`,
68
+ repo: c.repo,
69
+ path: c.path ? path.resolve(configDir, c.path) : undefined,
70
+ custom: true,
71
+ sources,
72
+ targets,
73
+ prefix: prefix === undefined
74
+ ? undefined
75
+ : (t) => (typeof prefix === "string" ? prefix : (prefix[t.tag] ?? prefix[t.lang])),
76
+ };
77
+ });
78
+ }
79
+ export function findById(id, catalog = CATALOG) {
80
+ const lower = id.toLowerCase();
81
+ return catalog.find((m) => m.id.toLowerCase() === lower || m.repo?.toLowerCase() === lower);
82
+ }
83
+ /** Best single model for a pair: custom models first, then dedicated pair models over multi-target ones. */
84
+ function direct(src, tgt, catalog) {
85
+ const candidates = catalog.filter((m) => m.sources.includes(src) && m.targets.includes(tgt.lang));
86
+ const rank = (m) => (m.custom ? 0 : 1000) + m.targets.length + m.sources.length;
87
+ candidates.sort((a, b) => rank(a) - rank(b));
88
+ const model = candidates[0];
89
+ return model && { model, prefix: model.prefix?.(tgt) };
90
+ }
91
+ const EN = { tag: "en", lang: "en" };
92
+ /**
93
+ * Finds the models needed to go from `source` to `target`: a direct model when one
94
+ * exists, otherwise a pivot through English. Returns undefined when unsupported.
95
+ */
96
+ export function resolveRoute(source, target, catalog = CATALOG) {
97
+ if (source.lang === target.lang)
98
+ return undefined;
99
+ const d = direct(source.lang, target, catalog);
100
+ if (d)
101
+ return [d];
102
+ if (source.lang === "en" || target.lang === "en")
103
+ return undefined;
104
+ const a = direct(source.lang, EN, catalog);
105
+ const b = direct("en", target, catalog);
106
+ return a && b ? [a, b] : undefined;
107
+ }
108
+ /** Every language code reachable from `source` (direct or via English). */
109
+ export function reachableTargets(source, catalog = CATALOG) {
110
+ const out = new Set();
111
+ for (const m of catalog)
112
+ if (m.sources.includes(source))
113
+ m.targets.forEach((t) => out.add(t));
114
+ if (source !== "en" && out.has("en")) {
115
+ for (const m of catalog)
116
+ if (m.sources.includes("en"))
117
+ m.targets.forEach((t) => out.add(t));
118
+ }
119
+ out.delete(source);
120
+ return [...out].sort();
121
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { parseArgs } from "node:util";
8
+ import { CATALOG, customEntries, findById, reachableTargets, resolveRoute, } from "./catalog.js";
9
+ import { translateStrings } from "./engine.js";
10
+ import { CONFIG_FILE, glossaryLookup, loadConfig, protectRegex } from "./glossary.js";
11
+ import { intlCode, parseLocale } from "./lang.js";
12
+ import { addPluralForms, buildTarget, detectLayout, pendingStrings, readJson, sourceFiles, targetFile, } from "./locales.js";
13
+ import { cacheRoot, install, isInstalled, loadTranslator, localModelProblem, planInstall, readManifest, uninstall, } from "./models.js";
14
+ /** Read from package.json so `npm version` is the single source of truth. */
15
+ const VERSION = JSON.parse(fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
16
+ const HELP = `locale-ai ${VERSION} — translate i18n JSON locale files locally, on CPU, for free.
17
+
18
+ Usage:
19
+ locale-ai translate --to <locales> [options]
20
+ locale-ai models list [--from <locale>]
21
+ locale-ai models installed
22
+ locale-ai models install <from-to | model-id> [--yes]
23
+ locale-ai models remove <model-id>
24
+ locale-ai models convert <hf-marian-model> --out <dir> [--source en --target ko]
25
+ Convert any Hugging Face MarianMT model (needs Python, see README)
26
+
27
+ Translate options:
28
+ --from <locale> Source locale (default: en)
29
+ --to <locales> Target locale(s), comma separated: fr,es,pt-BR
30
+ --input <dir> Folder containing <from>/ or <from>.json (auto-detected)
31
+ --overwrite Re-translate strings that already have a translation
32
+ --dry-run Show what would be translated without downloading or writing
33
+ --fast Greedy decoding: ~2x faster, slightly lower quality
34
+ --report <file> Write a JSON report of strings that need review
35
+ --config <file> Project config (default: ./${CONFIG_FILE} if present)
36
+ -y, --yes Download required models without asking
37
+
38
+ Models are cached in ${cacheRoot()} (override with LOCALE_AI_CACHE).
39
+ `;
40
+ const isTTY = process.stderr.isTTY;
41
+ const log = (msg = "") => process.stderr.write(msg + "\n");
42
+ const status = (msg) => {
43
+ if (isTTY)
44
+ process.stderr.write(`\r\x1b[2K${msg}`);
45
+ };
46
+ const endStatus = () => {
47
+ if (isTTY)
48
+ process.stderr.write("\r\x1b[2K");
49
+ };
50
+ const mb = (bytes) => `${Math.round(bytes / 1e6)} MB`;
51
+ const rel = (p) => path.relative(process.cwd(), p) || ".";
52
+ function bar(done, total, width = 24) {
53
+ const ratio = total ? Math.min(1, done / total) : 1;
54
+ const filled = Math.round(ratio * width);
55
+ return `[${"█".repeat(filled)}${"░".repeat(width - filled)}] ${Math.round(ratio * 100)}%`;
56
+ }
57
+ async function confirm(question, yes) {
58
+ if (yes)
59
+ return true;
60
+ if (!process.stdin.isTTY) {
61
+ throw new Error(`${question.trim()} Re-run with --yes to allow downloads in non-interactive mode.`);
62
+ }
63
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
64
+ const answer = (await rl.question(`${question} (Y/n) `)).trim().toLowerCase();
65
+ rl.close();
66
+ return answer === "" || answer === "y" || answer === "yes";
67
+ }
68
+ async function ensureInstalled(models, yes) {
69
+ for (const m of new Set(models)) {
70
+ const problem = localModelProblem(m);
71
+ if (problem)
72
+ throw new Error(`Model ${m.id}: ${problem}`);
73
+ }
74
+ const missing = [...new Set(models)].filter((m) => !m.path && !isInstalled(m));
75
+ if (!missing.length)
76
+ return;
77
+ status("Checking model sizes...");
78
+ const plans = [];
79
+ for (const m of missing)
80
+ plans.push(await planInstall(m));
81
+ endStatus();
82
+ const total = plans.reduce((s, p) => s + p.bytes, 0);
83
+ log("Models to download (one time, then cached for every project):");
84
+ for (const p of plans)
85
+ log(` ${p.model.id.padEnd(12)} ${mb(p.bytes).padStart(7)} ${p.model.repo ?? p.model.path}`);
86
+ if (!(await confirm(`Download ${mb(total)}?`, yes)))
87
+ throw new Error("Download declined.");
88
+ for (const p of plans) {
89
+ await install(p, (d, t) => status(`Downloading ${p.model.id} ${bar(d, t)} ${mb(d)}/${mb(t)}`));
90
+ endStatus();
91
+ log(`✓ Installed ${p.model.id}`);
92
+ }
93
+ }
94
+ async function cmdTranslate(argv) {
95
+ const { values } = parseArgs({
96
+ args: argv,
97
+ options: {
98
+ from: { type: "string", default: "en" },
99
+ to: { type: "string" },
100
+ input: { type: "string" },
101
+ overwrite: { type: "boolean", default: false },
102
+ "dry-run": { type: "boolean", default: false },
103
+ "batch-size": { type: "string", default: "32" },
104
+ fast: { type: "boolean", default: false },
105
+ beams: { type: "string" },
106
+ report: { type: "string" },
107
+ config: { type: "string" },
108
+ out: { type: "string" },
109
+ source: { type: "string" },
110
+ target: { type: "string" },
111
+ python: { type: "string" },
112
+ yes: { type: "boolean", short: "y", default: false },
113
+ },
114
+ });
115
+ if (!values.to)
116
+ throw new Error("Missing --to. Example: locale-ai translate --from en --to fr,es");
117
+ const from = parseLocale(values.from);
118
+ const targets = values.to.split(",").map((t) => t.trim()).filter(Boolean).map(parseLocale);
119
+ const batchSize = Math.max(1, Number(values["batch-size"]));
120
+ const beams = Math.max(1, Number(values.beams ?? (values.fast ? 1 : 4)));
121
+ const config = loadConfig(process.cwd(), values.config);
122
+ const catalog = catalogFor(config);
123
+ const protect = protectRegex(config);
124
+ const layout = detectLayout(process.cwd(), from.tag, values.input);
125
+ const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readJson(f) }));
126
+ log(`locale-ai ${VERSION}`);
127
+ log(`Found ${sources.length} ${from.tag} file${sources.length === 1 ? "" : "s"} in ${rel(layout.root)}/` +
128
+ (layout.kind === "dir" ? `${from.tag}/` : ""));
129
+ let failures = 0;
130
+ const jobs = [];
131
+ for (const locale of targets) {
132
+ const route = resolveRoute(from, locale, catalog);
133
+ if (!route) {
134
+ failures++;
135
+ const options = reachableTargets(from.lang, catalog);
136
+ log(`✗ ${locale.tag}: no local model for ${from.lang} → ${locale.lang}.`);
137
+ log(` Available from ${from.lang}: ${options.join(", ") || "(none)"}`);
138
+ continue;
139
+ }
140
+ const files = sources.map(({ file, doc }) => {
141
+ const target = targetFile(layout, from.tag, locale.tag, file);
142
+ const existing = fs.existsSync(target) ? readJson(target).data : undefined;
143
+ return { source: file, target, doc, existing, pending: pendingStrings(doc.data, existing, values.overwrite) };
144
+ });
145
+ const unique = new Set(files.flatMap((f) => f.pending)).size;
146
+ jobs.push({ locale, route, files, unique });
147
+ }
148
+ if (values["dry-run"]) {
149
+ for (const j of jobs) {
150
+ const models = j.route
151
+ .map((h) => `${h.model.id}${isInstalled(h.model) ? "" : " (not installed)"}`)
152
+ .join(" → ");
153
+ log(`${j.locale.tag.padEnd(8)} ${String(j.unique).padStart(5)} strings to translate model: ${models}`);
154
+ for (const f of j.files)
155
+ if (f.pending.length)
156
+ log(` ${rel(f.target)} +${f.pending.length}`);
157
+ }
158
+ return failures ? 1 : 0;
159
+ }
160
+ const active = jobs.filter((j) => j.unique > 0);
161
+ await ensureInstalled(active.flatMap((j) => j.route.map((h) => h.model)), values.yes);
162
+ const allIssues = [];
163
+ const loaded = new Map();
164
+ const key = (h) => `${h.model.id}|${h.prefix ?? ""}`;
165
+ const remainingUses = (repoKey, from) => active.slice(from).filter((j) => j.route.some((h) => key(h) === repoKey)).length;
166
+ for (const job of jobs) {
167
+ const started = Date.now();
168
+ let translations = new Map();
169
+ if (job.unique > 0) {
170
+ const translators = [];
171
+ for (const hop of job.route) {
172
+ if (!loaded.has(key(hop))) {
173
+ status(`${job.locale.tag}: loading ${hop.model.id}...`);
174
+ loaded.set(key(hop), await loadTranslator(hop, { beams }));
175
+ }
176
+ translators.push(loaded.get(key(hop)));
177
+ }
178
+ const strings = job.files.flatMap((f) => f.pending);
179
+ const result = await translateStrings(strings, translators, {
180
+ sourceLang: intlCode(from),
181
+ targetLang: intlCode(job.locale),
182
+ batchSize,
183
+ protect,
184
+ lookup: glossaryLookup(from, job.locale, config),
185
+ onProgress: (d, t) => status(`${job.locale.tag}: translating ${bar(d, t)} ${d}/${t}`),
186
+ });
187
+ endStatus();
188
+ translations = result.translations;
189
+ allIssues.push(...result.issues.map((i) => ({ ...i, locale: job.locale.tag })));
190
+ for (const hop of job.route) {
191
+ const k = key(hop);
192
+ if (remainingUses(k, active.indexOf(job) + 1) === 0 && loaded.has(k)) {
193
+ await loaded.get(k).dispose();
194
+ loaded.delete(k);
195
+ }
196
+ }
197
+ }
198
+ let written = 0;
199
+ let pluralsAdded = 0;
200
+ for (const f of job.files) {
201
+ const built = buildTarget(f.doc.data, f.existing, (s) => translations.get(s) ?? s, values.overwrite);
202
+ const { doc, added } = addPluralForms(built, intlCode(job.locale));
203
+ pluralsAdded += added;
204
+ const text = JSON.stringify(doc, null, f.doc.indent) + (f.doc.finalNewline ? "\n" : "");
205
+ const before = fs.existsSync(f.target) ? fs.readFileSync(f.target, "utf8") : undefined;
206
+ if (text !== before) {
207
+ fs.mkdirSync(path.dirname(f.target), { recursive: true });
208
+ fs.writeFileSync(f.target, text);
209
+ written++;
210
+ }
211
+ }
212
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
213
+ const issues = allIssues.filter((i) => i.locale === job.locale.tag).length;
214
+ const via = job.route.length > 1 ? ` (via ${job.route.map((h) => h.model.id).join(" → ")})` : "";
215
+ log(`✓ ${job.locale.tag.padEnd(7)} ${String(job.unique).padStart(5)} strings translated, ${written} file${written === 1 ? "" : "s"} written in ${secs}s${via}` +
216
+ (pluralsAdded ? `, ${pluralsAdded} plural forms added` : "") +
217
+ (issues ? `, ${issues} to review` : ""));
218
+ }
219
+ for (const t of loaded.values())
220
+ await t.dispose();
221
+ if (allIssues.length) {
222
+ log();
223
+ log(`${allIssues.length} string${allIssues.length === 1 ? "" : "s"} worth a quick review:`);
224
+ for (const i of allIssues.slice(0, 10)) {
225
+ log(` [${i.locale}] ${i.kind}: ${JSON.stringify(i.source)}${i.output ? ` → ${JSON.stringify(i.output)}` : ""}`);
226
+ }
227
+ if (allIssues.length > 10)
228
+ log(` ... and ${allIssues.length - 10} more${values.report ? "" : " (use --report <file>)"}`);
229
+ }
230
+ if (values.report) {
231
+ fs.writeFileSync(values.report, JSON.stringify({ version: VERSION, from: from.tag, issues: allIssues }, null, 2) + "\n");
232
+ log(`Report written to ${values.report}`);
233
+ }
234
+ return failures ? 1 : 0;
235
+ }
236
+ /** Runs scripts/convert-marian.py and prints the config entry for the new model. */
237
+ function convertModel(model, opts) {
238
+ if (!model || !opts.out)
239
+ throw new Error("Usage: locale-ai models convert <hf-marian-model> --out <dir>");
240
+ const script = fileURLToPath(new URL("../scripts/convert-marian.py", import.meta.url));
241
+ const python = opts.python ?? process.env.PYTHON ?? (process.platform === "win32" ? "python" : "python3");
242
+ const res = spawnSync(python, [script, model, "--out", opts.out], { stdio: "inherit" });
243
+ if (res.error || res.status !== 0) {
244
+ log(`\nConversion failed. It needs Python 3.10+ with: pip install "optimum-onnx[onnxruntime]" accelerate sentencepiece`);
245
+ log(`(use --python <path> to pick an interpreter, e.g. a virtualenv)`);
246
+ return 1;
247
+ }
248
+ const entry = {
249
+ source: opts.source ?? "en",
250
+ target: opts.target ?? "<target language code>",
251
+ path: path.relative(process.cwd(), path.resolve(opts.out)) || ".",
252
+ };
253
+ try {
254
+ const vocab = JSON.parse(fs.readFileSync(path.join(opts.out, "vocab.json"), "utf8"));
255
+ const codes = Object.keys(vocab).filter((k) => /^>>[\w-]+<<$/.test(k));
256
+ if (codes.length) {
257
+ entry.prefix = "<one of the target tokens below>";
258
+ log(`\nThis model is multi-target; its target tokens: ${codes.join(" ")}`);
259
+ }
260
+ }
261
+ catch {
262
+ // vocab.json missing: the script would have failed already.
263
+ }
264
+ log(`\nAdd to ${CONFIG_FILE} under "models":\n${JSON.stringify(entry, null, 2)}`);
265
+ return 0;
266
+ }
267
+ /** Built-in models plus the project's own (which take priority). */
268
+ function catalogFor(config) {
269
+ return [...customEntries(config.models, config.dir ?? process.cwd()), ...CATALOG];
270
+ }
271
+ async function cmdModels(argv) {
272
+ const [sub, ...rest] = argv;
273
+ const { values, positionals } = parseArgs({
274
+ args: rest,
275
+ allowPositionals: true,
276
+ options: {
277
+ from: { type: "string" },
278
+ config: { type: "string" },
279
+ out: { type: "string" },
280
+ source: { type: "string" },
281
+ target: { type: "string" },
282
+ python: { type: "string" },
283
+ yes: { type: "boolean", short: "y", default: false },
284
+ },
285
+ });
286
+ const catalog = catalogFor(loadConfig(process.cwd(), values.config));
287
+ switch (sub) {
288
+ case "list": {
289
+ const filter = values.from ? parseLocale(values.from).lang : undefined;
290
+ for (const m of catalog) {
291
+ if (filter && !m.sources.includes(filter))
292
+ continue;
293
+ const state = isInstalled(m) ? "installed" : "";
294
+ log(`${m.id.padEnd(12)} ${m.sources.join(",")} → ${m.targets.join(",").padEnd(22)} ${state}`);
295
+ }
296
+ if (filter)
297
+ log(`\nReachable from ${filter} (direct or via English): ${reachableTargets(filter, catalog).join(", ")}`);
298
+ return 0;
299
+ }
300
+ case "installed": {
301
+ const list = catalog.map((m) => ({ m, man: readManifest(m) })).filter((x) => x.man);
302
+ if (!list.length)
303
+ log("No models installed.");
304
+ for (const { m, man } of list)
305
+ log(`${m.id.padEnd(12)} ${mb(man.bytes).padStart(7)} ${man.installedAt.slice(0, 10)}`);
306
+ return 0;
307
+ }
308
+ case "install": {
309
+ if (!positionals[0])
310
+ throw new Error("Usage: locale-ai models install <from-to | model-id>");
311
+ let models;
312
+ const byId = findById(positionals[0], catalog);
313
+ if (byId)
314
+ models = [byId];
315
+ else {
316
+ const [a, ...b] = positionals[0].split("-");
317
+ const route = resolveRoute(parseLocale(a), parseLocale(b.join("-")), catalog);
318
+ if (!route)
319
+ throw new Error(`No model for ${positionals[0]}. See: locale-ai models list`);
320
+ models = route.map((h) => h.model);
321
+ }
322
+ const already = models.filter(isInstalled);
323
+ already.forEach((m) => log(`✓ ${m.id} already installed`));
324
+ await ensureInstalled(models, values.yes);
325
+ return 0;
326
+ }
327
+ case "remove": {
328
+ const m = positionals[0] && findById(positionals[0], catalog);
329
+ if (!m)
330
+ throw new Error("Usage: locale-ai models remove <model-id> (see: locale-ai models installed)");
331
+ log((await uninstall(m)) ? `Removed ${m.id}` : `${m.id} is not installed`);
332
+ return 0;
333
+ }
334
+ case "convert":
335
+ return convertModel(positionals[0], values);
336
+ default:
337
+ log(HELP);
338
+ return sub ? 1 : 0;
339
+ }
340
+ }
341
+ async function main() {
342
+ const [cmd, ...rest] = process.argv.slice(2);
343
+ switch (cmd) {
344
+ case "translate":
345
+ return cmdTranslate(rest);
346
+ case "models":
347
+ return cmdModels(rest);
348
+ case "-v":
349
+ case "--version":
350
+ console.log(VERSION);
351
+ return 0;
352
+ case undefined:
353
+ case "-h":
354
+ case "--help":
355
+ case "help":
356
+ log(HELP);
357
+ return 0;
358
+ default:
359
+ log(`Unknown command "${cmd}".\n`);
360
+ log(HELP);
361
+ return 1;
362
+ }
363
+ }
364
+ main().then((code) => {
365
+ process.exitCode = code;
366
+ }, (err) => {
367
+ endStatus();
368
+ log(`Error: ${err instanceof Error ? err.message : String(err)}`);
369
+ process.exitCode = 1;
370
+ });