locale-ai 0.3.1 → 0.5.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 +9 -7
- package/dist/catalog.js +10 -0
- package/dist/cli.js +6 -6
- package/dist/locales.js +41 -21
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](package.json)
|
|
8
8
|
|
|
9
|
-
Translate your i18n JSON locale files **locally, on CPU, for free**. No API keys, no Python, nothing leaves your machine.
|
|
9
|
+
Translate your i18n JSON/YAML 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,7 +26,7 @@ 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`. Common folders (`locales`, `src/locales`, `public/locales`, `src/i18n`, `messages`, `lang`, ...) are found automatically, or pass `--input`.
|
|
29
|
+
- **Layouts**: `locales/en/*.json` (nested folders too) → `locales/fr/...`, or `messages/en.json` → `messages/fr.json`. `.json`, `.yaml` and `.yml` 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...).
|
|
@@ -111,19 +111,21 @@ then declare it in `locale-ai.config.json`:
|
|
|
111
111
|
|
|
112
112
|
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
113
|
|
|
114
|
+
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.
|
|
115
|
+
|
|
114
116
|
## Languages
|
|
115
117
|
|
|
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`).
|
|
118
|
+
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
119
|
|
|
118
120
|
Measured on a sample app (see `test/fixtures`):
|
|
119
121
|
|
|
120
122
|
| Quality (built-in models) | Languages |
|
|
121
123
|
|---|---|
|
|
122
124
|
| Good — light review | fr, es, de, pt-BR, nl |
|
|
123
|
-
| Usable — review before shipping | it, ar, ru, zh-CN, zh-TW |
|
|
125
|
+
| Usable — review before shipping | it, ar, ru, pl, zh-CN, zh-TW |
|
|
124
126
|
| Untested | the other pairs in `locale-ai models list` |
|
|
125
127
|
|
|
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
|
|
128
|
+
`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
129
|
|
|
128
130
|
## How it works
|
|
129
131
|
|
|
@@ -150,5 +152,5 @@ npm run build # → dist/
|
|
|
150
152
|
|
|
151
153
|
## Next steps
|
|
152
154
|
|
|
153
|
-
1. **More languages**: ja, ko
|
|
154
|
-
2. **More formats**:
|
|
155
|
+
1. **More languages**: ja, ko have no usable OPUS-MT model yet; a different model family would be needed.
|
|
156
|
+
2. **More formats**: `.po` (gettext), ARB (Flutter), Apple `.strings`. (JSON and YAML are supported today.)
|
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
|
@@ -9,11 +9,11 @@ import { CATALOG, customEntries, findById, reachableTargets, resolveRoute, } fro
|
|
|
9
9
|
import { translateStrings } from "./engine.js";
|
|
10
10
|
import { CONFIG_FILE, glossaryLookup, loadConfig, protectRegex } from "./glossary.js";
|
|
11
11
|
import { intlCode, parseLocale } from "./lang.js";
|
|
12
|
-
import { addPluralForms, buildTarget, detectLayout, pendingStrings,
|
|
12
|
+
import { addPluralForms, buildTarget, detectLayout, pendingStrings, readLocaleDoc, sourceFiles, targetFile, } from "./locales.js";
|
|
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 JSON locale files locally, on CPU, for free.
|
|
16
|
+
const HELP = `locale-ai ${VERSION} — translate i18n JSON/YAML 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 (auto-detected)
|
|
30
|
+
--input <dir> Folder containing <from>/ or <from>.json/.yaml/.yml (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
|
|
@@ -122,7 +122,7 @@ async function cmdTranslate(argv) {
|
|
|
122
122
|
const catalog = catalogFor(config);
|
|
123
123
|
const protect = protectRegex(config);
|
|
124
124
|
const layout = detectLayout(process.cwd(), from.tag, values.input);
|
|
125
|
-
const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc:
|
|
125
|
+
const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f) }));
|
|
126
126
|
log(`locale-ai ${VERSION}`);
|
|
127
127
|
log(`Found ${sources.length} ${from.tag} file${sources.length === 1 ? "" : "s"} in ${rel(layout.root)}/` +
|
|
128
128
|
(layout.kind === "dir" ? `${from.tag}/` : ""));
|
|
@@ -139,7 +139,7 @@ async function cmdTranslate(argv) {
|
|
|
139
139
|
}
|
|
140
140
|
const files = sources.map(({ file, doc }) => {
|
|
141
141
|
const target = targetFile(layout, from.tag, locale.tag, file);
|
|
142
|
-
const existing = fs.existsSync(target) ?
|
|
142
|
+
const existing = fs.existsSync(target) ? readLocaleDoc(target).data : undefined;
|
|
143
143
|
return { source: file, target, doc, existing, pending: pendingStrings(doc.data, existing, values.overwrite) };
|
|
144
144
|
});
|
|
145
145
|
const unique = new Set(files.flatMap((f) => f.pending)).size;
|
|
@@ -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 =
|
|
204
|
+
const text = f.doc.serialize(doc);
|
|
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,5 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
4
|
+
const EXTENSIONS = [".json", ".yaml", ".yml"];
|
|
3
5
|
const CANDIDATES = [
|
|
4
6
|
"locales",
|
|
5
7
|
"src/locales",
|
|
@@ -24,12 +26,17 @@ const CANDIDATES = [
|
|
|
24
26
|
];
|
|
25
27
|
const isDir = (p) => fs.existsSync(p) && fs.statSync(p).isDirectory();
|
|
26
28
|
const isFile = (p) => fs.existsSync(p) && fs.statSync(p).isFile();
|
|
29
|
+
/** The source-locale file's extension, if `<root>/<from><ext>` exists for one of EXTENSIONS. */
|
|
30
|
+
function fileExtAt(root, from) {
|
|
31
|
+
return EXTENSIONS.find((ext) => isFile(path.join(root, `${from}${ext}`)));
|
|
32
|
+
}
|
|
27
33
|
function layoutAt(root, from) {
|
|
28
34
|
const dir = path.join(root, from);
|
|
29
|
-
if (isDir(dir) &&
|
|
35
|
+
if (isDir(dir) && listLocaleFiles(dir).length > 0)
|
|
30
36
|
return { kind: "dir", root };
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
const ext = fileExtAt(root, from);
|
|
38
|
+
if (ext)
|
|
39
|
+
return { kind: "file", root, ext };
|
|
33
40
|
return undefined;
|
|
34
41
|
}
|
|
35
42
|
export function detectLayout(cwd, from, input) {
|
|
@@ -38,11 +45,13 @@ export function detectLayout(cwd, from, input) {
|
|
|
38
45
|
// Accept pointing at the source locale itself: --input locales/en or locales/en.json
|
|
39
46
|
if (path.basename(abs) === from && isDir(abs))
|
|
40
47
|
return { kind: "dir", root: path.dirname(abs) };
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
const base = path.basename(abs);
|
|
49
|
+
const directExt = EXTENSIONS.find((ext) => base === `${from}${ext}`);
|
|
50
|
+
if (directExt && isFile(abs))
|
|
51
|
+
return { kind: "file", root: path.dirname(abs), ext: directExt };
|
|
43
52
|
const l = layoutAt(abs, from);
|
|
44
53
|
if (!l)
|
|
45
|
-
throw new Error(`No "${from}" locale found in ${input} (looked for ${from}/*.json and ${from}.json).`);
|
|
54
|
+
throw new Error(`No "${from}" locale found in ${input} (looked for ${from}/*.json and ${from}.json/.yaml/.yml).`);
|
|
46
55
|
return l;
|
|
47
56
|
}
|
|
48
57
|
for (const c of CANDIDATES) {
|
|
@@ -50,31 +59,32 @@ export function detectLayout(cwd, from, input) {
|
|
|
50
59
|
if (l)
|
|
51
60
|
return l;
|
|
52
61
|
}
|
|
53
|
-
throw new Error(`Could not find "${from}" locale files. Use --input <dir> to point at the folder containing ${from}/ or ${from}.json.`);
|
|
62
|
+
throw new Error(`Could not find "${from}" locale files. Use --input <dir> to point at the folder containing ${from}/ or ${from}.json/.yaml/.yml.`);
|
|
54
63
|
}
|
|
55
|
-
function
|
|
64
|
+
function listLocaleFiles(dir) {
|
|
56
65
|
const out = [];
|
|
57
66
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
58
67
|
if (e.name.startsWith(".") || e.name === "node_modules")
|
|
59
68
|
continue;
|
|
60
69
|
const p = path.join(dir, e.name);
|
|
61
70
|
if (e.isDirectory())
|
|
62
|
-
out.push(...
|
|
63
|
-
else if (e.isFile() && e.name.
|
|
71
|
+
out.push(...listLocaleFiles(p));
|
|
72
|
+
else if (e.isFile() && EXTENSIONS.includes(path.extname(e.name).toLowerCase()))
|
|
64
73
|
out.push(p);
|
|
65
74
|
}
|
|
66
75
|
return out.sort();
|
|
67
76
|
}
|
|
68
77
|
export function sourceFiles(layout, from) {
|
|
69
|
-
return layout.kind === "dir" ?
|
|
78
|
+
return layout.kind === "dir" ? listLocaleFiles(path.join(layout.root, from)) : [path.join(layout.root, `${from}${layout.ext}`)];
|
|
70
79
|
}
|
|
71
80
|
export function targetFile(layout, from, to, src) {
|
|
72
81
|
if (layout.kind === "file")
|
|
73
|
-
return path.join(layout.root, `${to}.
|
|
82
|
+
return path.join(layout.root, `${to}${layout.ext}`);
|
|
74
83
|
return path.join(layout.root, to, path.relative(path.join(layout.root, from), src));
|
|
75
84
|
}
|
|
76
|
-
|
|
77
|
-
|
|
85
|
+
const finalNewline = (text) => text === "" || text.endsWith("\n");
|
|
86
|
+
const withNewline = (text, keep) => text.replace(/\n*$/, keep ? "\n" : "");
|
|
87
|
+
function readJsonDoc(file, text) {
|
|
78
88
|
let data;
|
|
79
89
|
try {
|
|
80
90
|
data = JSON.parse(text);
|
|
@@ -83,14 +93,24 @@ export function readJson(file) {
|
|
|
83
93
|
throw new Error(`Invalid JSON in ${file}: ${e.message}`);
|
|
84
94
|
}
|
|
85
95
|
const indent = /^[{[]\r?\n([ \t]+)/.exec(text)?.[1] ?? (text.includes("\n") ? 2 : 0);
|
|
86
|
-
|
|
96
|
+
const keepNewline = finalNewline(text);
|
|
97
|
+
return { data, serialize: (d) => withNewline(JSON.stringify(d, null, indent), keepNewline) };
|
|
87
98
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
function readYamlDoc(file, text) {
|
|
100
|
+
let data;
|
|
101
|
+
try {
|
|
102
|
+
data = parseYaml(text) ?? {};
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
throw new Error(`Invalid YAML in ${file}: ${e.message}`);
|
|
106
|
+
}
|
|
107
|
+
const indent = Number(/\n( +)\S/.exec(text)?.[1].length) || 2;
|
|
108
|
+
const keepNewline = finalNewline(text);
|
|
109
|
+
return { data, serialize: (d) => withNewline(stringifyYaml(d, { indent }), keepNewline) };
|
|
110
|
+
}
|
|
111
|
+
export function readLocaleDoc(file) {
|
|
112
|
+
const text = fs.readFileSync(file, "utf8").replace(/^/, "");
|
|
113
|
+
return /\.ya?ml$/i.test(file) ? readYamlDoc(file, text) : readJsonDoc(file, text);
|
|
94
114
|
}
|
|
95
115
|
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
96
116
|
/** Null-prototype object so keys such as "__proto__" are stored as plain data. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "locale-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Translate your i18n locale files locally on CPU. Free, offline, no API keys.",
|
|
5
5
|
"author": "Taha KHABOUSS",
|
|
6
6
|
"repository": {
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
],
|
|
46
46
|
"license": "MIT",
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"onnxruntime-node": "^1.30.0"
|
|
48
|
+
"onnxruntime-node": "^1.30.0",
|
|
49
|
+
"yaml": "^2.9.1"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
52
|
"@types/node": "^26.6.6",
|