locale-ai 0.6.6 → 0.6.8
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 -1
- package/dist/cli.js +20 -10
- package/dist/locales.js +55 -5
- package/package.json +1 -1
- package/scripts/trim-onnxruntime.cjs +45 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Download 577 MB? (Y/n)
|
|
|
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.
|
|
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.
|
|
39
39
|
|
|
40
40
|
## Why locale-ai?
|
|
41
41
|
|
|
@@ -51,6 +51,7 @@ Download 577 MB? (Y/n)
|
|
|
51
51
|
```bash
|
|
52
52
|
locale-ai translate --to fr,pt-BR [--from en] [--input dir] [--dry-run] [--overwrite] [--fast] [--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
|
+
# (also warns, without failing, about strings left identical to the source)
|
|
54
55
|
locale-ai models list [--from en] # supported language pairs
|
|
55
56
|
locale-ai models installed
|
|
56
57
|
locale-ai models install en-de # pre-download (e.g. in CI)
|
|
@@ -62,6 +63,8 @@ locale-ai models convert <hf-marian-model> --out <dir> # bring your own model
|
|
|
62
63
|
|
|
63
64
|
Models are cached once per machine in `~/.cache/locale-ai` (`LOCALE_AI_CACHE` to change). In CI, pass `--yes`.
|
|
64
65
|
|
|
66
|
+
Installing trims the `onnxruntime-node` dependency down to the one CPU binary locale-ai actually loads (it ships binaries for every platform/arch plus GPU providers locale-ai never uses) — but only when nothing else in your `node_modules` also depends on it, so a shared copy another package needs for GPU inference is left alone. Set `LOCALE_AI_SKIP_TRIM=1` to disable this entirely.
|
|
67
|
+
|
|
65
68
|
## Project config (optional)
|
|
66
69
|
|
|
67
70
|
`locale-ai.config.json` in the project root:
|
package/dist/cli.js
CHANGED
|
@@ -10,9 +10,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
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, } from "./locales.js";
|
|
13
|
+
import { 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
|
-
import { mask } from "./placeholders.js";
|
|
15
|
+
import { hasTranslatableText, mask } from "./placeholders.js";
|
|
16
16
|
/** Read from package.json so `npm version` is the single source of truth. */
|
|
17
17
|
const VERSION = JSON.parse(fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
|
|
18
18
|
const HELP = `locale-ai ${VERSION} — translate i18n locale files locally, on CPU, for free.
|
|
@@ -258,16 +258,16 @@ async function cmdTranslate(argv) {
|
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
260
|
const built = buildTarget(f.doc.data, f.existing, (s) => translations.get(s) ?? s, values.overwrite, isProtectedKey, isStale);
|
|
261
|
-
const
|
|
262
|
-
pluralsAdded += added;
|
|
261
|
+
const plural = usesI18nextPlurals(f.source) ? addPluralForms(built, intlCode(job.locale)) : { doc: built, added: 0 };
|
|
262
|
+
pluralsAdded += plural.added;
|
|
263
263
|
const before = fs.existsSync(f.target) ? fs.readFileSync(f.target, "utf8") : undefined;
|
|
264
|
-
const text = f.doc.serialize(doc, job.locale.tag, before);
|
|
264
|
+
const text = f.doc.serialize(plural.doc, job.locale.tag, before);
|
|
265
265
|
if (text !== before) {
|
|
266
266
|
fs.mkdirSync(path.dirname(f.target), { recursive: true });
|
|
267
267
|
fs.writeFileSync(f.target, text);
|
|
268
268
|
written++;
|
|
269
269
|
}
|
|
270
|
-
const outFlat = new Map(flattenStrings(doc).map((e) => [e.path, e.value]));
|
|
270
|
+
const outFlat = new Map(flattenStrings(plural.doc).map((e) => [e.path, e.value]));
|
|
271
271
|
recordEntries(lock, lockKey, job.locale.tag, flattenStrings(f.doc.data).map(({ path: p, value }) => ({ path: p, source: value, written: outFlat.get(p) ?? "" })));
|
|
272
272
|
}
|
|
273
273
|
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
@@ -424,9 +424,11 @@ async function cmdCheck(argv) {
|
|
|
424
424
|
return 0;
|
|
425
425
|
}
|
|
426
426
|
let problems = 0;
|
|
427
|
+
let identicalTotal = 0;
|
|
427
428
|
for (const to of targets) {
|
|
428
429
|
let missing = 0;
|
|
429
430
|
let mismatched = 0;
|
|
431
|
+
let identical = 0;
|
|
430
432
|
for (const { file, doc } of sources) {
|
|
431
433
|
const target = targetFile(layout, from.tag, to, file);
|
|
432
434
|
if (!fs.existsSync(target)) {
|
|
@@ -443,19 +445,27 @@ async function cmdCheck(argv) {
|
|
|
443
445
|
log(`✗ ${to}: ${rel(target)} missing key "${p}"`);
|
|
444
446
|
continue;
|
|
445
447
|
}
|
|
446
|
-
const
|
|
448
|
+
const srcMasked = mask(srcVal, "natural");
|
|
447
449
|
const tgtTokens = mask(tgtVal, "natural").tokens.toSorted();
|
|
448
|
-
if (
|
|
450
|
+
if (srcMasked.tokens.toSorted().join("\u0001") !== tgtTokens.join("\u0001")) {
|
|
449
451
|
mismatched++;
|
|
450
|
-
log(`✗ ${to}: ${rel(target)} "${p}" placeholders differ: source [${
|
|
452
|
+
log(`✗ ${to}: ${rel(target)} "${p}" placeholders differ: source [${srcMasked.tokens.toSorted().join(", ")}], target [${tgtTokens.join(", ")}]`);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (tgtVal === srcVal && hasTranslatableText(srcMasked.text)) {
|
|
456
|
+
identical++;
|
|
457
|
+
log(`⚠ ${to}: ${rel(target)} "${p}" is untouched, identical to the ${from.tag} source`);
|
|
451
458
|
}
|
|
452
459
|
}
|
|
453
460
|
}
|
|
461
|
+
identicalTotal += identical;
|
|
454
462
|
if (missing || mismatched)
|
|
455
463
|
problems++;
|
|
456
464
|
else
|
|
457
|
-
log(`✓ ${to}`);
|
|
465
|
+
log(`✓ ${to}${identical ? ` (${identical} left identical to ${from.tag})` : ""}`);
|
|
458
466
|
}
|
|
467
|
+
if (identicalTotal)
|
|
468
|
+
log(`\n${identicalTotal} string${identicalTotal === 1 ? "" : "s"} left identical to the source — not a failure, but worth a look.`);
|
|
459
469
|
return problems ? 1 : 0;
|
|
460
470
|
}
|
|
461
471
|
async function main() {
|
package/dist/locales.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
4
|
+
import { parseLocale } from "./lang.js";
|
|
4
5
|
const EXTENSIONS = [".json", ".yaml", ".yml", ".po", ".arb", ".strings"];
|
|
5
6
|
const CANDIDATES = [
|
|
6
7
|
"locales",
|
|
@@ -82,6 +83,16 @@ export function targetFile(layout, from, to, src) {
|
|
|
82
83
|
return path.join(layout.root, `${to}${layout.ext}`);
|
|
83
84
|
return path.join(layout.root, to, path.relative(path.join(layout.root, from), src));
|
|
84
85
|
}
|
|
86
|
+
/** True when `tag` parses as a locale (e.g. "fr", "pt-BR") — rejects names like "__mocks__" or "schemas". */
|
|
87
|
+
function looksLikeLocale(tag) {
|
|
88
|
+
try {
|
|
89
|
+
parseLocale(tag);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
85
96
|
/** Locale tags already translated alongside `from` in this layout (for `locale-ai check`). */
|
|
86
97
|
export function existingTargets(layout, from) {
|
|
87
98
|
if (layout.kind === "file") {
|
|
@@ -90,12 +101,14 @@ export function existingTargets(layout, from) {
|
|
|
90
101
|
.readdirSync(layout.root)
|
|
91
102
|
.filter((f) => f.endsWith(ext) && f.slice(0, -ext.length) !== from)
|
|
92
103
|
.map((f) => f.slice(0, -ext.length))
|
|
104
|
+
.filter(looksLikeLocale)
|
|
93
105
|
.sort();
|
|
94
106
|
}
|
|
95
107
|
return fs
|
|
96
108
|
.readdirSync(layout.root, { withFileTypes: true })
|
|
97
109
|
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && e.name !== from)
|
|
98
110
|
.map((e) => e.name)
|
|
111
|
+
.filter(looksLikeLocale)
|
|
99
112
|
.filter((name) => listLocaleFiles(path.join(layout.root, name)).length > 0)
|
|
100
113
|
.sort();
|
|
101
114
|
}
|
|
@@ -138,19 +151,28 @@ function readYamlDoc(file, text, tag) {
|
|
|
138
151
|
}
|
|
139
152
|
/** Apple .strings: `"key" = "value";`, optionally preceded by a `/* ... *\/` comment. */
|
|
140
153
|
function unescapeC(s) {
|
|
141
|
-
return s.replace(/\\(.)/g, (_, c) =>
|
|
154
|
+
return s.replace(/\\(?:U([0-9a-fA-F]{4})|(.))/g, (_, hex, c) => {
|
|
155
|
+
// \Uxxxx is a UTF-16 code unit; two in a row combine into one character via
|
|
156
|
+
// ordinary JS string concatenation when they're a surrogate pair.
|
|
157
|
+
if (hex !== undefined)
|
|
158
|
+
return String.fromCharCode(parseInt(hex, 16));
|
|
159
|
+
return ({ n: "\n", t: "\t", r: "\r", '"': '"', "\\": "\\" })[c] ?? c;
|
|
160
|
+
});
|
|
142
161
|
}
|
|
143
162
|
function escapeC(s) {
|
|
144
163
|
return s.replace(/[\\"\n\t\r]/g, (c) => ({ "\\": "\\\\", '"': '\\"', "\n": "\\n", "\t": "\\t", "\r": "\\r" })[c]);
|
|
145
164
|
}
|
|
146
165
|
function readStringsDoc(file, text) {
|
|
147
166
|
// A key's preceding comment is either a /* ... */ block, or one or more consecutive // lines.
|
|
148
|
-
|
|
167
|
+
// The old NeXT/Apple plist-string format allows a bare, unquoted key (no spaces,
|
|
168
|
+
// "=" or ";") as well as a quoted one — Xcode itself only ever writes quoted keys,
|
|
169
|
+
// but hand-edited or third-party-exported .strings files sometimes have both.
|
|
170
|
+
const PAIR = /(?:((?:\/\*[\s\S]*?\*\/)|(?:\/\/[^\r\n]*(?:\r?\n[ \t]*\/\/[^\r\n]*)*))[ \t]*\r?\n+)?[ \t]*(?:"((?:\\.|[^"\\])*)"|([^\s"=;]+))\s*=\s*"((?:\\.|[^"\\])*)"\s*;/g;
|
|
149
171
|
const data = newObj();
|
|
150
172
|
const entries = [];
|
|
151
173
|
for (const m of text.matchAll(PAIR)) {
|
|
152
|
-
const key = unescapeC(m[2]);
|
|
153
|
-
data[key] = unescapeC(m[
|
|
174
|
+
const key = m[2] !== undefined ? unescapeC(m[2]) : m[3];
|
|
175
|
+
data[key] = unescapeC(m[4]);
|
|
154
176
|
entries.push({ key, comment: m[1] });
|
|
155
177
|
}
|
|
156
178
|
if (entries.length === 0 && text.trim() !== "") {
|
|
@@ -418,9 +440,28 @@ function readPoDoc(file, text) {
|
|
|
418
440
|
};
|
|
419
441
|
return { data, serialize };
|
|
420
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* Decodes a locale file's raw bytes to text, detecting a UTF-16 BOM first — Xcode's
|
|
445
|
+
* traditional default for `.strings` files, which `fs.readFileSync(file, "utf8")`
|
|
446
|
+
* would otherwise mangle into unreadable text (and then "no entries found").
|
|
447
|
+
*/
|
|
448
|
+
function readText(file) {
|
|
449
|
+
const buf = fs.readFileSync(file);
|
|
450
|
+
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe)
|
|
451
|
+
return buf.subarray(2).toString("utf16le");
|
|
452
|
+
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
|
|
453
|
+
const swapped = Buffer.alloc(buf.length - 2);
|
|
454
|
+
for (let i = 2; i + 1 < buf.length; i += 2) {
|
|
455
|
+
swapped[i - 2] = buf[i + 1];
|
|
456
|
+
swapped[i - 1] = buf[i];
|
|
457
|
+
}
|
|
458
|
+
return swapped.toString("utf16le");
|
|
459
|
+
}
|
|
460
|
+
return buf.toString("utf8").replace(/^/, "");
|
|
461
|
+
}
|
|
421
462
|
/** `tag` is the locale this file is for (the source tag, or an existing target's own tag). */
|
|
422
463
|
export function readLocaleDoc(file, tag) {
|
|
423
|
-
const text =
|
|
464
|
+
const text = readText(file);
|
|
424
465
|
const ext = path.extname(file).toLowerCase();
|
|
425
466
|
if (ext === ".yaml" || ext === ".yml")
|
|
426
467
|
return readYamlDoc(file, text, tag);
|
|
@@ -538,6 +579,15 @@ export function pluralOneStrings(data, out = new Set()) {
|
|
|
538
579
|
visit(data);
|
|
539
580
|
return out;
|
|
540
581
|
}
|
|
582
|
+
/**
|
|
583
|
+
* True for the file extensions that use i18next's `key_one`/`key_other` plural-key
|
|
584
|
+
* convention (flat JSON/YAML locale trees). A `.po`, `.arb` or `.strings` file might
|
|
585
|
+
* happen to have a key ending the same way without meaning it as an i18next plural,
|
|
586
|
+
* so addPluralForms should only ever run on these.
|
|
587
|
+
*/
|
|
588
|
+
export function usesI18nextPlurals(file) {
|
|
589
|
+
return /\.(json|ya?ml)$/i.test(file);
|
|
590
|
+
}
|
|
541
591
|
/**
|
|
542
592
|
* i18next stores plurals as `key_one` / `key_other`. Languages such as Arabic,
|
|
543
593
|
* Russian or Polish need more forms (`_few`, `_many` ...) and i18next does not fall
|
package/package.json
CHANGED
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
// onnxruntime-node has finished installing, shrinking a ~550 MB unpacked
|
|
10
10
|
// dependency down to the one binary locale-ai actually loads.
|
|
11
11
|
//
|
|
12
|
+
// Node's resolution can hoist onnxruntime-node to a node_modules shared with
|
|
13
|
+
// other packages, so this only trims when nothing else next to it in that
|
|
14
|
+
// same node_modules also depends on it — another consumer there might need a
|
|
15
|
+
// GPU provider, or run on a different platform than this machine (e.g. a
|
|
16
|
+
// node_modules folder built here and later mounted into a container). Set
|
|
17
|
+
// LOCALE_AI_SKIP_TRIM=1 to disable this entirely, e.g. if node_modules is
|
|
18
|
+
// going to be reused somewhere this check can't see (a different machine, a
|
|
19
|
+
// Docker image built from it later).
|
|
20
|
+
//
|
|
12
21
|
// Best-effort: never fails the install, even if onnxruntime-node changes
|
|
13
22
|
// its layout in a future version.
|
|
14
23
|
|
|
@@ -23,9 +32,44 @@ function rm(p) {
|
|
|
23
32
|
}
|
|
24
33
|
}
|
|
25
34
|
|
|
35
|
+
/** Any OTHER package in `nodeModulesDir` that itself depends on onnxruntime-node. */
|
|
36
|
+
function hasOtherConsumer(nodeModulesDir) {
|
|
37
|
+
let entries;
|
|
38
|
+
try {
|
|
39
|
+
entries = fs.readdirSync(nodeModulesDir, { withFileTypes: true });
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
for (const e of entries) {
|
|
44
|
+
if (!e.isDirectory() || e.name === 'onnxruntime-node' || e.name === '.bin') continue;
|
|
45
|
+
const names = e.name.startsWith('@')
|
|
46
|
+
? fs.readdirSync(path.join(nodeModulesDir, e.name), { withFileTypes: true })
|
|
47
|
+
.filter((s) => s.isDirectory())
|
|
48
|
+
.map((s) => `${e.name}/${s.name}`)
|
|
49
|
+
: [e.name];
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
if (name === 'locale-ai') continue;
|
|
52
|
+
try {
|
|
53
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(nodeModulesDir, name, 'package.json'), 'utf8'));
|
|
54
|
+
const deps = { ...pkg.dependencies, ...pkg.optionalDependencies, ...pkg.peerDependencies };
|
|
55
|
+
if (deps && 'onnxruntime-node' in deps) return true;
|
|
56
|
+
} catch {
|
|
57
|
+
// Not a real package (or unreadable) — ignore.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (process.env.LOCALE_AI_SKIP_TRIM) process.exit(0);
|
|
65
|
+
|
|
26
66
|
try {
|
|
27
67
|
const pkgJson = require.resolve('onnxruntime-node/package.json');
|
|
28
|
-
const
|
|
68
|
+
const onnxRoot = path.dirname(pkgJson);
|
|
69
|
+
const nodeModulesDir = path.dirname(onnxRoot);
|
|
70
|
+
if (hasOtherConsumer(nodeModulesDir)) process.exit(0);
|
|
71
|
+
|
|
72
|
+
const binRoot = path.join(onnxRoot, 'bin', 'napi-v6');
|
|
29
73
|
if (!fs.existsSync(binRoot)) process.exit(0);
|
|
30
74
|
|
|
31
75
|
const platform = process.platform;
|