locale-ai 0.6.4 → 0.6.6
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 +6 -3
- package/dist/cli.js +29 -3
- package/dist/engine.js +32 -0
- package/dist/glossary.js +2 -0
- package/dist/locales.js +81 -32
- package/dist/lock.js +56 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,8 +31,8 @@ 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` (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.
|
|
35
|
-
- **Incremental**: only missing keys are 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.
|
|
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
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.
|
|
@@ -41,7 +41,7 @@ Download 577 MB? (Y/n)
|
|
|
41
41
|
|
|
42
42
|
- **No API keys, no cloud bill.** Google/DeepL/OpenAI-based translation tools work well but mean an account, a key, a per-character cost, and your strings leaving your machine. locale-ai runs the model on your own CPU.
|
|
43
43
|
- **Built for i18n files, not plain text.** General MT tools translate strings; they don't know that `{count}`, `<0>bold</0>`, or `$t(common.footer.tagline)` must survive untouched. locale-ai is built around i18next/ICU conventions specifically, so placeholders, plurals, and nested references come out intact instead of mangled or dropped.
|
|
44
|
-
- **Incremental by default.** Add one new key to `en.json` and only that key gets translated — existing translations
|
|
44
|
+
- **Incremental by default.** Add one new key to `en.json` and only that key gets translated; edit an existing one and the lockfile catches it and re-translates just that key — existing, unrelated translations are left alone, so re-runs are cheap and safe to automate in CI.
|
|
45
45
|
- **Honest about quality.** The [Languages](#languages) section tells you which pairs are review-light vs. which need a human pass, instead of presenting every language as equally production-ready.
|
|
46
46
|
|
|
47
47
|
**When *not* to use it**: if you need professional/legal-grade translation, or languages outside the [supported list](#languages), use a human translator or a larger cloud model instead — locale-ai is built for keeping UI strings in sync across languages during development, with a review pass before shipping.
|
|
@@ -69,6 +69,7 @@ Models are cached once per machine in `~/.cache/locale-ai` (`LOCALE_AI_CACHE` to
|
|
|
69
69
|
```json
|
|
70
70
|
{
|
|
71
71
|
"doNotTranslate": ["Acme", "Acme Pro"],
|
|
72
|
+
"doNotTranslateKeys": ["brand.name", "config.statusEnum"],
|
|
72
73
|
"glossary": {
|
|
73
74
|
"fr": { "Workspace": "Espace de travail" },
|
|
74
75
|
"de": { "Sign in": "Einloggen" }
|
|
@@ -78,6 +79,8 @@ Models are cached once per machine in `~/.cache/locale-ai` (`LOCALE_AI_CACHE` to
|
|
|
78
79
|
|
|
79
80
|
`glossary` entries replace whole strings. `models` adds your own translation models (next section). A built-in glossary covers ~60 standard UI labels ("Sign in", "Settings", "Log out", ...) for fr, es, de, it, pt-BR, nl and ar, because one-to-three word labels are where sentence-level MT is weakest.
|
|
80
81
|
|
|
82
|
+
`doNotTranslate` protects a term everywhere it appears in any string. `doNotTranslateKeys` instead protects a key path (dot/`[i]` notation, e.g. `brand.name`, `items[0]`) — the whole value at that path, and everything nested under it if it's an object or array, is copied to every target verbatim. Use it for an enum array (`"statusEnum": ["ACTIVE", "PENDING"]`, which you'd otherwise have to list value-by-value in `doNotTranslate`) or a field a model might respell or inflect even when the word is listed in `doNotTranslate` elsewhere in the same string.
|
|
83
|
+
|
|
81
84
|
## Add a language, or a better model
|
|
82
85
|
|
|
83
86
|
Any [MarianMT / OPUS-MT](https://huggingface.co/Helsinki-NLP) model on Hugging Face can be used. Convert it once (needs Python):
|
package/dist/cli.js
CHANGED
|
@@ -9,7 +9,8 @@ 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 {
|
|
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
14
|
import { cacheRoot, install, isInstalled, loadTranslator, localModelProblem, planInstall, readManifest, uninstall, } from "./models.js";
|
|
14
15
|
import { mask } from "./placeholders.js";
|
|
15
16
|
/** Read from package.json so `npm version` is the single source of truth. */
|
|
@@ -54,6 +55,8 @@ const endStatus = () => {
|
|
|
54
55
|
};
|
|
55
56
|
const mb = (bytes) => `${Math.round(bytes / 1e6)} MB`;
|
|
56
57
|
const rel = (p) => path.relative(process.cwd(), p) || ".";
|
|
58
|
+
/** Portable lock-file key: forward slashes regardless of platform. */
|
|
59
|
+
const fileKey = (p) => rel(p).split(path.sep).join("/");
|
|
57
60
|
/**
|
|
58
61
|
* `Number("abc")` is NaN, and NaN silently poisons everything downstream (an
|
|
59
62
|
* all-garbage translation batch, or a raw ONNX crash deep in the model) instead of
|
|
@@ -158,8 +161,12 @@ async function cmdTranslate(argv) {
|
|
|
158
161
|
const config = loadConfig(process.cwd(), values.config);
|
|
159
162
|
const catalog = catalogFor(config);
|
|
160
163
|
const protect = protectRegex(config);
|
|
164
|
+
const isProtectedKey = keyPathProtect(config.doNotTranslateKeys);
|
|
161
165
|
const layout = detectLayout(process.cwd(), from.tag, values.input);
|
|
166
|
+
const lockPath = path.resolve(process.cwd(), LOCK_FILENAME);
|
|
167
|
+
const lock = loadLock(lockPath);
|
|
162
168
|
const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag) }));
|
|
169
|
+
const singularHints = new Set(sources.flatMap(({ doc }) => [...pluralOneStrings(doc.data)]));
|
|
163
170
|
log(`locale-ai ${VERSION}`);
|
|
164
171
|
log(`Found ${sources.length} ${from.tag} file${sources.length === 1 ? "" : "s"} in ${rel(layout.root)}/` +
|
|
165
172
|
(layout.kind === "dir" ? `${from.tag}/` : ""));
|
|
@@ -177,7 +184,14 @@ async function cmdTranslate(argv) {
|
|
|
177
184
|
const files = sources.map(({ file, doc }) => {
|
|
178
185
|
const target = targetFile(layout, from.tag, locale.tag, file);
|
|
179
186
|
const existing = fs.existsSync(target) ? readLocaleDoc(target, locale.tag).data : undefined;
|
|
180
|
-
|
|
187
|
+
const isStale = (p, s) => isSourceStale(lock, fileKey(file), p, s);
|
|
188
|
+
return {
|
|
189
|
+
source: file,
|
|
190
|
+
target,
|
|
191
|
+
doc,
|
|
192
|
+
existing,
|
|
193
|
+
pending: pendingStrings(doc.data, existing, values.overwrite, isProtectedKey, isStale),
|
|
194
|
+
};
|
|
181
195
|
});
|
|
182
196
|
const unique = new Set(files.flatMap((f) => f.pending)).size;
|
|
183
197
|
jobs.push({ locale, route, files, unique });
|
|
@@ -219,6 +233,7 @@ async function cmdTranslate(argv) {
|
|
|
219
233
|
batchSize,
|
|
220
234
|
protect,
|
|
221
235
|
lookup: glossaryLookup(from, job.locale, config),
|
|
236
|
+
singularHints,
|
|
222
237
|
onProgress: makeProgress((d, t) => `${job.locale.tag}: translating ${bar(d, t)} ${d}/${t}`),
|
|
223
238
|
});
|
|
224
239
|
endStatus();
|
|
@@ -235,7 +250,14 @@ async function cmdTranslate(argv) {
|
|
|
235
250
|
let written = 0;
|
|
236
251
|
let pluralsAdded = 0;
|
|
237
252
|
for (const f of job.files) {
|
|
238
|
-
const
|
|
253
|
+
const lockKey = fileKey(f.source);
|
|
254
|
+
const isStale = (p, s) => isSourceStale(lock, lockKey, p, s);
|
|
255
|
+
if (f.existing !== undefined) {
|
|
256
|
+
for (const editedPath of editedPaths(lock, lockKey, job.locale.tag, flattenStrings(f.existing))) {
|
|
257
|
+
allIssues.push({ source: editedPath, kind: "edited", locale: job.locale.tag });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const built = buildTarget(f.doc.data, f.existing, (s) => translations.get(s) ?? s, values.overwrite, isProtectedKey, isStale);
|
|
239
261
|
const { doc, added } = addPluralForms(built, intlCode(job.locale));
|
|
240
262
|
pluralsAdded += added;
|
|
241
263
|
const before = fs.existsSync(f.target) ? fs.readFileSync(f.target, "utf8") : undefined;
|
|
@@ -245,6 +267,8 @@ async function cmdTranslate(argv) {
|
|
|
245
267
|
fs.writeFileSync(f.target, text);
|
|
246
268
|
written++;
|
|
247
269
|
}
|
|
270
|
+
const outFlat = new Map(flattenStrings(doc).map((e) => [e.path, e.value]));
|
|
271
|
+
recordEntries(lock, lockKey, job.locale.tag, flattenStrings(f.doc.data).map(({ path: p, value }) => ({ path: p, source: value, written: outFlat.get(p) ?? "" })));
|
|
248
272
|
}
|
|
249
273
|
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
250
274
|
const issues = allIssues.filter((i) => i.locale === job.locale.tag).length;
|
|
@@ -255,6 +279,8 @@ async function cmdTranslate(argv) {
|
|
|
255
279
|
}
|
|
256
280
|
for (const t of loaded.values())
|
|
257
281
|
await t.dispose();
|
|
282
|
+
if (jobs.length)
|
|
283
|
+
saveLock(lockPath, lock);
|
|
258
284
|
if (allIssues.length) {
|
|
259
285
|
log();
|
|
260
286
|
log(`${allIssues.length} string${allIssues.length === 1 ? "" : "s"} worth a quick review:`);
|
package/dist/engine.js
CHANGED
|
@@ -208,6 +208,36 @@ async function splitFallback(units, hops, opts, progress, issues) {
|
|
|
208
208
|
issues.push({ source: unit.owner, kind: "placeholder-fallback", output: unit.result });
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
|
+
const COUNT_PLACEHOLDER = /\{\{\s*count\s*\}\}/;
|
|
212
|
+
/**
|
|
213
|
+
* A "_one" key's source is grammatically singular ("You have {{count}} file"), but the
|
|
214
|
+
* masked marker that stands in for {{count}} carries no number of its own, so the model
|
|
215
|
+
* sometimes still produces the target's PLURAL noun form (confirmed: en→fr "fileCount_one"
|
|
216
|
+
* came back "Vous avez {{count}} fichiers"). Retranslating with a literal "1" in place of
|
|
217
|
+
* {{count}} gives the model the real grammatical cue; only applied when {{count}} is the
|
|
218
|
+
* string's only placeholder (anything else needs the normal masking pipeline to survive),
|
|
219
|
+
* and only kept when the "1" survives translation intact enough to restore {{count}}.
|
|
220
|
+
*/
|
|
221
|
+
async function fixSingularGrammar(translations, singularHints, remaining, hops, opts, progress) {
|
|
222
|
+
for (const s of singularHints) {
|
|
223
|
+
if (!remaining.has(s) || translations.get(s) === undefined)
|
|
224
|
+
continue;
|
|
225
|
+
const tokens = mask(s, "natural").tokens;
|
|
226
|
+
if (tokens.length !== 1 || !COUNT_PLACEHOLDER.test(tokens[0]))
|
|
227
|
+
continue;
|
|
228
|
+
const m = COUNT_PLACEHOLDER.exec(s);
|
|
229
|
+
const pinned = s.slice(0, m.index) + "1" + s.slice(m.index + m[0].length);
|
|
230
|
+
const prepared = prepare(pinned);
|
|
231
|
+
const out = (await runModels(new Set([prepared]), hops, opts, progress)).get(prepared) ?? "";
|
|
232
|
+
if (isDegenerate(pinned, out))
|
|
233
|
+
continue;
|
|
234
|
+
const post = postprocess(pinned, out, opts.targetLang);
|
|
235
|
+
const restored = /(?<![\d.])1(?![\d.])/.exec(post);
|
|
236
|
+
if (!restored)
|
|
237
|
+
continue; // can't confirm the "1" survived: keep the original translation
|
|
238
|
+
translations.set(s, post.slice(0, restored.index) + m[0] + post.slice(restored.index + 1));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
211
241
|
export async function translateStrings(strings, hops, opts) {
|
|
212
242
|
const issues = [];
|
|
213
243
|
const progress = { done: 0, total: 0 };
|
|
@@ -236,5 +266,7 @@ export async function translateStrings(strings, hops, opts) {
|
|
|
236
266
|
const parts = p.units.map((u) => u.result ?? u.text);
|
|
237
267
|
translations.set(p.source, p.split ? p.split.rebuild(parts) : parts[0]);
|
|
238
268
|
}
|
|
269
|
+
if (opts.singularHints?.size)
|
|
270
|
+
await fixSingularGrammar(translations, opts.singularHints, new Set(remaining), hops, opts, progress);
|
|
239
271
|
return { translations, issues };
|
|
240
272
|
}
|
package/dist/glossary.js
CHANGED
|
@@ -84,6 +84,8 @@ export function loadConfig(cwd, file) {
|
|
|
84
84
|
const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
85
85
|
if (cfg.doNotTranslate && !Array.isArray(cfg.doNotTranslate))
|
|
86
86
|
throw new Error('"doNotTranslate" must be an array');
|
|
87
|
+
if (cfg.doNotTranslateKeys && !Array.isArray(cfg.doNotTranslateKeys))
|
|
88
|
+
throw new Error('"doNotTranslateKeys" must be an array');
|
|
87
89
|
if (cfg.models && !Array.isArray(cfg.models))
|
|
88
90
|
throw new Error('"models" must be an array');
|
|
89
91
|
return { ...cfg, dir: path.dirname(p) };
|
package/dist/locales.js
CHANGED
|
@@ -448,47 +448,96 @@ export function flattenStrings(data, prefix = "", out = []) {
|
|
|
448
448
|
flattenStrings(data[k], prefix ? `${prefix}.${k}` : k, out);
|
|
449
449
|
return out;
|
|
450
450
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
}
|
|
451
|
+
/**
|
|
452
|
+
* True when `path` is, or is nested under, one of `doNotTranslateKeys` — the same
|
|
453
|
+
* dot/`[i]` notation flattenStrings produces (e.g. "brand.name", "config.statusEnum").
|
|
454
|
+
* Protecting a path that points at an object or array protects every string nested
|
|
455
|
+
* under it too, which is what makes an enum array ("statusEnum": ["ACTIVE", ...])
|
|
456
|
+
* protectable without enumerating each value as a doNotTranslate term.
|
|
457
|
+
*/
|
|
458
|
+
export function keyPathProtect(doNotTranslateKeys) {
|
|
459
|
+
const keys = (doNotTranslateKeys ?? []).filter(Boolean);
|
|
460
|
+
if (!keys.length)
|
|
461
|
+
return undefined;
|
|
462
|
+
return (path) => keys.some((k) => path === k || path.startsWith(`${k}.`) || path.startsWith(`${k}[`));
|
|
463
|
+
}
|
|
464
|
+
export function pendingStrings(source, existing, overwrite, isProtected, isStale) {
|
|
465
|
+
const out = [];
|
|
466
|
+
const visit = (s, e, path) => {
|
|
467
|
+
if (typeof s === "string") {
|
|
468
|
+
if (!isProtected?.(path) && (needsTranslation(e, overwrite) || !!isStale?.(path, s)))
|
|
469
|
+
out.push(s);
|
|
470
|
+
}
|
|
471
|
+
else if (Array.isArray(s)) {
|
|
472
|
+
s.forEach((v, i) => visit(v, Array.isArray(e) ? e[i] : undefined, `${path}[${i}]`));
|
|
473
|
+
}
|
|
474
|
+
else if (isObj(s)) {
|
|
475
|
+
for (const k of Object.keys(s))
|
|
476
|
+
visit(s[k], isObj(e) ? e[k] : undefined, path ? `${path}.${k}` : k);
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
visit(source, existing, "");
|
|
463
480
|
return out;
|
|
464
481
|
}
|
|
465
482
|
/**
|
|
466
483
|
* Builds the target document: source structure and key order, existing
|
|
467
|
-
* translations kept (unless overwrite), new strings from `translate`,
|
|
468
|
-
* that only exist in the target preserved at the end.
|
|
484
|
+
* translations kept (unless overwrite or stale), new strings from `translate`,
|
|
485
|
+
* and keys that only exist in the target preserved at the end. A string at a
|
|
486
|
+
* protected path (see keyPathProtect) is always copied from the source
|
|
487
|
+
* verbatim. `isStale` (see the lock module) forces re-translation of a
|
|
488
|
+
* non-empty target when its source string has changed since it was last
|
|
489
|
+
* translated.
|
|
469
490
|
*/
|
|
470
|
-
export function buildTarget(source, existing, translate, overwrite) {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
if (isObj(source)) {
|
|
478
|
-
const out = newObj();
|
|
479
|
-
for (const k of Object.keys(source)) {
|
|
480
|
-
out[k] = buildTarget(source[k], isObj(existing) ? existing[k] : undefined, translate, overwrite);
|
|
491
|
+
export function buildTarget(source, existing, translate, overwrite, isProtected, isStale) {
|
|
492
|
+
const visit = (s, e, path) => {
|
|
493
|
+
if (typeof s === "string") {
|
|
494
|
+
if (isProtected?.(path))
|
|
495
|
+
return s;
|
|
496
|
+
return needsTranslation(e, overwrite) || isStale?.(path, s) ? translate(s) : e;
|
|
481
497
|
}
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
498
|
+
if (Array.isArray(s))
|
|
499
|
+
return s.map((v, i) => visit(v, Array.isArray(e) ? e[i] : undefined, `${path}[${i}]`));
|
|
500
|
+
if (isObj(s)) {
|
|
501
|
+
const out = newObj();
|
|
502
|
+
for (const k of Object.keys(s))
|
|
503
|
+
out[k] = visit(s[k], isObj(e) ? e[k] : undefined, path ? `${path}.${k}` : k);
|
|
504
|
+
if (isObj(e))
|
|
505
|
+
for (const k of Object.keys(e))
|
|
506
|
+
if (!(k in out))
|
|
507
|
+
out[k] = e[k];
|
|
508
|
+
return out;
|
|
509
|
+
}
|
|
510
|
+
return s;
|
|
511
|
+
};
|
|
512
|
+
return visit(source, existing, "");
|
|
489
513
|
}
|
|
490
514
|
const CATEGORIES = ["zero", "one", "two", "few", "many", "other"];
|
|
491
515
|
const PLURAL_KEY = /^(.+?)(_ordinal)?_(zero|one|two|few|many|other)$/;
|
|
516
|
+
/**
|
|
517
|
+
* Source strings at a cardinal i18next "_one" plural key ("itemCount_one": "{{count}}
|
|
518
|
+
* item"). These are grammatically singular in English, but the model sometimes infers
|
|
519
|
+
* the TARGET's plural form anyway because {{count}} reads as an opaque marker rather
|
|
520
|
+
* than a concrete number — the engine uses this set to retranslate with a real "1" in
|
|
521
|
+
* place of {{count}} and recover the correct grammatical number (see engine.ts).
|
|
522
|
+
*/
|
|
523
|
+
export function pluralOneStrings(data, out = new Set()) {
|
|
524
|
+
const visit = (v) => {
|
|
525
|
+
if (Array.isArray(v)) {
|
|
526
|
+
v.forEach(visit);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (!isObj(v))
|
|
530
|
+
return;
|
|
531
|
+
for (const k of Object.keys(v)) {
|
|
532
|
+
const m = PLURAL_KEY.exec(k);
|
|
533
|
+
if (m && !m[2] && m[3] === "one" && typeof v[k] === "string")
|
|
534
|
+
out.add(v[k]);
|
|
535
|
+
visit(v[k]);
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
visit(data);
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
492
541
|
/**
|
|
493
542
|
* i18next stores plurals as `key_one` / `key_other`. Languages such as Arabic,
|
|
494
543
|
* Russian or Polish need more forms (`_few`, `_many` ...) and i18next does not fall
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
export const LOCK_FILENAME = "locale-ai.lock.json";
|
|
4
|
+
export function hashOf(s) {
|
|
5
|
+
return crypto.createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
|
|
6
|
+
}
|
|
7
|
+
export function loadLock(file) {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
10
|
+
if (parsed && typeof parsed === "object" && parsed.files)
|
|
11
|
+
return { version: 1, files: parsed.files };
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// Missing or invalid: start fresh rather than fail the run.
|
|
15
|
+
}
|
|
16
|
+
return { version: 1, files: {} };
|
|
17
|
+
}
|
|
18
|
+
export function saveLock(file, lock) {
|
|
19
|
+
fs.writeFileSync(file, `${JSON.stringify(lock, null, 2)}\n`);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* True when `path`'s source string in `fileKey` has changed since it was last
|
|
23
|
+
* translated — meaning the existing target text is stale and needs redoing
|
|
24
|
+
* even though it's non-empty. False for a path the lock has never seen (new
|
|
25
|
+
* adoption, or a brand-new key, both already handled by the empty-target check).
|
|
26
|
+
*/
|
|
27
|
+
export function isSourceStale(lock, fileKey, path, source) {
|
|
28
|
+
const entry = lock.files[fileKey]?.[path];
|
|
29
|
+
return entry !== undefined && entry.source !== hashOf(source);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Key paths whose existing target text no longer matches the output this tool
|
|
33
|
+
* last wrote there for `targetTag` — i.e. a translator edited it since. Only
|
|
34
|
+
* reported; the edit is never overwritten.
|
|
35
|
+
*/
|
|
36
|
+
export function editedPaths(lock, fileKey, targetTag, entries) {
|
|
37
|
+
const file = lock.files[fileKey];
|
|
38
|
+
if (!file)
|
|
39
|
+
return [];
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const { path, value } of entries) {
|
|
42
|
+
const mt = file[path]?.mt[targetTag];
|
|
43
|
+
if (mt !== undefined && mt !== hashOf(value))
|
|
44
|
+
out.push(path);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/** Records, for every path translated this run, the current source hash and the hash of what ended up on disk. */
|
|
49
|
+
export function recordEntries(lock, fileKey, targetTag, entries) {
|
|
50
|
+
const file = (lock.files[fileKey] ??= {});
|
|
51
|
+
for (const { path, source, written } of entries) {
|
|
52
|
+
const entry = (file[path] ??= { source: hashOf(source), mt: {} });
|
|
53
|
+
entry.source = hashOf(source);
|
|
54
|
+
entry.mt[targetTag] = hashOf(written);
|
|
55
|
+
}
|
|
56
|
+
}
|