locale-ai 0.6.8 → 0.6.10

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,14 +28,14 @@ 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` (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`, ...) 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
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, 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`, `msgid` is the stable key (never changed) and `msgstr` is the translated value, as gettext expects — the English `.po` file's `msgstr` must hold the source text (most generators already fill it; an empty `msgstr` is treated as empty, not inherited from `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 is otherwise left as you last edited it (a hand-fixed `Plural-Forms`, `Last-Translator`, ...) instead of being regenerated on every rerun. 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 `.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
@@ -33,11 +33,12 @@ Usage:
33
33
  Translate options:
34
34
  --from <locale> Source locale (default: en)
35
35
  --to <locales> Target locale(s), comma separated: fr,es,pt-BR
36
- --input <dir> Folder containing <from>/ or <from>.json/.yaml/.yml/.po/.arb/.strings (auto-detected)
36
+ --input <dir> Folder containing <from>/ or <from>.json/.yaml/.yml/.po/.pot/.arb/.strings (auto-detected)
37
37
  --overwrite Re-translate strings that already have a translation
38
38
  --dry-run Show what would be translated without downloading or writing
39
39
  --fast Greedy decoding: ~2x faster, slightly lower quality
40
40
  --report <file> Write a JSON report of strings that need review
41
+ --fuzzy For .po/.pot: flag every entry this run (re)translated as "#, fuzzy"
41
42
  --config <file> Project config (default: ./${CONFIG_FILE} if present)
42
43
  -y, --yes Download required models without asking
43
44
 
@@ -149,6 +150,7 @@ async function cmdTranslate(argv) {
149
150
  beams: { type: "string" },
150
151
  report: { type: "string" },
151
152
  config: { type: "string" },
153
+ fuzzy: { type: "boolean", default: false },
152
154
  yes: { type: "boolean", short: "y", default: false },
153
155
  },
154
156
  });
@@ -165,7 +167,7 @@ async function cmdTranslate(argv) {
165
167
  const layout = detectLayout(process.cwd(), from.tag, values.input);
166
168
  const lockPath = path.resolve(process.cwd(), LOCK_FILENAME);
167
169
  const lock = loadLock(lockPath);
168
- const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag) }));
170
+ const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag, true, values.fuzzy) }));
169
171
  const singularHints = new Set(sources.flatMap(({ doc }) => [...pluralOneStrings(doc.data)]));
170
172
  log(`locale-ai ${VERSION}`);
171
173
  log(`Found ${sources.length} ${from.tag} file${sources.length === 1 ? "" : "s"} in ${rel(layout.root)}/` +
@@ -417,7 +419,7 @@ async function cmdCheck(argv) {
417
419
  });
418
420
  const from = parseLocale(values.from);
419
421
  const layout = detectLayout(process.cwd(), from.tag, values.input);
420
- const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag) }));
422
+ const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag, true) }));
421
423
  const targets = existingTargets(layout, from.tag);
422
424
  if (!targets.length) {
423
425
  log(`No translated locales found next to ${from.tag}.`);
package/dist/locales.js CHANGED
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
4
  import { parseLocale } from "./lang.js";
5
- const EXTENSIONS = [".json", ".yaml", ".yml", ".po", ".arb", ".strings"];
5
+ const EXTENSIONS = [".json", ".yaml", ".yml", ".po", ".pot", ".arb", ".strings"];
6
6
  const CANDIDATES = [
7
7
  "locales",
8
8
  "src/locales",
@@ -78,10 +78,14 @@ function listLocaleFiles(dir) {
78
78
  export function sourceFiles(layout, from) {
79
79
  return layout.kind === "dir" ? listLocaleFiles(path.join(layout.root, from)) : [path.join(layout.root, `${from}${layout.ext}`)];
80
80
  }
81
+ /** A translated file is a real PO, never a template, regardless of what the source was. */
82
+ const outputExt = (ext) => (ext === ".pot" ? ".po" : ext);
81
83
  export function targetFile(layout, from, to, src) {
82
84
  if (layout.kind === "file")
83
- return path.join(layout.root, `${to}${layout.ext}`);
84
- return path.join(layout.root, to, path.relative(path.join(layout.root, from), src));
85
+ return path.join(layout.root, `${to}${outputExt(layout.ext)}`);
86
+ const rel = path.relative(path.join(layout.root, from), src);
87
+ const ext = path.extname(rel);
88
+ return path.join(layout.root, to, ext === ".pot" ? `${rel.slice(0, -ext.length)}.po` : rel);
85
89
  }
86
90
  /** True when `tag` parses as a locale (e.g. "fr", "pt-BR") — rejects names like "__mocks__" or "schemas". */
87
91
  function looksLikeLocale(tag) {
@@ -364,21 +368,37 @@ function parsePo(text) {
364
368
  /** Synthetic data keys for a plural entry's two translatable forms. */
365
369
  const pluralOneKey = (e) => poKey(e) + "\u0005one";
366
370
  const pluralOtherKey = (e) => poKey(e) + "\u0005other";
367
- function readPoDoc(file, text) {
371
+ /** Adds a `#, fuzzy` gettext flag to a comment block, merging onto an existing `#,` line rather than duplicating it. */
372
+ function withFuzzyFlag(comments) {
373
+ const i = comments.findIndex((c) => /^#,/.test(c));
374
+ if (i === -1)
375
+ return [...comments, "#, fuzzy"];
376
+ if (/\bfuzzy\b/.test(comments[i]))
377
+ return comments;
378
+ return comments.map((c, j) => (j === i ? `${c}, fuzzy` : c));
379
+ }
380
+ function readPoDoc(file, text, isSource, fuzzy) {
368
381
  const { entries, leading } = parsePo(text);
369
382
  const data = newObj();
370
383
  for (const e of entries) {
371
384
  if (e.isHeader)
372
385
  continue;
373
- if (e.msgidPlural === undefined)
374
- data[poKey(e)] = e.msgstr;
386
+ if (e.msgidPlural === undefined) {
387
+ // A template (.pot) or a project that keeps the English text in msgid and
388
+ // leaves the source .po's own msgstr empty: fall back to msgid so the source
389
+ // text isn't just "", which would otherwise translate every entry to nothing
390
+ // with no warning. Never done for a target file, where an empty msgstr
391
+ // legitimately means "not translated yet".
392
+ data[poKey(e)] = isSource && e.msgstr === "" ? e.msgid : e.msgstr;
393
+ }
375
394
  else {
376
395
  // Like msgstr for a singular entry, msgstrPlural holds this FILE's own text: the
377
396
  // English source form in the source .po, the translated forms in a target .po
378
397
  // (msgid/msgid_plural are the stable keys, identical across every locale).
379
398
  const forms = e.msgstrPlural ?? [];
380
- data[pluralOneKey(e)] = forms[0] ?? "";
381
- data[pluralOtherKey(e)] = forms[forms.length - 1] ?? forms[0] ?? "";
399
+ const useMsgid = isSource && forms.every((f) => f === "");
400
+ data[pluralOneKey(e)] = useMsgid ? e.msgid : forms[0] ?? "";
401
+ data[pluralOtherKey(e)] = useMsgid ? e.msgidPlural : forms[forms.length - 1] ?? forms[0] ?? "";
382
402
  }
383
403
  }
384
404
  const keepNewline = finalNewline(text);
@@ -386,24 +406,35 @@ function readPoDoc(file, text) {
386
406
  const obj = isObj(d) ? d : newObj();
387
407
  const seen = new Set();
388
408
  const blocks = leading.length ? [leading.join("\n")] : [];
409
+ const existingEntries = existingText ? parsePo(existingText).entries : [];
389
410
  // A previous run may have left a header a translator then hand-fixed (a corrected
390
411
  // Plural-Forms, an added Last-Translator, ...). Keep it as-is rather than
391
412
  // regenerating from the source every time; only a brand-new target file gets a
392
413
  // freshly computed header.
393
- const existingHeader = existingText && parsePo(existingText).entries.find((e) => e.isHeader);
414
+ const existingHeader = existingEntries.find((e) => e.isHeader);
415
+ // Comments (translator notes, `#, fuzzy` flags, ...) a translator may have added
416
+ // directly to the target file, keyed the same way entries are. Previously
417
+ // serialize always used the SOURCE's own comments for every entry, silently
418
+ // wiping anything a translator had added to the target on the very next run.
419
+ const existingByKey = new Map(existingEntries.filter((e) => !e.isHeader).map((e) => [poKey(e), e]));
394
420
  for (const e of entries) {
395
- const lines = [...e.comments];
396
- if (e.msgctxt !== undefined)
397
- lines.push(`msgctxt "${escapeC(e.msgctxt)}"`);
398
421
  if (e.isHeader) {
399
- lines.push(`msgid ""`);
422
+ const lines = [...e.comments, `msgid ""`];
400
423
  const header = existingHeader
401
424
  ? existingHeader.msgstr
402
425
  : e.msgstr.replace(/^Language:.*$/m, `Language: ${targetTag}`).replace(/^Plural-Forms:.*$/m, `Plural-Forms: ${pluralFormsHeader(targetTag)}`);
403
426
  lines.push(`msgstr "${escapeC(header)}"`);
427
+ blocks.push(lines.join("\n"));
428
+ continue;
404
429
  }
405
- else if (e.msgidPlural !== undefined) {
406
- lines.push(`msgid "${escapeC(e.msgid)}"`, `msgid_plural "${escapeC(e.msgidPlural)}"`);
430
+ const existingEntry = existingByKey.get(poKey(e));
431
+ let comments = existingEntry?.comments ?? e.comments;
432
+ const content = [];
433
+ if (e.msgctxt !== undefined)
434
+ content.push(`msgctxt "${escapeC(e.msgctxt)}"`);
435
+ let changed;
436
+ if (e.msgidPlural !== undefined) {
437
+ content.push(`msgid "${escapeC(e.msgid)}"`, `msgid_plural "${escapeC(e.msgidPlural)}"`);
407
438
  const oneKey = pluralOneKey(e);
408
439
  const otherKey = pluralOtherKey(e);
409
440
  seen.add(oneKey);
@@ -416,15 +447,20 @@ function readPoDoc(file, text) {
416
447
  // plural-sourced one. A language with only one category (ja, zh, ...) gets
417
448
  // just the plural form, since it has no separate singular count.
418
449
  for (let i = 0; i < n; i++)
419
- lines.push(`msgstr[${i}] "${escapeC(n === 1 ? other : i === 0 ? one : other)}"`);
450
+ content.push(`msgstr[${i}] "${escapeC(n === 1 ? other : i === 0 ? one : other)}"`);
451
+ const forms = existingEntry?.msgstrPlural;
452
+ changed = !forms || forms[0] !== one || forms[forms.length - 1] !== other;
420
453
  }
421
454
  else {
422
455
  const key = poKey(e);
423
456
  seen.add(key);
424
457
  const v = typeof obj[key] === "string" ? obj[key] : "";
425
- lines.push(`msgid "${escapeC(e.msgid)}"`, `msgstr "${escapeC(v)}"`);
458
+ content.push(`msgid "${escapeC(e.msgid)}"`, `msgstr "${escapeC(v)}"`);
459
+ changed = existingEntry?.msgstr !== v;
426
460
  }
427
- blocks.push(lines.join("\n"));
461
+ if (fuzzy && changed)
462
+ comments = withFuzzyFlag(comments);
463
+ blocks.push([...comments, ...content].join("\n"));
428
464
  }
429
465
  for (const k of Object.keys(obj)) {
430
466
  if (seen.has(k) || typeof obj[k] !== "string")
@@ -459,14 +495,21 @@ function readText(file) {
459
495
  }
460
496
  return buf.toString("utf8").replace(/^/, "");
461
497
  }
462
- /** `tag` is the locale this file is for (the source tag, or an existing target's own tag). */
463
- export function readLocaleDoc(file, tag) {
498
+ /**
499
+ * `tag` is the locale this file is for (the source tag, or an existing target's own
500
+ * tag). `isSource` marks this as the FROM-locale file being read as the thing to
501
+ * translate (as opposed to an already-existing target) — only then does a `.po`
502
+ * entry's empty `msgstr` fall back to `msgid` as the source text (see readPoDoc).
503
+ * `fuzzy`, for `.po`/`.pot` only, marks every entry this run actually (re)translated
504
+ * with gettext's standard `#, fuzzy` "needs review" flag.
505
+ */
506
+ export function readLocaleDoc(file, tag, isSource = false, fuzzy = false) {
464
507
  const text = readText(file);
465
508
  const ext = path.extname(file).toLowerCase();
466
509
  if (ext === ".yaml" || ext === ".yml")
467
510
  return readYamlDoc(file, text, tag);
468
- if (ext === ".po")
469
- return readPoDoc(file, text);
511
+ if (ext === ".po" || ext === ".pot")
512
+ return readPoDoc(file, text, isSource, fuzzy);
470
513
  if (ext === ".arb")
471
514
  return readArbDoc(file, text);
472
515
  if (ext === ".strings")
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.8",
3
+ "version": "0.6.10",
4
4
  "description": "Translate your i18n locale files locally on CPU. Free, offline, no API keys.",
5
5
  "author": "Taha KHABOUSS",
6
6
  "repository": {