locale-ai 0.6.7 → 0.6.9
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 +2 -2
- package/dist/cli.js +5 -3
- package/dist/locales.js +98 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,14 +28,14 @@ Download 577 MB? (Y/n)
|
|
|
28
28
|
|
|
29
29
|
## What it handles
|
|
30
30
|
|
|
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`.
|
|
31
|
+
- **Layouts**: `locales/en/*.json` (nested folders too) → `locales/fr/...`, or `messages/en.json` → `messages/fr.json`. `.json`, `.yaml`, `.yml`, `.po`/`.pot` (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`.
|
|
32
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.
|
|
33
33
|
- **ICU MessageFormat**: `{count, plural, one {# file} other {# files}}`, `select`, nesting. Only the text is translated.
|
|
34
34
|
- **i18next plurals**: adds the forms the target language needs (`_few`, `_many` for Arabic, Russian, Polish...), so the app doesn't fall back to the English string — each added form copies the `_other` translation verbatim rather than inflecting it, so review and adjust the wording of `_zero`/`_two`/`_few`/`_many` by hand for languages where those forms use a different noun/adjective form. The `_one` form is translated with its `{{count}}` temporarily replaced by a literal "1", since the model otherwise sometimes picks the target's plural noun form despite the English source being grammatically singular.
|
|
35
35
|
- **Incremental, and edit-aware**: only missing or changed keys are translated; untouched translations are kept, so re-runs are no-ops. A small `locale-ai.lock.json` (commit it, like a package lock) remembers a hash of each source string and of the output last written for it per target locale — so editing a source string gets it re-translated automatically (it isn't enough for the target to already hold *some* text), while editing a target string by hand is detected and reported for review instead of being silently overwritten on the next run. `--overwrite` bypasses the lock and re-translates everything.
|
|
36
36
|
- **Structure**: key order, numbers, booleans, arrays, indentation and trailing newline are preserved.
|
|
37
37
|
|
|
38
|
-
**Format notes**: for `.po`, `msgid` is the stable key (never changed) and `msgstr` is the translated value, as gettext expects — the English
|
|
38
|
+
**Format notes**: for `.po`/`.pot`, `msgid` is the stable key (never changed) and `msgstr` is the translated value, as gettext expects — the English source's `msgstr` holds the source text when it's filled in (most generators do), and falls back to `msgid` itself when it's empty, which is normal for a `.pot` template or a project that just keeps the English text in `msgid`. That fallback only ever applies to the source file: an empty `msgstr` in an *existing target* still means "not translated yet", never "same as msgid". `msgid_plural`/`msgstr[n]` plural entries are translated too (the singular and plural source forms), then expanded to however many `msgstr[n]` the target language's plural rules need (6 for Arabic, 1 for Japanese, ...) — not full grammatical inflection for in-between categories (`few`/`many`/...), which get the plural-sourced translation verbatim, the same simplification `addPluralForms` makes for i18next. `Plural-Forms` in the header is computed for the target language, not copied from the source; an existing target file's header, and any entry's own comments (a translator's note, a hand-added `#, fuzzy`), are otherwise left exactly as last edited instead of being regenerated from the source on every rerun. `--fuzzy` flags every entry a run actually (re)translated with gettext's standard `#, fuzzy` "needs review" marker, merging onto an existing `#,` line rather than adding a second one. A `.pot` source always writes a `.po` target, never a `.pot`. For `.arb`, `@key` metadata and `@@locale` are never translated; `@@locale` is set to the target locale on output. For `.strings`, a file saved as UTF-16 (Xcode's traditional default) is read like a UTF-8 one, an unquoted key (`title = "Settings";`, without quotes around `title`) is read the same as a quoted one, and `\U00e9`-style escapes are decoded — output is always written as UTF-8 with quoted keys, regardless of how the source was saved.
|
|
39
39
|
|
|
40
40
|
## Why locale-ai?
|
|
41
41
|
|
package/dist/cli.js
CHANGED
|
@@ -33,11 +33,12 @@ Usage:
|
|
|
33
33
|
Translate options:
|
|
34
34
|
--from <locale> Source locale (default: en)
|
|
35
35
|
--to <locales> Target locale(s), comma separated: fr,es,pt-BR
|
|
36
|
-
--input <dir> Folder containing <from>/ or <from>.json/.yaml/.yml/.po/.arb/.strings (auto-detected)
|
|
36
|
+
--input <dir> Folder containing <from>/ or <from>.json/.yaml/.yml/.po/.pot/.arb/.strings (auto-detected)
|
|
37
37
|
--overwrite Re-translate strings that already have a translation
|
|
38
38
|
--dry-run Show what would be translated without downloading or writing
|
|
39
39
|
--fast Greedy decoding: ~2x faster, slightly lower quality
|
|
40
40
|
--report <file> Write a JSON report of strings that need review
|
|
41
|
+
--fuzzy For .po/.pot: flag every entry this run (re)translated as "#, fuzzy"
|
|
41
42
|
--config <file> Project config (default: ./${CONFIG_FILE} if present)
|
|
42
43
|
-y, --yes Download required models without asking
|
|
43
44
|
|
|
@@ -149,6 +150,7 @@ async function cmdTranslate(argv) {
|
|
|
149
150
|
beams: { type: "string" },
|
|
150
151
|
report: { type: "string" },
|
|
151
152
|
config: { type: "string" },
|
|
153
|
+
fuzzy: { type: "boolean", default: false },
|
|
152
154
|
yes: { type: "boolean", short: "y", default: false },
|
|
153
155
|
},
|
|
154
156
|
});
|
|
@@ -165,7 +167,7 @@ async function cmdTranslate(argv) {
|
|
|
165
167
|
const layout = detectLayout(process.cwd(), from.tag, values.input);
|
|
166
168
|
const lockPath = path.resolve(process.cwd(), LOCK_FILENAME);
|
|
167
169
|
const lock = loadLock(lockPath);
|
|
168
|
-
const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag) }));
|
|
170
|
+
const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag, true, values.fuzzy) }));
|
|
169
171
|
const singularHints = new Set(sources.flatMap(({ doc }) => [...pluralOneStrings(doc.data)]));
|
|
170
172
|
log(`locale-ai ${VERSION}`);
|
|
171
173
|
log(`Found ${sources.length} ${from.tag} file${sources.length === 1 ? "" : "s"} in ${rel(layout.root)}/` +
|
|
@@ -417,7 +419,7 @@ async function cmdCheck(argv) {
|
|
|
417
419
|
});
|
|
418
420
|
const from = parseLocale(values.from);
|
|
419
421
|
const layout = detectLayout(process.cwd(), from.tag, values.input);
|
|
420
|
-
const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag) }));
|
|
422
|
+
const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag, true) }));
|
|
421
423
|
const targets = existingTargets(layout, from.tag);
|
|
422
424
|
if (!targets.length) {
|
|
423
425
|
log(`No translated locales found next to ${from.tag}.`);
|
package/dist/locales.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
4
4
|
import { parseLocale } from "./lang.js";
|
|
5
|
-
const EXTENSIONS = [".json", ".yaml", ".yml", ".po", ".arb", ".strings"];
|
|
5
|
+
const EXTENSIONS = [".json", ".yaml", ".yml", ".po", ".pot", ".arb", ".strings"];
|
|
6
6
|
const CANDIDATES = [
|
|
7
7
|
"locales",
|
|
8
8
|
"src/locales",
|
|
@@ -78,10 +78,14 @@ function listLocaleFiles(dir) {
|
|
|
78
78
|
export function sourceFiles(layout, from) {
|
|
79
79
|
return layout.kind === "dir" ? listLocaleFiles(path.join(layout.root, from)) : [path.join(layout.root, `${from}${layout.ext}`)];
|
|
80
80
|
}
|
|
81
|
+
/** A translated file is a real PO, never a template, regardless of what the source was. */
|
|
82
|
+
const outputExt = (ext) => (ext === ".pot" ? ".po" : ext);
|
|
81
83
|
export function targetFile(layout, from, to, src) {
|
|
82
84
|
if (layout.kind === "file")
|
|
83
|
-
return path.join(layout.root, `${to}${layout.ext}`);
|
|
84
|
-
|
|
85
|
+
return path.join(layout.root, `${to}${outputExt(layout.ext)}`);
|
|
86
|
+
const rel = path.relative(path.join(layout.root, from), src);
|
|
87
|
+
const ext = path.extname(rel);
|
|
88
|
+
return path.join(layout.root, to, ext === ".pot" ? `${rel.slice(0, -ext.length)}.po` : rel);
|
|
85
89
|
}
|
|
86
90
|
/** True when `tag` parses as a locale (e.g. "fr", "pt-BR") — rejects names like "__mocks__" or "schemas". */
|
|
87
91
|
function looksLikeLocale(tag) {
|
|
@@ -151,19 +155,28 @@ function readYamlDoc(file, text, tag) {
|
|
|
151
155
|
}
|
|
152
156
|
/** Apple .strings: `"key" = "value";`, optionally preceded by a `/* ... *\/` comment. */
|
|
153
157
|
function unescapeC(s) {
|
|
154
|
-
return s.replace(/\\(.)/g, (_, c) =>
|
|
158
|
+
return s.replace(/\\(?:U([0-9a-fA-F]{4})|(.))/g, (_, hex, c) => {
|
|
159
|
+
// \Uxxxx is a UTF-16 code unit; two in a row combine into one character via
|
|
160
|
+
// ordinary JS string concatenation when they're a surrogate pair.
|
|
161
|
+
if (hex !== undefined)
|
|
162
|
+
return String.fromCharCode(parseInt(hex, 16));
|
|
163
|
+
return ({ n: "\n", t: "\t", r: "\r", '"': '"', "\\": "\\" })[c] ?? c;
|
|
164
|
+
});
|
|
155
165
|
}
|
|
156
166
|
function escapeC(s) {
|
|
157
167
|
return s.replace(/[\\"\n\t\r]/g, (c) => ({ "\\": "\\\\", '"': '\\"', "\n": "\\n", "\t": "\\t", "\r": "\\r" })[c]);
|
|
158
168
|
}
|
|
159
169
|
function readStringsDoc(file, text) {
|
|
160
170
|
// A key's preceding comment is either a /* ... */ block, or one or more consecutive // lines.
|
|
161
|
-
|
|
171
|
+
// The old NeXT/Apple plist-string format allows a bare, unquoted key (no spaces,
|
|
172
|
+
// "=" or ";") as well as a quoted one — Xcode itself only ever writes quoted keys,
|
|
173
|
+
// but hand-edited or third-party-exported .strings files sometimes have both.
|
|
174
|
+
const PAIR = /(?:((?:\/\*[\s\S]*?\*\/)|(?:\/\/[^\r\n]*(?:\r?\n[ \t]*\/\/[^\r\n]*)*))[ \t]*\r?\n+)?[ \t]*(?:"((?:\\.|[^"\\])*)"|([^\s"=;]+))\s*=\s*"((?:\\.|[^"\\])*)"\s*;/g;
|
|
162
175
|
const data = newObj();
|
|
163
176
|
const entries = [];
|
|
164
177
|
for (const m of text.matchAll(PAIR)) {
|
|
165
|
-
const key = unescapeC(m[2]);
|
|
166
|
-
data[key] = unescapeC(m[
|
|
178
|
+
const key = m[2] !== undefined ? unescapeC(m[2]) : m[3];
|
|
179
|
+
data[key] = unescapeC(m[4]);
|
|
167
180
|
entries.push({ key, comment: m[1] });
|
|
168
181
|
}
|
|
169
182
|
if (entries.length === 0 && text.trim() !== "") {
|
|
@@ -355,21 +368,37 @@ function parsePo(text) {
|
|
|
355
368
|
/** Synthetic data keys for a plural entry's two translatable forms. */
|
|
356
369
|
const pluralOneKey = (e) => poKey(e) + "\u0005one";
|
|
357
370
|
const pluralOtherKey = (e) => poKey(e) + "\u0005other";
|
|
358
|
-
|
|
371
|
+
/** Adds a `#, fuzzy` gettext flag to a comment block, merging onto an existing `#,` line rather than duplicating it. */
|
|
372
|
+
function withFuzzyFlag(comments) {
|
|
373
|
+
const i = comments.findIndex((c) => /^#,/.test(c));
|
|
374
|
+
if (i === -1)
|
|
375
|
+
return [...comments, "#, fuzzy"];
|
|
376
|
+
if (/\bfuzzy\b/.test(comments[i]))
|
|
377
|
+
return comments;
|
|
378
|
+
return comments.map((c, j) => (j === i ? `${c}, fuzzy` : c));
|
|
379
|
+
}
|
|
380
|
+
function readPoDoc(file, text, isSource, fuzzy) {
|
|
359
381
|
const { entries, leading } = parsePo(text);
|
|
360
382
|
const data = newObj();
|
|
361
383
|
for (const e of entries) {
|
|
362
384
|
if (e.isHeader)
|
|
363
385
|
continue;
|
|
364
|
-
if (e.msgidPlural === undefined)
|
|
365
|
-
|
|
386
|
+
if (e.msgidPlural === undefined) {
|
|
387
|
+
// A template (.pot) or a project that keeps the English text in msgid and
|
|
388
|
+
// leaves the source .po's own msgstr empty: fall back to msgid so the source
|
|
389
|
+
// text isn't just "", which would otherwise translate every entry to nothing
|
|
390
|
+
// with no warning. Never done for a target file, where an empty msgstr
|
|
391
|
+
// legitimately means "not translated yet".
|
|
392
|
+
data[poKey(e)] = isSource && e.msgstr === "" ? e.msgid : e.msgstr;
|
|
393
|
+
}
|
|
366
394
|
else {
|
|
367
395
|
// Like msgstr for a singular entry, msgstrPlural holds this FILE's own text: the
|
|
368
396
|
// English source form in the source .po, the translated forms in a target .po
|
|
369
397
|
// (msgid/msgid_plural are the stable keys, identical across every locale).
|
|
370
398
|
const forms = e.msgstrPlural ?? [];
|
|
371
|
-
|
|
372
|
-
data[
|
|
399
|
+
const useMsgid = isSource && forms.every((f) => f === "");
|
|
400
|
+
data[pluralOneKey(e)] = useMsgid ? e.msgid : forms[0] ?? "";
|
|
401
|
+
data[pluralOtherKey(e)] = useMsgid ? e.msgidPlural : forms[forms.length - 1] ?? forms[0] ?? "";
|
|
373
402
|
}
|
|
374
403
|
}
|
|
375
404
|
const keepNewline = finalNewline(text);
|
|
@@ -377,24 +406,35 @@ function readPoDoc(file, text) {
|
|
|
377
406
|
const obj = isObj(d) ? d : newObj();
|
|
378
407
|
const seen = new Set();
|
|
379
408
|
const blocks = leading.length ? [leading.join("\n")] : [];
|
|
409
|
+
const existingEntries = existingText ? parsePo(existingText).entries : [];
|
|
380
410
|
// A previous run may have left a header a translator then hand-fixed (a corrected
|
|
381
411
|
// Plural-Forms, an added Last-Translator, ...). Keep it as-is rather than
|
|
382
412
|
// regenerating from the source every time; only a brand-new target file gets a
|
|
383
413
|
// freshly computed header.
|
|
384
|
-
const existingHeader =
|
|
414
|
+
const existingHeader = existingEntries.find((e) => e.isHeader);
|
|
415
|
+
// Comments (translator notes, `#, fuzzy` flags, ...) a translator may have added
|
|
416
|
+
// directly to the target file, keyed the same way entries are. Previously
|
|
417
|
+
// serialize always used the SOURCE's own comments for every entry, silently
|
|
418
|
+
// wiping anything a translator had added to the target on the very next run.
|
|
419
|
+
const existingByKey = new Map(existingEntries.filter((e) => !e.isHeader).map((e) => [poKey(e), e]));
|
|
385
420
|
for (const e of entries) {
|
|
386
|
-
const lines = [...e.comments];
|
|
387
|
-
if (e.msgctxt !== undefined)
|
|
388
|
-
lines.push(`msgctxt "${escapeC(e.msgctxt)}"`);
|
|
389
421
|
if (e.isHeader) {
|
|
390
|
-
lines.
|
|
422
|
+
const lines = [...e.comments, `msgid ""`];
|
|
391
423
|
const header = existingHeader
|
|
392
424
|
? existingHeader.msgstr
|
|
393
425
|
: e.msgstr.replace(/^Language:.*$/m, `Language: ${targetTag}`).replace(/^Plural-Forms:.*$/m, `Plural-Forms: ${pluralFormsHeader(targetTag)}`);
|
|
394
426
|
lines.push(`msgstr "${escapeC(header)}"`);
|
|
427
|
+
blocks.push(lines.join("\n"));
|
|
428
|
+
continue;
|
|
395
429
|
}
|
|
396
|
-
|
|
397
|
-
|
|
430
|
+
const existingEntry = existingByKey.get(poKey(e));
|
|
431
|
+
let comments = existingEntry?.comments ?? e.comments;
|
|
432
|
+
const content = [];
|
|
433
|
+
if (e.msgctxt !== undefined)
|
|
434
|
+
content.push(`msgctxt "${escapeC(e.msgctxt)}"`);
|
|
435
|
+
let changed;
|
|
436
|
+
if (e.msgidPlural !== undefined) {
|
|
437
|
+
content.push(`msgid "${escapeC(e.msgid)}"`, `msgid_plural "${escapeC(e.msgidPlural)}"`);
|
|
398
438
|
const oneKey = pluralOneKey(e);
|
|
399
439
|
const otherKey = pluralOtherKey(e);
|
|
400
440
|
seen.add(oneKey);
|
|
@@ -407,15 +447,20 @@ function readPoDoc(file, text) {
|
|
|
407
447
|
// plural-sourced one. A language with only one category (ja, zh, ...) gets
|
|
408
448
|
// just the plural form, since it has no separate singular count.
|
|
409
449
|
for (let i = 0; i < n; i++)
|
|
410
|
-
|
|
450
|
+
content.push(`msgstr[${i}] "${escapeC(n === 1 ? other : i === 0 ? one : other)}"`);
|
|
451
|
+
const forms = existingEntry?.msgstrPlural;
|
|
452
|
+
changed = !forms || forms[0] !== one || forms[forms.length - 1] !== other;
|
|
411
453
|
}
|
|
412
454
|
else {
|
|
413
455
|
const key = poKey(e);
|
|
414
456
|
seen.add(key);
|
|
415
457
|
const v = typeof obj[key] === "string" ? obj[key] : "";
|
|
416
|
-
|
|
458
|
+
content.push(`msgid "${escapeC(e.msgid)}"`, `msgstr "${escapeC(v)}"`);
|
|
459
|
+
changed = existingEntry?.msgstr !== v;
|
|
417
460
|
}
|
|
418
|
-
|
|
461
|
+
if (fuzzy && changed)
|
|
462
|
+
comments = withFuzzyFlag(comments);
|
|
463
|
+
blocks.push([...comments, ...content].join("\n"));
|
|
419
464
|
}
|
|
420
465
|
for (const k of Object.keys(obj)) {
|
|
421
466
|
if (seen.has(k) || typeof obj[k] !== "string")
|
|
@@ -431,14 +476,40 @@ function readPoDoc(file, text) {
|
|
|
431
476
|
};
|
|
432
477
|
return { data, serialize };
|
|
433
478
|
}
|
|
434
|
-
/**
|
|
435
|
-
|
|
436
|
-
|
|
479
|
+
/**
|
|
480
|
+
* Decodes a locale file's raw bytes to text, detecting a UTF-16 BOM first — Xcode's
|
|
481
|
+
* traditional default for `.strings` files, which `fs.readFileSync(file, "utf8")`
|
|
482
|
+
* would otherwise mangle into unreadable text (and then "no entries found").
|
|
483
|
+
*/
|
|
484
|
+
function readText(file) {
|
|
485
|
+
const buf = fs.readFileSync(file);
|
|
486
|
+
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe)
|
|
487
|
+
return buf.subarray(2).toString("utf16le");
|
|
488
|
+
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
|
|
489
|
+
const swapped = Buffer.alloc(buf.length - 2);
|
|
490
|
+
for (let i = 2; i + 1 < buf.length; i += 2) {
|
|
491
|
+
swapped[i - 2] = buf[i + 1];
|
|
492
|
+
swapped[i - 1] = buf[i];
|
|
493
|
+
}
|
|
494
|
+
return swapped.toString("utf16le");
|
|
495
|
+
}
|
|
496
|
+
return buf.toString("utf8").replace(/^/, "");
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* `tag` is the locale this file is for (the source tag, or an existing target's own
|
|
500
|
+
* tag). `isSource` marks this as the FROM-locale file being read as the thing to
|
|
501
|
+
* translate (as opposed to an already-existing target) — only then does a `.po`
|
|
502
|
+
* entry's empty `msgstr` fall back to `msgid` as the source text (see readPoDoc).
|
|
503
|
+
* `fuzzy`, for `.po`/`.pot` only, marks every entry this run actually (re)translated
|
|
504
|
+
* with gettext's standard `#, fuzzy` "needs review" flag.
|
|
505
|
+
*/
|
|
506
|
+
export function readLocaleDoc(file, tag, isSource = false, fuzzy = false) {
|
|
507
|
+
const text = readText(file);
|
|
437
508
|
const ext = path.extname(file).toLowerCase();
|
|
438
509
|
if (ext === ".yaml" || ext === ".yml")
|
|
439
510
|
return readYamlDoc(file, text, tag);
|
|
440
|
-
if (ext === ".po")
|
|
441
|
-
return readPoDoc(file, text);
|
|
511
|
+
if (ext === ".po" || ext === ".pot")
|
|
512
|
+
return readPoDoc(file, text, isSource, fuzzy);
|
|
442
513
|
if (ext === ".arb")
|
|
443
514
|
return readArbDoc(file, text);
|
|
444
515
|
if (ext === ".strings")
|