locale-ai 0.5.0 → 0.6.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/README.md +6 -3
- package/dist/cli.js +3 -3
- package/dist/locales.js +207 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
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
|
+
|
|
11
|
+

|
|
10
12
|
|
|
11
13
|
```bash
|
|
12
14
|
npx locale-ai translate --from en --to fr,es,de
|
|
@@ -26,13 +28,15 @@ Download 577 MB? (Y/n)
|
|
|
26
28
|
|
|
27
29
|
## What it handles
|
|
28
30
|
|
|
29
|
-
- **Layouts**: `locales/en/*.json` (nested folders too) → `locales/fr/...`, or `messages/en.json` → `messages/fr.json`. `.json`, `.yaml` and `.
|
|
31
|
+
- **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
32
|
- **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
33
|
- **ICU MessageFormat**: `{count, plural, one {# file} other {# files}}`, `select`, nesting. Only the text is translated.
|
|
32
34
|
- **i18next plurals**: adds the forms the target language needs (`_few`, `_many` for Arabic, Russian, Polish...).
|
|
33
35
|
- **Incremental**: only missing keys are translated, existing translations are kept, re-runs are no-ops. `--overwrite` re-translates everything.
|
|
34
36
|
- **Structure**: key order, numbers, booleans, arrays, indentation and trailing newline are preserved.
|
|
35
37
|
|
|
38
|
+
**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.
|
|
39
|
+
|
|
36
40
|
## Why locale-ai?
|
|
37
41
|
|
|
38
42
|
- **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.
|
|
@@ -153,4 +157,3 @@ npm run build # → dist/
|
|
|
153
157
|
## Next steps
|
|
154
158
|
|
|
155
159
|
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/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. */
|