locale-ai 0.6.13 → 0.6.15

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
@@ -28,7 +28,7 @@ Download 577 MB? (Y/n)
28
28
 
29
29
  ## What it handles
30
30
 
31
- - **Layouts**: `locales/en/*.json` (nested folders too) → `locales/fr/...`, or `messages/en.json` → `messages/fr.json`. `.json`, `.yaml`, `.yml`, `.po`/`.pot` (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`.
31
+ - **Layouts**: `locales/en/*.json` (nested folders too) → `locales/fr/...`, or `messages/en.json` → `messages/fr.json`. `.json`, `.yaml`, `.yml`, `.po`/`.pot` (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`, `config/locales` (Rails), ...) are found automatically, or pass `--input`. Framework-specific naming is also recognized: `flutter gen-l10n`'s `app_en.arb`/`app_fr.arb` filename prefix, and Apple's `en.lproj`/`fr.lproj` directory-suffix convention (`--input en.lproj` or just the project root both work), on both the source and the generated target side.
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
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. Rails-style nested plurals (`item:` with `one:`/`other:` children instead of `item_one`/`item_other`) get the same missing-forms treatment.
@@ -140,7 +140,7 @@ Measured on a sample app (see `test/fixtures`):
140
140
 
141
141
  ## How it works
142
142
 
143
- - **Models**: [OPUS-MT](https://huggingface.co/Helsinki-NLP) (MarianMT) in ONNX, int8-quantized (int8 vs fp32 measured equal: en-fr chrF 68.9 vs 69.0, en-ar 47.8 vs 47.8). Downloads are pinned to a commit and verified (sha256 / git blob hash). Every model is OPUS-MT by Helsinki-NLP (Tiedemann & Thottingal, 2020 — cite this if you use the models); each is licensed individually (most CC-BY 4.0, some Apache 2.0 — check the model's own card). The larger `tc-big` and multi-target checkpoints are re-hosted as ONNX conversions under [khabouss/*](https://huggingface.co/khabouss), each model card carrying the original's license, crediting and citing it, and listing only the ONNX conversion and quantization as locale-ai's own addition.
143
+ - **Models**: [OPUS-MT](https://huggingface.co/Helsinki-NLP) (MarianMT) in ONNX, int8-quantized (int8 vs fp32 measured equal: en-fr chrF 68.9 vs 69.0, en-ar 47.8 vs 47.8). Downloads are pinned to a commit and verified (sha256 / git blob hash) — third-party repos (Xenova's OPUS-MT conversions) to a commit captured when that model was added, so an upstream change there never silently changes your translations; this project's own `khabouss/*` repos stay on their latest commit, since we only ever change those ourselves. Every model is OPUS-MT by Helsinki-NLP (Tiedemann & Thottingal, 2020 — cite this if you use the models); each is licensed individually (most CC-BY 4.0, some Apache 2.0 — check the model's own card). The larger `tc-big` and multi-target checkpoints are re-hosted as ONNX conversions under [khabouss/*](https://huggingface.co/khabouss), each model card carrying the original's license, crediting and citing it, and listing only the ONNX conversion and quantization as locale-ai's own addition.
144
144
  - **Own runtime** on [onnxruntime](https://onnxruntime.ai) (`src/marian.ts`), no transformers.js:
145
145
  - a SentencePiece Unigram tokenizer including the model's precompiled normalisation map, identical to Python `sentencepiece` on 3,577/3,577 test sentences across 5 models (`src/spm.ts`);
146
146
  - real beam search with Hugging Face semantics, identical to `transformers` PyTorch beam search on 599/600 sentences; log-softmax + top-k run natively in a tiny embedded ONNX graph (`scripts/make-topk-graph.py`);
package/dist/catalog.js CHANGED
@@ -17,7 +17,90 @@ const DIRECT = [
17
17
  "th-en", "tr-en", "uk-en", "uk-ru", "vi-en", "xh-en", "zh-en",
18
18
  ];
19
19
  const ROMANCE = ["es", "pt", "ca", "gl", "oc", "it", "ro", "fr"];
20
- export const CATALOG = [
20
+ /**
21
+ * Every third-party repo this catalog downloads from, pinned to the commit that was
22
+ * current when each pair was added — so every install gets the exact same files
23
+ * instead of silently picking up whatever Xenova's "main" branch has moved to by
24
+ * then, with no changelog entry or version bump on our side to explain why
25
+ * translations changed. This project's own `khabouss/*` repos are deliberately left
26
+ * out: we only ever change those ourselves, so there's no silent-drift risk to
27
+ * guard against, and pinning them would just mean remembering to bump a SHA by hand
28
+ * every time a better conversion is published.
29
+ */
30
+ const PINNED_REVISIONS = {
31
+ "Xenova/opus-mt-ROMANCE-en": "f60feaabe8bb435a5871e7702b25f53a01e770f8",
32
+ "Xenova/opus-mt-af-en": "0eba4e40af86c6ad316a58fc9a7ed1134b05fd90",
33
+ "Xenova/opus-mt-ar-en": "09c7556866400dcc53562ba8d7035119e7d9a2c1",
34
+ "Xenova/opus-mt-cs-en": "c9f7336d1cf49ba5b7a412cba3b86999fa781716",
35
+ "Xenova/opus-mt-da-de": "3784087953108e4ee9957c8f9eff11c0bf0cabda",
36
+ "Xenova/opus-mt-da-en": "6496d5e47a016552357be8f06294096609256ac4",
37
+ "Xenova/opus-mt-de-en": "399dfd68706739fffd503f876093e455ae268a06",
38
+ "Xenova/opus-mt-de-es": "d339419ee3bda301bdcd00249d4436b74664ee7e",
39
+ "Xenova/opus-mt-de-fr": "e783da03d247c4d16778e938530e9b5d7bd1523d",
40
+ "Xenova/opus-mt-en-ROMANCE": "9d2ba69ac80c8e8453c3d9a1e2323a0e7b8ca3cd",
41
+ "Xenova/opus-mt-en-af": "3d48312617d092d77cb5aef9f9e9636ad639dd8d",
42
+ "Xenova/opus-mt-en-cs": "ed163ca109a93297e2f055eb3397bd33230141ad",
43
+ "Xenova/opus-mt-en-da": "33862dc071a5cc43e821e60742553c501c587e77",
44
+ "Xenova/opus-mt-en-de": "1ca130c44c4c5441ef16d48aae521a424ab644f7",
45
+ "Xenova/opus-mt-en-es": "4b002a4c7edd54a7ced58877258b87f7efd3f892",
46
+ "Xenova/opus-mt-en-fi": "4e5799273dbd7cce94c1e99cc9d3acb9f191b8eb",
47
+ "Xenova/opus-mt-en-hi": "7aad72006a1588a9c3485ed6203aed99eebbc872",
48
+ "Xenova/opus-mt-en-hu": "1b7df958c1bd8f094eff1ce76d536468923ed7e3",
49
+ "Xenova/opus-mt-en-id": "b3b41f654c2fb3286d007dee1da7b0f47fd27d82",
50
+ "Xenova/opus-mt-en-it": "075406e3c8c2c30634d4a1bd8f00c21d9e162011",
51
+ "Xenova/opus-mt-en-nl": "c3a0541231a7329b4492809fc2bd7ab7d2ceb0bc",
52
+ "Xenova/opus-mt-en-ro": "3d816891778276930df2a45cbfb70689b3fe8981",
53
+ "Xenova/opus-mt-en-sv": "33d522d806c041cdebecd549c558d22a2b9b4ea4",
54
+ "Xenova/opus-mt-en-vi": "30bcd461c0d7338ad106b36314d3eedc2d4e998c",
55
+ "Xenova/opus-mt-en-xh": "1ab9c4f4400ea4805f220ff5d551d3d0169b7a96",
56
+ "Xenova/opus-mt-en-zh": "046f55aec303cdee3e0318604406d4df20f1e8ea",
57
+ "Xenova/opus-mt-es-de": "7f493e4b81b684478396f37de4c1ebc28ecd5855",
58
+ "Xenova/opus-mt-es-en": "eadfd7c658a9d8929ac3b8e996b68a68e2c7d480",
59
+ "Xenova/opus-mt-es-fr": "3bef5f612825d503f6fb85e0527a1fe30f2578c2",
60
+ "Xenova/opus-mt-es-it": "596c67cda7cb919f0d67221f3a31bd7df79f19d0",
61
+ "Xenova/opus-mt-es-ru": "268ccd60bde3cabde17068d482ca6930512f1a85",
62
+ "Xenova/opus-mt-et-en": "58104952b730220eb7a9cdbbe19533a064f40dc0",
63
+ "Xenova/opus-mt-fi-de": "6d93339597e19574f543259315d2400939067c1e",
64
+ "Xenova/opus-mt-fi-en": "a623e4812687be788e3ab720c8a950ab64704793",
65
+ "Xenova/opus-mt-fr-de": "7eea1996b4480d073cf560fcba6d4480aaf9a2bb",
66
+ "Xenova/opus-mt-fr-en": "6b166a182780e118c997879d0ad5be4b53671644",
67
+ "Xenova/opus-mt-fr-es": "962338be51033b548c13dbb6f4da3ddb8c8aba0e",
68
+ "Xenova/opus-mt-fr-ro": "d2b0db550987291ebb35cdeb3a5394d176ecdcc6",
69
+ "Xenova/opus-mt-fr-ru": "c2ec732b045ae9f0e1ae1ea9267840145a8c03fe",
70
+ "Xenova/opus-mt-hi-en": "99853100ed9d932987a6816e8cf7b1b6742a6116",
71
+ "Xenova/opus-mt-hu-en": "c9e8a7617fb4cb4fca3785c5bb22cfe457cde00b",
72
+ "Xenova/opus-mt-id-en": "c38ef36c843b71177da903442835c3c870975fd6",
73
+ "Xenova/opus-mt-it-en": "fd0b89b9c052adc1f2f64152f555aa17353728be",
74
+ "Xenova/opus-mt-it-es": "0eba27fe6da2208231ea5e7047b0f62241ffc2bb",
75
+ "Xenova/opus-mt-it-fr": "7843e3b7137d19face19cb32e27256888d26fe84",
76
+ "Xenova/opus-mt-ja-en": "1a906cfaaf7c8f4193f67f5885c082aa6dbd9d16",
77
+ "Xenova/opus-mt-ko-en": "d9fa1ac6008242100fecb4fff9b0a5917a1be81f",
78
+ "Xenova/opus-mt-nl-en": "82c42d95d1508037cbe7172d85fd4e940a2f3584",
79
+ "Xenova/opus-mt-nl-fr": "6c8ebb68522ffd80cf3474b2dc44f4b5f741e9cd",
80
+ "Xenova/opus-mt-no-de": "5ed60d1d245fe19d3ee80a172de6b38724642eb3",
81
+ "Xenova/opus-mt-pl-en": "aeaf0e003b045248c28a559a5ce6027b54ebdaba",
82
+ "Xenova/opus-mt-ro-fr": "48d206ed25c6f5b869a49a79207906c401334727",
83
+ "Xenova/opus-mt-ru-en": "afe8c6c738ec81b6d033fd8f44f9678a639a7c67",
84
+ "Xenova/opus-mt-ru-es": "9cb5fb9fae4d5c0c731dcc3ba74c4e0a87f15eb0",
85
+ "Xenova/opus-mt-ru-fr": "c7275f468246036d24bfe14ce8465e94e1037af9",
86
+ "Xenova/opus-mt-ru-uk": "6d8a744ed6eafd9003bcc2b052c4575de0485e1e",
87
+ "Xenova/opus-mt-sv-en": "63a40256869b6a2b29a84e47aa7f03524fa5f3a2",
88
+ "Xenova/opus-mt-th-en": "7f0406b972f742985cfbe3be8f61dead9f1bc0e3",
89
+ "Xenova/opus-mt-tr-en": "78247f41f354af036d24e6f0f8a3d02c6806f96e",
90
+ "Xenova/opus-mt-uk-en": "b2ad6afa424aaeff8913ff350860b7a7b7c0a607",
91
+ "Xenova/opus-mt-uk-ru": "cfb76ccd207734d7847679664fa4538cda5ed597",
92
+ "Xenova/opus-mt-vi-en": "541dfd72ec07e8f1563795e0224408ad8d729e80",
93
+ "Xenova/opus-mt-xh-en": "f2950c1503ae17ba532ea858914ea13838bc2ab1",
94
+ "Xenova/opus-mt-zh-en": "39d480d52a9ea3065a1f117adfe4dbc55de10e6f",
95
+ };
96
+ /** Applies PINNED_REVISIONS by the entry's final `repo` (after any override), not by where `pair()` first pointed it. */
97
+ function pinned(entries) {
98
+ return entries.map((m) => ({
99
+ ...m,
100
+ revision: m.repo && !m.repo.startsWith("khabouss/") ? PINNED_REVISIONS[m.repo] : undefined,
101
+ }));
102
+ }
103
+ export const CATALOG = pinned([
21
104
  ...DIRECT.map((p) => {
22
105
  const [s, t] = p.split("-");
23
106
  return pair(s, t);
@@ -88,7 +171,7 @@ export const CATALOG = [
88
171
  sources: ROMANCE,
89
172
  targets: ["en"],
90
173
  },
91
- ];
174
+ ]);
92
175
  const asList = (v) => (Array.isArray(v) ? v : [v]).map((x) => parseLocale(x).lang);
93
176
  /**
94
177
  * A Hugging Face repo id is always exactly "namespace/name". `models.ts` joins it
package/dist/cli.js CHANGED
@@ -173,7 +173,7 @@ async function cmdTranslate(argv) {
173
173
  const singularHints = new Set(sources.flatMap(({ doc }) => [...pluralOneStrings(doc.data)]));
174
174
  log(`locale-ai ${VERSION}`);
175
175
  log(`Found ${sources.length} ${from.tag} file${sources.length === 1 ? "" : "s"} in ${rel(layout.root)}/` +
176
- (layout.kind === "dir" ? `${from.tag}/` : ""));
176
+ (layout.kind === "dir" ? `${from.tag}${layout.dirSuffix ?? ""}/` : ""));
177
177
  let failures = 0;
178
178
  const jobs = [];
179
179
  for (const locale of targets) {
package/dist/locales.js CHANGED
@@ -23,6 +23,7 @@ const CANDIDATES = [
23
23
  "src/locale",
24
24
  "app/i18n",
25
25
  "static/locales",
26
+ "config/locales",
26
27
  ".",
27
28
  ];
28
29
  const isDir = (p) => fs.existsSync(p) && fs.statSync(p).isDirectory();
@@ -31,25 +32,60 @@ const isFile = (p) => fs.existsSync(p) && fs.statSync(p).isFile();
31
32
  function fileExtAt(root, from) {
32
33
  return EXTENSIONS.find((ext) => isFile(path.join(root, `${from}${ext}`)));
33
34
  }
35
+ /**
36
+ * A prefixed source-locale file, if `<root>/<prefix>_<from><ext>` exists for one of
37
+ * EXTENSIONS — the convention `flutter gen-l10n` uses by default ("app_en.arb").
38
+ */
39
+ function prefixedFileAt(root, from) {
40
+ if (!isDir(root))
41
+ return undefined;
42
+ for (const entry of fs.readdirSync(root)) {
43
+ if (!isFile(path.join(root, entry)))
44
+ continue;
45
+ for (const ext of EXTENSIONS) {
46
+ const suffix = `_${from}${ext}`;
47
+ if (entry.endsWith(suffix) && entry.length > suffix.length) {
48
+ return { ext, prefix: entry.slice(0, -suffix.length) };
49
+ }
50
+ }
51
+ }
52
+ return undefined;
53
+ }
34
54
  function layoutAt(root, from) {
35
55
  const dir = path.join(root, from);
36
56
  if (isDir(dir) && listLocaleFiles(dir).length > 0)
37
57
  return { kind: "dir", root };
58
+ const lprojDir = path.join(root, `${from}.lproj`);
59
+ if (isDir(lprojDir) && listLocaleFiles(lprojDir).length > 0)
60
+ return { kind: "dir", root, dirSuffix: ".lproj" };
38
61
  const ext = fileExtAt(root, from);
39
62
  if (ext)
40
63
  return { kind: "file", root, ext };
64
+ const prefixed = prefixedFileAt(root, from);
65
+ if (prefixed)
66
+ return { kind: "file", root, ext: prefixed.ext, prefix: prefixed.prefix };
41
67
  return undefined;
42
68
  }
69
+ const dirNameFor = (layout, tag) => `${tag}${layout.dirSuffix ?? ""}`;
70
+ const fileNameFor = (layout, tag, ext) => `${layout.prefix ? `${layout.prefix}_` : ""}${tag}${ext}`;
43
71
  export function detectLayout(cwd, from, input) {
44
72
  if (input) {
45
73
  const abs = path.resolve(cwd, input);
46
74
  // Accept pointing at the source locale itself: --input locales/en or locales/en.json
47
75
  if (path.basename(abs) === from && isDir(abs))
48
76
  return { kind: "dir", root: path.dirname(abs) };
77
+ if (path.basename(abs) === `${from}.lproj` && isDir(abs)) {
78
+ return { kind: "dir", root: path.dirname(abs), dirSuffix: ".lproj" };
79
+ }
49
80
  const base = path.basename(abs);
50
81
  const directExt = EXTENSIONS.find((ext) => base === `${from}${ext}`);
51
82
  if (directExt && isFile(abs))
52
83
  return { kind: "file", root: path.dirname(abs), ext: directExt };
84
+ const prefixedExt = EXTENSIONS.find((ext) => base.endsWith(`_${from}${ext}`) && base.length > `_${from}${ext}`.length);
85
+ if (prefixedExt && isFile(abs)) {
86
+ const suffix = `_${from}${prefixedExt}`;
87
+ return { kind: "file", root: path.dirname(abs), ext: prefixedExt, prefix: base.slice(0, -suffix.length) };
88
+ }
53
89
  const l = layoutAt(abs, from);
54
90
  if (!l)
55
91
  throw new Error(`No "${from}" locale found in ${input} (looked for ${from}/* and ${from}${EXTENSIONS.join("/")}).`);
@@ -76,16 +112,18 @@ function listLocaleFiles(dir) {
76
112
  return out.sort();
77
113
  }
78
114
  export function sourceFiles(layout, from) {
79
- return layout.kind === "dir" ? listLocaleFiles(path.join(layout.root, from)) : [path.join(layout.root, `${from}${layout.ext}`)];
115
+ return layout.kind === "dir"
116
+ ? listLocaleFiles(path.join(layout.root, dirNameFor(layout, from)))
117
+ : [path.join(layout.root, fileNameFor(layout, from, layout.ext))];
80
118
  }
81
119
  /** A translated file is a real PO, never a template, regardless of what the source was. */
82
120
  const outputExt = (ext) => (ext === ".pot" ? ".po" : ext);
83
121
  export function targetFile(layout, from, to, src) {
84
122
  if (layout.kind === "file")
85
- return path.join(layout.root, `${to}${outputExt(layout.ext)}`);
86
- const rel = path.relative(path.join(layout.root, from), src);
123
+ return path.join(layout.root, fileNameFor(layout, to, outputExt(layout.ext)));
124
+ const rel = path.relative(path.join(layout.root, dirNameFor(layout, from)), src);
87
125
  const ext = path.extname(rel);
88
- return path.join(layout.root, to, ext === ".pot" ? `${rel.slice(0, -ext.length)}.po` : rel);
126
+ return path.join(layout.root, dirNameFor(layout, to), ext === ".pot" ? `${rel.slice(0, -ext.length)}.po` : rel);
89
127
  }
90
128
  /** True when `tag` parses as a locale (e.g. "fr", "pt-BR") — rejects names like "__mocks__" or "schemas". */
91
129
  function looksLikeLocale(tag) {
@@ -101,19 +139,22 @@ function looksLikeLocale(tag) {
101
139
  export function existingTargets(layout, from) {
102
140
  if (layout.kind === "file") {
103
141
  const ext = layout.ext;
142
+ const prefix = layout.prefix ? `${layout.prefix}_` : "";
104
143
  return fs
105
144
  .readdirSync(layout.root)
106
- .filter((f) => f.endsWith(ext) && f.slice(0, -ext.length) !== from)
107
- .map((f) => f.slice(0, -ext.length))
145
+ .filter((f) => f.startsWith(prefix) && f.endsWith(ext) && f.slice(prefix.length, -ext.length) !== from)
146
+ .map((f) => f.slice(prefix.length, -ext.length))
108
147
  .filter(looksLikeLocale)
109
148
  .sort();
110
149
  }
150
+ const suffix = layout.dirSuffix ?? "";
111
151
  return fs
112
152
  .readdirSync(layout.root, { withFileTypes: true })
113
- .filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && e.name !== from)
114
- .map((e) => e.name)
153
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && e.name.endsWith(suffix))
154
+ .map((e) => (suffix ? e.name.slice(0, -suffix.length) : e.name))
155
+ .filter((name) => name !== from)
115
156
  .filter(looksLikeLocale)
116
- .filter((name) => listLocaleFiles(path.join(layout.root, name)).length > 0)
157
+ .filter((name) => listLocaleFiles(path.join(layout.root, dirNameFor(layout, name))).length > 0)
117
158
  .sort();
118
159
  }
119
160
  const finalNewline = (text) => text === "" || text.endsWith("\n");
package/dist/models.js CHANGED
@@ -125,7 +125,7 @@ async function fetchWithRetry(url, attempts = 3) {
125
125
  export async function planInstall(m) {
126
126
  if (!m.repo)
127
127
  throw new Error(`${m.id} is a local model; nothing to download.`);
128
- const info = (await getJson(`${HF}/api/models/${m.repo}/revision/main`));
128
+ const info = (await getJson(`${HF}/api/models/${m.repo}/revision/${m.revision ?? "main"}`));
129
129
  const tree = (await getJson(`${HF}/api/models/${m.repo}/tree/${info.sha}?recursive=true`));
130
130
  const byPath = new Map(tree.map((e) => [e.path, e]));
131
131
  const pick = (names) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locale-ai",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
4
4
  "description": "Translate your i18n locale files locally on CPU. Free, offline, no API keys.",
5
5
  "author": "Taha KHABOUSS",
6
6
  "repository": {