locale-ai 0.6.3 → 0.6.4

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
@@ -35,7 +35,7 @@ Download 577 MB? (Y/n)
35
35
  - **Incremental**: only missing keys are translated, existing translations are kept, re-runs are no-ops. `--overwrite` 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`), 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.
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.
39
39
 
40
40
  ## Why locale-ai?
41
41
 
@@ -50,6 +50,7 @@ Download 577 MB? (Y/n)
50
50
 
51
51
  ```bash
52
52
  locale-ai translate --to fr,pt-BR [--from en] [--input dir] [--dry-run] [--overwrite] [--fast] [--report file] [-y]
53
+ locale-ai check [--from en] [--input dir] # CI: fails if a translated locale has missing keys or placeholder mismatches
53
54
  locale-ai models list [--from en] # supported language pairs
54
55
  locale-ai models installed
55
56
  locale-ai models install en-de # pre-download (e.g. in CI)
package/dist/cli.js CHANGED
@@ -9,14 +9,19 @@ import { CATALOG, customEntries, findById, reachableTargets, resolveRoute, } fro
9
9
  import { translateStrings } from "./engine.js";
10
10
  import { CONFIG_FILE, glossaryLookup, loadConfig, protectRegex } from "./glossary.js";
11
11
  import { intlCode, parseLocale } from "./lang.js";
12
- import { addPluralForms, buildTarget, detectLayout, pendingStrings, readLocaleDoc, sourceFiles, targetFile, } from "./locales.js";
12
+ import { addPluralForms, buildTarget, detectLayout, existingTargets, flattenStrings, pendingStrings, readLocaleDoc, sourceFiles, targetFile, } from "./locales.js";
13
13
  import { cacheRoot, install, isInstalled, loadTranslator, localModelProblem, planInstall, readManifest, uninstall, } from "./models.js";
14
+ import { mask } from "./placeholders.js";
14
15
  /** Read from package.json so `npm version` is the single source of truth. */
15
16
  const VERSION = JSON.parse(fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
16
17
  const HELP = `locale-ai ${VERSION} — translate i18n locale files locally, on CPU, for free.
17
18
 
18
19
  Usage:
19
20
  locale-ai translate --to <locales> [options]
21
+ locale-ai check [--from <locale>]
22
+ Validate that already-translated locales have the same keys and
23
+ placeholders as the source, without translating. Exits non-zero
24
+ on a mismatch (CI-friendly).
20
25
  locale-ai models list [--from <locale>]
21
26
  locale-ai models installed
22
27
  locale-ai models install <from-to | model-id> [--yes]
@@ -370,11 +375,70 @@ async function cmdModels(argv) {
370
375
  return sub ? 1 : 0;
371
376
  }
372
377
  }
378
+ /**
379
+ * Validates already-translated locales against the source without translating:
380
+ * same key structure, and the same placeholders in each matching string. Meant for
381
+ * CI, so a mismatch (a key a translator dropped, a {name} a translation lost) fails
382
+ * the build instead of shipping silently.
383
+ */
384
+ async function cmdCheck(argv) {
385
+ const { values } = parseArgs({
386
+ args: argv,
387
+ options: {
388
+ from: { type: "string", default: "en" },
389
+ input: { type: "string" },
390
+ },
391
+ });
392
+ const from = parseLocale(values.from);
393
+ const layout = detectLayout(process.cwd(), from.tag, values.input);
394
+ const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag) }));
395
+ const targets = existingTargets(layout, from.tag);
396
+ if (!targets.length) {
397
+ log(`No translated locales found next to ${from.tag}.`);
398
+ return 0;
399
+ }
400
+ let problems = 0;
401
+ for (const to of targets) {
402
+ let missing = 0;
403
+ let mismatched = 0;
404
+ for (const { file, doc } of sources) {
405
+ const target = targetFile(layout, from.tag, to, file);
406
+ if (!fs.existsSync(target)) {
407
+ missing += flattenStrings(doc.data).length;
408
+ log(`✗ ${to}: ${rel(target)} is missing entirely`);
409
+ continue;
410
+ }
411
+ const srcFlat = flattenStrings(doc.data);
412
+ const tgtByPath = new Map(flattenStrings(readLocaleDoc(target, to).data).map((e) => [e.path, e.value]));
413
+ for (const { path: p, value: srcVal } of srcFlat) {
414
+ const tgtVal = tgtByPath.get(p);
415
+ if (tgtVal === undefined) {
416
+ missing++;
417
+ log(`✗ ${to}: ${rel(target)} missing key "${p}"`);
418
+ continue;
419
+ }
420
+ const srcTokens = mask(srcVal, "natural").tokens.toSorted();
421
+ const tgtTokens = mask(tgtVal, "natural").tokens.toSorted();
422
+ if (srcTokens.join("\u0001") !== tgtTokens.join("\u0001")) {
423
+ mismatched++;
424
+ log(`✗ ${to}: ${rel(target)} "${p}" placeholders differ: source [${srcTokens.join(", ")}], target [${tgtTokens.join(", ")}]`);
425
+ }
426
+ }
427
+ }
428
+ if (missing || mismatched)
429
+ problems++;
430
+ else
431
+ log(`✓ ${to}`);
432
+ }
433
+ return problems ? 1 : 0;
434
+ }
373
435
  async function main() {
374
436
  const [cmd, ...rest] = process.argv.slice(2);
375
437
  switch (cmd) {
376
438
  case "translate":
377
439
  return cmdTranslate(rest);
440
+ case "check":
441
+ return cmdCheck(rest);
378
442
  case "models":
379
443
  return cmdModels(rest);
380
444
  case "-v":
package/dist/locales.js CHANGED
@@ -82,6 +82,23 @@ export function targetFile(layout, from, to, src) {
82
82
  return path.join(layout.root, `${to}${layout.ext}`);
83
83
  return path.join(layout.root, to, path.relative(path.join(layout.root, from), src));
84
84
  }
85
+ /** Locale tags already translated alongside `from` in this layout (for `locale-ai check`). */
86
+ export function existingTargets(layout, from) {
87
+ if (layout.kind === "file") {
88
+ const ext = layout.ext;
89
+ return fs
90
+ .readdirSync(layout.root)
91
+ .filter((f) => f.endsWith(ext) && f.slice(0, -ext.length) !== from)
92
+ .map((f) => f.slice(0, -ext.length))
93
+ .sort();
94
+ }
95
+ return fs
96
+ .readdirSync(layout.root, { withFileTypes: true })
97
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && e.name !== from)
98
+ .map((e) => e.name)
99
+ .filter((name) => listLocaleFiles(path.join(layout.root, name)).length > 0)
100
+ .sort();
101
+ }
85
102
  const finalNewline = (text) => text === "" || text.endsWith("\n");
86
103
  const withNewline = (text, keep) => text.replace(/\n*$/, keep ? "\n" : "");
87
104
  function readJsonDoc(file, text) {
@@ -242,6 +259,10 @@ function pluralFormsHeader(lang) {
242
259
  const branches = Array.from({ length: n - 1 }, (_, i) => `n==${i + 1} ? ${i} :`).join(" ");
243
260
  return `nplurals=${n}; plural=(${branches} ${n - 1});`;
244
261
  }
262
+ /** The `nplurals=N` count from pluralFormsHeader, for expanding msgstr[] to the right length. */
263
+ function pluralCount(lang) {
264
+ return Number(/nplurals=(\d+)/.exec(pluralFormsHeader(lang))[1]);
265
+ }
245
266
  function parsePo(text) {
246
267
  const entries = [];
247
268
  let leading = [];
@@ -318,12 +339,26 @@ function parsePo(text) {
318
339
  flushEntry();
319
340
  return { entries, leading };
320
341
  }
342
+ /** Synthetic data keys for a plural entry's two translatable forms. */
343
+ const pluralOneKey = (e) => poKey(e) + "\u0005one";
344
+ const pluralOtherKey = (e) => poKey(e) + "\u0005other";
321
345
  function readPoDoc(file, text) {
322
346
  const { entries, leading } = parsePo(text);
323
347
  const data = newObj();
324
- for (const e of entries)
325
- if (!e.isHeader && e.msgidPlural === undefined)
348
+ for (const e of entries) {
349
+ if (e.isHeader)
350
+ continue;
351
+ if (e.msgidPlural === undefined)
326
352
  data[poKey(e)] = e.msgstr;
353
+ else {
354
+ // Like msgstr for a singular entry, msgstrPlural holds this FILE's own text: the
355
+ // English source form in the source .po, the translated forms in a target .po
356
+ // (msgid/msgid_plural are the stable keys, identical across every locale).
357
+ const forms = e.msgstrPlural ?? [];
358
+ data[pluralOneKey(e)] = forms[0] ?? "";
359
+ data[pluralOtherKey(e)] = forms[forms.length - 1] ?? forms[0] ?? "";
360
+ }
361
+ }
327
362
  const keepNewline = finalNewline(text);
328
363
  const serialize = (d, targetTag, existingText) => {
329
364
  const obj = isObj(d) ? d : newObj();
@@ -347,7 +382,19 @@ function readPoDoc(file, text) {
347
382
  }
348
383
  else if (e.msgidPlural !== undefined) {
349
384
  lines.push(`msgid "${escapeC(e.msgid)}"`, `msgid_plural "${escapeC(e.msgidPlural)}"`);
350
- (e.msgstrPlural ?? []).forEach((s, i) => lines.push(`msgstr[${i}] "${escapeC(s)}"`));
385
+ const oneKey = pluralOneKey(e);
386
+ const otherKey = pluralOtherKey(e);
387
+ seen.add(oneKey);
388
+ seen.add(otherKey);
389
+ const one = typeof obj[oneKey] === "string" ? obj[oneKey] : e.msgid;
390
+ const other = typeof obj[otherKey] === "string" ? obj[otherKey] : e.msgidPlural;
391
+ const n = pluralCount(targetTag);
392
+ // Not full grammatical inflection (same simplification as addPluralForms for
393
+ // i18next): index 0 gets the singular-sourced form, every other index the
394
+ // plural-sourced one. A language with only one category (ja, zh, ...) gets
395
+ // just the plural form, since it has no separate singular count.
396
+ for (let i = 0; i < n; i++)
397
+ lines.push(`msgstr[${i}] "${escapeC(n === 1 ? other : i === 0 ? one : other)}"`);
351
398
  }
352
399
  else {
353
400
  const key = poKey(e);
@@ -390,6 +437,17 @@ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
390
437
  const newObj = () => Object.create(null);
391
438
  /** A string needs translating when the target has no non-empty string at the same path. */
392
439
  const needsTranslation = (existing, overwrite) => overwrite || typeof existing !== "string" || existing === "";
440
+ /** Flattens a locale tree into key-path -> string-value pairs (for `locale-ai check`). */
441
+ export function flattenStrings(data, prefix = "", out = []) {
442
+ if (typeof data === "string")
443
+ out.push({ path: prefix || "(root)", value: data });
444
+ else if (Array.isArray(data))
445
+ data.forEach((v, i) => flattenStrings(v, `${prefix}[${i}]`, out));
446
+ else if (isObj(data))
447
+ for (const k of Object.keys(data))
448
+ flattenStrings(data[k], prefix ? `${prefix}.${k}` : k, out);
449
+ return out;
450
+ }
393
451
  export function pendingStrings(source, existing, overwrite, out = []) {
394
452
  if (typeof source === "string") {
395
453
  if (needsTranslation(existing, overwrite))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locale-ai",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Translate your i18n locale files locally on CPU. Free, offline, no API keys.",
5
5
  "author": "Taha KHABOUSS",
6
6
  "repository": {