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 CHANGED
@@ -6,7 +6,7 @@
6
6
  [![license](https://img.shields.io/npm/l/locale-ai.svg)](LICENSE)
7
7
  [![node](https://img.shields.io/node/v/locale-ai.svg)](package.json)
8
8
 
9
- Translate your i18n JSON/YAML locale files **locally, on CPU, for free**. No API keys, no Python, nothing leaves your machine.
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 `.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`.
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>`, `&nbsp;`, 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 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.
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, pl have no usable OPUS-MT model yet; a different model family would be needed.
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 JSON/YAML locale files locally, on CPU, for free.
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
- return /\.ya?ml$/i.test(file) ? readYamlDoc(file, text) : readJsonDoc(file, text);
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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locale-ai",
3
- "version": "0.4.0",
3
+ "version": "0.6.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": {