locale-ai 0.4.0 → 0.6.0
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/README.md +10 -7
- package/dist/catalog.js +10 -0
- package/dist/cli.js +3 -3
- package/dist/locales.js +207 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](package.json)
|
|
8
8
|
|
|
9
|
-
Translate your i18n
|
|
9
|
+
Translate your i18n locale files **locally, on CPU, for free**. No API keys, no Python, nothing leaves your machine.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npx locale-ai translate --from en --to fr,es,de
|
|
@@ -26,13 +26,15 @@ Download 577 MB? (Y/n)
|
|
|
26
26
|
|
|
27
27
|
## What it handles
|
|
28
28
|
|
|
29
|
-
- **Layouts**: `locales/en/*.json` (nested folders too) → `locales/fr/...`, or `messages/en.json` → `messages/fr.json`. `.json`, `.yaml` and `.
|
|
29
|
+
- **Layouts**: `locales/en/*.json` (nested folders too) → `locales/fr/...`, or `messages/en.json` → `messages/fr.json`. `.json`, `.yaml`, `.yml`, `.po` (gettext), `.arb` (Flutter) and `.strings` (Apple) are all supported, mixed freely in the same project. Common folders (`locales`, `src/locales`, `public/locales`, `src/i18n`, `messages`, `lang`, ...) are found automatically, or pass `--input`.
|
|
30
30
|
- **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.
|
|
31
31
|
- **ICU MessageFormat**: `{count, plural, one {# file} other {# files}}`, `select`, nesting. Only the text is translated.
|
|
32
32
|
- **i18next plurals**: adds the forms the target language needs (`_few`, `_many` for Arabic, Russian, Polish...).
|
|
33
33
|
- **Incremental**: only missing keys are translated, existing translations are kept, re-runs are no-ops. `--overwrite` re-translates everything.
|
|
34
34
|
- **Structure**: key order, numbers, booleans, arrays, indentation and trailing newline are preserved.
|
|
35
35
|
|
|
36
|
+
**Format notes**: for `.po`, `msgid` is the stable key (never changed) and `msgstr` is the translated value, as gettext expects — the English `.po` file's `msgstr` must hold the source text (most generators already fill it; an empty `msgstr` is treated as empty, not inherited from `msgid`), and `msgid_plural`/`msgstr[n]` plural entries pass through untranslated (translate those by hand, or use ICU plurals in `.arb`/JSON/YAML instead). For `.arb`, `@key` metadata and `@@locale` are never translated; `@@locale` is set to the target locale on output.
|
|
37
|
+
|
|
36
38
|
## Why locale-ai?
|
|
37
39
|
|
|
38
40
|
- **No API keys, no cloud bill.** Google/DeepL/OpenAI-based translation tools work well but mean an account, a key, a per-character cost, and your strings leaving your machine. locale-ai runs the model on your own CPU.
|
|
@@ -111,19 +113,21 @@ then declare it in `locale-ai.config.json`:
|
|
|
111
113
|
|
|
112
114
|
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.
|
|
113
115
|
|
|
116
|
+
Also new: **pl** (chrF 62.6 / BLEU 40.3, `>>pol<<`), hosted at [khabouss/opus-mt-en-sla-onnx](https://huggingface.co/khabouss/opus-mt-en-sla-onnx) — no tc-big `en-pl` checkpoint exists, so this uses Helsinki-NLP's multi-target Slavic model instead.
|
|
117
|
+
|
|
114
118
|
## Languages
|
|
115
119
|
|
|
116
|
-
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`).
|
|
120
|
+
From English: af, ar, ca, cs, da, de, es, fi, fr, gl, hi, hu, id, it, nl, oc, pl, 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`).
|
|
117
121
|
|
|
118
122
|
Measured on a sample app (see `test/fixtures`):
|
|
119
123
|
|
|
120
124
|
| Quality (built-in models) | Languages |
|
|
121
125
|
|---|---|
|
|
122
126
|
| Good — light review | fr, es, de, pt-BR, nl |
|
|
123
|
-
| Usable — review before shipping | it, ar, ru, zh-CN, zh-TW |
|
|
127
|
+
| Usable — review before shipping | it, ar, ru, pl, zh-CN, zh-TW |
|
|
124
128
|
| Untested | the other pairs in `locale-ai models list` |
|
|
125
129
|
|
|
126
|
-
`en-fr` is already on the tc-big model (chrF 72.5 / BLEU 55.9) as a built-in, no config needed. With the other tc-big models above, it, ar and ru move up a tier and tr
|
|
130
|
+
`en-fr` is already on the tc-big model (chrF 72.5 / BLEU 55.9) as a built-in, no config needed. With the other tc-big models above, it, ar and ru move up a tier, and tr and pl become available. Not available yet: ja, ko 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.
|
|
127
131
|
|
|
128
132
|
## How it works
|
|
129
133
|
|
|
@@ -150,5 +154,4 @@ npm run build # → dist/
|
|
|
150
154
|
|
|
151
155
|
## Next steps
|
|
152
156
|
|
|
153
|
-
1. **More languages**: ja, ko
|
|
154
|
-
2. **More formats**: `.po` (gettext), ARB (Flutter), Apple `.strings`. (JSON and YAML are supported today.)
|
|
157
|
+
1. **More languages**: ja, ko have no usable OPUS-MT model yet; a different model family would be needed.
|
package/dist/catalog.js
CHANGED
|
@@ -47,6 +47,16 @@ export const CATALOG = [
|
|
|
47
47
|
targets: ["ru", "uk"],
|
|
48
48
|
prefix: (t) => (t.lang === "ru" ? ">>rus<<" : ">>ukr<<"),
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
// New: Polish, not supported by any built-in model before this. The tc-big
|
|
52
|
+
// family has no en-pl checkpoint; this is Helsinki-NLP's multi-target Slavic
|
|
53
|
+
// model, used for its >>pol<< target only.
|
|
54
|
+
id: "en-pl",
|
|
55
|
+
repo: "khabouss/opus-mt-en-sla-onnx", // chrF 62.6, BLEU 40.3
|
|
56
|
+
sources: ["en"],
|
|
57
|
+
targets: ["pl"],
|
|
58
|
+
prefix: () => ">>pol<<",
|
|
59
|
+
},
|
|
50
60
|
{
|
|
51
61
|
// Note: the model card lists Moroccan Arabic ("ary") but its output stays close
|
|
52
62
|
// to Modern Standard Arabic, so Darija is not treated as supported.
|
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import { addPluralForms, buildTarget, detectLayout, pendingStrings, readLocaleDo
|
|
|
13
13
|
import { cacheRoot, install, isInstalled, loadTranslator, localModelProblem, planInstall, readManifest, uninstall, } from "./models.js";
|
|
14
14
|
/** Read from package.json so `npm version` is the single source of truth. */
|
|
15
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
|
|
16
|
+
const HELP = `locale-ai ${VERSION} — translate i18n locale files locally, on CPU, for free.
|
|
17
17
|
|
|
18
18
|
Usage:
|
|
19
19
|
locale-ai translate --to <locales> [options]
|
|
@@ -27,7 +27,7 @@ Usage:
|
|
|
27
27
|
Translate options:
|
|
28
28
|
--from <locale> Source locale (default: en)
|
|
29
29
|
--to <locales> Target locale(s), comma separated: fr,es,pt-BR
|
|
30
|
-
--input <dir> Folder containing <from>/ or <from>.json/.yaml/.yml (auto-detected)
|
|
30
|
+
--input <dir> Folder containing <from>/ or <from>.json/.yaml/.yml/.po/.arb/.strings (auto-detected)
|
|
31
31
|
--overwrite Re-translate strings that already have a translation
|
|
32
32
|
--dry-run Show what would be translated without downloading or writing
|
|
33
33
|
--fast Greedy decoding: ~2x faster, slightly lower quality
|
|
@@ -201,7 +201,7 @@ async function cmdTranslate(argv) {
|
|
|
201
201
|
const built = buildTarget(f.doc.data, f.existing, (s) => translations.get(s) ?? s, values.overwrite);
|
|
202
202
|
const { doc, added } = addPluralForms(built, intlCode(job.locale));
|
|
203
203
|
pluralsAdded += added;
|
|
204
|
-
const text = f.doc.serialize(doc);
|
|
204
|
+
const text = f.doc.serialize(doc, job.locale.tag);
|
|
205
205
|
const before = fs.existsSync(f.target) ? fs.readFileSync(f.target, "utf8") : undefined;
|
|
206
206
|
if (text !== before) {
|
|
207
207
|
fs.mkdirSync(path.dirname(f.target), { recursive: true });
|
package/dist/locales.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
4
|
-
const EXTENSIONS = [".json", ".yaml", ".yml"];
|
|
4
|
+
const EXTENSIONS = [".json", ".yaml", ".yml", ".po", ".arb", ".strings"];
|
|
5
5
|
const CANDIDATES = [
|
|
6
6
|
"locales",
|
|
7
7
|
"src/locales",
|
|
@@ -108,9 +108,214 @@ function readYamlDoc(file, text) {
|
|
|
108
108
|
const keepNewline = finalNewline(text);
|
|
109
109
|
return { data, serialize: (d) => withNewline(stringifyYaml(d, { indent }), keepNewline) };
|
|
110
110
|
}
|
|
111
|
+
/** Apple .strings: `"key" = "value";`, optionally preceded by a `/* ... *\/` comment. */
|
|
112
|
+
function unescapeC(s) {
|
|
113
|
+
return s.replace(/\\(.)/g, (_, c) => ({ n: "\n", t: "\t", r: "\r", '"': '"', "\\": "\\" })[c] ?? c);
|
|
114
|
+
}
|
|
115
|
+
function escapeC(s) {
|
|
116
|
+
return s.replace(/[\\"\n\t\r]/g, (c) => ({ "\\": "\\\\", '"': '\\"', "\n": "\\n", "\t": "\\t", "\r": "\\r" })[c]);
|
|
117
|
+
}
|
|
118
|
+
function readStringsDoc(file, text) {
|
|
119
|
+
const PAIR = /(?:(\/\*[\s\S]*?\*\/)[ \t]*\r?\n+)?[ \t]*"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)"\s*;/g;
|
|
120
|
+
const data = newObj();
|
|
121
|
+
const entries = [];
|
|
122
|
+
for (const m of text.matchAll(PAIR)) {
|
|
123
|
+
const key = unescapeC(m[2]);
|
|
124
|
+
data[key] = unescapeC(m[3]);
|
|
125
|
+
entries.push({ key, comment: m[1] });
|
|
126
|
+
}
|
|
127
|
+
if (entries.length === 0 && text.trim() !== "") {
|
|
128
|
+
throw new Error(`No "key" = "value"; entries found in ${file}`);
|
|
129
|
+
}
|
|
130
|
+
const keepNewline = finalNewline(text);
|
|
131
|
+
const serialize = (d) => {
|
|
132
|
+
const obj = isObj(d) ? d : newObj();
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
const blocks = [];
|
|
135
|
+
for (const e of entries) {
|
|
136
|
+
seen.add(e.key);
|
|
137
|
+
const v = typeof obj[e.key] === "string" ? obj[e.key] : "";
|
|
138
|
+
blocks.push((e.comment ? e.comment + "\n" : "") + `"${escapeC(e.key)}" = "${escapeC(v)}";`);
|
|
139
|
+
}
|
|
140
|
+
for (const k of Object.keys(obj)) {
|
|
141
|
+
if (seen.has(k) || typeof obj[k] !== "string")
|
|
142
|
+
continue;
|
|
143
|
+
blocks.push(`"${escapeC(k)}" = "${escapeC(obj[k])}";`);
|
|
144
|
+
}
|
|
145
|
+
return withNewline(blocks.join("\n\n"), keepNewline);
|
|
146
|
+
};
|
|
147
|
+
return { data, serialize };
|
|
148
|
+
}
|
|
149
|
+
/** ARB (Flutter): plain JSON, but "@key" / "@@locale" entries are metadata, not translatable content. */
|
|
150
|
+
function readArbDoc(file, text) {
|
|
151
|
+
let raw;
|
|
152
|
+
try {
|
|
153
|
+
raw = JSON.parse(text);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
throw new Error(`Invalid ARB (JSON) in ${file}: ${e.message}`);
|
|
157
|
+
}
|
|
158
|
+
const data = newObj();
|
|
159
|
+
for (const k of Object.keys(raw))
|
|
160
|
+
if (!k.startsWith("@") && typeof raw[k] === "string")
|
|
161
|
+
data[k] = raw[k];
|
|
162
|
+
const indent = /^\{\r?\n([ \t]+)/.exec(text)?.[1] ?? (text.includes("\n") ? 2 : 0);
|
|
163
|
+
const keepNewline = finalNewline(text);
|
|
164
|
+
const hasLocale = typeof raw["@@locale"] === "string";
|
|
165
|
+
const serialize = (d, targetTag) => {
|
|
166
|
+
const obj = isObj(d) ? d : newObj();
|
|
167
|
+
const out = newObj();
|
|
168
|
+
if (hasLocale)
|
|
169
|
+
out["@@locale"] = targetTag;
|
|
170
|
+
for (const k of Object.keys(raw)) {
|
|
171
|
+
if (k === "@@locale" || k.startsWith("@"))
|
|
172
|
+
continue;
|
|
173
|
+
out[k] = typeof obj[k] === "string" ? obj[k] : raw[k];
|
|
174
|
+
if (`@${k}` in raw)
|
|
175
|
+
out[`@${k}`] = raw[`@${k}`];
|
|
176
|
+
}
|
|
177
|
+
for (const k of Object.keys(obj))
|
|
178
|
+
if (!(k in out) && !k.startsWith("@"))
|
|
179
|
+
out[k] = obj[k];
|
|
180
|
+
return withNewline(JSON.stringify(out, null, indent), keepNewline);
|
|
181
|
+
};
|
|
182
|
+
return { data, serialize };
|
|
183
|
+
}
|
|
184
|
+
const poKey = (e) => (e.msgctxt ?? "") + "\u0004" + e.msgid;
|
|
185
|
+
function parsePo(text) {
|
|
186
|
+
const entries = [];
|
|
187
|
+
let leading = [];
|
|
188
|
+
let cur = { comments: [] };
|
|
189
|
+
let field = null;
|
|
190
|
+
let buf = "";
|
|
191
|
+
const flushField = () => {
|
|
192
|
+
if (field === null)
|
|
193
|
+
return;
|
|
194
|
+
const val = unescapeC(buf);
|
|
195
|
+
if (field === "msgctxt")
|
|
196
|
+
cur.msgctxt = val;
|
|
197
|
+
else if (field === "msgid")
|
|
198
|
+
cur.msgid = val;
|
|
199
|
+
else if (field === "msgid_plural")
|
|
200
|
+
cur.msgidPlural = val;
|
|
201
|
+
else if (field === "msgstr")
|
|
202
|
+
cur.msgstr = val;
|
|
203
|
+
else
|
|
204
|
+
(cur.msgstrPlural ??= [])[Number(field.slice(7, -1))] = val;
|
|
205
|
+
field = null;
|
|
206
|
+
buf = "";
|
|
207
|
+
};
|
|
208
|
+
const flushEntry = () => {
|
|
209
|
+
flushField();
|
|
210
|
+
if (cur.msgid !== undefined) {
|
|
211
|
+
entries.push({ comments: cur.comments, msgctxt: cur.msgctxt, msgid: cur.msgid, msgidPlural: cur.msgidPlural,
|
|
212
|
+
msgstrPlural: cur.msgstrPlural, msgstr: cur.msgstr ?? "", isHeader: cur.msgid === "" && cur.msgctxt === undefined });
|
|
213
|
+
}
|
|
214
|
+
else if (entries.length === 0 && cur.comments.length && leading.length === 0) {
|
|
215
|
+
leading = cur.comments;
|
|
216
|
+
}
|
|
217
|
+
cur = { comments: [] };
|
|
218
|
+
};
|
|
219
|
+
for (const line of text.split(/\r?\n/)) {
|
|
220
|
+
if (line.trim() === "") {
|
|
221
|
+
flushEntry();
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
let m;
|
|
225
|
+
if (/^#/.test(line)) {
|
|
226
|
+
flushField();
|
|
227
|
+
cur.comments.push(line);
|
|
228
|
+
}
|
|
229
|
+
else if ((m = /^msgctxt\s+"((?:\\.|[^"\\])*)"\s*$/.exec(line))) {
|
|
230
|
+
flushField();
|
|
231
|
+
field = "msgctxt";
|
|
232
|
+
buf = m[1];
|
|
233
|
+
}
|
|
234
|
+
else if ((m = /^msgid_plural\s+"((?:\\.|[^"\\])*)"\s*$/.exec(line))) {
|
|
235
|
+
flushField();
|
|
236
|
+
field = "msgid_plural";
|
|
237
|
+
buf = m[1];
|
|
238
|
+
}
|
|
239
|
+
else if ((m = /^msgid\s+"((?:\\.|[^"\\])*)"\s*$/.exec(line))) {
|
|
240
|
+
flushField();
|
|
241
|
+
field = "msgid";
|
|
242
|
+
buf = m[1];
|
|
243
|
+
}
|
|
244
|
+
else if ((m = /^msgstr\[(\d+)\]\s+"((?:\\.|[^"\\])*)"\s*$/.exec(line))) {
|
|
245
|
+
flushField();
|
|
246
|
+
field = `msgstr[${m[1]}]`;
|
|
247
|
+
buf = m[2];
|
|
248
|
+
}
|
|
249
|
+
else if ((m = /^msgstr\s+"((?:\\.|[^"\\])*)"\s*$/.exec(line))) {
|
|
250
|
+
flushField();
|
|
251
|
+
field = "msgstr";
|
|
252
|
+
buf = m[1];
|
|
253
|
+
}
|
|
254
|
+
else if ((m = /^"((?:\\.|[^"\\])*)"\s*$/.exec(line))) {
|
|
255
|
+
buf += m[1];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
flushEntry();
|
|
259
|
+
return { entries, leading };
|
|
260
|
+
}
|
|
261
|
+
function readPoDoc(file, text) {
|
|
262
|
+
const { entries, leading } = parsePo(text);
|
|
263
|
+
const data = newObj();
|
|
264
|
+
for (const e of entries)
|
|
265
|
+
if (!e.isHeader && e.msgidPlural === undefined)
|
|
266
|
+
data[poKey(e)] = e.msgstr;
|
|
267
|
+
const keepNewline = finalNewline(text);
|
|
268
|
+
const serialize = (d, targetTag) => {
|
|
269
|
+
const obj = isObj(d) ? d : newObj();
|
|
270
|
+
const seen = new Set();
|
|
271
|
+
const blocks = leading.length ? [leading.join("\n")] : [];
|
|
272
|
+
for (const e of entries) {
|
|
273
|
+
const lines = [...e.comments];
|
|
274
|
+
if (e.msgctxt !== undefined)
|
|
275
|
+
lines.push(`msgctxt "${escapeC(e.msgctxt)}"`);
|
|
276
|
+
if (e.isHeader) {
|
|
277
|
+
lines.push(`msgid ""`);
|
|
278
|
+
const header = e.msgstr.replace(/^Language:.*$/m, `Language: ${targetTag}`);
|
|
279
|
+
lines.push(`msgstr "${escapeC(header)}"`);
|
|
280
|
+
}
|
|
281
|
+
else if (e.msgidPlural !== undefined) {
|
|
282
|
+
lines.push(`msgid "${escapeC(e.msgid)}"`, `msgid_plural "${escapeC(e.msgidPlural)}"`);
|
|
283
|
+
(e.msgstrPlural ?? []).forEach((s, i) => lines.push(`msgstr[${i}] "${escapeC(s)}"`));
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
const key = poKey(e);
|
|
287
|
+
seen.add(key);
|
|
288
|
+
const v = typeof obj[key] === "string" ? obj[key] : "";
|
|
289
|
+
lines.push(`msgid "${escapeC(e.msgid)}"`, `msgstr "${escapeC(v)}"`);
|
|
290
|
+
}
|
|
291
|
+
blocks.push(lines.join("\n"));
|
|
292
|
+
}
|
|
293
|
+
for (const k of Object.keys(obj)) {
|
|
294
|
+
if (seen.has(k) || typeof obj[k] !== "string")
|
|
295
|
+
continue;
|
|
296
|
+
const i = k.indexOf("\u0004");
|
|
297
|
+
const ctx = i === -1 ? undefined : k.slice(0, i);
|
|
298
|
+
const id = i === -1 ? k : k.slice(i + 1);
|
|
299
|
+
const lines = ctx ? [`msgctxt "${escapeC(ctx)}"`] : [];
|
|
300
|
+
lines.push(`msgid "${escapeC(id)}"`, `msgstr "${escapeC(obj[k])}"`);
|
|
301
|
+
blocks.push(lines.join("\n"));
|
|
302
|
+
}
|
|
303
|
+
return withNewline(blocks.join("\n\n"), keepNewline);
|
|
304
|
+
};
|
|
305
|
+
return { data, serialize };
|
|
306
|
+
}
|
|
111
307
|
export function readLocaleDoc(file) {
|
|
112
308
|
const text = fs.readFileSync(file, "utf8").replace(/^/, "");
|
|
113
|
-
|
|
309
|
+
const ext = path.extname(file).toLowerCase();
|
|
310
|
+
if (ext === ".yaml" || ext === ".yml")
|
|
311
|
+
return readYamlDoc(file, text);
|
|
312
|
+
if (ext === ".po")
|
|
313
|
+
return readPoDoc(file, text);
|
|
314
|
+
if (ext === ".arb")
|
|
315
|
+
return readArbDoc(file, text);
|
|
316
|
+
if (ext === ".strings")
|
|
317
|
+
return readStringsDoc(file, text);
|
|
318
|
+
return readJsonDoc(file, text);
|
|
114
319
|
}
|
|
115
320
|
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
116
321
|
/** Null-prototype object so keys such as "__proto__" are stored as plain data. */
|