locale-ai 0.6.8 → 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 +65 -22
- 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) {
|
|
@@ -364,21 +368,37 @@ function parsePo(text) {
|
|
|
364
368
|
/** Synthetic data keys for a plural entry's two translatable forms. */
|
|
365
369
|
const pluralOneKey = (e) => poKey(e) + "\u0005one";
|
|
366
370
|
const pluralOtherKey = (e) => poKey(e) + "\u0005other";
|
|
367
|
-
|
|
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) {
|
|
368
381
|
const { entries, leading } = parsePo(text);
|
|
369
382
|
const data = newObj();
|
|
370
383
|
for (const e of entries) {
|
|
371
384
|
if (e.isHeader)
|
|
372
385
|
continue;
|
|
373
|
-
if (e.msgidPlural === undefined)
|
|
374
|
-
|
|
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
|
+
}
|
|
375
394
|
else {
|
|
376
395
|
// Like msgstr for a singular entry, msgstrPlural holds this FILE's own text: the
|
|
377
396
|
// English source form in the source .po, the translated forms in a target .po
|
|
378
397
|
// (msgid/msgid_plural are the stable keys, identical across every locale).
|
|
379
398
|
const forms = e.msgstrPlural ?? [];
|
|
380
|
-
|
|
381
|
-
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] ?? "";
|
|
382
402
|
}
|
|
383
403
|
}
|
|
384
404
|
const keepNewline = finalNewline(text);
|
|
@@ -386,24 +406,35 @@ function readPoDoc(file, text) {
|
|
|
386
406
|
const obj = isObj(d) ? d : newObj();
|
|
387
407
|
const seen = new Set();
|
|
388
408
|
const blocks = leading.length ? [leading.join("\n")] : [];
|
|
409
|
+
const existingEntries = existingText ? parsePo(existingText).entries : [];
|
|
389
410
|
// A previous run may have left a header a translator then hand-fixed (a corrected
|
|
390
411
|
// Plural-Forms, an added Last-Translator, ...). Keep it as-is rather than
|
|
391
412
|
// regenerating from the source every time; only a brand-new target file gets a
|
|
392
413
|
// freshly computed header.
|
|
393
|
-
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]));
|
|
394
420
|
for (const e of entries) {
|
|
395
|
-
const lines = [...e.comments];
|
|
396
|
-
if (e.msgctxt !== undefined)
|
|
397
|
-
lines.push(`msgctxt "${escapeC(e.msgctxt)}"`);
|
|
398
421
|
if (e.isHeader) {
|
|
399
|
-
lines.
|
|
422
|
+
const lines = [...e.comments, `msgid ""`];
|
|
400
423
|
const header = existingHeader
|
|
401
424
|
? existingHeader.msgstr
|
|
402
425
|
: e.msgstr.replace(/^Language:.*$/m, `Language: ${targetTag}`).replace(/^Plural-Forms:.*$/m, `Plural-Forms: ${pluralFormsHeader(targetTag)}`);
|
|
403
426
|
lines.push(`msgstr "${escapeC(header)}"`);
|
|
427
|
+
blocks.push(lines.join("\n"));
|
|
428
|
+
continue;
|
|
404
429
|
}
|
|
405
|
-
|
|
406
|
-
|
|
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)}"`);
|
|
407
438
|
const oneKey = pluralOneKey(e);
|
|
408
439
|
const otherKey = pluralOtherKey(e);
|
|
409
440
|
seen.add(oneKey);
|
|
@@ -416,15 +447,20 @@ function readPoDoc(file, text) {
|
|
|
416
447
|
// plural-sourced one. A language with only one category (ja, zh, ...) gets
|
|
417
448
|
// just the plural form, since it has no separate singular count.
|
|
418
449
|
for (let i = 0; i < n; i++)
|
|
419
|
-
|
|
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;
|
|
420
453
|
}
|
|
421
454
|
else {
|
|
422
455
|
const key = poKey(e);
|
|
423
456
|
seen.add(key);
|
|
424
457
|
const v = typeof obj[key] === "string" ? obj[key] : "";
|
|
425
|
-
|
|
458
|
+
content.push(`msgid "${escapeC(e.msgid)}"`, `msgstr "${escapeC(v)}"`);
|
|
459
|
+
changed = existingEntry?.msgstr !== v;
|
|
426
460
|
}
|
|
427
|
-
|
|
461
|
+
if (fuzzy && changed)
|
|
462
|
+
comments = withFuzzyFlag(comments);
|
|
463
|
+
blocks.push([...comments, ...content].join("\n"));
|
|
428
464
|
}
|
|
429
465
|
for (const k of Object.keys(obj)) {
|
|
430
466
|
if (seen.has(k) || typeof obj[k] !== "string")
|
|
@@ -459,14 +495,21 @@ function readText(file) {
|
|
|
459
495
|
}
|
|
460
496
|
return buf.toString("utf8").replace(/^/, "");
|
|
461
497
|
}
|
|
462
|
-
/**
|
|
463
|
-
|
|
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) {
|
|
464
507
|
const text = readText(file);
|
|
465
508
|
const ext = path.extname(file).toLowerCase();
|
|
466
509
|
if (ext === ".yaml" || ext === ".yml")
|
|
467
510
|
return readYamlDoc(file, text, tag);
|
|
468
|
-
if (ext === ".po")
|
|
469
|
-
return readPoDoc(file, text);
|
|
511
|
+
if (ext === ".po" || ext === ".pot")
|
|
512
|
+
return readPoDoc(file, text, isSource, fuzzy);
|
|
470
513
|
if (ext === ".arb")
|
|
471
514
|
return readArbDoc(file, text);
|
|
472
515
|
if (ext === ".strings")
|