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 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>`, `&nbsp;`, 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 `.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`). `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 is otherwise left as you last edited it (a hand-fixed `Plural-Forms`, `Last-Translator`, ...) instead of being regenerated on every rerun. For `.arb`, `@key` metadata and `@@locale` are never translated; `@@locale` is set to the target locale on output.
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
- return path.join(layout.root, to, path.relative(path.join(layout.root, from), src));
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) => ({ n: "\n", t: "\t", r: "\r", '"': '"', "\\": "\\" })[c] ?? 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
- const PAIR = /(?:((?:\/\*[\s\S]*?\*\/)|(?:\/\/[^\r\n]*(?:\r?\n[ \t]*\/\/[^\r\n]*)*))[ \t]*\r?\n+)?[ \t]*"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)"\s*;/g;
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[3]);
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
- function readPoDoc(file, text) {
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
- data[poKey(e)] = e.msgstr;
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
- data[pluralOneKey(e)] = forms[0] ?? "";
372
- data[pluralOtherKey(e)] = forms[forms.length - 1] ?? forms[0] ?? "";
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 = existingText && parsePo(existingText).entries.find((e) => e.isHeader);
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.push(`msgid ""`);
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
- else if (e.msgidPlural !== undefined) {
397
- lines.push(`msgid "${escapeC(e.msgid)}"`, `msgid_plural "${escapeC(e.msgidPlural)}"`);
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
- lines.push(`msgstr[${i}] "${escapeC(n === 1 ? other : i === 0 ? one : other)}"`);
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
- lines.push(`msgid "${escapeC(e.msgid)}"`, `msgstr "${escapeC(v)}"`);
458
+ content.push(`msgid "${escapeC(e.msgid)}"`, `msgstr "${escapeC(v)}"`);
459
+ changed = existingEntry?.msgstr !== v;
417
460
  }
418
- blocks.push(lines.join("\n"));
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
- /** `tag` is the locale this file is for (the source tag, or an existing target's own tag). */
435
- export function readLocaleDoc(file, tag) {
436
- const text = fs.readFileSync(file, "utf8").replace(/^/, "");
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")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locale-ai",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "description": "Translate your i18n locale files locally on CPU. Free, offline, no API keys.",
5
5
  "author": "Taha KHABOUSS",
6
6
  "repository": {