locale-ai 0.6.9 → 0.6.11

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,11 +31,11 @@ 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`/`.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`.
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. 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.
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.
35
35
  - **Incremental, and edit-aware**: only missing or changed keys are translated; untouched translations are kept, so re-runs are no-ops. A small `locale-ai.lock.json` (commit it, like a package lock) remembers a hash of each source string and of the output last written for it per target locale — so editing a source string gets it re-translated automatically (it isn't enough for the target to already hold *some* text), while editing a target string by hand is detected and reported for review instead of being silently overwritten on the next run. `--overwrite` bypasses the lock and re-translates everything.
36
36
  - **Structure**: key order, numbers, booleans, arrays, indentation and trailing newline are preserved.
37
37
 
38
- **Format notes**: for `.po`/`.pot`, `msgid` is the stable key (never changed) and `msgstr` is the translated value, as gettext expects — the English source's `msgstr` holds the source text when it's filled in (most generators do), and falls back to `msgid` itself when it's empty, which is normal for a `.pot` template or a project that just keeps the English text in `msgid`. That fallback only ever applies to the source file: an empty `msgstr` in an *existing target* still means "not translated yet", never "same as 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, and any entry's own comments (a translator's note, a hand-added `#, fuzzy`), are otherwise left exactly as last edited instead of being regenerated from the source on every rerun. `--fuzzy` flags every entry a run actually (re)translated with gettext's standard `#, fuzzy` "needs review" marker, merging onto an existing `#,` line rather than adding a second one. A `.pot` source always writes a `.po` target, never a `.pot`. For `.arb`, `@key` metadata and `@@locale` are never translated; `@@locale` is set to the target locale on output. For `.strings`, a file saved as UTF-16 (Xcode's traditional default) is read like a UTF-8 one, an unquoted key (`title = "Settings";`, without quotes around `title`) is read the same as a quoted one, and `\U00e9`-style escapes are decoded — output is always written as UTF-8 with quoted keys, regardless of how the source was saved.
38
+ **Format notes**: for `.yaml`/`.yml`, `#` comments (a translator note, an inline `# ...` after a value) survive a translation run — the target file is edited in place rather than rebuilt from scratch, so an existing comment's own value is updated without disturbing the comment, and a brand-new key is appended with no comment of its own. A newly added plural-category key (see above) is likewise appended rather than inserted between existing ones, so its position in the file won't always match the canonical `zero`/`one`/`two`/`few`/`many`/`other` order. For `.po`/`.pot`, `msgid` is the stable key (never changed) and `msgstr` is the translated value, as gettext expects — the English source's `msgstr` holds the source text when it's filled in (most generators do), and falls back to `msgid` itself when it's empty, which is normal for a `.pot` template or a project that just keeps the English text in `msgid`. That fallback only ever applies to the source file: an empty `msgstr` in an *existing target* still means "not translated yet", never "same as 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, and any entry's own comments (a translator's note, a hand-added `#, fuzzy`), are otherwise left exactly as last edited instead of being regenerated from the source on every rerun. `--fuzzy` flags every entry a run actually (re)translated with gettext's standard `#, fuzzy` "needs review" marker, merging onto an existing `#,` line rather than adding a second one. A `.pot` source always writes a `.po` target, never a `.pot`. For `.arb`, `@key` metadata and `@@locale` are never translated; `@@locale` is set to the target locale on output. For `.strings`, a file saved as UTF-16 (Xcode's traditional default) is read like a UTF-8 one, an unquoted key (`title = "Settings";`, without quotes around `title`) is read the same as a quoted one, and `\U00e9`-style escapes are decoded — output is always written as UTF-8 with quoted keys, regardless of how the source was saved.
39
39
 
40
40
  ## Why locale-ai?
41
41
 
@@ -105,7 +105,7 @@ then declare it in `locale-ai.config.json`:
105
105
  }
106
106
  ```
107
107
 
108
- - `path` is a local folder (relative to the config file); `repo` is a Hugging Face repo with the converted files (uploaded output folder), downloaded and cached like built-in models.
108
+ - `path` is a local folder (relative to the config file); `repo` is a Hugging Face repo with the converted files (uploaded output folder), downloaded and cached like built-in models — it must look like `namespace/name`, rejected otherwise (it's joined onto the cache directory with no further checks, so anything else could resolve outside it).
109
109
  - `prefix` is required for multi-target models (`convert` lists the model's `>>xx<<` tokens).
110
110
  - Project models take priority over built-ins for their language pairs and can be used for pivoting (e.g. `ja → en → tr`).
111
111
 
package/dist/catalog.js CHANGED
@@ -90,10 +90,23 @@ export const CATALOG = [
90
90
  },
91
91
  ];
92
92
  const asList = (v) => (Array.isArray(v) ? v : [v]).map((x) => parseLocale(x).lang);
93
+ /**
94
+ * A Hugging Face repo id is always exactly "namespace/name". `models.ts` joins it
95
+ * onto the cache root with no further checks, so a `repo` of "../../../etc" (or any
96
+ * segment that isn't a real name) would resolve outside the cache directory — and
97
+ * install() writes there, uninstall() recursively deletes there.
98
+ */
99
+ function validRepo(repo) {
100
+ const parts = repo.split("/");
101
+ return parts.length === 2 && parts.every((p) => p !== "" && p !== "." && p !== ".." && !p.includes("\\"));
102
+ }
93
103
  export function customEntries(models, configDir) {
94
104
  return (models ?? []).map((c, i) => {
95
105
  if (!c.repo === !c.path)
96
106
  throw new Error(`models[${i}]: set exactly one of "repo" or "path".`);
107
+ if (c.repo && !validRepo(c.repo)) {
108
+ throw new Error(`models[${i}]: "repo" must look like "namespace/name" (a Hugging Face repo id), got "${c.repo}".`);
109
+ }
97
110
  if (!c.source || !c.target)
98
111
  throw new Error(`models[${i}]: "source" and "target" are required.`);
99
112
  const sources = asList(c.source);
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ 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, usesI18nextPlurals, } from "./locales.js";
13
+ import { addNestedPluralForms, 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
15
  import { hasTranslatableText, mask } from "./placeholders.js";
16
16
  /** Read from package.json so `npm version` is the single source of truth. */
@@ -260,7 +260,9 @@ async function cmdTranslate(argv) {
260
260
  }
261
261
  }
262
262
  const built = buildTarget(f.doc.data, f.existing, (s) => translations.get(s) ?? s, values.overwrite, isProtectedKey, isStale);
263
- const plural = usesI18nextPlurals(f.source) ? addPluralForms(built, intlCode(job.locale)) : { doc: built, added: 0 };
263
+ let plural = usesI18nextPlurals(f.source) ? addPluralForms(built, intlCode(job.locale)) : { doc: built, added: 0 };
264
+ const nested = addNestedPluralForms(plural.doc, intlCode(job.locale));
265
+ plural = { doc: nested.doc, added: plural.added + nested.added };
264
266
  pluralsAdded += plural.added;
265
267
  const before = fs.existsSync(f.target) ? fs.readFileSync(f.target, "utf8") : undefined;
266
268
  const text = f.doc.serialize(plural.doc, job.locale.tag, before);
package/dist/locales.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
3
+ import { Document, isMap, isScalar, parseDocument } from "yaml";
4
4
  import { parseLocale } from "./lang.js";
5
5
  const EXTENSIONS = [".json", ".yaml", ".yml", ".po", ".pot", ".arb", ".strings"];
6
6
  const CANDIDATES = [
@@ -130,27 +130,78 @@ function readJsonDoc(file, text) {
130
130
  const keepNewline = finalNewline(text);
131
131
  return { data, serialize: (d) => withNewline(JSON.stringify(d, null, indent), keepNewline) };
132
132
  }
133
+ /** Renames a single-key document's top-level key, keeping that key's own comments. */
134
+ function rewrapKey(doc, tag) {
135
+ const map = doc.contents;
136
+ if (isMap(map) && map.items.length === 1 && isScalar(map.items[0].key)) {
137
+ map.items[0].key.value = tag;
138
+ }
139
+ else {
140
+ doc.contents = doc.createNode({ [tag]: doc.toJS() ?? {} });
141
+ }
142
+ }
143
+ /**
144
+ * Writes every string/number/boolean leaf of `v` into `doc` at `base` (plus the
145
+ * leaf's own key path). `Document#setIn` mutates an existing scalar node's value in
146
+ * place rather than replacing it, so any comment already attached to that node (a
147
+ * trailing `# ...`, or one on its own line above) survives untouched; a leaf with no
148
+ * existing node there yet is simply inserted, with no comment of its own.
149
+ */
150
+ function setLeaves(doc, base, v) {
151
+ if (Array.isArray(v))
152
+ v.forEach((item, i) => setLeaves(doc, [...base, i], item));
153
+ else if (isObj(v))
154
+ for (const [k, val] of Object.entries(v))
155
+ setLeaves(doc, [...base, k], val);
156
+ else
157
+ doc.setIn(base, v);
158
+ }
133
159
  /**
134
160
  * Rails and some other frameworks nest an entire YAML locale file under a single
135
161
  * top-level key matching the locale tag itself (`en:\n hello: Hi`), so the file is
136
162
  * valid wherever it's loaded from. Detect that wrapper and unwrap it before handing
137
163
  * `data` to the rest of the pipeline (which otherwise treats "en" as just another
138
164
  * translatable key), then re-wrap under the target's own tag on serialize.
165
+ *
166
+ * Re-serializing edits an existing YAML document (the target file's own, when it
167
+ * already exists, otherwise a clone of the source's) in place via `setIn` instead of
168
+ * building a plain object and stringifying it from scratch — the latter would throw
169
+ * away every `#` comment in the file on every run.
139
170
  */
140
171
  function readYamlDoc(file, text, tag) {
141
- let raw;
172
+ let srcDoc;
142
173
  try {
143
- raw = parseYaml(text) ?? {};
174
+ srcDoc = parseDocument(text);
144
175
  }
145
176
  catch (e) {
146
177
  throw new Error(`Invalid YAML in ${file}: ${e.message}`);
147
178
  }
179
+ if (srcDoc.errors.length)
180
+ throw new Error(`Invalid YAML in ${file}: ${srcDoc.errors[0].message}`);
181
+ const raw = srcDoc.toJS() ?? {};
148
182
  const indent = Number(/\n( +)\S/.exec(text)?.[1].length) || 2;
149
183
  const keepNewline = finalNewline(text);
150
184
  const keys = isObj(raw) ? Object.keys(raw) : [];
151
185
  const wrapped = keys.length === 1 && keys[0] === tag;
152
186
  const data = wrapped ? raw[tag] : raw;
153
- const serialize = (d, targetTag) => withNewline(stringifyYaml(wrapped ? { [targetTag]: d } : d, { indent }), keepNewline);
187
+ const serialize = (d, targetTag, existingText) => {
188
+ let base;
189
+ if (existingText !== undefined) {
190
+ try {
191
+ const parsed = parseDocument(existingText);
192
+ if (parsed.errors.length === 0)
193
+ base = parsed;
194
+ }
195
+ catch {
196
+ // Fall through to the source clone below.
197
+ }
198
+ }
199
+ base ??= srcDoc.clone();
200
+ if (wrapped)
201
+ rewrapKey(base, targetTag);
202
+ setLeaves(base, wrapped ? [targetTag] : [], d);
203
+ return withNewline(base.toString({ indent }), keepNewline);
204
+ };
154
205
  return { data, serialize };
155
206
  }
156
207
  /** Apple .strings: `"key" = "value";`, optionally preceded by a `/* ... *\/` comment. */
@@ -700,3 +751,48 @@ export function addPluralForms(doc, targetLang) {
700
751
  };
701
752
  return { doc: visit(doc), added };
702
753
  }
754
+ /**
755
+ * Rails and some other frameworks nest plural forms under the key itself
756
+ * (`item:\n one: "%{count} item"\n other: "%{count} items"`) instead of i18next's
757
+ * `item_one`/`item_other` suffix convention. A value is treated as a plural group
758
+ * when every one of its keys is a CLDR plural category and "other" (the mandatory
759
+ * fallback) is among them and there's at least one more alongside it — a single
760
+ * `{ other: "..." }` object is too common as an ordinary translation value (e.g. a
761
+ * key literally named "other") to treat as a plural group on its own.
762
+ */
763
+ export function addNestedPluralForms(doc, targetLang) {
764
+ let added = 0;
765
+ const visit = (v) => {
766
+ if (Array.isArray(v))
767
+ return v.map(visit);
768
+ if (!isObj(v))
769
+ return v;
770
+ const keys = Object.keys(v);
771
+ const isGroup = keys.length >= 2 && keys.includes("other") && keys.every((k) => CATEGORIES.includes(k) && typeof v[k] === "string");
772
+ if (!isGroup) {
773
+ const out = newObj();
774
+ for (const k of keys)
775
+ out[k] = visit(v[k]);
776
+ return out;
777
+ }
778
+ let needed;
779
+ try {
780
+ needed = new Intl.PluralRules(targetLang).resolvedOptions().pluralCategories;
781
+ }
782
+ catch {
783
+ needed = keys;
784
+ }
785
+ const other = v.other;
786
+ const out = newObj();
787
+ for (const cat of CATEGORIES) {
788
+ if (keys.includes(cat))
789
+ out[cat] = v[cat];
790
+ else if (needed.includes(cat)) {
791
+ out[cat] = other;
792
+ added++;
793
+ }
794
+ }
795
+ return out;
796
+ };
797
+ return { doc: visit(doc), added };
798
+ }
package/dist/models.js CHANGED
@@ -25,8 +25,26 @@ export function cacheRoot() {
25
25
  return path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "locale-ai");
26
26
  }
27
27
  const modelsRoot = () => path.join(cacheRoot(), "models");
28
- /** Where a model's files live: its local folder, or its cache folder. */
29
- export const modelDir = (m) => m.path ?? path.join(modelsRoot(), ...m.repo.split("/"));
28
+ /**
29
+ * Where a model's files live: its local folder, or its cache folder. `repo` is
30
+ * validated as a plain "namespace/name" Hugging Face id when it comes from a
31
+ * project config (see catalog.ts's customEntries) — this is a second,
32
+ * independent check that the computed cache folder actually stays inside the
33
+ * cache root, since install() writes there and uninstall() recursively deletes
34
+ * it: a `repo` that somehow smuggled a ".." segment past that first check must
35
+ * never be allowed to touch a path outside the cache.
36
+ */
37
+ export function modelDir(m) {
38
+ if (m.path)
39
+ return m.path;
40
+ const root = modelsRoot();
41
+ const dir = path.join(root, ...m.repo.split("/"));
42
+ const rel = path.relative(root, dir);
43
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
44
+ throw new Error(`${m.id}: "repo" (${m.repo}) resolves outside the model cache.`);
45
+ }
46
+ return dir;
47
+ }
30
48
  export function readManifest(m) {
31
49
  if (m.path)
32
50
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locale-ai",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
4
4
  "description": "Translate your i18n locale files locally on CPU. Free, offline, no API keys.",
5
5
  "author": "Taha KHABOUSS",
6
6
  "repository": {