locale-ai 0.6.4 → 0.6.5

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
@@ -31,7 +31,7 @@ Download 577 MB? (Y/n)
31
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`.
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
- - **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.
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**: 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
 
@@ -69,6 +69,7 @@ Models are cached once per machine in `~/.cache/locale-ai` (`LOCALE_AI_CACHE` to
69
69
  ```json
70
70
  {
71
71
  "doNotTranslate": ["Acme", "Acme Pro"],
72
+ "doNotTranslateKeys": ["brand.name", "config.statusEnum"],
72
73
  "glossary": {
73
74
  "fr": { "Workspace": "Espace de travail" },
74
75
  "de": { "Sign in": "Einloggen" }
@@ -78,6 +79,8 @@ Models are cached once per machine in `~/.cache/locale-ai` (`LOCALE_AI_CACHE` to
78
79
 
79
80
  `glossary` entries replace whole strings. `models` adds your own translation models (next section). A built-in glossary covers ~60 standard UI labels ("Sign in", "Settings", "Log out", ...) for fr, es, de, it, pt-BR, nl and ar, because one-to-three word labels are where sentence-level MT is weakest.
80
81
 
82
+ `doNotTranslate` protects a term everywhere it appears in any string. `doNotTranslateKeys` instead protects a key path (dot/`[i]` notation, e.g. `brand.name`, `items[0]`) — the whole value at that path, and everything nested under it if it's an object or array, is copied to every target verbatim. Use it for an enum array (`"statusEnum": ["ACTIVE", "PENDING"]`, which you'd otherwise have to list value-by-value in `doNotTranslate`) or a field a model might respell or inflect even when the word is listed in `doNotTranslate` elsewhere in the same string.
83
+
81
84
  ## Add a language, or a better model
82
85
 
83
86
  Any [MarianMT / OPUS-MT](https://huggingface.co/Helsinki-NLP) model on Hugging Face can be used. Convert it once (needs Python):
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ 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, existingTargets, flattenStrings, pendingStrings, readLocaleDoc, sourceFiles, targetFile, } from "./locales.js";
12
+ import { addPluralForms, buildTarget, detectLayout, existingTargets, flattenStrings, keyPathProtect, pendingStrings, pluralOneStrings, readLocaleDoc, sourceFiles, targetFile, } from "./locales.js";
13
13
  import { cacheRoot, install, isInstalled, loadTranslator, localModelProblem, planInstall, readManifest, uninstall, } from "./models.js";
14
14
  import { mask } from "./placeholders.js";
15
15
  /** Read from package.json so `npm version` is the single source of truth. */
@@ -158,8 +158,10 @@ async function cmdTranslate(argv) {
158
158
  const config = loadConfig(process.cwd(), values.config);
159
159
  const catalog = catalogFor(config);
160
160
  const protect = protectRegex(config);
161
+ const isProtectedKey = keyPathProtect(config.doNotTranslateKeys);
161
162
  const layout = detectLayout(process.cwd(), from.tag, values.input);
162
163
  const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag) }));
164
+ const singularHints = new Set(sources.flatMap(({ doc }) => [...pluralOneStrings(doc.data)]));
163
165
  log(`locale-ai ${VERSION}`);
164
166
  log(`Found ${sources.length} ${from.tag} file${sources.length === 1 ? "" : "s"} in ${rel(layout.root)}/` +
165
167
  (layout.kind === "dir" ? `${from.tag}/` : ""));
@@ -177,7 +179,13 @@ async function cmdTranslate(argv) {
177
179
  const files = sources.map(({ file, doc }) => {
178
180
  const target = targetFile(layout, from.tag, locale.tag, file);
179
181
  const existing = fs.existsSync(target) ? readLocaleDoc(target, locale.tag).data : undefined;
180
- return { source: file, target, doc, existing, pending: pendingStrings(doc.data, existing, values.overwrite) };
182
+ return {
183
+ source: file,
184
+ target,
185
+ doc,
186
+ existing,
187
+ pending: pendingStrings(doc.data, existing, values.overwrite, isProtectedKey),
188
+ };
181
189
  });
182
190
  const unique = new Set(files.flatMap((f) => f.pending)).size;
183
191
  jobs.push({ locale, route, files, unique });
@@ -219,6 +227,7 @@ async function cmdTranslate(argv) {
219
227
  batchSize,
220
228
  protect,
221
229
  lookup: glossaryLookup(from, job.locale, config),
230
+ singularHints,
222
231
  onProgress: makeProgress((d, t) => `${job.locale.tag}: translating ${bar(d, t)} ${d}/${t}`),
223
232
  });
224
233
  endStatus();
@@ -235,7 +244,7 @@ async function cmdTranslate(argv) {
235
244
  let written = 0;
236
245
  let pluralsAdded = 0;
237
246
  for (const f of job.files) {
238
- const built = buildTarget(f.doc.data, f.existing, (s) => translations.get(s) ?? s, values.overwrite);
247
+ const built = buildTarget(f.doc.data, f.existing, (s) => translations.get(s) ?? s, values.overwrite, isProtectedKey);
239
248
  const { doc, added } = addPluralForms(built, intlCode(job.locale));
240
249
  pluralsAdded += added;
241
250
  const before = fs.existsSync(f.target) ? fs.readFileSync(f.target, "utf8") : undefined;
package/dist/engine.js CHANGED
@@ -208,6 +208,36 @@ async function splitFallback(units, hops, opts, progress, issues) {
208
208
  issues.push({ source: unit.owner, kind: "placeholder-fallback", output: unit.result });
209
209
  }
210
210
  }
211
+ const COUNT_PLACEHOLDER = /\{\{\s*count\s*\}\}/;
212
+ /**
213
+ * A "_one" key's source is grammatically singular ("You have {{count}} file"), but the
214
+ * masked marker that stands in for {{count}} carries no number of its own, so the model
215
+ * sometimes still produces the target's PLURAL noun form (confirmed: en→fr "fileCount_one"
216
+ * came back "Vous avez {{count}} fichiers"). Retranslating with a literal "1" in place of
217
+ * {{count}} gives the model the real grammatical cue; only applied when {{count}} is the
218
+ * string's only placeholder (anything else needs the normal masking pipeline to survive),
219
+ * and only kept when the "1" survives translation intact enough to restore {{count}}.
220
+ */
221
+ async function fixSingularGrammar(translations, singularHints, remaining, hops, opts, progress) {
222
+ for (const s of singularHints) {
223
+ if (!remaining.has(s) || translations.get(s) === undefined)
224
+ continue;
225
+ const tokens = mask(s, "natural").tokens;
226
+ if (tokens.length !== 1 || !COUNT_PLACEHOLDER.test(tokens[0]))
227
+ continue;
228
+ const m = COUNT_PLACEHOLDER.exec(s);
229
+ const pinned = s.slice(0, m.index) + "1" + s.slice(m.index + m[0].length);
230
+ const prepared = prepare(pinned);
231
+ const out = (await runModels(new Set([prepared]), hops, opts, progress)).get(prepared) ?? "";
232
+ if (isDegenerate(pinned, out))
233
+ continue;
234
+ const post = postprocess(pinned, out, opts.targetLang);
235
+ const restored = /(?<![\d.])1(?![\d.])/.exec(post);
236
+ if (!restored)
237
+ continue; // can't confirm the "1" survived: keep the original translation
238
+ translations.set(s, post.slice(0, restored.index) + m[0] + post.slice(restored.index + 1));
239
+ }
240
+ }
211
241
  export async function translateStrings(strings, hops, opts) {
212
242
  const issues = [];
213
243
  const progress = { done: 0, total: 0 };
@@ -236,5 +266,7 @@ export async function translateStrings(strings, hops, opts) {
236
266
  const parts = p.units.map((u) => u.result ?? u.text);
237
267
  translations.set(p.source, p.split ? p.split.rebuild(parts) : parts[0]);
238
268
  }
269
+ if (opts.singularHints?.size)
270
+ await fixSingularGrammar(translations, opts.singularHints, new Set(remaining), hops, opts, progress);
239
271
  return { translations, issues };
240
272
  }
package/dist/glossary.js CHANGED
@@ -84,6 +84,8 @@ export function loadConfig(cwd, file) {
84
84
  const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
85
85
  if (cfg.doNotTranslate && !Array.isArray(cfg.doNotTranslate))
86
86
  throw new Error('"doNotTranslate" must be an array');
87
+ if (cfg.doNotTranslateKeys && !Array.isArray(cfg.doNotTranslateKeys))
88
+ throw new Error('"doNotTranslateKeys" must be an array');
87
89
  if (cfg.models && !Array.isArray(cfg.models))
88
90
  throw new Error('"models" must be an array');
89
91
  return { ...cfg, dir: path.dirname(p) };
package/dist/locales.js CHANGED
@@ -448,47 +448,93 @@ export function flattenStrings(data, prefix = "", out = []) {
448
448
  flattenStrings(data[k], prefix ? `${prefix}.${k}` : k, out);
449
449
  return out;
450
450
  }
451
- export function pendingStrings(source, existing, overwrite, out = []) {
452
- if (typeof source === "string") {
453
- if (needsTranslation(existing, overwrite))
454
- out.push(source);
455
- }
456
- else if (Array.isArray(source)) {
457
- source.forEach((v, i) => pendingStrings(v, Array.isArray(existing) ? existing[i] : undefined, overwrite, out));
458
- }
459
- else if (isObj(source)) {
460
- for (const k of Object.keys(source))
461
- pendingStrings(source[k], isObj(existing) ? existing[k] : undefined, overwrite, out);
462
- }
451
+ /**
452
+ * True when `path` is, or is nested under, one of `doNotTranslateKeys` — the same
453
+ * dot/`[i]` notation flattenStrings produces (e.g. "brand.name", "config.statusEnum").
454
+ * Protecting a path that points at an object or array protects every string nested
455
+ * under it too, which is what makes an enum array ("statusEnum": ["ACTIVE", ...])
456
+ * protectable without enumerating each value as a doNotTranslate term.
457
+ */
458
+ export function keyPathProtect(doNotTranslateKeys) {
459
+ const keys = (doNotTranslateKeys ?? []).filter(Boolean);
460
+ if (!keys.length)
461
+ return undefined;
462
+ return (path) => keys.some((k) => path === k || path.startsWith(`${k}.`) || path.startsWith(`${k}[`));
463
+ }
464
+ export function pendingStrings(source, existing, overwrite, isProtected) {
465
+ const out = [];
466
+ const visit = (s, e, path) => {
467
+ if (typeof s === "string") {
468
+ if (!isProtected?.(path) && needsTranslation(e, overwrite))
469
+ out.push(s);
470
+ }
471
+ else if (Array.isArray(s)) {
472
+ s.forEach((v, i) => visit(v, Array.isArray(e) ? e[i] : undefined, `${path}[${i}]`));
473
+ }
474
+ else if (isObj(s)) {
475
+ for (const k of Object.keys(s))
476
+ visit(s[k], isObj(e) ? e[k] : undefined, path ? `${path}.${k}` : k);
477
+ }
478
+ };
479
+ visit(source, existing, "");
463
480
  return out;
464
481
  }
465
482
  /**
466
483
  * Builds the target document: source structure and key order, existing
467
484
  * translations kept (unless overwrite), new strings from `translate`, and keys
468
- * that only exist in the target preserved at the end.
485
+ * that only exist in the target preserved at the end. A string at a protected
486
+ * path (see keyPathProtect) is always copied from the source verbatim.
469
487
  */
470
- export function buildTarget(source, existing, translate, overwrite) {
471
- if (typeof source === "string") {
472
- return needsTranslation(existing, overwrite) ? translate(source) : existing;
473
- }
474
- if (Array.isArray(source)) {
475
- return source.map((v, i) => buildTarget(v, Array.isArray(existing) ? existing[i] : undefined, translate, overwrite));
476
- }
477
- if (isObj(source)) {
478
- const out = newObj();
479
- for (const k of Object.keys(source)) {
480
- out[k] = buildTarget(source[k], isObj(existing) ? existing[k] : undefined, translate, overwrite);
488
+ export function buildTarget(source, existing, translate, overwrite, isProtected) {
489
+ const visit = (s, e, path) => {
490
+ if (typeof s === "string") {
491
+ if (isProtected?.(path))
492
+ return s;
493
+ return needsTranslation(e, overwrite) ? translate(s) : e;
481
494
  }
482
- if (isObj(existing))
483
- for (const k of Object.keys(existing))
484
- if (!(k in out))
485
- out[k] = existing[k];
486
- return out;
487
- }
488
- return source;
495
+ if (Array.isArray(s))
496
+ return s.map((v, i) => visit(v, Array.isArray(e) ? e[i] : undefined, `${path}[${i}]`));
497
+ if (isObj(s)) {
498
+ const out = newObj();
499
+ for (const k of Object.keys(s))
500
+ out[k] = visit(s[k], isObj(e) ? e[k] : undefined, path ? `${path}.${k}` : k);
501
+ if (isObj(e))
502
+ for (const k of Object.keys(e))
503
+ if (!(k in out))
504
+ out[k] = e[k];
505
+ return out;
506
+ }
507
+ return s;
508
+ };
509
+ return visit(source, existing, "");
489
510
  }
490
511
  const CATEGORIES = ["zero", "one", "two", "few", "many", "other"];
491
512
  const PLURAL_KEY = /^(.+?)(_ordinal)?_(zero|one|two|few|many|other)$/;
513
+ /**
514
+ * Source strings at a cardinal i18next "_one" plural key ("itemCount_one": "{{count}}
515
+ * item"). These are grammatically singular in English, but the model sometimes infers
516
+ * the TARGET's plural form anyway because {{count}} reads as an opaque marker rather
517
+ * than a concrete number — the engine uses this set to retranslate with a real "1" in
518
+ * place of {{count}} and recover the correct grammatical number (see engine.ts).
519
+ */
520
+ export function pluralOneStrings(data, out = new Set()) {
521
+ const visit = (v) => {
522
+ if (Array.isArray(v)) {
523
+ v.forEach(visit);
524
+ return;
525
+ }
526
+ if (!isObj(v))
527
+ return;
528
+ for (const k of Object.keys(v)) {
529
+ const m = PLURAL_KEY.exec(k);
530
+ if (m && !m[2] && m[3] === "one" && typeof v[k] === "string")
531
+ out.add(v[k]);
532
+ visit(v[k]);
533
+ }
534
+ };
535
+ visit(data);
536
+ return out;
537
+ }
492
538
  /**
493
539
  * i18next stores plurals as `key_one` / `key_other`. Languages such as Arabic,
494
540
  * Russian or Polish need more forms (`_few`, `_many` ...) and i18next does not fall
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locale-ai",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Translate your i18n locale files locally on CPU. Free, offline, no API keys.",
5
5
  "author": "Taha KHABOUSS",
6
6
  "repository": {