locale-ai 0.6.10 → 0.6.12
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 +4 -4
- package/dist/cli.js +6 -2
- package/dist/locales.js +100 -4
- package/dist/placeholders.js +2 -2
- package/package.json +1 -1
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>`, ` `, 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
|
|
|
@@ -49,7 +49,7 @@ Download 577 MB? (Y/n)
|
|
|
49
49
|
## Commands
|
|
50
50
|
|
|
51
51
|
```bash
|
|
52
|
-
locale-ai translate --to fr,pt-BR [--from en] [--input dir] [--dry-run] [--overwrite] [--fast] [--report file] [-y]
|
|
52
|
+
locale-ai translate --to fr,pt-BR [--from en] [--input dir] [--dry-run] [--overwrite] [--fast] [--beams n] [--batch-size n] [--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
54
|
# (also warns, without failing, about strings left identical to the source)
|
|
55
55
|
locale-ai models list [--from en] # supported language pairs
|
|
@@ -59,7 +59,7 @@ locale-ai models remove en-de
|
|
|
59
59
|
locale-ai models convert <hf-marian-model> --out <dir> # bring your own model (see below)
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
`translate` uses beam search (4 beams) by default. `--fast` uses greedy decoding: 2–3x faster, about 0.5 chrF lower.
|
|
62
|
+
`translate` uses beam search (4 beams) by default; `--beams n` overrides the width directly. `--fast` uses greedy decoding (equivalent to `--beams 1`): 2–3x faster, about 0.5 chrF lower. `--batch-size n` (default 32) controls how many strings go through the model per batch — lower it if you're hitting memory limits, raise it for a bit more throughput on a machine with room to spare.
|
|
63
63
|
|
|
64
64
|
Models are cached once per machine in `~/.cache/locale-ai` (`LOCALE_AI_CACHE` to change). In CI, pass `--yes`.
|
|
65
65
|
|
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. */
|
|
@@ -37,6 +37,8 @@ Translate options:
|
|
|
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
|
+
--beams <n> Beam width for decoding (default: 4, or 1 with --fast)
|
|
41
|
+
--batch-size <n> Strings translated per model batch (default: 32)
|
|
40
42
|
--report <file> Write a JSON report of strings that need review
|
|
41
43
|
--fuzzy For .po/.pot: flag every entry this run (re)translated as "#, fuzzy"
|
|
42
44
|
--config <file> Project config (default: ./${CONFIG_FILE} if present)
|
|
@@ -260,7 +262,9 @@ async function cmdTranslate(argv) {
|
|
|
260
262
|
}
|
|
261
263
|
}
|
|
262
264
|
const built = buildTarget(f.doc.data, f.existing, (s) => translations.get(s) ?? s, values.overwrite, isProtectedKey, isStale);
|
|
263
|
-
|
|
265
|
+
let plural = usesI18nextPlurals(f.source) ? addPluralForms(built, intlCode(job.locale)) : { doc: built, added: 0 };
|
|
266
|
+
const nested = addNestedPluralForms(plural.doc, intlCode(job.locale));
|
|
267
|
+
plural = { doc: nested.doc, added: plural.added + nested.added };
|
|
264
268
|
pluralsAdded += plural.added;
|
|
265
269
|
const before = fs.existsSync(f.target) ? fs.readFileSync(f.target, "utf8") : undefined;
|
|
266
270
|
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 {
|
|
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
|
|
172
|
+
let srcDoc;
|
|
142
173
|
try {
|
|
143
|
-
|
|
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) =>
|
|
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/placeholders.js
CHANGED
|
@@ -24,7 +24,7 @@ const SOURCES = [
|
|
|
24
24
|
/\$\{[^{}]*\}/.source, // ${name}
|
|
25
25
|
/%\{[^{}]+\}/.source, // %{name} ruby / polyglot
|
|
26
26
|
/%\([\w.]+\)[sdifr]/.source, // %(name)s python
|
|
27
|
-
/%(?:\d+\$)?[-+0#]*\d*(?:\.\d+)?[sdifuxXeEgGcpo@]/.source, // %s %d %1$s %.2f %@
|
|
27
|
+
/%(?:\d+\$)?[-+0#]*\d*(?:\.\d+)?[sdifuxXeEgGcpo@](?![A-Za-z])/.source, // %s %d %1$s %.2f %@ (not "100%off")
|
|
28
28
|
/@(?:\.\w+)?:\(?[\w.-]+\)?/.source, // @:key @.lower:key vue-i18n linked messages
|
|
29
29
|
/\{\s*[\w.-]+\s*(?:,[^{}]*)?\}/.source, // {name} {0} {count, number} ICU simple args
|
|
30
30
|
/<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>/.source, // <b> </b> <a href="..."> <Trans/>
|
|
@@ -33,7 +33,7 @@ const SOURCES = [
|
|
|
33
33
|
/https?:\/\/[^\s<>"']*[^\s<>"'.,;:!?)\]]/.source, // URLs
|
|
34
34
|
/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/.source, // e-mail addresses
|
|
35
35
|
/(?<=^|[\s(])(?::[a-zA-Z_]\w*)/.source, // :attribute laravel
|
|
36
|
-
/\$[A-Za-z_]\w*/.source, // $name
|
|
36
|
+
/\$(?![A-Z]{2,}(?![a-zA-Z0-9_]))[A-Za-z_]\w*/.source, // $name (not "$USD", "$CAD" — currency codes)
|
|
37
37
|
];
|
|
38
38
|
const PLACEHOLDER_SRC = SOURCES.map((s) => `(?:${s})`).join("|");
|
|
39
39
|
const PLACEHOLDER_RE = new RegExp(PLACEHOLDER_SRC, "gu");
|