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 +21 -0
- package/README.md +140 -2
- package/dist/catalog.js +121 -0
- package/dist/cli.js +370 -0
- package/dist/engine.js +226 -0
- package/dist/glossary.js +144 -0
- package/dist/icu.js +151 -0
- package/dist/lang.js +27 -0
- package/dist/locales.js +209 -0
- package/dist/marian.js +326 -0
- package/dist/models.js +254 -0
- package/dist/placeholders.js +214 -0
- package/dist/segment.js +42 -0
- package/dist/spm.js +321 -0
- package/dist/topk-graph.js +2 -0
- package/package.json +50 -4
- package/scripts/convert-marian.py +121 -0
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
|
-
#
|
|
1
|
+
# locale-ai
|
|
2
2
|
|
|
3
|
-
|
|
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>`, ` `, 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`.
|
package/dist/catalog.js
ADDED
|
@@ -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
|
+
});
|