locale-ai 0.6.6 → 0.6.7

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
@@ -51,6 +51,7 @@ Download 577 MB? (Y/n)
51
51
  ```bash
52
52
  locale-ai translate --to fr,pt-BR [--from en] [--input dir] [--dry-run] [--overwrite] [--fast] [--report file] [-y]
53
53
  locale-ai check [--from en] [--input dir] # CI: fails if a translated locale has missing keys or placeholder mismatches
54
+ # (also warns, without failing, about strings left identical to the source)
54
55
  locale-ai models list [--from en] # supported language pairs
55
56
  locale-ai models installed
56
57
  locale-ai models install en-de # pre-download (e.g. in CI)
@@ -62,6 +63,8 @@ locale-ai models convert <hf-marian-model> --out <dir> # bring your own model
62
63
 
63
64
  Models are cached once per machine in `~/.cache/locale-ai` (`LOCALE_AI_CACHE` to change). In CI, pass `--yes`.
64
65
 
66
+ Installing trims the `onnxruntime-node` dependency down to the one CPU binary locale-ai actually loads (it ships binaries for every platform/arch plus GPU providers locale-ai never uses) — but only when nothing else in your `node_modules` also depends on it, so a shared copy another package needs for GPU inference is left alone. Set `LOCALE_AI_SKIP_TRIM=1` to disable this entirely.
67
+
65
68
  ## Project config (optional)
66
69
 
67
70
  `locale-ai.config.json` in the project root:
package/dist/cli.js CHANGED
@@ -10,9 +10,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
12
  import { editedPaths, isSourceStale, LOCK_FILENAME, loadLock, recordEntries, saveLock } from "./lock.js";
13
- import { addPluralForms, buildTarget, detectLayout, existingTargets, flattenStrings, keyPathProtect, pendingStrings, pluralOneStrings, readLocaleDoc, sourceFiles, targetFile, } from "./locales.js";
13
+ import { addPluralForms, buildTarget, detectLayout, existingTargets, flattenStrings, keyPathProtect, pendingStrings, pluralOneStrings, readLocaleDoc, sourceFiles, targetFile, usesI18nextPlurals, } from "./locales.js";
14
14
  import { cacheRoot, install, isInstalled, loadTranslator, localModelProblem, planInstall, readManifest, uninstall, } from "./models.js";
15
- import { mask } from "./placeholders.js";
15
+ import { hasTranslatableText, mask } from "./placeholders.js";
16
16
  /** Read from package.json so `npm version` is the single source of truth. */
17
17
  const VERSION = JSON.parse(fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
18
18
  const HELP = `locale-ai ${VERSION} — translate i18n locale files locally, on CPU, for free.
@@ -258,16 +258,16 @@ async function cmdTranslate(argv) {
258
258
  }
259
259
  }
260
260
  const built = buildTarget(f.doc.data, f.existing, (s) => translations.get(s) ?? s, values.overwrite, isProtectedKey, isStale);
261
- const { doc, added } = addPluralForms(built, intlCode(job.locale));
262
- pluralsAdded += added;
261
+ const plural = usesI18nextPlurals(f.source) ? addPluralForms(built, intlCode(job.locale)) : { doc: built, added: 0 };
262
+ pluralsAdded += plural.added;
263
263
  const before = fs.existsSync(f.target) ? fs.readFileSync(f.target, "utf8") : undefined;
264
- const text = f.doc.serialize(doc, job.locale.tag, before);
264
+ const text = f.doc.serialize(plural.doc, job.locale.tag, before);
265
265
  if (text !== before) {
266
266
  fs.mkdirSync(path.dirname(f.target), { recursive: true });
267
267
  fs.writeFileSync(f.target, text);
268
268
  written++;
269
269
  }
270
- const outFlat = new Map(flattenStrings(doc).map((e) => [e.path, e.value]));
270
+ const outFlat = new Map(flattenStrings(plural.doc).map((e) => [e.path, e.value]));
271
271
  recordEntries(lock, lockKey, job.locale.tag, flattenStrings(f.doc.data).map(({ path: p, value }) => ({ path: p, source: value, written: outFlat.get(p) ?? "" })));
272
272
  }
273
273
  const secs = ((Date.now() - started) / 1000).toFixed(1);
@@ -424,9 +424,11 @@ async function cmdCheck(argv) {
424
424
  return 0;
425
425
  }
426
426
  let problems = 0;
427
+ let identicalTotal = 0;
427
428
  for (const to of targets) {
428
429
  let missing = 0;
429
430
  let mismatched = 0;
431
+ let identical = 0;
430
432
  for (const { file, doc } of sources) {
431
433
  const target = targetFile(layout, from.tag, to, file);
432
434
  if (!fs.existsSync(target)) {
@@ -443,19 +445,27 @@ async function cmdCheck(argv) {
443
445
  log(`✗ ${to}: ${rel(target)} missing key "${p}"`);
444
446
  continue;
445
447
  }
446
- const srcTokens = mask(srcVal, "natural").tokens.toSorted();
448
+ const srcMasked = mask(srcVal, "natural");
447
449
  const tgtTokens = mask(tgtVal, "natural").tokens.toSorted();
448
- if (srcTokens.join("\u0001") !== tgtTokens.join("\u0001")) {
450
+ if (srcMasked.tokens.toSorted().join("\u0001") !== tgtTokens.join("\u0001")) {
449
451
  mismatched++;
450
- log(`✗ ${to}: ${rel(target)} "${p}" placeholders differ: source [${srcTokens.join(", ")}], target [${tgtTokens.join(", ")}]`);
452
+ log(`✗ ${to}: ${rel(target)} "${p}" placeholders differ: source [${srcMasked.tokens.toSorted().join(", ")}], target [${tgtTokens.join(", ")}]`);
453
+ continue;
454
+ }
455
+ if (tgtVal === srcVal && hasTranslatableText(srcMasked.text)) {
456
+ identical++;
457
+ log(`⚠ ${to}: ${rel(target)} "${p}" is untouched, identical to the ${from.tag} source`);
451
458
  }
452
459
  }
453
460
  }
461
+ identicalTotal += identical;
454
462
  if (missing || mismatched)
455
463
  problems++;
456
464
  else
457
- log(`✓ ${to}`);
465
+ log(`✓ ${to}${identical ? ` (${identical} left identical to ${from.tag})` : ""}`);
458
466
  }
467
+ if (identicalTotal)
468
+ log(`\n${identicalTotal} string${identicalTotal === 1 ? "" : "s"} left identical to the source — not a failure, but worth a look.`);
459
469
  return problems ? 1 : 0;
460
470
  }
461
471
  async function main() {
package/dist/locales.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
+ import { parseLocale } from "./lang.js";
4
5
  const EXTENSIONS = [".json", ".yaml", ".yml", ".po", ".arb", ".strings"];
5
6
  const CANDIDATES = [
6
7
  "locales",
@@ -82,6 +83,16 @@ export function targetFile(layout, from, to, src) {
82
83
  return path.join(layout.root, `${to}${layout.ext}`);
83
84
  return path.join(layout.root, to, path.relative(path.join(layout.root, from), src));
84
85
  }
86
+ /** True when `tag` parses as a locale (e.g. "fr", "pt-BR") — rejects names like "__mocks__" or "schemas". */
87
+ function looksLikeLocale(tag) {
88
+ try {
89
+ parseLocale(tag);
90
+ return true;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
85
96
  /** Locale tags already translated alongside `from` in this layout (for `locale-ai check`). */
86
97
  export function existingTargets(layout, from) {
87
98
  if (layout.kind === "file") {
@@ -90,12 +101,14 @@ export function existingTargets(layout, from) {
90
101
  .readdirSync(layout.root)
91
102
  .filter((f) => f.endsWith(ext) && f.slice(0, -ext.length) !== from)
92
103
  .map((f) => f.slice(0, -ext.length))
104
+ .filter(looksLikeLocale)
93
105
  .sort();
94
106
  }
95
107
  return fs
96
108
  .readdirSync(layout.root, { withFileTypes: true })
97
109
  .filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && e.name !== from)
98
110
  .map((e) => e.name)
111
+ .filter(looksLikeLocale)
99
112
  .filter((name) => listLocaleFiles(path.join(layout.root, name)).length > 0)
100
113
  .sort();
101
114
  }
@@ -538,6 +551,15 @@ export function pluralOneStrings(data, out = new Set()) {
538
551
  visit(data);
539
552
  return out;
540
553
  }
554
+ /**
555
+ * True for the file extensions that use i18next's `key_one`/`key_other` plural-key
556
+ * convention (flat JSON/YAML locale trees). A `.po`, `.arb` or `.strings` file might
557
+ * happen to have a key ending the same way without meaning it as an i18next plural,
558
+ * so addPluralForms should only ever run on these.
559
+ */
560
+ export function usesI18nextPlurals(file) {
561
+ return /\.(json|ya?ml)$/i.test(file);
562
+ }
541
563
  /**
542
564
  * i18next stores plurals as `key_one` / `key_other`. Languages such as Arabic,
543
565
  * 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.6",
3
+ "version": "0.6.7",
4
4
  "description": "Translate your i18n locale files locally on CPU. Free, offline, no API keys.",
5
5
  "author": "Taha KHABOUSS",
6
6
  "repository": {
@@ -9,6 +9,15 @@
9
9
  // onnxruntime-node has finished installing, shrinking a ~550 MB unpacked
10
10
  // dependency down to the one binary locale-ai actually loads.
11
11
  //
12
+ // Node's resolution can hoist onnxruntime-node to a node_modules shared with
13
+ // other packages, so this only trims when nothing else next to it in that
14
+ // same node_modules also depends on it — another consumer there might need a
15
+ // GPU provider, or run on a different platform than this machine (e.g. a
16
+ // node_modules folder built here and later mounted into a container). Set
17
+ // LOCALE_AI_SKIP_TRIM=1 to disable this entirely, e.g. if node_modules is
18
+ // going to be reused somewhere this check can't see (a different machine, a
19
+ // Docker image built from it later).
20
+ //
12
21
  // Best-effort: never fails the install, even if onnxruntime-node changes
13
22
  // its layout in a future version.
14
23
 
@@ -23,9 +32,44 @@ function rm(p) {
23
32
  }
24
33
  }
25
34
 
35
+ /** Any OTHER package in `nodeModulesDir` that itself depends on onnxruntime-node. */
36
+ function hasOtherConsumer(nodeModulesDir) {
37
+ let entries;
38
+ try {
39
+ entries = fs.readdirSync(nodeModulesDir, { withFileTypes: true });
40
+ } catch {
41
+ return false;
42
+ }
43
+ for (const e of entries) {
44
+ if (!e.isDirectory() || e.name === 'onnxruntime-node' || e.name === '.bin') continue;
45
+ const names = e.name.startsWith('@')
46
+ ? fs.readdirSync(path.join(nodeModulesDir, e.name), { withFileTypes: true })
47
+ .filter((s) => s.isDirectory())
48
+ .map((s) => `${e.name}/${s.name}`)
49
+ : [e.name];
50
+ for (const name of names) {
51
+ if (name === 'locale-ai') continue;
52
+ try {
53
+ const pkg = JSON.parse(fs.readFileSync(path.join(nodeModulesDir, name, 'package.json'), 'utf8'));
54
+ const deps = { ...pkg.dependencies, ...pkg.optionalDependencies, ...pkg.peerDependencies };
55
+ if (deps && 'onnxruntime-node' in deps) return true;
56
+ } catch {
57
+ // Not a real package (or unreadable) — ignore.
58
+ }
59
+ }
60
+ }
61
+ return false;
62
+ }
63
+
64
+ if (process.env.LOCALE_AI_SKIP_TRIM) process.exit(0);
65
+
26
66
  try {
27
67
  const pkgJson = require.resolve('onnxruntime-node/package.json');
28
- const binRoot = path.join(path.dirname(pkgJson), 'bin', 'napi-v6');
68
+ const onnxRoot = path.dirname(pkgJson);
69
+ const nodeModulesDir = path.dirname(onnxRoot);
70
+ if (hasOtherConsumer(nodeModulesDir)) process.exit(0);
71
+
72
+ const binRoot = path.join(onnxRoot, 'bin', 'napi-v6');
29
73
  if (!fs.existsSync(binRoot)) process.exit(0);
30
74
 
31
75
  const platform = process.platform;