locale-ai 0.6.2 → 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
@@ -15,7 +15,7 @@ npx locale-ai translate --from en --to fr,es,de
15
15
  ```
16
16
 
17
17
  ```
18
- locale-ai 0.2.0
18
+ locale-ai 0.6.3
19
19
  Found 3 en files in locales/en/
20
20
  Models to download (one time, then cached for every project):
21
21
  en-fr 345 MB khabouss/opus-mt-tc-big-en-fr-onnx
@@ -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)
@@ -102,24 +103,24 @@ then declare it in `locale-ai.config.json`:
102
103
  - `prefix` is required for multi-target models (`convert` lists the model's `>>xx<<` tokens).
103
104
  - Project models take priority over built-ins for their language pairs and can be used for pivoting (e.g. `ja → en → tr`).
104
105
 
105
- **Recommended for quality**: the `opus-mt-tc-big-en-*` models (es, it, pt, ar, tr, and `zle` for ru/uk). They are ~3x larger (270–370 MB int8) and ~1.5x slower, and clearly better. `en-fr` already uses one of these as its built-in model (see [Languages](#languages)), hosted at [khabouss/opus-mt-tc-big-en-fr-onnx](https://huggingface.co/khabouss/opus-mt-tc-big-en-fr-onnx) — no conversion needed for it.
106
+ **Already built-in, no conversion needed**: `fr`, `es`, `it`, `pt`, `ar` and `tr` all use the larger, clearly-better `opus-mt-tc-big-en-*` checkpoints as their built-in model (~3x larger, 270–370 MB int8, ~1.5x slower than the plain OPUS-MT default); `ru`/`uk` use the tc-big `zle` multi-target checkpoint, and `pl` uses Helsinki-NLP's multi-target Slavic model (no tc-big `en-pl` checkpoint exists). The section above is for adding a language or model that *isn't* already built in.
106
107
 
107
- | Target | Built-in model, chrF / BLEU | tc-big model, chrF / BLEU |
108
+ | Target | Previous (plain OPUS-MT), chrF / BLEU | Built-in today (tc-big / better), chrF / BLEU |
108
109
  |---|---|---|
110
+ | fr | 68.9 / 53.7 | **72.5 / 55.9** |
109
111
  | es | 72.9 / 56.9 | **73.6 / 57.4** |
110
112
  | it | 73.8 / 54.9 | **75.3 / 58.2** |
111
113
  | pt | 68.3 / 44.4 | **70.6 / 47.7** (`>>por<<`, or `>>pob<<` for pt-BR) |
112
114
  | ru | 65.7 / 46.0 | **68.2 / 48.1** (`>>rus<<`) |
113
115
  | ar | 47.8 / 16.6 | **49.7 / 19.9** (`>>ara<<`, with `--fast`) |
114
- | tr | — | **67.4 / 42.1** |
116
+ | tr | — | **67.4 / 42.1** (not supported before) |
117
+ | pl | — | **62.6 / 40.3** (`>>pol<<`, not supported before) |
115
118
 
116
119
  300 Tatoeba test sentences per language, int8, beam 4 unless noted; reproduce with `bench/` (see `bench/README.md`). `opus-mt-tc-big-en-ko` is broken upstream (garbage output even in PyTorch) and is not recommended.
117
120
 
118
- Also new: **pl** (chrF 62.6 / BLEU 40.3, `>>pol<<`), hosted at [khabouss/opus-mt-en-sla-onnx](https://huggingface.co/khabouss/opus-mt-en-sla-onnx) — no tc-big `en-pl` checkpoint exists, so this uses Helsinki-NLP's multi-target Slavic model instead.
119
-
120
121
  ## Languages
121
122
 
122
- From English: af, ar, ca, cs, da, de, es, fi, fr, gl, hi, hu, id, it, nl, oc, pl, pt (pt-BR, pt-PT), ro, ru, sv, uk, vi, xh, zh (zh-CN, zh-TW). Other source languages are supported directly or through English (`locale-ai models list --from fr`).
123
+ From English: af, ar, ca, cs, da, de, es, fi, fr, gl, hi, hu, id, it, nl, oc, pl, pt (pt-BR, pt-PT), ro, ru, sv, tr, uk, vi, xh, zh (zh-CN, zh-TW). Other source languages are supported directly or through English (`locale-ai models list --from fr`).
123
124
 
124
125
  Measured on a sample app (see `test/fixtures`):
125
126
 
@@ -129,7 +130,7 @@ Measured on a sample app (see `test/fixtures`):
129
130
  | Usable — review before shipping | it, ar, ru, pl, zh-CN, zh-TW |
130
131
  | Untested | the other pairs in `locale-ai models list` |
131
132
 
132
- `en-fr` is already on the tc-big model (chrF 72.5 / BLEU 55.9) as a built-in, no config needed. With the other tc-big models above, it, ar and ru move up a tier, and tr and pl become available. Not available yet: ja, ko and others; he works with a converted `opus-mt-en-he` (weaker). Moroccan Arabic (Darija): the tc-big en-ar model has a `>>ary<<` token, but its output is close to Modern Standard Arabic — not supported.
133
+ `fr`, `es`, `it`, `pt`, `ar`, `ru`/`uk`, `tr` and `pl` all ship on the tc-big (or better) models above as built-ins already — no config needed. Not available yet: ja, ko and others; he works with a converted `opus-mt-en-he` (weaker). Moroccan Arabic (Darija): the tc-big en-ar model has a `>>ary<<` token, but its output is close to Modern Standard Arabic — not supported.
133
134
 
134
135
  ## How it works
135
136
 
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]
@@ -49,16 +54,52 @@ const endStatus = () => {
49
54
  };
50
55
  const mb = (bytes) => `${Math.round(bytes / 1e6)} MB`;
51
56
  const rel = (p) => path.relative(process.cwd(), p) || ".";
57
+ /**
58
+ * `Number("abc")` is NaN, and NaN silently poisons everything downstream (an
59
+ * all-garbage translation batch, or a raw ONNX crash deep in the model) instead of
60
+ * a clear error at the CLI boundary where the bad flag value actually came from.
61
+ */
62
+ function positiveInt(raw, flag, fallback) {
63
+ if (raw === undefined) {
64
+ if (fallback === undefined)
65
+ throw new Error(`Missing --${flag}.`);
66
+ return fallback;
67
+ }
68
+ const n = Number(raw);
69
+ if (!Number.isInteger(n) || n < 1)
70
+ throw new Error(`Invalid --${flag} "${raw}": expected a positive integer.`);
71
+ return n;
72
+ }
52
73
  function bar(done, total, width = 24) {
53
74
  const ratio = total ? Math.min(1, done / total) : 1;
54
75
  const filled = Math.round(ratio * width);
55
76
  return `[${"█".repeat(filled)}${"░".repeat(width - filled)}] ${Math.round(ratio * 100)}%`;
56
77
  }
78
+ /**
79
+ * A long download or translation batch can run for a minute or more. The `\r`-redrawn
80
+ * bar only ever reaches a real terminal, so a non-TTY run (CI logs, piped output) would
81
+ * otherwise print nothing at all between the start and the final result, indistinguishable
82
+ * from a hang. This prints one plain line per 10% there instead.
83
+ */
84
+ function makeProgress(render) {
85
+ let lastPct = -1;
86
+ return (done, total) => {
87
+ if (isTTY) {
88
+ status(render(done, total));
89
+ return;
90
+ }
91
+ const pct = total ? Math.floor((done / total) * 100) : 100;
92
+ if (pct >= lastPct + 10 || (done >= total && lastPct < 100)) {
93
+ lastPct = done >= total ? 100 : pct;
94
+ log(render(done, total));
95
+ }
96
+ };
97
+ }
57
98
  async function confirm(question, yes) {
58
99
  if (yes)
59
100
  return true;
60
101
  if (!process.stdin.isTTY) {
61
- throw new Error(`${question.trim()} Re-run with --yes to allow downloads in non-interactive mode.`);
102
+ throw new Error(`Cannot ask "${question.trim()}" in a non-interactive shell. Re-run with --yes to allow downloads.`);
62
103
  }
63
104
  const rl = createInterface({ input: process.stdin, output: process.stderr });
64
105
  const answer = (await rl.question(`${question} (Y/n) `)).trim().toLowerCase();
@@ -86,7 +127,7 @@ async function ensureInstalled(models, yes) {
86
127
  if (!(await confirm(`Download ${mb(total)}?`, yes)))
87
128
  throw new Error("Download declined.");
88
129
  for (const p of plans) {
89
- await install(p, (d, t) => status(`Downloading ${p.model.id} ${bar(d, t)} ${mb(d)}/${mb(t)}`));
130
+ await install(p, makeProgress((d, t) => `Downloading ${p.model.id} ${bar(d, t)} ${mb(d)}/${mb(t)}`));
90
131
  endStatus();
91
132
  log(`✓ Installed ${p.model.id}`);
92
133
  }
@@ -105,10 +146,6 @@ async function cmdTranslate(argv) {
105
146
  beams: { type: "string" },
106
147
  report: { type: "string" },
107
148
  config: { type: "string" },
108
- out: { type: "string" },
109
- source: { type: "string" },
110
- target: { type: "string" },
111
- python: { type: "string" },
112
149
  yes: { type: "boolean", short: "y", default: false },
113
150
  },
114
151
  });
@@ -116,13 +153,13 @@ async function cmdTranslate(argv) {
116
153
  throw new Error("Missing --to. Example: locale-ai translate --from en --to fr,es");
117
154
  const from = parseLocale(values.from);
118
155
  const targets = values.to.split(",").map((t) => t.trim()).filter(Boolean).map(parseLocale);
119
- const batchSize = Math.max(1, Number(values["batch-size"]));
120
- const beams = Math.max(1, Number(values.beams ?? (values.fast ? 1 : 4)));
156
+ const batchSize = positiveInt(values["batch-size"], "batch-size");
157
+ const beams = positiveInt(values.beams, "beams", values.fast ? 1 : 4);
121
158
  const config = loadConfig(process.cwd(), values.config);
122
159
  const catalog = catalogFor(config);
123
160
  const protect = protectRegex(config);
124
161
  const layout = detectLayout(process.cwd(), from.tag, values.input);
125
- const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f) }));
162
+ const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag) }));
126
163
  log(`locale-ai ${VERSION}`);
127
164
  log(`Found ${sources.length} ${from.tag} file${sources.length === 1 ? "" : "s"} in ${rel(layout.root)}/` +
128
165
  (layout.kind === "dir" ? `${from.tag}/` : ""));
@@ -139,7 +176,7 @@ async function cmdTranslate(argv) {
139
176
  }
140
177
  const files = sources.map(({ file, doc }) => {
141
178
  const target = targetFile(layout, from.tag, locale.tag, file);
142
- const existing = fs.existsSync(target) ? readLocaleDoc(target).data : undefined;
179
+ const existing = fs.existsSync(target) ? readLocaleDoc(target, locale.tag).data : undefined;
143
180
  return { source: file, target, doc, existing, pending: pendingStrings(doc.data, existing, values.overwrite) };
144
181
  });
145
182
  const unique = new Set(files.flatMap((f) => f.pending)).size;
@@ -182,7 +219,7 @@ async function cmdTranslate(argv) {
182
219
  batchSize,
183
220
  protect,
184
221
  lookup: glossaryLookup(from, job.locale, config),
185
- onProgress: (d, t) => status(`${job.locale.tag}: translating ${bar(d, t)} ${d}/${t}`),
222
+ onProgress: makeProgress((d, t) => `${job.locale.tag}: translating ${bar(d, t)} ${d}/${t}`),
186
223
  });
187
224
  endStatus();
188
225
  translations = result.translations;
@@ -201,8 +238,8 @@ async function cmdTranslate(argv) {
201
238
  const built = buildTarget(f.doc.data, f.existing, (s) => translations.get(s) ?? s, values.overwrite);
202
239
  const { doc, added } = addPluralForms(built, intlCode(job.locale));
203
240
  pluralsAdded += added;
204
- const text = f.doc.serialize(doc, job.locale.tag);
205
241
  const before = fs.existsSync(f.target) ? fs.readFileSync(f.target, "utf8") : undefined;
242
+ const text = f.doc.serialize(doc, job.locale.tag, before);
206
243
  if (text !== before) {
207
244
  fs.mkdirSync(path.dirname(f.target), { recursive: true });
208
245
  fs.writeFileSync(f.target, text);
@@ -338,11 +375,70 @@ async function cmdModels(argv) {
338
375
  return sub ? 1 : 0;
339
376
  }
340
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
+ }
341
435
  async function main() {
342
436
  const [cmd, ...rest] = process.argv.slice(2);
343
437
  switch (cmd) {
344
438
  case "translate":
345
439
  return cmdTranslate(rest);
440
+ case "check":
441
+ return cmdCheck(rest);
346
442
  case "models":
347
443
  return cmdModels(rest);
348
444
  case "-v":
package/dist/engine.js CHANGED
@@ -137,21 +137,34 @@ async function attempt(units, style, hops, opts, progress, issues) {
137
137
  }
138
138
  let ok = true;
139
139
  const found = [];
140
- const joined = pieces
141
- .map((p) => {
140
+ const parts = await Promise.all(pieces.map(async (p) => {
142
141
  // No spaces between sentences in Chinese, Japanese, Thai...
143
142
  if (!p.translate)
144
143
  return NO_SPACES.test(opts.targetLang) ? p.value.replace(/^[ \t]+$/, "") : p.value;
145
- const out = results.get(prepare(p.value)) ?? "";
146
- if (isDegenerate(p.value, out))
144
+ let out = results.get(prepare(p.value)) ?? "";
145
+ const suspicionKind = (o) => o.length > 3 * p.value.length + 20 ? "suspicious-length" : /(\b\p{L}+\b)(?:\W+\1\b){3,}/u.test(o) ? "repetition" : undefined;
146
+ let degenerate = isDegenerate(p.value, out);
147
+ let kind = degenerate ? undefined : suspicionKind(out);
148
+ // Quantizing a batched tensor computes scale factors from the whole batch, so one
149
+ // row's output can depend on what else shared its batch. Retrying the same input
150
+ // alone (batch of 1) often clears a hallucination that batch composition caused.
151
+ if (degenerate || kind !== undefined) {
152
+ const retry = await runModels(new Set([prepare(p.value)]), hops, opts, progress);
153
+ const retryOut = retry.get(prepare(p.value)) ?? out;
154
+ const retryDegenerate = isDegenerate(p.value, retryOut);
155
+ if (!retryDegenerate && suspicionKind(retryOut) === undefined) {
156
+ out = retryOut;
157
+ degenerate = false;
158
+ kind = undefined;
159
+ }
160
+ }
161
+ if (degenerate)
147
162
  ok = false;
148
- else if (out.length > 3 * p.value.length + 20)
149
- found.push({ source: unit.owner, kind: "suspicious-length", output: out });
150
- else if (/(\b\p{L}+\b)(?:\W+\1\b){3,}/u.test(out))
151
- found.push({ source: unit.owner, kind: "repetition", output: out });
163
+ else if (kind)
164
+ found.push({ source: unit.owner, kind, output: out });
152
165
  return postprocess(p.value, out, opts.targetLang);
153
- })
154
- .join("");
166
+ }));
167
+ const joined = parts.join("");
155
168
  const restored = ok ? unmask(joined, masked) : undefined;
156
169
  if (restored === undefined)
157
170
  failed.push(unit);
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) {
@@ -96,17 +113,28 @@ function readJsonDoc(file, text) {
96
113
  const keepNewline = finalNewline(text);
97
114
  return { data, serialize: (d) => withNewline(JSON.stringify(d, null, indent), keepNewline) };
98
115
  }
99
- function readYamlDoc(file, text) {
100
- let data;
116
+ /**
117
+ * Rails and some other frameworks nest an entire YAML locale file under a single
118
+ * top-level key matching the locale tag itself (`en:\n hello: Hi`), so the file is
119
+ * valid wherever it's loaded from. Detect that wrapper and unwrap it before handing
120
+ * `data` to the rest of the pipeline (which otherwise treats "en" as just another
121
+ * translatable key), then re-wrap under the target's own tag on serialize.
122
+ */
123
+ function readYamlDoc(file, text, tag) {
124
+ let raw;
101
125
  try {
102
- data = parseYaml(text) ?? {};
126
+ raw = parseYaml(text) ?? {};
103
127
  }
104
128
  catch (e) {
105
129
  throw new Error(`Invalid YAML in ${file}: ${e.message}`);
106
130
  }
107
131
  const indent = Number(/\n( +)\S/.exec(text)?.[1].length) || 2;
108
132
  const keepNewline = finalNewline(text);
109
- return { data, serialize: (d) => withNewline(stringifyYaml(d, { indent }), keepNewline) };
133
+ const keys = isObj(raw) ? Object.keys(raw) : [];
134
+ const wrapped = keys.length === 1 && keys[0] === tag;
135
+ const data = wrapped ? raw[tag] : raw;
136
+ const serialize = (d, targetTag) => withNewline(stringifyYaml(wrapped ? { [targetTag]: d } : d, { indent }), keepNewline);
137
+ return { data, serialize };
110
138
  }
111
139
  /** Apple .strings: `"key" = "value";`, optionally preceded by a `/* ... *\/` comment. */
112
140
  function unescapeC(s) {
@@ -116,7 +144,8 @@ function escapeC(s) {
116
144
  return s.replace(/[\\"\n\t\r]/g, (c) => ({ "\\": "\\\\", '"': '\\"', "\n": "\\n", "\t": "\\t", "\r": "\\r" })[c]);
117
145
  }
118
146
  function readStringsDoc(file, text) {
119
- const PAIR = /(?:(\/\*[\s\S]*?\*\/)[ \t]*\r?\n+)?[ \t]*"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)"\s*;/g;
147
+ // A key's preceding comment is either a /* ... */ block, or one or more consecutive // lines.
148
+ const PAIR = /(?:((?:\/\*[\s\S]*?\*\/)|(?:\/\/[^\r\n]*(?:\r?\n[ \t]*\/\/[^\r\n]*)*))[ \t]*\r?\n+)?[ \t]*"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)"\s*;/g;
120
149
  const data = newObj();
121
150
  const entries = [];
122
151
  for (const m of text.matchAll(PAIR)) {
@@ -182,6 +211,58 @@ function readArbDoc(file, text) {
182
211
  return { data, serialize };
183
212
  }
184
213
  const poKey = (e) => (e.msgctxt ?? "") + "\u0004" + e.msgid;
214
+ /**
215
+ * gettext's well-established Plural-Forms formulas, keyed by language. Copying the
216
+ * source header's line verbatim (as a naive patch does) is wrong whenever the target
217
+ * needs a different count — Arabic needs 6 categories, an English source's line says
218
+ * 2, and anything consuming the .po indexes msgstr[] short or drops forms silently.
219
+ */
220
+ const PLURAL_FORMS = {
221
+ ja: "nplurals=1; plural=0;",
222
+ zh: "nplurals=1; plural=0;",
223
+ ko: "nplurals=1; plural=0;",
224
+ th: "nplurals=1; plural=0;",
225
+ vi: "nplurals=1; plural=0;",
226
+ id: "nplurals=1; plural=0;",
227
+ ms: "nplurals=1; plural=0;",
228
+ fr: "nplurals=2; plural=(n > 1);",
229
+ pt: "nplurals=2; plural=(n != 1);",
230
+ cs: "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;",
231
+ sk: "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;",
232
+ pl: "nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);",
233
+ ru: "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);",
234
+ uk: "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);",
235
+ ro: "nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2);",
236
+ ar: "nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);",
237
+ };
238
+ /**
239
+ * Falls back to CLDR's plural category count (via Intl.PluralRules) for a language
240
+ * not in the table above: not necessarily linguistically exact (it buckets purely by
241
+ * small integer value), but it keeps nplurals correct, which is the part that breaks
242
+ * gettext consumers when wrong.
243
+ */
244
+ function pluralFormsHeader(lang) {
245
+ const known = PLURAL_FORMS[lang];
246
+ if (known)
247
+ return known;
248
+ let n;
249
+ try {
250
+ n = new Intl.PluralRules(lang).resolvedOptions().pluralCategories.length;
251
+ }
252
+ catch {
253
+ n = 2;
254
+ }
255
+ if (n <= 1)
256
+ return "nplurals=1; plural=0;";
257
+ if (n === 2)
258
+ return "nplurals=2; plural=(n != 1);";
259
+ const branches = Array.from({ length: n - 1 }, (_, i) => `n==${i + 1} ? ${i} :`).join(" ");
260
+ return `nplurals=${n}; plural=(${branches} ${n - 1});`;
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
+ }
185
266
  function parsePo(text) {
186
267
  const entries = [];
187
268
  let leading = [];
@@ -258,29 +339,62 @@ function parsePo(text) {
258
339
  flushEntry();
259
340
  return { entries, leading };
260
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";
261
345
  function readPoDoc(file, text) {
262
346
  const { entries, leading } = parsePo(text);
263
347
  const data = newObj();
264
- for (const e of entries)
265
- if (!e.isHeader && e.msgidPlural === undefined)
348
+ for (const e of entries) {
349
+ if (e.isHeader)
350
+ continue;
351
+ if (e.msgidPlural === undefined)
266
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
+ }
267
362
  const keepNewline = finalNewline(text);
268
- const serialize = (d, targetTag) => {
363
+ const serialize = (d, targetTag, existingText) => {
269
364
  const obj = isObj(d) ? d : newObj();
270
365
  const seen = new Set();
271
366
  const blocks = leading.length ? [leading.join("\n")] : [];
367
+ // A previous run may have left a header a translator then hand-fixed (a corrected
368
+ // Plural-Forms, an added Last-Translator, ...). Keep it as-is rather than
369
+ // regenerating from the source every time; only a brand-new target file gets a
370
+ // freshly computed header.
371
+ const existingHeader = existingText && parsePo(existingText).entries.find((e) => e.isHeader);
272
372
  for (const e of entries) {
273
373
  const lines = [...e.comments];
274
374
  if (e.msgctxt !== undefined)
275
375
  lines.push(`msgctxt "${escapeC(e.msgctxt)}"`);
276
376
  if (e.isHeader) {
277
377
  lines.push(`msgid ""`);
278
- const header = e.msgstr.replace(/^Language:.*$/m, `Language: ${targetTag}`);
378
+ const header = existingHeader
379
+ ? existingHeader.msgstr
380
+ : e.msgstr.replace(/^Language:.*$/m, `Language: ${targetTag}`).replace(/^Plural-Forms:.*$/m, `Plural-Forms: ${pluralFormsHeader(targetTag)}`);
279
381
  lines.push(`msgstr "${escapeC(header)}"`);
280
382
  }
281
383
  else if (e.msgidPlural !== undefined) {
282
384
  lines.push(`msgid "${escapeC(e.msgid)}"`, `msgid_plural "${escapeC(e.msgidPlural)}"`);
283
- (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)}"`);
284
398
  }
285
399
  else {
286
400
  const key = poKey(e);
@@ -304,11 +418,12 @@ function readPoDoc(file, text) {
304
418
  };
305
419
  return { data, serialize };
306
420
  }
307
- export function readLocaleDoc(file) {
421
+ /** `tag` is the locale this file is for (the source tag, or an existing target's own tag). */
422
+ export function readLocaleDoc(file, tag) {
308
423
  const text = fs.readFileSync(file, "utf8").replace(/^/, "");
309
424
  const ext = path.extname(file).toLowerCase();
310
425
  if (ext === ".yaml" || ext === ".yml")
311
- return readYamlDoc(file, text);
426
+ return readYamlDoc(file, text, tag);
312
427
  if (ext === ".po")
313
428
  return readPoDoc(file, text);
314
429
  if (ext === ".arb")
@@ -322,6 +437,17 @@ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
322
437
  const newObj = () => Object.create(null);
323
438
  /** A string needs translating when the target has no non-empty string at the same path. */
324
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
+ }
325
451
  export function pendingStrings(source, existing, overwrite, out = []) {
326
452
  if (typeof source === "string") {
327
453
  if (needsTranslation(existing, overwrite))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locale-ai",
3
- "version": "0.6.2",
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": {