locale-ai 0.3.0 → 0.4.0

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
@@ -6,7 +6,7 @@
6
6
  [![license](https://img.shields.io/npm/l/locale-ai.svg)](LICENSE)
7
7
  [![node](https://img.shields.io/node/v/locale-ai.svg)](package.json)
8
8
 
9
- Translate your i18n JSON locale files **locally, on CPU, for free**. No API keys, no Python, nothing leaves your machine.
9
+ Translate your i18n JSON/YAML locale files **locally, on CPU, for free**. No API keys, no Python, nothing leaves your machine.
10
10
 
11
11
  ```bash
12
12
  npx locale-ai translate --from en --to fr,es,de
@@ -26,7 +26,7 @@ Download 577 MB? (Y/n)
26
26
 
27
27
  ## What it handles
28
28
 
29
- - **Layouts**: `locales/en/*.json` (nested folders too) → `locales/fr/...`, or `messages/en.json` → `messages/fr.json`. Common folders (`locales`, `src/locales`, `public/locales`, `src/i18n`, `messages`, `lang`, ...) are found automatically, or pass `--input`.
29
+ - **Layouts**: `locales/en/*.json` (nested folders too) → `locales/fr/...`, or `messages/en.json` → `messages/fr.json`. `.json`, `.yaml` and `.yml` 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`.
30
30
  - **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.
31
31
  - **ICU MessageFormat**: `{count, plural, one {# file} other {# files}}`, `select`, nesting. Only the text is translated.
32
32
  - **i18next plurals**: adds the forms the target language needs (`_few`, `_many` for Arabic, Russian, Polish...).
@@ -135,6 +135,7 @@ Measured on a sample app (see `test/fixtures`):
135
135
  - **Sentence splitting**: OPUS-MT drops sentences from multi-sentence inputs, so each sentence is translated separately.
136
136
  - **Placeholder masking** with three marker styles tried in order (`{0}` / `<0>…</0>`, then numbers `900`, then `_P0_`), validating every marker; numbers survive models that mangle braces (en-zh, en-ru). A last-resort fallback translates around placeholders, and anything that needed it is listed for review.
137
137
  - **Speed** (20-core CPU, ~1 GB RAM): sample app (43 strings) in ~3 s per language with beam search, ~2 s with `--fast`; full sentences at ~18/s (built-in, beam 4), ~12/s (tc-big, beam 4), ~33/s (tc-big, `--fast`).
138
+ - **Install size**: `onnxruntime-node` bundles binaries for every platform, and on linux/x64 downloads a ~260 MB CUDA provider by default — locale-ai only ever runs on CPU (`executionProviders: ["cpu"]`), so a `postinstall` script ([`scripts/trim-onnxruntime.cjs`](scripts/trim-onnxruntime.cjs)) removes the unused platforms and GPU providers after install, cutting it from ~550 MB to ~45 MB on your current platform.
138
139
 
139
140
  ## Development
140
141
 
@@ -149,7 +150,5 @@ npm run build # → dist/
149
150
 
150
151
  ## Next steps
151
152
 
152
- 1. **Host the tc-big models** (converted ONNX) on Hugging Face so they can become the built-in defaults instead of a manual conversion. `en-fr` is done ([khabouss/opus-mt-tc-big-en-fr-onnx](https://huggingface.co/khabouss/opus-mt-tc-big-en-fr-onnx)); es, it, pt, ar, ru/uk, tr remain.
153
- 2. **Smaller install.** `onnxruntime-node` is ~550 MB unpacked: it ships binaries for every platform plus a 260 MB CUDA provider we never use.
154
- 3. **More languages**: ja, ko, pl have no usable OPUS-MT model yet; a different model family would be needed.
155
- 4. **More formats**: YAML, `.po`, ARB, Apple `.strings`.
153
+ 1. **More languages**: ja, ko, pl have no usable OPUS-MT model yet; a different model family would be needed.
154
+ 2. **More formats**: `.po` (gettext), ARB (Flutter), Apple `.strings`. (JSON and YAML are supported today.)
package/dist/cli.js CHANGED
@@ -9,11 +9,11 @@ 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, readJson, sourceFiles, targetFile, } from "./locales.js";
12
+ import { addPluralForms, buildTarget, detectLayout, pendingStrings, readLocaleDoc, sourceFiles, targetFile, } from "./locales.js";
13
13
  import { cacheRoot, install, isInstalled, loadTranslator, localModelProblem, planInstall, readManifest, uninstall, } from "./models.js";
14
14
  /** Read from package.json so `npm version` is the single source of truth. */
15
15
  const VERSION = JSON.parse(fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
16
- const HELP = `locale-ai ${VERSION} — translate i18n JSON locale files locally, on CPU, for free.
16
+ const HELP = `locale-ai ${VERSION} — translate i18n JSON/YAML locale files locally, on CPU, for free.
17
17
 
18
18
  Usage:
19
19
  locale-ai translate --to <locales> [options]
@@ -27,7 +27,7 @@ Usage:
27
27
  Translate options:
28
28
  --from <locale> Source locale (default: en)
29
29
  --to <locales> Target locale(s), comma separated: fr,es,pt-BR
30
- --input <dir> Folder containing <from>/ or <from>.json (auto-detected)
30
+ --input <dir> Folder containing <from>/ or <from>.json/.yaml/.yml (auto-detected)
31
31
  --overwrite Re-translate strings that already have a translation
32
32
  --dry-run Show what would be translated without downloading or writing
33
33
  --fast Greedy decoding: ~2x faster, slightly lower quality
@@ -122,7 +122,7 @@ async function cmdTranslate(argv) {
122
122
  const catalog = catalogFor(config);
123
123
  const protect = protectRegex(config);
124
124
  const layout = detectLayout(process.cwd(), from.tag, values.input);
125
- const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readJson(f) }));
125
+ const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f) }));
126
126
  log(`locale-ai ${VERSION}`);
127
127
  log(`Found ${sources.length} ${from.tag} file${sources.length === 1 ? "" : "s"} in ${rel(layout.root)}/` +
128
128
  (layout.kind === "dir" ? `${from.tag}/` : ""));
@@ -139,7 +139,7 @@ async function cmdTranslate(argv) {
139
139
  }
140
140
  const files = sources.map(({ file, doc }) => {
141
141
  const target = targetFile(layout, from.tag, locale.tag, file);
142
- const existing = fs.existsSync(target) ? readJson(target).data : undefined;
142
+ const existing = fs.existsSync(target) ? readLocaleDoc(target).data : undefined;
143
143
  return { source: file, target, doc, existing, pending: pendingStrings(doc.data, existing, values.overwrite) };
144
144
  });
145
145
  const unique = new Set(files.flatMap((f) => f.pending)).size;
@@ -201,7 +201,7 @@ async function cmdTranslate(argv) {
201
201
  const built = buildTarget(f.doc.data, f.existing, (s) => translations.get(s) ?? s, values.overwrite);
202
202
  const { doc, added } = addPluralForms(built, intlCode(job.locale));
203
203
  pluralsAdded += added;
204
- const text = JSON.stringify(doc, null, f.doc.indent) + (f.doc.finalNewline ? "\n" : "");
204
+ const text = f.doc.serialize(doc);
205
205
  const before = fs.existsSync(f.target) ? fs.readFileSync(f.target, "utf8") : undefined;
206
206
  if (text !== before) {
207
207
  fs.mkdirSync(path.dirname(f.target), { recursive: true });
package/dist/locales.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
+ const EXTENSIONS = [".json", ".yaml", ".yml"];
3
5
  const CANDIDATES = [
4
6
  "locales",
5
7
  "src/locales",
@@ -24,12 +26,17 @@ const CANDIDATES = [
24
26
  ];
25
27
  const isDir = (p) => fs.existsSync(p) && fs.statSync(p).isDirectory();
26
28
  const isFile = (p) => fs.existsSync(p) && fs.statSync(p).isFile();
29
+ /** The source-locale file's extension, if `<root>/<from><ext>` exists for one of EXTENSIONS. */
30
+ function fileExtAt(root, from) {
31
+ return EXTENSIONS.find((ext) => isFile(path.join(root, `${from}${ext}`)));
32
+ }
27
33
  function layoutAt(root, from) {
28
34
  const dir = path.join(root, from);
29
- if (isDir(dir) && listJson(dir).length > 0)
35
+ if (isDir(dir) && listLocaleFiles(dir).length > 0)
30
36
  return { kind: "dir", root };
31
- if (isFile(path.join(root, `${from}.json`)))
32
- return { kind: "file", root };
37
+ const ext = fileExtAt(root, from);
38
+ if (ext)
39
+ return { kind: "file", root, ext };
33
40
  return undefined;
34
41
  }
35
42
  export function detectLayout(cwd, from, input) {
@@ -38,11 +45,13 @@ export function detectLayout(cwd, from, input) {
38
45
  // Accept pointing at the source locale itself: --input locales/en or locales/en.json
39
46
  if (path.basename(abs) === from && isDir(abs))
40
47
  return { kind: "dir", root: path.dirname(abs) };
41
- if (path.basename(abs) === `${from}.json` && isFile(abs))
42
- return { kind: "file", root: path.dirname(abs) };
48
+ const base = path.basename(abs);
49
+ const directExt = EXTENSIONS.find((ext) => base === `${from}${ext}`);
50
+ if (directExt && isFile(abs))
51
+ return { kind: "file", root: path.dirname(abs), ext: directExt };
43
52
  const l = layoutAt(abs, from);
44
53
  if (!l)
45
- throw new Error(`No "${from}" locale found in ${input} (looked for ${from}/*.json and ${from}.json).`);
54
+ throw new Error(`No "${from}" locale found in ${input} (looked for ${from}/*.json and ${from}.json/.yaml/.yml).`);
46
55
  return l;
47
56
  }
48
57
  for (const c of CANDIDATES) {
@@ -50,31 +59,32 @@ export function detectLayout(cwd, from, input) {
50
59
  if (l)
51
60
  return l;
52
61
  }
53
- throw new Error(`Could not find "${from}" locale files. Use --input <dir> to point at the folder containing ${from}/ or ${from}.json.`);
62
+ throw new Error(`Could not find "${from}" locale files. Use --input <dir> to point at the folder containing ${from}/ or ${from}.json/.yaml/.yml.`);
54
63
  }
55
- function listJson(dir) {
64
+ function listLocaleFiles(dir) {
56
65
  const out = [];
57
66
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
58
67
  if (e.name.startsWith(".") || e.name === "node_modules")
59
68
  continue;
60
69
  const p = path.join(dir, e.name);
61
70
  if (e.isDirectory())
62
- out.push(...listJson(p));
63
- else if (e.isFile() && e.name.endsWith(".json"))
71
+ out.push(...listLocaleFiles(p));
72
+ else if (e.isFile() && EXTENSIONS.includes(path.extname(e.name).toLowerCase()))
64
73
  out.push(p);
65
74
  }
66
75
  return out.sort();
67
76
  }
68
77
  export function sourceFiles(layout, from) {
69
- return layout.kind === "dir" ? listJson(path.join(layout.root, from)) : [path.join(layout.root, `${from}.json`)];
78
+ return layout.kind === "dir" ? listLocaleFiles(path.join(layout.root, from)) : [path.join(layout.root, `${from}${layout.ext}`)];
70
79
  }
71
80
  export function targetFile(layout, from, to, src) {
72
81
  if (layout.kind === "file")
73
- return path.join(layout.root, `${to}.json`);
82
+ return path.join(layout.root, `${to}${layout.ext}`);
74
83
  return path.join(layout.root, to, path.relative(path.join(layout.root, from), src));
75
84
  }
76
- export function readJson(file) {
77
- const text = fs.readFileSync(file, "utf8").replace(/^/, "");
85
+ const finalNewline = (text) => text === "" || text.endsWith("\n");
86
+ const withNewline = (text, keep) => text.replace(/\n*$/, keep ? "\n" : "");
87
+ function readJsonDoc(file, text) {
78
88
  let data;
79
89
  try {
80
90
  data = JSON.parse(text);
@@ -83,14 +93,24 @@ export function readJson(file) {
83
93
  throw new Error(`Invalid JSON in ${file}: ${e.message}`);
84
94
  }
85
95
  const indent = /^[{[]\r?\n([ \t]+)/.exec(text)?.[1] ?? (text.includes("\n") ? 2 : 0);
86
- return { data, indent, finalNewline: text.endsWith("\n") };
96
+ const keepNewline = finalNewline(text);
97
+ return { data, serialize: (d) => withNewline(JSON.stringify(d, null, indent), keepNewline) };
87
98
  }
88
- export function writeJson(file, data, style) {
89
- fs.mkdirSync(path.dirname(file), { recursive: true });
90
- const text = JSON.stringify(data, null, style.indent) + (style.finalNewline ? "\n" : "");
91
- const tmp = `${file}.tmp-${process.pid}`;
92
- fs.writeFileSync(tmp, text);
93
- fs.renameSync(tmp, file);
99
+ function readYamlDoc(file, text) {
100
+ let data;
101
+ try {
102
+ data = parseYaml(text) ?? {};
103
+ }
104
+ catch (e) {
105
+ throw new Error(`Invalid YAML in ${file}: ${e.message}`);
106
+ }
107
+ const indent = Number(/\n( +)\S/.exec(text)?.[1].length) || 2;
108
+ const keepNewline = finalNewline(text);
109
+ return { data, serialize: (d) => withNewline(stringifyYaml(d, { indent }), keepNewline) };
110
+ }
111
+ export function readLocaleDoc(file) {
112
+ const text = fs.readFileSync(file, "utf8").replace(/^/, "");
113
+ return /\.ya?ml$/i.test(file) ? readYamlDoc(file, text) : readJsonDoc(file, text);
94
114
  }
95
115
  const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
96
116
  /** Null-prototype object so keys such as "__proto__" are stored as plain data. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locale-ai",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Translate your i18n locale files locally on CPU. Free, offline, no API keys.",
5
5
  "author": "Taha KHABOUSS",
6
6
  "repository": {
@@ -17,7 +17,8 @@
17
17
  },
18
18
  "files": [
19
19
  "dist",
20
- "scripts/convert-marian.py"
20
+ "scripts/convert-marian.py",
21
+ "scripts/trim-onnxruntime.cjs"
21
22
  ],
22
23
  "engines": {
23
24
  "node": ">=22.18"
@@ -28,7 +29,8 @@
28
29
  "dev": "node src/cli.ts",
29
30
  "test": "node --test test/*.test.ts",
30
31
  "test:e2e": "LOCALE_AI_E2E=1 node --test test/e2e.test.ts",
31
- "prepublishOnly": "npm run build && npm test"
32
+ "prepublishOnly": "npm run build && npm test",
33
+ "postinstall": "node scripts/trim-onnxruntime.cjs"
32
34
  },
33
35
  "keywords": [
34
36
  "i18n",
@@ -43,10 +45,14 @@
43
45
  ],
44
46
  "license": "MIT",
45
47
  "dependencies": {
46
- "onnxruntime-node": "^1.30.0"
48
+ "onnxruntime-node": "^1.30.0",
49
+ "yaml": "^2.9.1"
47
50
  },
48
51
  "devDependencies": {
49
52
  "@types/node": "^26.6.6",
50
53
  "typescript": "^7.0.2"
54
+ },
55
+ "allowScripts": {
56
+ "onnxruntime-node@1.30.0": true
51
57
  }
52
58
  }
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // locale-ai only ever runs onnxruntime-node with executionProviders: ["cpu"]
5
+ // (see src/marian.ts) and only ever runs on one platform/arch per install.
6
+ // onnxruntime-node still bundles binaries for every platform, and on
7
+ // linux/x64 its own postinstall downloads a 260 MB CUDA provider we never
8
+ // use (see its script/install-metadata.js). This trims all of that after
9
+ // onnxruntime-node has finished installing, shrinking a ~550 MB unpacked
10
+ // dependency down to the one binary locale-ai actually loads.
11
+ //
12
+ // Best-effort: never fails the install, even if onnxruntime-node changes
13
+ // its layout in a future version.
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ function rm(p) {
19
+ try {
20
+ fs.rmSync(p, { recursive: true, force: true });
21
+ } catch {
22
+ // ignore — best-effort cleanup only
23
+ }
24
+ }
25
+
26
+ try {
27
+ const pkgJson = require.resolve('onnxruntime-node/package.json');
28
+ const binRoot = path.join(path.dirname(pkgJson), 'bin', 'napi-v6');
29
+ if (!fs.existsSync(binRoot)) process.exit(0);
30
+
31
+ const platform = process.platform;
32
+ const arch = process.arch;
33
+
34
+ for (const entry of fs.readdirSync(binRoot)) {
35
+ const platformDir = path.join(binRoot, entry);
36
+ if (entry !== platform) {
37
+ rm(platformDir);
38
+ continue;
39
+ }
40
+ // Same platform: drop every other architecture.
41
+ for (const archEntry of fs.readdirSync(platformDir)) {
42
+ if (archEntry !== arch) rm(path.join(platformDir, archEntry));
43
+ }
44
+ // Drop GPU execution providers locale-ai never requests.
45
+ const archDir = path.join(platformDir, arch);
46
+ for (const name of fs.readdirSync(archDir)) {
47
+ if (/^libonnxruntime_providers_(cuda|tensorrt|rocm|dml)\./.test(name)) {
48
+ rm(path.join(archDir, name));
49
+ }
50
+ }
51
+ }
52
+ } catch {
53
+ // onnxruntime-node not installed, or its layout changed — nothing to do.
54
+ }