locale-ai 0.6.2 → 0.6.4
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 +10 -9
- package/dist/cli.js +109 -13
- package/dist/engine.js +23 -10
- package/dist/locales.js +138 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ npx locale-ai translate --from en --to fr,es,de
|
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
```
|
|
18
|
-
locale-ai 0.
|
|
18
|
+
locale-ai 0.6.3
|
|
19
19
|
Found 3 en files in locales/en/
|
|
20
20
|
Models to download (one time, then cached for every project):
|
|
21
21
|
en-fr 345 MB khabouss/opus-mt-tc-big-en-fr-onnx
|
|
@@ -35,7 +35,7 @@ Download 577 MB? (Y/n)
|
|
|
35
35
|
- **Incremental**: only missing keys are translated, existing translations are kept, re-runs are no-ops. `--overwrite` 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`)
|
|
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.
|
|
39
39
|
|
|
40
40
|
## Why locale-ai?
|
|
41
41
|
|
|
@@ -50,6 +50,7 @@ Download 577 MB? (Y/n)
|
|
|
50
50
|
|
|
51
51
|
```bash
|
|
52
52
|
locale-ai translate --to fr,pt-BR [--from en] [--input dir] [--dry-run] [--overwrite] [--fast] [--report file] [-y]
|
|
53
|
+
locale-ai check [--from en] [--input dir] # CI: fails if a translated locale has missing keys or placeholder mismatches
|
|
53
54
|
locale-ai models list [--from en] # supported language pairs
|
|
54
55
|
locale-ai models installed
|
|
55
56
|
locale-ai models install en-de # pre-download (e.g. in CI)
|
|
@@ -102,24 +103,24 @@ then declare it in `locale-ai.config.json`:
|
|
|
102
103
|
- `prefix` is required for multi-target models (`convert` lists the model's `>>xx<<` tokens).
|
|
103
104
|
- Project models take priority over built-ins for their language pairs and can be used for pivoting (e.g. `ja → en → tr`).
|
|
104
105
|
|
|
105
|
-
**
|
|
106
|
+
**Already built-in, no conversion needed**: `fr`, `es`, `it`, `pt`, `ar` and `tr` all use the larger, clearly-better `opus-mt-tc-big-en-*` checkpoints as their built-in model (~3x larger, 270–370 MB int8, ~1.5x slower than the plain OPUS-MT default); `ru`/`uk` use the tc-big `zle` multi-target checkpoint, and `pl` uses Helsinki-NLP's multi-target Slavic model (no tc-big `en-pl` checkpoint exists). The section above is for adding a language or model that *isn't* already built in.
|
|
106
107
|
|
|
107
|
-
| Target |
|
|
108
|
+
| Target | Previous (plain OPUS-MT), chrF / BLEU | Built-in today (tc-big / better), chrF / BLEU |
|
|
108
109
|
|---|---|---|
|
|
110
|
+
| fr | 68.9 / 53.7 | **72.5 / 55.9** |
|
|
109
111
|
| es | 72.9 / 56.9 | **73.6 / 57.4** |
|
|
110
112
|
| it | 73.8 / 54.9 | **75.3 / 58.2** |
|
|
111
113
|
| pt | 68.3 / 44.4 | **70.6 / 47.7** (`>>por<<`, or `>>pob<<` for pt-BR) |
|
|
112
114
|
| ru | 65.7 / 46.0 | **68.2 / 48.1** (`>>rus<<`) |
|
|
113
115
|
| ar | 47.8 / 16.6 | **49.7 / 19.9** (`>>ara<<`, with `--fast`) |
|
|
114
|
-
| tr | — | **67.4 / 42.1** |
|
|
116
|
+
| tr | — | **67.4 / 42.1** (not supported before) |
|
|
117
|
+
| pl | — | **62.6 / 40.3** (`>>pol<<`, not supported before) |
|
|
115
118
|
|
|
116
119
|
300 Tatoeba test sentences per language, int8, beam 4 unless noted; reproduce with `bench/` (see `bench/README.md`). `opus-mt-tc-big-en-ko` is broken upstream (garbage output even in PyTorch) and is not recommended.
|
|
117
120
|
|
|
118
|
-
Also new: **pl** (chrF 62.6 / BLEU 40.3, `>>pol<<`), hosted at [khabouss/opus-mt-en-sla-onnx](https://huggingface.co/khabouss/opus-mt-en-sla-onnx) — no tc-big `en-pl` checkpoint exists, so this uses Helsinki-NLP's multi-target Slavic model instead.
|
|
119
|
-
|
|
120
121
|
## Languages
|
|
121
122
|
|
|
122
|
-
From English: af, ar, ca, cs, da, de, es, fi, fr, gl, hi, hu, id, it, nl, oc, pl, pt (pt-BR, pt-PT), ro, ru, sv, uk, vi, xh, zh (zh-CN, zh-TW). Other source languages are supported directly or through English (`locale-ai models list --from fr`).
|
|
123
|
+
From English: af, ar, ca, cs, da, de, es, fi, fr, gl, hi, hu, id, it, nl, oc, pl, pt (pt-BR, pt-PT), ro, ru, sv, tr, uk, vi, xh, zh (zh-CN, zh-TW). Other source languages are supported directly or through English (`locale-ai models list --from fr`).
|
|
123
124
|
|
|
124
125
|
Measured on a sample app (see `test/fixtures`):
|
|
125
126
|
|
|
@@ -129,7 +130,7 @@ Measured on a sample app (see `test/fixtures`):
|
|
|
129
130
|
| Usable — review before shipping | it, ar, ru, pl, zh-CN, zh-TW |
|
|
130
131
|
| Untested | the other pairs in `locale-ai models list` |
|
|
131
132
|
|
|
132
|
-
`
|
|
133
|
+
`fr`, `es`, `it`, `pt`, `ar`, `ru`/`uk`, `tr` and `pl` all ship on the tc-big (or better) models above as built-ins already — no config needed. Not available yet: ja, ko and others; he works with a converted `opus-mt-en-he` (weaker). Moroccan Arabic (Darija): the tc-big en-ar model has a `>>ary<<` token, but its output is close to Modern Standard Arabic — not supported.
|
|
133
134
|
|
|
134
135
|
## How it works
|
|
135
136
|
|
package/dist/cli.js
CHANGED
|
@@ -9,14 +9,19 @@ import { CATALOG, customEntries, findById, reachableTargets, resolveRoute, } fro
|
|
|
9
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
|
-
import { addPluralForms, buildTarget, detectLayout, pendingStrings, readLocaleDoc, sourceFiles, targetFile, } from "./locales.js";
|
|
12
|
+
import { addPluralForms, buildTarget, detectLayout, existingTargets, flattenStrings, pendingStrings, readLocaleDoc, sourceFiles, targetFile, } from "./locales.js";
|
|
13
13
|
import { cacheRoot, install, isInstalled, loadTranslator, localModelProblem, planInstall, readManifest, uninstall, } from "./models.js";
|
|
14
|
+
import { mask } from "./placeholders.js";
|
|
14
15
|
/** Read from package.json so `npm version` is the single source of truth. */
|
|
15
16
|
const VERSION = JSON.parse(fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
|
|
16
17
|
const HELP = `locale-ai ${VERSION} — translate i18n locale files locally, on CPU, for free.
|
|
17
18
|
|
|
18
19
|
Usage:
|
|
19
20
|
locale-ai translate --to <locales> [options]
|
|
21
|
+
locale-ai check [--from <locale>]
|
|
22
|
+
Validate that already-translated locales have the same keys and
|
|
23
|
+
placeholders as the source, without translating. Exits non-zero
|
|
24
|
+
on a mismatch (CI-friendly).
|
|
20
25
|
locale-ai models list [--from <locale>]
|
|
21
26
|
locale-ai models installed
|
|
22
27
|
locale-ai models install <from-to | model-id> [--yes]
|
|
@@ -49,16 +54,52 @@ const endStatus = () => {
|
|
|
49
54
|
};
|
|
50
55
|
const mb = (bytes) => `${Math.round(bytes / 1e6)} MB`;
|
|
51
56
|
const rel = (p) => path.relative(process.cwd(), p) || ".";
|
|
57
|
+
/**
|
|
58
|
+
* `Number("abc")` is NaN, and NaN silently poisons everything downstream (an
|
|
59
|
+
* all-garbage translation batch, or a raw ONNX crash deep in the model) instead of
|
|
60
|
+
* a clear error at the CLI boundary where the bad flag value actually came from.
|
|
61
|
+
*/
|
|
62
|
+
function positiveInt(raw, flag, fallback) {
|
|
63
|
+
if (raw === undefined) {
|
|
64
|
+
if (fallback === undefined)
|
|
65
|
+
throw new Error(`Missing --${flag}.`);
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
const n = Number(raw);
|
|
69
|
+
if (!Number.isInteger(n) || n < 1)
|
|
70
|
+
throw new Error(`Invalid --${flag} "${raw}": expected a positive integer.`);
|
|
71
|
+
return n;
|
|
72
|
+
}
|
|
52
73
|
function bar(done, total, width = 24) {
|
|
53
74
|
const ratio = total ? Math.min(1, done / total) : 1;
|
|
54
75
|
const filled = Math.round(ratio * width);
|
|
55
76
|
return `[${"█".repeat(filled)}${"░".repeat(width - filled)}] ${Math.round(ratio * 100)}%`;
|
|
56
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* A long download or translation batch can run for a minute or more. The `\r`-redrawn
|
|
80
|
+
* bar only ever reaches a real terminal, so a non-TTY run (CI logs, piped output) would
|
|
81
|
+
* otherwise print nothing at all between the start and the final result, indistinguishable
|
|
82
|
+
* from a hang. This prints one plain line per 10% there instead.
|
|
83
|
+
*/
|
|
84
|
+
function makeProgress(render) {
|
|
85
|
+
let lastPct = -1;
|
|
86
|
+
return (done, total) => {
|
|
87
|
+
if (isTTY) {
|
|
88
|
+
status(render(done, total));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const pct = total ? Math.floor((done / total) * 100) : 100;
|
|
92
|
+
if (pct >= lastPct + 10 || (done >= total && lastPct < 100)) {
|
|
93
|
+
lastPct = done >= total ? 100 : pct;
|
|
94
|
+
log(render(done, total));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
57
98
|
async function confirm(question, yes) {
|
|
58
99
|
if (yes)
|
|
59
100
|
return true;
|
|
60
101
|
if (!process.stdin.isTTY) {
|
|
61
|
-
throw new Error(
|
|
102
|
+
throw new Error(`Cannot ask "${question.trim()}" in a non-interactive shell. Re-run with --yes to allow downloads.`);
|
|
62
103
|
}
|
|
63
104
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
64
105
|
const answer = (await rl.question(`${question} (Y/n) `)).trim().toLowerCase();
|
|
@@ -86,7 +127,7 @@ async function ensureInstalled(models, yes) {
|
|
|
86
127
|
if (!(await confirm(`Download ${mb(total)}?`, yes)))
|
|
87
128
|
throw new Error("Download declined.");
|
|
88
129
|
for (const p of plans) {
|
|
89
|
-
await install(p, (d, t) =>
|
|
130
|
+
await install(p, makeProgress((d, t) => `Downloading ${p.model.id} ${bar(d, t)} ${mb(d)}/${mb(t)}`));
|
|
90
131
|
endStatus();
|
|
91
132
|
log(`✓ Installed ${p.model.id}`);
|
|
92
133
|
}
|
|
@@ -105,10 +146,6 @@ async function cmdTranslate(argv) {
|
|
|
105
146
|
beams: { type: "string" },
|
|
106
147
|
report: { type: "string" },
|
|
107
148
|
config: { type: "string" },
|
|
108
|
-
out: { type: "string" },
|
|
109
|
-
source: { type: "string" },
|
|
110
|
-
target: { type: "string" },
|
|
111
|
-
python: { type: "string" },
|
|
112
149
|
yes: { type: "boolean", short: "y", default: false },
|
|
113
150
|
},
|
|
114
151
|
});
|
|
@@ -116,13 +153,13 @@ async function cmdTranslate(argv) {
|
|
|
116
153
|
throw new Error("Missing --to. Example: locale-ai translate --from en --to fr,es");
|
|
117
154
|
const from = parseLocale(values.from);
|
|
118
155
|
const targets = values.to.split(",").map((t) => t.trim()).filter(Boolean).map(parseLocale);
|
|
119
|
-
const batchSize =
|
|
120
|
-
const beams =
|
|
156
|
+
const batchSize = positiveInt(values["batch-size"], "batch-size");
|
|
157
|
+
const beams = positiveInt(values.beams, "beams", values.fast ? 1 : 4);
|
|
121
158
|
const config = loadConfig(process.cwd(), values.config);
|
|
122
159
|
const catalog = catalogFor(config);
|
|
123
160
|
const protect = protectRegex(config);
|
|
124
161
|
const layout = detectLayout(process.cwd(), from.tag, values.input);
|
|
125
|
-
const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f) }));
|
|
162
|
+
const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag) }));
|
|
126
163
|
log(`locale-ai ${VERSION}`);
|
|
127
164
|
log(`Found ${sources.length} ${from.tag} file${sources.length === 1 ? "" : "s"} in ${rel(layout.root)}/` +
|
|
128
165
|
(layout.kind === "dir" ? `${from.tag}/` : ""));
|
|
@@ -139,7 +176,7 @@ async function cmdTranslate(argv) {
|
|
|
139
176
|
}
|
|
140
177
|
const files = sources.map(({ file, doc }) => {
|
|
141
178
|
const target = targetFile(layout, from.tag, locale.tag, file);
|
|
142
|
-
const existing = fs.existsSync(target) ? readLocaleDoc(target).data : undefined;
|
|
179
|
+
const existing = fs.existsSync(target) ? readLocaleDoc(target, locale.tag).data : undefined;
|
|
143
180
|
return { source: file, target, doc, existing, pending: pendingStrings(doc.data, existing, values.overwrite) };
|
|
144
181
|
});
|
|
145
182
|
const unique = new Set(files.flatMap((f) => f.pending)).size;
|
|
@@ -182,7 +219,7 @@ async function cmdTranslate(argv) {
|
|
|
182
219
|
batchSize,
|
|
183
220
|
protect,
|
|
184
221
|
lookup: glossaryLookup(from, job.locale, config),
|
|
185
|
-
onProgress: (d, t) =>
|
|
222
|
+
onProgress: makeProgress((d, t) => `${job.locale.tag}: translating ${bar(d, t)} ${d}/${t}`),
|
|
186
223
|
});
|
|
187
224
|
endStatus();
|
|
188
225
|
translations = result.translations;
|
|
@@ -201,8 +238,8 @@ async function cmdTranslate(argv) {
|
|
|
201
238
|
const built = buildTarget(f.doc.data, f.existing, (s) => translations.get(s) ?? s, values.overwrite);
|
|
202
239
|
const { doc, added } = addPluralForms(built, intlCode(job.locale));
|
|
203
240
|
pluralsAdded += added;
|
|
204
|
-
const text = f.doc.serialize(doc, job.locale.tag);
|
|
205
241
|
const before = fs.existsSync(f.target) ? fs.readFileSync(f.target, "utf8") : undefined;
|
|
242
|
+
const text = f.doc.serialize(doc, job.locale.tag, before);
|
|
206
243
|
if (text !== before) {
|
|
207
244
|
fs.mkdirSync(path.dirname(f.target), { recursive: true });
|
|
208
245
|
fs.writeFileSync(f.target, text);
|
|
@@ -338,11 +375,70 @@ async function cmdModels(argv) {
|
|
|
338
375
|
return sub ? 1 : 0;
|
|
339
376
|
}
|
|
340
377
|
}
|
|
378
|
+
/**
|
|
379
|
+
* Validates already-translated locales against the source without translating:
|
|
380
|
+
* same key structure, and the same placeholders in each matching string. Meant for
|
|
381
|
+
* CI, so a mismatch (a key a translator dropped, a {name} a translation lost) fails
|
|
382
|
+
* the build instead of shipping silently.
|
|
383
|
+
*/
|
|
384
|
+
async function cmdCheck(argv) {
|
|
385
|
+
const { values } = parseArgs({
|
|
386
|
+
args: argv,
|
|
387
|
+
options: {
|
|
388
|
+
from: { type: "string", default: "en" },
|
|
389
|
+
input: { type: "string" },
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
const from = parseLocale(values.from);
|
|
393
|
+
const layout = detectLayout(process.cwd(), from.tag, values.input);
|
|
394
|
+
const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag) }));
|
|
395
|
+
const targets = existingTargets(layout, from.tag);
|
|
396
|
+
if (!targets.length) {
|
|
397
|
+
log(`No translated locales found next to ${from.tag}.`);
|
|
398
|
+
return 0;
|
|
399
|
+
}
|
|
400
|
+
let problems = 0;
|
|
401
|
+
for (const to of targets) {
|
|
402
|
+
let missing = 0;
|
|
403
|
+
let mismatched = 0;
|
|
404
|
+
for (const { file, doc } of sources) {
|
|
405
|
+
const target = targetFile(layout, from.tag, to, file);
|
|
406
|
+
if (!fs.existsSync(target)) {
|
|
407
|
+
missing += flattenStrings(doc.data).length;
|
|
408
|
+
log(`✗ ${to}: ${rel(target)} is missing entirely`);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const srcFlat = flattenStrings(doc.data);
|
|
412
|
+
const tgtByPath = new Map(flattenStrings(readLocaleDoc(target, to).data).map((e) => [e.path, e.value]));
|
|
413
|
+
for (const { path: p, value: srcVal } of srcFlat) {
|
|
414
|
+
const tgtVal = tgtByPath.get(p);
|
|
415
|
+
if (tgtVal === undefined) {
|
|
416
|
+
missing++;
|
|
417
|
+
log(`✗ ${to}: ${rel(target)} missing key "${p}"`);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const srcTokens = mask(srcVal, "natural").tokens.toSorted();
|
|
421
|
+
const tgtTokens = mask(tgtVal, "natural").tokens.toSorted();
|
|
422
|
+
if (srcTokens.join("\u0001") !== tgtTokens.join("\u0001")) {
|
|
423
|
+
mismatched++;
|
|
424
|
+
log(`✗ ${to}: ${rel(target)} "${p}" placeholders differ: source [${srcTokens.join(", ")}], target [${tgtTokens.join(", ")}]`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (missing || mismatched)
|
|
429
|
+
problems++;
|
|
430
|
+
else
|
|
431
|
+
log(`✓ ${to}`);
|
|
432
|
+
}
|
|
433
|
+
return problems ? 1 : 0;
|
|
434
|
+
}
|
|
341
435
|
async function main() {
|
|
342
436
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
343
437
|
switch (cmd) {
|
|
344
438
|
case "translate":
|
|
345
439
|
return cmdTranslate(rest);
|
|
440
|
+
case "check":
|
|
441
|
+
return cmdCheck(rest);
|
|
346
442
|
case "models":
|
|
347
443
|
return cmdModels(rest);
|
|
348
444
|
case "-v":
|
package/dist/engine.js
CHANGED
|
@@ -137,21 +137,34 @@ async function attempt(units, style, hops, opts, progress, issues) {
|
|
|
137
137
|
}
|
|
138
138
|
let ok = true;
|
|
139
139
|
const found = [];
|
|
140
|
-
const
|
|
141
|
-
.map((p) => {
|
|
140
|
+
const parts = await Promise.all(pieces.map(async (p) => {
|
|
142
141
|
// No spaces between sentences in Chinese, Japanese, Thai...
|
|
143
142
|
if (!p.translate)
|
|
144
143
|
return NO_SPACES.test(opts.targetLang) ? p.value.replace(/^[ \t]+$/, "") : p.value;
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
let out = results.get(prepare(p.value)) ?? "";
|
|
145
|
+
const suspicionKind = (o) => o.length > 3 * p.value.length + 20 ? "suspicious-length" : /(\b\p{L}+\b)(?:\W+\1\b){3,}/u.test(o) ? "repetition" : undefined;
|
|
146
|
+
let degenerate = isDegenerate(p.value, out);
|
|
147
|
+
let kind = degenerate ? undefined : suspicionKind(out);
|
|
148
|
+
// Quantizing a batched tensor computes scale factors from the whole batch, so one
|
|
149
|
+
// row's output can depend on what else shared its batch. Retrying the same input
|
|
150
|
+
// alone (batch of 1) often clears a hallucination that batch composition caused.
|
|
151
|
+
if (degenerate || kind !== undefined) {
|
|
152
|
+
const retry = await runModels(new Set([prepare(p.value)]), hops, opts, progress);
|
|
153
|
+
const retryOut = retry.get(prepare(p.value)) ?? out;
|
|
154
|
+
const retryDegenerate = isDegenerate(p.value, retryOut);
|
|
155
|
+
if (!retryDegenerate && suspicionKind(retryOut) === undefined) {
|
|
156
|
+
out = retryOut;
|
|
157
|
+
degenerate = false;
|
|
158
|
+
kind = undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (degenerate)
|
|
147
162
|
ok = false;
|
|
148
|
-
else if (
|
|
149
|
-
found.push({ source: unit.owner, kind
|
|
150
|
-
else if (/(\b\p{L}+\b)(?:\W+\1\b){3,}/u.test(out))
|
|
151
|
-
found.push({ source: unit.owner, kind: "repetition", output: out });
|
|
163
|
+
else if (kind)
|
|
164
|
+
found.push({ source: unit.owner, kind, output: out });
|
|
152
165
|
return postprocess(p.value, out, opts.targetLang);
|
|
153
|
-
})
|
|
154
|
-
|
|
166
|
+
}));
|
|
167
|
+
const joined = parts.join("");
|
|
155
168
|
const restored = ok ? unmask(joined, masked) : undefined;
|
|
156
169
|
if (restored === undefined)
|
|
157
170
|
failed.push(unit);
|
package/dist/locales.js
CHANGED
|
@@ -82,6 +82,23 @@ export function targetFile(layout, from, to, src) {
|
|
|
82
82
|
return path.join(layout.root, `${to}${layout.ext}`);
|
|
83
83
|
return path.join(layout.root, to, path.relative(path.join(layout.root, from), src));
|
|
84
84
|
}
|
|
85
|
+
/** Locale tags already translated alongside `from` in this layout (for `locale-ai check`). */
|
|
86
|
+
export function existingTargets(layout, from) {
|
|
87
|
+
if (layout.kind === "file") {
|
|
88
|
+
const ext = layout.ext;
|
|
89
|
+
return fs
|
|
90
|
+
.readdirSync(layout.root)
|
|
91
|
+
.filter((f) => f.endsWith(ext) && f.slice(0, -ext.length) !== from)
|
|
92
|
+
.map((f) => f.slice(0, -ext.length))
|
|
93
|
+
.sort();
|
|
94
|
+
}
|
|
95
|
+
return fs
|
|
96
|
+
.readdirSync(layout.root, { withFileTypes: true })
|
|
97
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && e.name !== from)
|
|
98
|
+
.map((e) => e.name)
|
|
99
|
+
.filter((name) => listLocaleFiles(path.join(layout.root, name)).length > 0)
|
|
100
|
+
.sort();
|
|
101
|
+
}
|
|
85
102
|
const finalNewline = (text) => text === "" || text.endsWith("\n");
|
|
86
103
|
const withNewline = (text, keep) => text.replace(/\n*$/, keep ? "\n" : "");
|
|
87
104
|
function readJsonDoc(file, text) {
|
|
@@ -96,17 +113,28 @@ function readJsonDoc(file, text) {
|
|
|
96
113
|
const keepNewline = finalNewline(text);
|
|
97
114
|
return { data, serialize: (d) => withNewline(JSON.stringify(d, null, indent), keepNewline) };
|
|
98
115
|
}
|
|
99
|
-
|
|
100
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Rails and some other frameworks nest an entire YAML locale file under a single
|
|
118
|
+
* top-level key matching the locale tag itself (`en:\n hello: Hi`), so the file is
|
|
119
|
+
* valid wherever it's loaded from. Detect that wrapper and unwrap it before handing
|
|
120
|
+
* `data` to the rest of the pipeline (which otherwise treats "en" as just another
|
|
121
|
+
* translatable key), then re-wrap under the target's own tag on serialize.
|
|
122
|
+
*/
|
|
123
|
+
function readYamlDoc(file, text, tag) {
|
|
124
|
+
let raw;
|
|
101
125
|
try {
|
|
102
|
-
|
|
126
|
+
raw = parseYaml(text) ?? {};
|
|
103
127
|
}
|
|
104
128
|
catch (e) {
|
|
105
129
|
throw new Error(`Invalid YAML in ${file}: ${e.message}`);
|
|
106
130
|
}
|
|
107
131
|
const indent = Number(/\n( +)\S/.exec(text)?.[1].length) || 2;
|
|
108
132
|
const keepNewline = finalNewline(text);
|
|
109
|
-
|
|
133
|
+
const keys = isObj(raw) ? Object.keys(raw) : [];
|
|
134
|
+
const wrapped = keys.length === 1 && keys[0] === tag;
|
|
135
|
+
const data = wrapped ? raw[tag] : raw;
|
|
136
|
+
const serialize = (d, targetTag) => withNewline(stringifyYaml(wrapped ? { [targetTag]: d } : d, { indent }), keepNewline);
|
|
137
|
+
return { data, serialize };
|
|
110
138
|
}
|
|
111
139
|
/** Apple .strings: `"key" = "value";`, optionally preceded by a `/* ... *\/` comment. */
|
|
112
140
|
function unescapeC(s) {
|
|
@@ -116,7 +144,8 @@ function escapeC(s) {
|
|
|
116
144
|
return s.replace(/[\\"\n\t\r]/g, (c) => ({ "\\": "\\\\", '"': '\\"', "\n": "\\n", "\t": "\\t", "\r": "\\r" })[c]);
|
|
117
145
|
}
|
|
118
146
|
function readStringsDoc(file, text) {
|
|
119
|
-
|
|
147
|
+
// A key's preceding comment is either a /* ... */ block, or one or more consecutive // lines.
|
|
148
|
+
const PAIR = /(?:((?:\/\*[\s\S]*?\*\/)|(?:\/\/[^\r\n]*(?:\r?\n[ \t]*\/\/[^\r\n]*)*))[ \t]*\r?\n+)?[ \t]*"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)"\s*;/g;
|
|
120
149
|
const data = newObj();
|
|
121
150
|
const entries = [];
|
|
122
151
|
for (const m of text.matchAll(PAIR)) {
|
|
@@ -182,6 +211,58 @@ function readArbDoc(file, text) {
|
|
|
182
211
|
return { data, serialize };
|
|
183
212
|
}
|
|
184
213
|
const poKey = (e) => (e.msgctxt ?? "") + "\u0004" + e.msgid;
|
|
214
|
+
/**
|
|
215
|
+
* gettext's well-established Plural-Forms formulas, keyed by language. Copying the
|
|
216
|
+
* source header's line verbatim (as a naive patch does) is wrong whenever the target
|
|
217
|
+
* needs a different count — Arabic needs 6 categories, an English source's line says
|
|
218
|
+
* 2, and anything consuming the .po indexes msgstr[] short or drops forms silently.
|
|
219
|
+
*/
|
|
220
|
+
const PLURAL_FORMS = {
|
|
221
|
+
ja: "nplurals=1; plural=0;",
|
|
222
|
+
zh: "nplurals=1; plural=0;",
|
|
223
|
+
ko: "nplurals=1; plural=0;",
|
|
224
|
+
th: "nplurals=1; plural=0;",
|
|
225
|
+
vi: "nplurals=1; plural=0;",
|
|
226
|
+
id: "nplurals=1; plural=0;",
|
|
227
|
+
ms: "nplurals=1; plural=0;",
|
|
228
|
+
fr: "nplurals=2; plural=(n > 1);",
|
|
229
|
+
pt: "nplurals=2; plural=(n != 1);",
|
|
230
|
+
cs: "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;",
|
|
231
|
+
sk: "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;",
|
|
232
|
+
pl: "nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);",
|
|
233
|
+
ru: "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);",
|
|
234
|
+
uk: "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);",
|
|
235
|
+
ro: "nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2);",
|
|
236
|
+
ar: "nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);",
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* Falls back to CLDR's plural category count (via Intl.PluralRules) for a language
|
|
240
|
+
* not in the table above: not necessarily linguistically exact (it buckets purely by
|
|
241
|
+
* small integer value), but it keeps nplurals correct, which is the part that breaks
|
|
242
|
+
* gettext consumers when wrong.
|
|
243
|
+
*/
|
|
244
|
+
function pluralFormsHeader(lang) {
|
|
245
|
+
const known = PLURAL_FORMS[lang];
|
|
246
|
+
if (known)
|
|
247
|
+
return known;
|
|
248
|
+
let n;
|
|
249
|
+
try {
|
|
250
|
+
n = new Intl.PluralRules(lang).resolvedOptions().pluralCategories.length;
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
n = 2;
|
|
254
|
+
}
|
|
255
|
+
if (n <= 1)
|
|
256
|
+
return "nplurals=1; plural=0;";
|
|
257
|
+
if (n === 2)
|
|
258
|
+
return "nplurals=2; plural=(n != 1);";
|
|
259
|
+
const branches = Array.from({ length: n - 1 }, (_, i) => `n==${i + 1} ? ${i} :`).join(" ");
|
|
260
|
+
return `nplurals=${n}; plural=(${branches} ${n - 1});`;
|
|
261
|
+
}
|
|
262
|
+
/** The `nplurals=N` count from pluralFormsHeader, for expanding msgstr[] to the right length. */
|
|
263
|
+
function pluralCount(lang) {
|
|
264
|
+
return Number(/nplurals=(\d+)/.exec(pluralFormsHeader(lang))[1]);
|
|
265
|
+
}
|
|
185
266
|
function parsePo(text) {
|
|
186
267
|
const entries = [];
|
|
187
268
|
let leading = [];
|
|
@@ -258,29 +339,62 @@ function parsePo(text) {
|
|
|
258
339
|
flushEntry();
|
|
259
340
|
return { entries, leading };
|
|
260
341
|
}
|
|
342
|
+
/** Synthetic data keys for a plural entry's two translatable forms. */
|
|
343
|
+
const pluralOneKey = (e) => poKey(e) + "\u0005one";
|
|
344
|
+
const pluralOtherKey = (e) => poKey(e) + "\u0005other";
|
|
261
345
|
function readPoDoc(file, text) {
|
|
262
346
|
const { entries, leading } = parsePo(text);
|
|
263
347
|
const data = newObj();
|
|
264
|
-
for (const e of entries)
|
|
265
|
-
if (
|
|
348
|
+
for (const e of entries) {
|
|
349
|
+
if (e.isHeader)
|
|
350
|
+
continue;
|
|
351
|
+
if (e.msgidPlural === undefined)
|
|
266
352
|
data[poKey(e)] = e.msgstr;
|
|
353
|
+
else {
|
|
354
|
+
// Like msgstr for a singular entry, msgstrPlural holds this FILE's own text: the
|
|
355
|
+
// English source form in the source .po, the translated forms in a target .po
|
|
356
|
+
// (msgid/msgid_plural are the stable keys, identical across every locale).
|
|
357
|
+
const forms = e.msgstrPlural ?? [];
|
|
358
|
+
data[pluralOneKey(e)] = forms[0] ?? "";
|
|
359
|
+
data[pluralOtherKey(e)] = forms[forms.length - 1] ?? forms[0] ?? "";
|
|
360
|
+
}
|
|
361
|
+
}
|
|
267
362
|
const keepNewline = finalNewline(text);
|
|
268
|
-
const serialize = (d, targetTag) => {
|
|
363
|
+
const serialize = (d, targetTag, existingText) => {
|
|
269
364
|
const obj = isObj(d) ? d : newObj();
|
|
270
365
|
const seen = new Set();
|
|
271
366
|
const blocks = leading.length ? [leading.join("\n")] : [];
|
|
367
|
+
// A previous run may have left a header a translator then hand-fixed (a corrected
|
|
368
|
+
// Plural-Forms, an added Last-Translator, ...). Keep it as-is rather than
|
|
369
|
+
// regenerating from the source every time; only a brand-new target file gets a
|
|
370
|
+
// freshly computed header.
|
|
371
|
+
const existingHeader = existingText && parsePo(existingText).entries.find((e) => e.isHeader);
|
|
272
372
|
for (const e of entries) {
|
|
273
373
|
const lines = [...e.comments];
|
|
274
374
|
if (e.msgctxt !== undefined)
|
|
275
375
|
lines.push(`msgctxt "${escapeC(e.msgctxt)}"`);
|
|
276
376
|
if (e.isHeader) {
|
|
277
377
|
lines.push(`msgid ""`);
|
|
278
|
-
const header =
|
|
378
|
+
const header = existingHeader
|
|
379
|
+
? existingHeader.msgstr
|
|
380
|
+
: e.msgstr.replace(/^Language:.*$/m, `Language: ${targetTag}`).replace(/^Plural-Forms:.*$/m, `Plural-Forms: ${pluralFormsHeader(targetTag)}`);
|
|
279
381
|
lines.push(`msgstr "${escapeC(header)}"`);
|
|
280
382
|
}
|
|
281
383
|
else if (e.msgidPlural !== undefined) {
|
|
282
384
|
lines.push(`msgid "${escapeC(e.msgid)}"`, `msgid_plural "${escapeC(e.msgidPlural)}"`);
|
|
283
|
-
|
|
385
|
+
const oneKey = pluralOneKey(e);
|
|
386
|
+
const otherKey = pluralOtherKey(e);
|
|
387
|
+
seen.add(oneKey);
|
|
388
|
+
seen.add(otherKey);
|
|
389
|
+
const one = typeof obj[oneKey] === "string" ? obj[oneKey] : e.msgid;
|
|
390
|
+
const other = typeof obj[otherKey] === "string" ? obj[otherKey] : e.msgidPlural;
|
|
391
|
+
const n = pluralCount(targetTag);
|
|
392
|
+
// Not full grammatical inflection (same simplification as addPluralForms for
|
|
393
|
+
// i18next): index 0 gets the singular-sourced form, every other index the
|
|
394
|
+
// plural-sourced one. A language with only one category (ja, zh, ...) gets
|
|
395
|
+
// just the plural form, since it has no separate singular count.
|
|
396
|
+
for (let i = 0; i < n; i++)
|
|
397
|
+
lines.push(`msgstr[${i}] "${escapeC(n === 1 ? other : i === 0 ? one : other)}"`);
|
|
284
398
|
}
|
|
285
399
|
else {
|
|
286
400
|
const key = poKey(e);
|
|
@@ -304,11 +418,12 @@ function readPoDoc(file, text) {
|
|
|
304
418
|
};
|
|
305
419
|
return { data, serialize };
|
|
306
420
|
}
|
|
307
|
-
|
|
421
|
+
/** `tag` is the locale this file is for (the source tag, or an existing target's own tag). */
|
|
422
|
+
export function readLocaleDoc(file, tag) {
|
|
308
423
|
const text = fs.readFileSync(file, "utf8").replace(/^/, "");
|
|
309
424
|
const ext = path.extname(file).toLowerCase();
|
|
310
425
|
if (ext === ".yaml" || ext === ".yml")
|
|
311
|
-
return readYamlDoc(file, text);
|
|
426
|
+
return readYamlDoc(file, text, tag);
|
|
312
427
|
if (ext === ".po")
|
|
313
428
|
return readPoDoc(file, text);
|
|
314
429
|
if (ext === ".arb")
|
|
@@ -322,6 +437,17 @@ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
|
322
437
|
const newObj = () => Object.create(null);
|
|
323
438
|
/** A string needs translating when the target has no non-empty string at the same path. */
|
|
324
439
|
const needsTranslation = (existing, overwrite) => overwrite || typeof existing !== "string" || existing === "";
|
|
440
|
+
/** Flattens a locale tree into key-path -> string-value pairs (for `locale-ai check`). */
|
|
441
|
+
export function flattenStrings(data, prefix = "", out = []) {
|
|
442
|
+
if (typeof data === "string")
|
|
443
|
+
out.push({ path: prefix || "(root)", value: data });
|
|
444
|
+
else if (Array.isArray(data))
|
|
445
|
+
data.forEach((v, i) => flattenStrings(v, `${prefix}[${i}]`, out));
|
|
446
|
+
else if (isObj(data))
|
|
447
|
+
for (const k of Object.keys(data))
|
|
448
|
+
flattenStrings(data[k], prefix ? `${prefix}.${k}` : k, out);
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
325
451
|
export function pendingStrings(source, existing, overwrite, out = []) {
|
|
326
452
|
if (typeof source === "string") {
|
|
327
453
|
if (needsTranslation(existing, overwrite))
|