localship 0.1.0 → 0.3.0
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 +54 -44
- package/dist/{chunk-OWT47JBL.js → chunk-GNL5EFNP.js} +1017 -517
- package/dist/cli.js +72 -41
- package/dist/{server-GZNS57EQ.js → server-5AITZXZV.js} +30 -18
- package/package.json +2 -1
- package/skills/localship/SKILL.md +66 -43
- package/skills/localship-extract/SKILL.md +123 -39
- package/skills/localship-extract/references/platform-localization.md +65 -0
- package/skills/localship-review/SKILL.md +11 -2
- package/skills/localship-setup/SKILL.md +50 -36
- package/skills/localship-translate/SKILL.md +32 -19
|
@@ -341,15 +341,58 @@ async function addLocales(fs, file, requested) {
|
|
|
341
341
|
}
|
|
342
342
|
|
|
343
343
|
// src/core/fs.ts
|
|
344
|
+
import { randomUUID } from "crypto";
|
|
344
345
|
import { promises as fsp } from "fs";
|
|
346
|
+
import os from "os";
|
|
345
347
|
import path2 from "path";
|
|
348
|
+
function busy(file) {
|
|
349
|
+
return new UserError(
|
|
350
|
+
`Another Localship write is in progress (${file}). Retry after it finishes. If a process crashed, verify no writer is running before removing this lock.`
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
async function renameWithRetry(from, to) {
|
|
354
|
+
for (let attempt = 0; ; attempt++) {
|
|
355
|
+
try {
|
|
356
|
+
await fsp.rename(from, to);
|
|
357
|
+
return;
|
|
358
|
+
} catch (e) {
|
|
359
|
+
const code = e.code;
|
|
360
|
+
if (attempt >= 20 || !(code === "EPERM" || code === "EACCES" || code === "EBUSY")) throw e;
|
|
361
|
+
await new Promise((resolve) => setTimeout(resolve, 10 + attempt * 10));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
346
365
|
var nodeFs = {
|
|
347
366
|
readFile: (file) => fsp.readFile(file, "utf8"),
|
|
348
367
|
async writeFile(file, content) {
|
|
349
368
|
await fsp.mkdir(path2.dirname(file), { recursive: true });
|
|
350
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
351
|
-
|
|
352
|
-
|
|
369
|
+
const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
370
|
+
try {
|
|
371
|
+
await fsp.writeFile(tmp, content, { encoding: "utf8", flag: "wx" });
|
|
372
|
+
await renameWithRetry(tmp, file);
|
|
373
|
+
} finally {
|
|
374
|
+
await fsp.rm(tmp, { force: true });
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
async withLock(file, run) {
|
|
378
|
+
await fsp.mkdir(path2.dirname(file), { recursive: true });
|
|
379
|
+
const handle = await fsp.open(file, "wx").catch((e) => {
|
|
380
|
+
if (e.code === "EEXIST") throw busy(file);
|
|
381
|
+
throw e;
|
|
382
|
+
});
|
|
383
|
+
try {
|
|
384
|
+
await handle.writeFile(
|
|
385
|
+
JSON.stringify({
|
|
386
|
+
pid: process.pid,
|
|
387
|
+
host: os.hostname(),
|
|
388
|
+
started: (/* @__PURE__ */ new Date()).toISOString()
|
|
389
|
+
})
|
|
390
|
+
);
|
|
391
|
+
return await run();
|
|
392
|
+
} finally {
|
|
393
|
+
await handle.close();
|
|
394
|
+
await fsp.unlink(file);
|
|
395
|
+
}
|
|
353
396
|
},
|
|
354
397
|
async exists(file) {
|
|
355
398
|
try {
|
|
@@ -426,6 +469,29 @@ function voiceFor(context, locale2) {
|
|
|
426
469
|
const text = parts.filter(Boolean).join("\n\n");
|
|
427
470
|
return text || void 0;
|
|
428
471
|
}
|
|
472
|
+
var VOICE_CONVENTIONS = {
|
|
473
|
+
hi: "Address the user as \u0906\u092A (polite). Everyday Hindi over Sanskritized words; common English tech words (app, sync) may stay in English.",
|
|
474
|
+
ar: "Modern Standard Arabic, addressing the user neutrally. Short sentences.",
|
|
475
|
+
de: "Address the user as du, as most consumer apps do.",
|
|
476
|
+
fr: "Address the user as vous.",
|
|
477
|
+
es: "Address the user as t\xFA. Neutral Spanish that works in Spain and Latin America.",
|
|
478
|
+
it: "Address the user as tu.",
|
|
479
|
+
"pt-BR": "Address the user as voc\xEA (Brazilian Portuguese conventions).",
|
|
480
|
+
"pt-PT": "European Portuguese spelling and vocabulary (utilizador, ecr\xE3), not Brazilian.",
|
|
481
|
+
nl: "Address the user as je.",
|
|
482
|
+
ru: "Address the user as \u0432\u044B (lowercase).",
|
|
483
|
+
ja: "Polite \u3067\u3059/\u307E\u3059 form. Short, natural UI phrasing.",
|
|
484
|
+
ko: "Polite \uD574\uC694\uCCB4. Short, natural UI phrasing.",
|
|
485
|
+
zh: "Neutral, concise UI language; no honorifics needed.",
|
|
486
|
+
tr: "Address the user as siz.",
|
|
487
|
+
pl: "Address the user informally (ty), as most consumer apps do.",
|
|
488
|
+
id: "Address the user as Anda.",
|
|
489
|
+
vi: "Address the user as b\u1EA1n.",
|
|
490
|
+
th: "Polite, neutral tone; use \u0E04\u0E23\u0E31\u0E1A/\u0E04\u0E48\u0E30 only where a sentence needs it."
|
|
491
|
+
};
|
|
492
|
+
function conventionalVoice(locale2) {
|
|
493
|
+
return VOICE_CONVENTIONS[locale2] ?? VOICE_CONVENTIONS[locale2.split("-")[0]] ?? "Polite, neutral tone, as a well-made app in this language would use.";
|
|
494
|
+
}
|
|
429
495
|
var WORD = "[\\p{L}\\p{M}\\p{N}]";
|
|
430
496
|
var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
431
497
|
function glossaryHits(glossary, text) {
|
|
@@ -604,8 +670,8 @@ async function matchPattern(fs, root, pattern) {
|
|
|
604
670
|
}
|
|
605
671
|
return out.sort((a, b) => a.locale.localeCompare(b.locale));
|
|
606
672
|
}
|
|
607
|
-
function inferLocaleStyle(
|
|
608
|
-
for (const m of
|
|
673
|
+
function inferLocaleStyle(matches2, fallback) {
|
|
674
|
+
for (const m of matches2) {
|
|
609
675
|
if (/[_@]/.test(m.segment)) return "gettext";
|
|
610
676
|
if (m.segment.includes("-")) return "bcp47";
|
|
611
677
|
}
|
|
@@ -615,18 +681,40 @@ function inferLocaleStyle(matches, fallback) {
|
|
|
615
681
|
// src/core/plurals.ts
|
|
616
682
|
var SAMPLE_INTEGERS = [...Array.from({ length: 1001 }, (_, i) => i), 1e4, 1e6];
|
|
617
683
|
var SAMPLE_DECIMALS = [0.5, 1.5, 2.5];
|
|
684
|
+
var specs = /* @__PURE__ */ new Map();
|
|
685
|
+
var copySpec = (spec) => ({
|
|
686
|
+
...spec,
|
|
687
|
+
forms: spec.forms.map((form) => ({ ...form }))
|
|
688
|
+
});
|
|
618
689
|
function pluralCategories(locale2) {
|
|
619
690
|
const found = new Intl.PluralRules(locale2).resolvedOptions().pluralCategories;
|
|
620
691
|
return PLURAL_CATEGORIES.filter((c) => found.includes(c));
|
|
621
692
|
}
|
|
622
693
|
function cldrPluralSpec(locale2) {
|
|
694
|
+
const key = Intl.getCanonicalLocales(locale2)[0] ?? locale2;
|
|
695
|
+
const cached = specs.get(key);
|
|
696
|
+
if (cached) return copySpec(cached);
|
|
623
697
|
const rules = new Intl.PluralRules(locale2);
|
|
624
|
-
|
|
698
|
+
const integers = /* @__PURE__ */ new Map();
|
|
699
|
+
const fractions = /* @__PURE__ */ new Map();
|
|
700
|
+
for (const [samples, grouped] of [
|
|
701
|
+
[SAMPLE_INTEGERS, integers],
|
|
702
|
+
[SAMPLE_DECIMALS, fractions]
|
|
703
|
+
]) {
|
|
704
|
+
for (const n of samples) {
|
|
705
|
+
const category = rules.select(n);
|
|
706
|
+
const values = grouped.get(category) ?? [];
|
|
707
|
+
values.push(n);
|
|
708
|
+
grouped.set(category, values);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const categories = rules.resolvedOptions().pluralCategories;
|
|
712
|
+
const spec = {
|
|
625
713
|
scheme: "cldr",
|
|
626
714
|
source: "CLDR",
|
|
627
|
-
forms:
|
|
628
|
-
const ints =
|
|
629
|
-
const decimals =
|
|
715
|
+
forms: PLURAL_CATEGORIES.filter((id2) => categories.includes(id2)).map((id2) => {
|
|
716
|
+
const ints = integers.get(id2) ?? [];
|
|
717
|
+
const decimals = fractions.get(id2) ?? [];
|
|
630
718
|
return {
|
|
631
719
|
id: id2,
|
|
632
720
|
examples: describeSamples(ints, decimals),
|
|
@@ -635,6 +723,10 @@ function cldrPluralSpec(locale2) {
|
|
|
635
723
|
};
|
|
636
724
|
})
|
|
637
725
|
};
|
|
726
|
+
const oldest = specs.keys().next().value;
|
|
727
|
+
if (specs.size >= 256 && oldest !== void 0) specs.delete(oldest);
|
|
728
|
+
specs.set(key, spec);
|
|
729
|
+
return copySpec(spec);
|
|
638
730
|
}
|
|
639
731
|
function describeSamples(ints, decimals = []) {
|
|
640
732
|
const shown = ints.slice(0, 5).map(String);
|
|
@@ -1020,6 +1112,7 @@ var androidAdapter = {
|
|
|
1020
1112
|
const issues = [];
|
|
1021
1113
|
const rootAttrs = /* @__PURE__ */ new Map();
|
|
1022
1114
|
const localeFiles = /* @__PURE__ */ new Map();
|
|
1115
|
+
let hasQualifiedStrings = false;
|
|
1023
1116
|
const target = {
|
|
1024
1117
|
id: id2,
|
|
1025
1118
|
format: "android-xml",
|
|
@@ -1045,8 +1138,7 @@ var androidAdapter = {
|
|
|
1045
1138
|
const dirs = (await fs.listDir(resDir)).filter((d) => d.isDirectory && d.name.startsWith("values")).map((d) => d.name).sort();
|
|
1046
1139
|
for (const dir of dirs) {
|
|
1047
1140
|
const locale2 = folderToLocale(dir);
|
|
1048
|
-
|
|
1049
|
-
const catalog = locale2 === null ? source : { locale: locale2, entries: /* @__PURE__ */ new Map() };
|
|
1141
|
+
const catalog = locale2 === null ? source : { locale: locale2 ?? sourceLocale, entries: /* @__PURE__ */ new Map() };
|
|
1050
1142
|
const xmlFiles = (await fs.listDir(path5.join(resDir, dir))).filter((f) => !f.isDirectory && f.name.endsWith(".xml")).map((f) => f.name).sort();
|
|
1051
1143
|
for (const name of xmlFiles) {
|
|
1052
1144
|
const abs = path5.join(resDir, dir, name);
|
|
@@ -1069,6 +1161,30 @@ var androidAdapter = {
|
|
|
1069
1161
|
}
|
|
1070
1162
|
const resources = doc.root;
|
|
1071
1163
|
if (resources?.name !== "resources") continue;
|
|
1164
|
+
if (locale2 === void 0) {
|
|
1165
|
+
const qualified = { locale: sourceLocale, entries: /* @__PURE__ */ new Map() };
|
|
1166
|
+
readEntries(
|
|
1167
|
+
doc,
|
|
1168
|
+
resources,
|
|
1169
|
+
rel,
|
|
1170
|
+
qualified,
|
|
1171
|
+
(issue) => issues.push({ ...issue, target: id2 })
|
|
1172
|
+
);
|
|
1173
|
+
if (qualified.entries.size > 0) hasQualifiedStrings = true;
|
|
1174
|
+
const entries = [...qualified.entries.values()].filter((e) => e.translatable);
|
|
1175
|
+
if (entries.length > 0) {
|
|
1176
|
+
issues.push({
|
|
1177
|
+
severity: "warning",
|
|
1178
|
+
blocksCompleteness: true,
|
|
1179
|
+
code: "resource-configuration-unsupported",
|
|
1180
|
+
message: `${entries.length} string resource${entries.length === 1 ? "" : "s"} in ${dir} use configuration qualifiers that Localship cannot validate or translate. Preserved unchanged; check this configuration with Android lint and on a device.`,
|
|
1181
|
+
target: id2,
|
|
1182
|
+
file: rel,
|
|
1183
|
+
line: entries[0]?.line
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1072
1188
|
const before = catalog.entries.size;
|
|
1073
1189
|
const spans = /* @__PURE__ */ new Map();
|
|
1074
1190
|
readEntries(
|
|
@@ -1087,7 +1203,17 @@ var androidAdapter = {
|
|
|
1087
1203
|
);
|
|
1088
1204
|
}
|
|
1089
1205
|
}
|
|
1090
|
-
if (locale2 !== null && catalog.entries.size > 0)
|
|
1206
|
+
if (locale2 !== null && locale2 !== void 0 && catalog.entries.size > 0)
|
|
1207
|
+
locales.set(locale2, catalog);
|
|
1208
|
+
}
|
|
1209
|
+
if (source.entries.size === 0 && (locales.size > 0 || hasQualifiedStrings)) {
|
|
1210
|
+
issues.push({
|
|
1211
|
+
severity: "error",
|
|
1212
|
+
code: "default-resources-missing",
|
|
1213
|
+
message: "String resources exist in qualified folders, but this target has no default strings in values. Restore the defaults or configure the target containing them; verify merged resources with Android lint.",
|
|
1214
|
+
target: id2,
|
|
1215
|
+
file: relativePath(root, path5.join(resDir, "values"))
|
|
1216
|
+
});
|
|
1091
1217
|
}
|
|
1092
1218
|
async function writeLocale(catalog) {
|
|
1093
1219
|
const folder = path5.join(resDir, localeToFolder(catalog.locale));
|
|
@@ -1771,14 +1897,14 @@ function escapeString(s) {
|
|
|
1771
1897
|
for (const c of s) out += ESCAPES[c] ?? c;
|
|
1772
1898
|
return out;
|
|
1773
1899
|
}
|
|
1774
|
-
function formatString(keyword, value,
|
|
1900
|
+
function formatString(keyword, value, width2) {
|
|
1775
1901
|
const segments = value.split(/(?<=\n)/).map(escapeString);
|
|
1776
1902
|
if (segments.length === 0) segments.push("");
|
|
1777
1903
|
const single = `${keyword} "${segments.join("")}"`;
|
|
1778
1904
|
const innerNewline = value.indexOf("\n") >= 0 && value.indexOf("\n") < value.length - 1;
|
|
1779
|
-
if (!innerNewline && (
|
|
1780
|
-
const pieces =
|
|
1781
|
-
if (!innerNewline &&
|
|
1905
|
+
if (!innerNewline && (width2 === false || displayWidth(single) <= width2)) return [single];
|
|
1906
|
+
const pieces = width2 === false ? segments : segments.flatMap((seg) => wrapSegment(seg, width2 - 2));
|
|
1907
|
+
if (!innerNewline && width2 !== false && pieces.length === 1 && displayWidth(pieces[0] ?? "") > width2 - 2) {
|
|
1782
1908
|
return [single];
|
|
1783
1909
|
}
|
|
1784
1910
|
return [`${keyword} ""`, ...pieces.map((s) => `"${s}"`)];
|
|
@@ -1826,12 +1952,12 @@ function charWidth(c) {
|
|
|
1826
1952
|
if (ZERO_WIDTH.test(c)) return 0;
|
|
1827
1953
|
return WIDE.test(c) ? 2 : 1;
|
|
1828
1954
|
}
|
|
1829
|
-
function wrapReferences(refs,
|
|
1830
|
-
if (
|
|
1955
|
+
function wrapReferences(refs, width2) {
|
|
1956
|
+
if (width2 === false) return [`#: ${refs.join(" ")}`];
|
|
1831
1957
|
const lines = [];
|
|
1832
1958
|
let line = "#:";
|
|
1833
1959
|
for (const r of refs) {
|
|
1834
|
-
if (line !== "#:" && displayWidth(line) + 1 + displayWidth(r) >
|
|
1960
|
+
if (line !== "#:" && displayWidth(line) + 1 + displayWidth(r) > width2) {
|
|
1835
1961
|
lines.push(line);
|
|
1836
1962
|
line = "#:";
|
|
1837
1963
|
}
|
|
@@ -1922,9 +2048,9 @@ var poAdapter = {
|
|
|
1922
2048
|
}
|
|
1923
2049
|
return byKey;
|
|
1924
2050
|
};
|
|
1925
|
-
const
|
|
1926
|
-
const style = inferLocaleStyle(
|
|
1927
|
-
for (const m of
|
|
2051
|
+
const matches2 = await matchPattern(fs, root, config.path);
|
|
2052
|
+
const style = inferLocaleStyle(matches2, "gettext");
|
|
2053
|
+
for (const m of matches2) {
|
|
1928
2054
|
const po = await read2(m.file, m.locale);
|
|
1929
2055
|
if (po) files.set(m.locale, { file: m.file, po, byKey: indexEntries(po, m.file, m.locale) });
|
|
1930
2056
|
}
|
|
@@ -2387,6 +2513,11 @@ function deletePath(root, path17) {
|
|
|
2387
2513
|
// src/formats/xcstrings/adapter.ts
|
|
2388
2514
|
var order = (a, b) => a.localeCompare(b, "en");
|
|
2389
2515
|
var UNREVIEWED = /* @__PURE__ */ new Set(["needs_review", "new", "stale"]);
|
|
2516
|
+
function hasUnsupportedVariation(loc) {
|
|
2517
|
+
return !!loc.substitutions || !!loc.variations && (Object.keys(loc.variations).some((axis) => axis !== "plural") || Object.values(loc.variations.plural ?? {}).some(
|
|
2518
|
+
(form) => form.variations || form.substitutions
|
|
2519
|
+
));
|
|
2520
|
+
}
|
|
2390
2521
|
var xcstringsAdapter = {
|
|
2391
2522
|
format: "xcstrings",
|
|
2392
2523
|
async open(config, { fs, root, sourceLocale, id: id2 }) {
|
|
@@ -2474,23 +2605,35 @@ var xcstringsAdapter = {
|
|
|
2474
2605
|
});
|
|
2475
2606
|
}
|
|
2476
2607
|
const strings = child(doc, "strings");
|
|
2477
|
-
let unsupported = 0;
|
|
2478
2608
|
for (const e of strings?.kind === "object" ? strings.entries : []) {
|
|
2479
2609
|
const key = e.key;
|
|
2480
2610
|
const info = toJs(e.value) ?? {};
|
|
2481
2611
|
const locs = info.localizations ?? {};
|
|
2482
|
-
const variant = Object.values(locs).some(
|
|
2483
|
-
if (variant) unsupported++;
|
|
2612
|
+
const variant = Object.values(locs).some(hasUnsupportedVariation);
|
|
2484
2613
|
const line = lineOf(e.offset);
|
|
2614
|
+
const intended = key !== "" && info.shouldTranslate !== false && info.extractionState !== "stale";
|
|
2615
|
+
if (intended && variant) {
|
|
2616
|
+
issues.push({
|
|
2617
|
+
severity: "warning",
|
|
2618
|
+
blocksCompleteness: true,
|
|
2619
|
+
code: "variation-unsupported",
|
|
2620
|
+
message: "Device, nested or other unsupported variations or substitutions are present; this entire key is preserved but excluded from translation and validation. Review it in Xcode; Localship cannot certify completeness for this catalog.",
|
|
2621
|
+
target: id2,
|
|
2622
|
+
key,
|
|
2623
|
+
file: rel,
|
|
2624
|
+
line
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
const own = read(locs[sourceLanguage]);
|
|
2485
2628
|
const base = {
|
|
2486
2629
|
key,
|
|
2487
2630
|
// "" is Xcode's placeholder for empty Text(""); stale strings are gone from the code.
|
|
2488
|
-
|
|
2631
|
+
// Keys with no words (" ", "·", "%@%@") come from text built in pieces: nothing to translate.
|
|
2632
|
+
translatable: intended && !variant && hasWords(own?.message ?? { kind: "text", value: key }),
|
|
2489
2633
|
file: rel,
|
|
2490
2634
|
line
|
|
2491
2635
|
};
|
|
2492
2636
|
if (info.comment) base.comment = info.comment;
|
|
2493
|
-
const own = read(locs[sourceLanguage]);
|
|
2494
2637
|
source.entries.set(key, { ...base, message: own?.message ?? { kind: "text", value: key } });
|
|
2495
2638
|
if (!base.translatable) continue;
|
|
2496
2639
|
for (const [locale2, loc] of Object.entries(locs)) {
|
|
@@ -2507,15 +2650,6 @@ var xcstringsAdapter = {
|
|
|
2507
2650
|
catalog.entries.set(key, entry);
|
|
2508
2651
|
}
|
|
2509
2652
|
}
|
|
2510
|
-
if (unsupported > 0) {
|
|
2511
|
-
issues.push({
|
|
2512
|
-
severity: "info",
|
|
2513
|
-
code: "variation-unsupported",
|
|
2514
|
-
message: `${unsupported} string${unsupported === 1 ? " uses" : "s use"} device variations or substitutions, which aren't supported yet; skipped`,
|
|
2515
|
-
target: id2,
|
|
2516
|
-
file: rel
|
|
2517
|
-
});
|
|
2518
|
-
}
|
|
2519
2653
|
async function writeLocale(catalog) {
|
|
2520
2654
|
if (!doc) throw new Error(`${rel} could not be read, so it can't be written`);
|
|
2521
2655
|
for (const entry of catalog.entries.values()) {
|
|
@@ -2592,6 +2726,11 @@ function sortDeep(v) {
|
|
|
2592
2726
|
Object.keys(v).sort(order).map((k) => [k, sortDeep(v[k])])
|
|
2593
2727
|
);
|
|
2594
2728
|
}
|
|
2729
|
+
var FORMAT_SPECIFIER = /%(?:\d+\$)?[-+ #0]*\d*(?:\.\d+)?(?:hh|h|ll|l|q|z|t|j)?[@dDuUxXoOfFeEgGcCsSpaA%]/g;
|
|
2730
|
+
function hasWords(message) {
|
|
2731
|
+
const values = message.kind === "text" ? [message.value] : message.kind === "plural" ? Object.values(message.forms) : message.items;
|
|
2732
|
+
return values.some((v) => new RegExp("\\p{L}", "u").test(v.replace(FORMAT_SPECIFIER, "")));
|
|
2733
|
+
}
|
|
2595
2734
|
|
|
2596
2735
|
// src/core/lock.ts
|
|
2597
2736
|
function emptyLock() {
|
|
@@ -2658,6 +2797,20 @@ async function openProject(fs, opts) {
|
|
|
2658
2797
|
fs
|
|
2659
2798
|
};
|
|
2660
2799
|
}
|
|
2800
|
+
function projectGlossary(project) {
|
|
2801
|
+
const known = new Set(project.context.glossary.map((t) => t.term.toLowerCase()));
|
|
2802
|
+
const extra = [];
|
|
2803
|
+
for (const { target } of project.targets) {
|
|
2804
|
+
if (target.format !== "android-xml") continue;
|
|
2805
|
+
const entry = target.source.entries.get("app_name");
|
|
2806
|
+
const value = entry?.message.kind === "text" ? messageValues(entry.message)[0]?.[1] : void 0;
|
|
2807
|
+
const name = entry && value ? target.analyze(value, entry).text.trim() : "";
|
|
2808
|
+
if (!name || known.has(name.toLowerCase())) continue;
|
|
2809
|
+
known.add(name.toLowerCase());
|
|
2810
|
+
extra.push({ term: name, translate: false, caseSensitive: false });
|
|
2811
|
+
}
|
|
2812
|
+
return extra.length ? [...project.context.glossary, ...extra] : project.context.glossary;
|
|
2813
|
+
}
|
|
2661
2814
|
function openTarget(fs, root, config, sourceLocale, id2) {
|
|
2662
2815
|
const adapter = ADAPTERS[config.format];
|
|
2663
2816
|
return adapter.open(config, { fs, root, sourceLocale, id: id2 });
|
|
@@ -2665,6 +2818,15 @@ function openTarget(fs, root, config, sourceLocale, id2) {
|
|
|
2665
2818
|
function reopenProject(project) {
|
|
2666
2819
|
return openProject(project.fs, { cwd: project.root, config: project.configFile });
|
|
2667
2820
|
}
|
|
2821
|
+
async function withProjectWrite(project, dryRun, run) {
|
|
2822
|
+
if (dryRun) return run(project);
|
|
2823
|
+
return project.fs.withLock(path8.join(project.root, ".localship", "write.lock"), async () => {
|
|
2824
|
+
const fresh = await reopenProject(project);
|
|
2825
|
+
const result = await run(fresh);
|
|
2826
|
+
project.lock = fresh.lock;
|
|
2827
|
+
return result;
|
|
2828
|
+
});
|
|
2829
|
+
}
|
|
2668
2830
|
function findTarget(project, id2) {
|
|
2669
2831
|
return project.targets.find((t) => t.target.id === id2)?.target;
|
|
2670
2832
|
}
|
|
@@ -3167,7 +3329,7 @@ async function detectXcstrings(fs, root, files) {
|
|
|
3167
3329
|
locales: [...t.locales.keys()].sort(),
|
|
3168
3330
|
...declared ? { sourceLocale: declared } : {},
|
|
3169
3331
|
questions: strings === 0 ? [
|
|
3170
|
-
"The catalog is empty. Build the
|
|
3332
|
+
"The catalog is empty. Build the targets that use it, then fill it: Xcode's catalog editor does this with the project open; from the command line, run `xcrun xcstringstool sync <catalog> --stringsdata <each target's .stringsdata in DerivedData>`, since a command-line build alone leaves the catalog unchanged. Then run detect again."
|
|
3171
3333
|
] : []
|
|
3172
3334
|
});
|
|
3173
3335
|
}
|
|
@@ -3209,12 +3371,12 @@ function hints(files, candidates, unsupported, hardcoded) {
|
|
|
3209
3371
|
}
|
|
3210
3372
|
const has = (format) => candidates.some((c) => c.target.format === format);
|
|
3211
3373
|
const appleLegacy = unsupported.some((u) => u.format.startsWith("Apple"));
|
|
3212
|
-
const
|
|
3213
|
-
|
|
3214
|
-
);
|
|
3374
|
+
const generator = files.some((f) => /(^|\/)project\.yml$/.test(f)) ? "XcodeGen (project.yml)" : files.some((f) => /(^|\/)Project\.swift$/.test(f)) ? "Tuist (Project.swift)" : void 0;
|
|
3375
|
+
const xcode = !!generator || files.some((f) => f.endsWith(".xcodeproj/project.pbxproj"));
|
|
3215
3376
|
if (xcode && !has("xcstrings") && !appleLegacy) {
|
|
3377
|
+
const add2 = generator ? `The project is generated by ${generator}: put a Localizable.xcstrings in each target's sources and set SWIFT_EMIT_LOC_STRINGS there, then regenerate; changes made in Xcode are lost when the project is regenerated` : "Add one in Xcode (File \u203A New \u203A File \u203A String Catalog, named Localizable) to each target that shows text";
|
|
3216
3378
|
out.push(
|
|
3217
|
-
`This looks like an Xcode project without a String Catalog.
|
|
3379
|
+
`This looks like an Xcode project without a String Catalog. ${add2}. Build, and fill the catalog from the build: Xcode's catalog editor does this; from the command line use \`xcrun xcstringstool sync\`. Text built from variables needs moving to String(localized:) first (the localship-extract skill helps)${iosCode ? `; UIKit code also has ${hardcodedSummary(iosCode)}` : ""}. Then run detect again.`
|
|
3218
3380
|
);
|
|
3219
3381
|
}
|
|
3220
3382
|
const gradle = files.some((f) => /(^|\/)build\.gradle(\.kts)?$/.test(f));
|
|
@@ -3276,6 +3438,7 @@ function entryState(source, target, lock) {
|
|
|
3276
3438
|
if (!lock) return target.needsReview ? "draft" : "untracked";
|
|
3277
3439
|
if (lock.source !== hashMessage(source.message)) return "stale";
|
|
3278
3440
|
if (lock.target !== hashMessage(target.message)) return "edited";
|
|
3441
|
+
if (target.needsReview && lock.state === "approved") return "draft";
|
|
3279
3442
|
return lock.state;
|
|
3280
3443
|
}
|
|
3281
3444
|
function editedByHand(target, lock) {
|
|
@@ -3305,6 +3468,15 @@ function computeStatus(project, locales = projectLocales(project)) {
|
|
|
3305
3468
|
function statusNotes(project, locales = projectLocales(project)) {
|
|
3306
3469
|
const notes = [];
|
|
3307
3470
|
for (const { target: loaded } of project.targets) {
|
|
3471
|
+
for (const issue of loaded.issues) {
|
|
3472
|
+
if (!issue.blocksCompleteness || issue.severity === "error") continue;
|
|
3473
|
+
notes.push({
|
|
3474
|
+
target: loaded.id,
|
|
3475
|
+
severity: "warning",
|
|
3476
|
+
code: issue.code,
|
|
3477
|
+
message: `${issue.file ? `${issue.file}: ` : ""}${issue.message}`
|
|
3478
|
+
});
|
|
3479
|
+
}
|
|
3308
3480
|
const errors = loaded.issues.filter((i) => i.severity === "error");
|
|
3309
3481
|
const byCode = /* @__PURE__ */ new Map();
|
|
3310
3482
|
for (const e of errors) byCode.set(e.code, (byCode.get(e.code) ?? 0) + 1);
|
|
@@ -3327,7 +3499,7 @@ function statusNotes(project, locales = projectLocales(project)) {
|
|
|
3327
3499
|
locale: locale2,
|
|
3328
3500
|
severity: "warning",
|
|
3329
3501
|
code: "orphan",
|
|
3330
|
-
message: `${orphans.length} translation${orphans.length === 1 ? "" : "s"}
|
|
3502
|
+
message: `${orphans.length} translation${orphans.length === 1 ? "" : "s"} without a matching source; restore missing defaults or, after confirming the keys are unused, remove them with \`localship prune\``
|
|
3331
3503
|
});
|
|
3332
3504
|
}
|
|
3333
3505
|
}
|
|
@@ -3520,31 +3692,30 @@ function printPlan(p) {
|
|
|
3520
3692
|
if (p.locales.length) {
|
|
3521
3693
|
const per = Math.max(0, ...Object.values(p.work));
|
|
3522
3694
|
const total = Object.values(p.work).reduce((a, b) => a + b, 0);
|
|
3523
|
-
const
|
|
3524
|
-
const count = p.locales.length > 1 ? `${about}${per} strings, ${total} translations` : `${about}${per} strings`;
|
|
3695
|
+
const count = p.locales.length > 1 ? `${per} strings, ${total} translations` : `${per} strings`;
|
|
3525
3696
|
console.log(
|
|
3526
3697
|
`Languages: ${p.locales.map((l) => `${l.name} (${l.code}${l.isNew ? ", new" : ""})`).join(", ")} \xB7 ${count}`
|
|
3527
3698
|
);
|
|
3528
3699
|
}
|
|
3529
3700
|
for (const w of p.warnings) console.log(`${yellow("\u25B2")} ${w}`);
|
|
3530
|
-
for (const b of p.blockers) console.log(`${yellow("\u25B2")} ${b.message}`);
|
|
3531
|
-
console.log();
|
|
3532
|
-
|
|
3533
|
-
for (const [i, s] of p.steps.entries()) console.log(` ${i + 1}. ${s}`);
|
|
3534
|
-
if (p.locales.length || p.defaults.keepAsWritten.length) {
|
|
3701
|
+
for (const b of p.blockers) console.log(`${yellow("\u25B2")} ${b.message} ${dim(b.fix)}`);
|
|
3702
|
+
for (const n of p.notes) console.log(`${dim("\xB7")} ${n}`);
|
|
3703
|
+
if (p.locales.length) {
|
|
3535
3704
|
console.log();
|
|
3536
|
-
console.log(bold("
|
|
3537
|
-
for (const [l, v] of Object.entries(p.
|
|
3538
|
-
|
|
3539
|
-
console.log(` Kept as written: ${p.defaults.keepAsWritten.join(", ")}`);
|
|
3705
|
+
console.log(bold("Translators go by"));
|
|
3706
|
+
for (const [l, v] of Object.entries(p.voice)) {
|
|
3707
|
+
console.log(` ${l}: ${v.text}${v.fromProject ? "" : dim(" (usual convention)")}`);
|
|
3540
3708
|
}
|
|
3709
|
+
if (p.keepAsWritten.length) console.log(` Kept as written: ${p.keepAsWritten.join(", ")}`);
|
|
3710
|
+
if (!p.brief)
|
|
3711
|
+
console.log(dim(" No brief yet (.localship/brief.md); the README gives context."));
|
|
3541
3712
|
}
|
|
3542
3713
|
console.log();
|
|
3543
3714
|
const codes = p.locales.map((l) => l.code).join(",");
|
|
3544
3715
|
console.log(
|
|
3545
|
-
|
|
3546
|
-
`Ready.
|
|
3547
|
-
) :
|
|
3716
|
+
p.ready ? green(
|
|
3717
|
+
`Ready. Ask your agent to translate, or run \`localship start ${codes} --model \u2026\` to translate here.`
|
|
3718
|
+
) : yellow("Sort out the item above first.")
|
|
3548
3719
|
);
|
|
3549
3720
|
}
|
|
3550
3721
|
|
|
@@ -3694,139 +3865,536 @@ async function runInit(fs, opts, io) {
|
|
|
3694
3865
|
}
|
|
3695
3866
|
}
|
|
3696
3867
|
|
|
3697
|
-
// src/core/
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
severity: opts.strict ? "error" : "info",
|
|
3721
|
-
code: "locale-missing",
|
|
3722
|
-
message: `No ${locale2} translations yet`,
|
|
3723
|
-
target: loaded.id,
|
|
3724
|
-
locale: locale2
|
|
3725
|
-
});
|
|
3726
|
-
continue;
|
|
3727
|
-
}
|
|
3728
|
-
const spec = loaded.pluralSpec(locale2);
|
|
3729
|
-
const hasPlurals = [...catalog.entries.values()].some((e) => e.message.kind === "plural");
|
|
3730
|
-
if (!spec && hasPlurals) {
|
|
3731
|
-
issues.push({
|
|
3732
|
-
severity: "warning",
|
|
3733
|
-
code: "plural-forms-unknown",
|
|
3734
|
-
message: `Plural rules for ${locale2} are unknown, so plural forms were not checked`,
|
|
3735
|
-
target: loaded.id,
|
|
3736
|
-
locale: locale2
|
|
3737
|
-
});
|
|
3738
|
-
}
|
|
3739
|
-
let missing = 0;
|
|
3740
|
-
for (const src of loaded.source.entries.values()) {
|
|
3741
|
-
if (src.translatable && !catalog.entries.has(src.key)) {
|
|
3742
|
-
missing++;
|
|
3743
|
-
if (opts.strict) {
|
|
3744
|
-
issues.push({
|
|
3745
|
-
...at(src, locale2),
|
|
3746
|
-
severity: "error",
|
|
3747
|
-
code: "missing",
|
|
3748
|
-
message: "Not translated"
|
|
3749
|
-
});
|
|
3750
|
-
}
|
|
3751
|
-
}
|
|
3752
|
-
}
|
|
3753
|
-
if (missing > 0 && !opts.strict) {
|
|
3754
|
-
issues.push({
|
|
3755
|
-
severity: "info",
|
|
3756
|
-
code: "missing",
|
|
3757
|
-
message: `${missing} string${missing === 1 ? "" : "s"} not translated yet`,
|
|
3758
|
-
target: loaded.id,
|
|
3759
|
-
locale: locale2
|
|
3760
|
-
});
|
|
3868
|
+
// src/core/usage.ts
|
|
3869
|
+
import path15 from "path";
|
|
3870
|
+
var MAX_USAGES = 3;
|
|
3871
|
+
var MAX_FILE_BYTES2 = 512 * 1024;
|
|
3872
|
+
var ANDROID_CODE2 = /\.(kt|kts|java|xml)$/;
|
|
3873
|
+
var SWIFT_CODE = /\.(swift|m|mm)$/;
|
|
3874
|
+
var ANDROID_REF = /\bR\.(?:string|plurals|array)\.(\w+)|@(?:string|plurals|array)\/(\w+)/g;
|
|
3875
|
+
var STRING_LITERAL = /@?"((?:[^"\\\n]|\\.)*)"/g;
|
|
3876
|
+
var APPLE_SPEC = /%%|%(?:\d+\$)?[-+ #0]*\d*(?:\.\d+)?(?:hh|h|ll|l|q|z|t|j)?[@dDuUxXoOfeEgGcCsSaAp]/g;
|
|
3877
|
+
var SLOT = "";
|
|
3878
|
+
async function buildUsageIndex(project) {
|
|
3879
|
+
const formats = new Set(project.targets.map((t) => t.target.format));
|
|
3880
|
+
const needsCode = formats.has("android-xml") || formats.has("xcstrings");
|
|
3881
|
+
const files = needsCode ? (await walk(project.fs, project.root)).files : [];
|
|
3882
|
+
const lines = /* @__PURE__ */ new Map();
|
|
3883
|
+
const read2 = async (rel) => {
|
|
3884
|
+
let cached = lines.get(rel);
|
|
3885
|
+
if (!cached) {
|
|
3886
|
+
try {
|
|
3887
|
+
const text = await project.fs.readFile(path15.join(project.root, rel));
|
|
3888
|
+
cached = text.length > MAX_FILE_BYTES2 ? [] : text.split(/\r?\n/);
|
|
3889
|
+
} catch {
|
|
3890
|
+
cached = [];
|
|
3761
3891
|
}
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
issues.push({
|
|
3776
|
-
...where,
|
|
3777
|
-
severity: "warning",
|
|
3778
|
-
code: "not-translatable",
|
|
3779
|
-
message: 'Source is marked translatable="false"; remove this translation'
|
|
3780
|
-
});
|
|
3781
|
-
continue;
|
|
3892
|
+
lines.set(rel, cached);
|
|
3893
|
+
}
|
|
3894
|
+
return cached;
|
|
3895
|
+
};
|
|
3896
|
+
const android = /* @__PURE__ */ new Map();
|
|
3897
|
+
if (formats.has("android-xml")) {
|
|
3898
|
+
for (const rel of files) {
|
|
3899
|
+
if (!ANDROID_CODE2.test(rel) || /(^|\/)res\/values[^/]*\//.test(rel)) continue;
|
|
3900
|
+
const text = await read2(rel);
|
|
3901
|
+
text.forEach((line, i) => {
|
|
3902
|
+
for (const m of line.matchAll(ANDROID_REF)) {
|
|
3903
|
+
const key = m[1] ?? m[2];
|
|
3904
|
+
add(android, key, rel, text, i);
|
|
3782
3905
|
}
|
|
3783
|
-
|
|
3784
|
-
issues.push(
|
|
3785
|
-
...checkTranslation(loaded, spec, src, tgt, {
|
|
3786
|
-
locale: locale2,
|
|
3787
|
-
sourceLocale: project.config.sourceLocale,
|
|
3788
|
-
glossary: project.context.glossary
|
|
3789
|
-
}).map((i) => ({ ...where, ...i }))
|
|
3790
|
-
);
|
|
3791
|
-
}
|
|
3906
|
+
});
|
|
3792
3907
|
}
|
|
3793
3908
|
}
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
const printf2 = loose.every((p) => p.raw.startsWith("%") && p.type);
|
|
3804
|
-
out.push({
|
|
3805
|
-
severity: "warning",
|
|
3806
|
-
code: "placeholder-not-positional",
|
|
3807
|
-
message: printf2 ? `Use numbered placeholders (${loose.map((p, i) => `%${i + 1}$${p.type}`).join(", ")}) so translators can reorder them` : `${loose.map((p) => p.raw).join(", ")} can't be reordered; name or number them so translators can`,
|
|
3808
|
-
...form ? { form } : {}
|
|
3909
|
+
const apple = /* @__PURE__ */ new Map();
|
|
3910
|
+
if (formats.has("xcstrings")) {
|
|
3911
|
+
for (const rel of files) {
|
|
3912
|
+
if (!SWIFT_CODE.test(rel)) continue;
|
|
3913
|
+
const text = await read2(rel);
|
|
3914
|
+
text.forEach((line, i) => {
|
|
3915
|
+
for (const m of line.matchAll(STRING_LITERAL)) {
|
|
3916
|
+
add(apple, normalizeSwiftLiteral(m[1]), rel, text, i);
|
|
3917
|
+
}
|
|
3809
3918
|
});
|
|
3810
3919
|
}
|
|
3811
|
-
}
|
|
3812
|
-
const
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
for (const
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3920
|
+
}
|
|
3921
|
+
const po = /* @__PURE__ */ new Map();
|
|
3922
|
+
for (const { target } of project.targets) {
|
|
3923
|
+
if (target.format !== "po") continue;
|
|
3924
|
+
for (const entry of target.source.entries.values()) {
|
|
3925
|
+
const found = [];
|
|
3926
|
+
for (const ref of entry.references ?? []) {
|
|
3927
|
+
if (found.length >= MAX_USAGES) break;
|
|
3928
|
+
const m = /^(.+?):(\d+)$/.exec(ref);
|
|
3929
|
+
const rel = m?.[1] ?? ref;
|
|
3930
|
+
const text = m ? await read2(rel) : [];
|
|
3931
|
+
const i = Number(m?.[2] ?? 0) - 1;
|
|
3932
|
+
found.push(text[i] !== void 0 ? usageAt(rel, text, i) : { at: ref, code: "" });
|
|
3933
|
+
}
|
|
3934
|
+
if (found.length) po.set(`${target.id}\0${entry.key}`, found);
|
|
3823
3935
|
}
|
|
3824
|
-
for (const [c, v] of Object.entries(m.forms)) each(v, c);
|
|
3825
3936
|
}
|
|
3826
|
-
return
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3937
|
+
return {
|
|
3938
|
+
find(target, entry) {
|
|
3939
|
+
switch (target.format) {
|
|
3940
|
+
case "android-xml":
|
|
3941
|
+
return android.get(entry.key) ?? [];
|
|
3942
|
+
case "xcstrings":
|
|
3943
|
+
return apple.get(normalizeCatalogKey(entry.key)) ?? [];
|
|
3944
|
+
case "po":
|
|
3945
|
+
return po.get(`${target.id}\0${entry.key}`) ?? [];
|
|
3946
|
+
default:
|
|
3947
|
+
return [];
|
|
3948
|
+
}
|
|
3949
|
+
}
|
|
3950
|
+
};
|
|
3951
|
+
}
|
|
3952
|
+
function add(index, key, rel, text, i) {
|
|
3953
|
+
const list = index.get(key) ?? [];
|
|
3954
|
+
if (list.length >= MAX_USAGES) return;
|
|
3955
|
+
if (list.some((u) => u.at.startsWith(`${rel}:`))) return;
|
|
3956
|
+
list.push(usageAt(rel, text, i));
|
|
3957
|
+
index.set(key, list);
|
|
3958
|
+
}
|
|
3959
|
+
function usageAt(rel, text, i) {
|
|
3960
|
+
const line = text[i] ?? "";
|
|
3961
|
+
let previous;
|
|
3962
|
+
for (let j = i - 1; j >= Math.max(0, i - 3); j--) {
|
|
3963
|
+
const candidate = text[j];
|
|
3964
|
+
if (candidate.trim() === "") continue;
|
|
3965
|
+
if (/^\s*(\/\/|\*|\/\*|#)/.test(candidate)) break;
|
|
3966
|
+
previous = candidate;
|
|
3967
|
+
break;
|
|
3968
|
+
}
|
|
3969
|
+
const picked = previous !== void 0 ? [previous, line] : [line];
|
|
3970
|
+
const indent = Math.min(...picked.map((l) => /^\s*/.exec(l)?.[0].length ?? 0));
|
|
3971
|
+
const code = picked.map((l) => l.slice(indent).trimEnd()).join("\n").slice(0, 240);
|
|
3972
|
+
return { at: `${rel}:${i + 1}`, code };
|
|
3973
|
+
}
|
|
3974
|
+
function normalizeSwiftLiteral(literal) {
|
|
3975
|
+
let out = "";
|
|
3976
|
+
for (let i = 0; i < literal.length; i++) {
|
|
3977
|
+
const c = literal[i];
|
|
3978
|
+
if (c === "\\" && literal[i + 1] === "(") {
|
|
3979
|
+
let depth = 0;
|
|
3980
|
+
let j = i + 1;
|
|
3981
|
+
for (; j < literal.length; j++) {
|
|
3982
|
+
if (literal[j] === "(") depth++;
|
|
3983
|
+
else if (literal[j] === ")" && --depth === 0) break;
|
|
3984
|
+
}
|
|
3985
|
+
out += SLOT;
|
|
3986
|
+
i = j;
|
|
3987
|
+
} else if (c === "\\" && i + 1 < literal.length) {
|
|
3988
|
+
const n = literal[++i];
|
|
3989
|
+
out += n === "n" ? "\n" : n === "t" ? " " : n;
|
|
3990
|
+
} else {
|
|
3991
|
+
out += c;
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
return out;
|
|
3995
|
+
}
|
|
3996
|
+
function normalizeCatalogKey(key) {
|
|
3997
|
+
return key.replace(APPLE_SPEC, (m) => m === "%%" ? "%" : SLOT);
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
// src/core/qa.ts
|
|
4001
|
+
function textChecks(s, t, opts = {}) {
|
|
4002
|
+
const out = [];
|
|
4003
|
+
const source = visible(s);
|
|
4004
|
+
const target = visible(t);
|
|
4005
|
+
const language = opts.locale?.split("-")[0] ?? "";
|
|
4006
|
+
if (!opts.countImplied) {
|
|
4007
|
+
const have = numbers(target);
|
|
4008
|
+
for (const n of numbers(source)) {
|
|
4009
|
+
if (DUAL.has(language) && (n === "1" || n === "2")) continue;
|
|
4010
|
+
const i = have.indexOf(n);
|
|
4011
|
+
if (i >= 0) have.splice(i, 1);
|
|
4012
|
+
else
|
|
4013
|
+
out.push({
|
|
4014
|
+
severity: "warning",
|
|
4015
|
+
code: "number-mismatch",
|
|
4016
|
+
message: `The number ${n} from the source is missing`
|
|
4017
|
+
});
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
const ending = closing(source);
|
|
4021
|
+
if (ending && !NO_QUESTION_MARK.has(language) && !ending.marks.some((m) => endsWith(target, m))) {
|
|
4022
|
+
out.push({
|
|
4023
|
+
severity: "warning",
|
|
4024
|
+
code: "punctuation-end",
|
|
4025
|
+
message: `The source ends with "${ending.shown}"; the translation doesn't`
|
|
4026
|
+
});
|
|
4027
|
+
}
|
|
4028
|
+
if (isAllCaps(source) && readsLowercase(withoutKeptWords(target, source))) {
|
|
4029
|
+
out.push({
|
|
4030
|
+
severity: "warning",
|
|
4031
|
+
code: "case-mismatch",
|
|
4032
|
+
message: "The source is in capitals (a label or heading); the translation isn't"
|
|
4033
|
+
});
|
|
4034
|
+
}
|
|
4035
|
+
for (const token of [...matches(source, URL), ...matches(source, EMAIL)]) {
|
|
4036
|
+
if (!target.includes(token))
|
|
4037
|
+
out.push({
|
|
4038
|
+
severity: "warning",
|
|
4039
|
+
code: "token-missing",
|
|
4040
|
+
message: `${token} from the source is missing or changed`
|
|
4041
|
+
});
|
|
4042
|
+
}
|
|
4043
|
+
const emoji = matches(target, EMOJI);
|
|
4044
|
+
for (const e of matches(source, EMOJI)) {
|
|
4045
|
+
const i = emoji.indexOf(e);
|
|
4046
|
+
if (i >= 0) emoji.splice(i, 1);
|
|
4047
|
+
else
|
|
4048
|
+
out.push({
|
|
4049
|
+
severity: "warning",
|
|
4050
|
+
code: "token-missing",
|
|
4051
|
+
message: `The emoji ${e} from the source is missing`
|
|
4052
|
+
});
|
|
4053
|
+
}
|
|
4054
|
+
return out;
|
|
4055
|
+
}
|
|
4056
|
+
function visible(a) {
|
|
4057
|
+
let text = a.text;
|
|
4058
|
+
for (const p of a.placeholders) text = text.split(p.raw).join(" ");
|
|
4059
|
+
return text;
|
|
4060
|
+
}
|
|
4061
|
+
function numbers(text) {
|
|
4062
|
+
const out = [];
|
|
4063
|
+
for (const m of text.matchAll(new RegExp("\\p{Nd}+(?:[.,\xA0\u202F'\u2019]\\p{Nd}+)*", "gu"))) {
|
|
4064
|
+
const digits = [...m[0]].filter((c) => new RegExp("\\p{Nd}", "u").test(c)).map(digitValue);
|
|
4065
|
+
out.push(digits.join(""));
|
|
4066
|
+
}
|
|
4067
|
+
return out;
|
|
4068
|
+
}
|
|
4069
|
+
var ZEROS = [
|
|
4070
|
+
48,
|
|
4071
|
+
1632,
|
|
4072
|
+
1776,
|
|
4073
|
+
1984,
|
|
4074
|
+
2406,
|
|
4075
|
+
2534,
|
|
4076
|
+
2662,
|
|
4077
|
+
2790,
|
|
4078
|
+
2918,
|
|
4079
|
+
3046,
|
|
4080
|
+
3174,
|
|
4081
|
+
3302,
|
|
4082
|
+
3430,
|
|
4083
|
+
3558,
|
|
4084
|
+
3664,
|
|
4085
|
+
3792,
|
|
4086
|
+
3872,
|
|
4087
|
+
4160,
|
|
4088
|
+
4240,
|
|
4089
|
+
6112,
|
|
4090
|
+
6160,
|
|
4091
|
+
6470,
|
|
4092
|
+
6608,
|
|
4093
|
+
43216,
|
|
4094
|
+
43264,
|
|
4095
|
+
65296
|
|
4096
|
+
];
|
|
4097
|
+
function digitValue(c) {
|
|
4098
|
+
const cp = c.codePointAt(0) ?? 0;
|
|
4099
|
+
const zero = ZEROS.find((z4) => cp >= z4 && cp <= z4 + 9);
|
|
4100
|
+
return zero === void 0 ? c : String(cp - zero);
|
|
4101
|
+
}
|
|
4102
|
+
var ENDINGS = [
|
|
4103
|
+
{ shown: "?", marks: ["?", "\uFF1F", "\u061F", ";"] },
|
|
4104
|
+
{ shown: "!", marks: ["!", "\uFF01"] },
|
|
4105
|
+
{ shown: ":", marks: [":", "\uFF1A", "\uFE55"] },
|
|
4106
|
+
{ shown: "\u2026", marks: ["\u2026", "...", "\u22EF", "\u2025"] }
|
|
4107
|
+
];
|
|
4108
|
+
var DUAL = /* @__PURE__ */ new Set(["ar", "he", "iw"]);
|
|
4109
|
+
var NO_QUESTION_MARK = /* @__PURE__ */ new Set(["th", "lo", "km", "my"]);
|
|
4110
|
+
function closing(text) {
|
|
4111
|
+
const end = trimEnd(text);
|
|
4112
|
+
if (end.endsWith("...")) return ENDINGS[3];
|
|
4113
|
+
return ENDINGS.find((e) => end.endsWith(e.shown));
|
|
4114
|
+
}
|
|
4115
|
+
function endsWith(text, mark) {
|
|
4116
|
+
return trimEnd(text).endsWith(mark);
|
|
4117
|
+
}
|
|
4118
|
+
function trimEnd(text) {
|
|
4119
|
+
return text.replace(/[\s"'”’»)\]}」』]+$/u, "");
|
|
4120
|
+
}
|
|
4121
|
+
function withoutKeptWords(text, source) {
|
|
4122
|
+
const kept = new Set(source.toLowerCase().match(new RegExp("\\p{L}+", "gu")) ?? []);
|
|
4123
|
+
return text.replace(new RegExp("\\p{L}+", "gu"), (w) => kept.has(w.toLowerCase()) ? " " : w);
|
|
4124
|
+
}
|
|
4125
|
+
function isAllCaps(text) {
|
|
4126
|
+
const cased = [...text].filter(hasCase);
|
|
4127
|
+
return cased.length >= 2 && cased.every((c) => c === c.toUpperCase());
|
|
4128
|
+
}
|
|
4129
|
+
function readsLowercase(text) {
|
|
4130
|
+
const letters = [...text].filter((c) => new RegExp("\\p{L}", "u").test(c));
|
|
4131
|
+
const cased = letters.filter(hasCase);
|
|
4132
|
+
if (cased.length < 2 || cased.length * 2 < letters.length) return false;
|
|
4133
|
+
const lower = cased.filter((c) => c !== c.toUpperCase()).length;
|
|
4134
|
+
return lower > cased.length - lower;
|
|
4135
|
+
}
|
|
4136
|
+
function hasCase(c) {
|
|
4137
|
+
return c.toLowerCase() !== c.toUpperCase();
|
|
4138
|
+
}
|
|
4139
|
+
var URL = /https?:\/\/[^\s<>"')\]]+[^\s<>"')\].,;:!?]/gu;
|
|
4140
|
+
var EMAIL = /[\p{L}\p{N}._%+-]+@[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+/gu;
|
|
4141
|
+
var EMOJI = new RegExp("\\p{Extended_Pictographic}", "gu");
|
|
4142
|
+
function matches(text, pattern) {
|
|
4143
|
+
return [...text.matchAll(pattern)].map((m) => m[0]);
|
|
4144
|
+
}
|
|
4145
|
+
function inconsistencies(project, locales) {
|
|
4146
|
+
const out = [];
|
|
4147
|
+
for (const locale2 of locales) {
|
|
4148
|
+
const bySource = /* @__PURE__ */ new Map();
|
|
4149
|
+
for (const { target } of project.targets) {
|
|
4150
|
+
const catalog = target.locales.get(locale2);
|
|
4151
|
+
if (!catalog) continue;
|
|
4152
|
+
for (const src of target.source.entries.values()) {
|
|
4153
|
+
if (!src.translatable || src.message.kind !== "text") continue;
|
|
4154
|
+
const tgt = catalog.entries.get(src.key);
|
|
4155
|
+
if (tgt?.message.kind !== "text") continue;
|
|
4156
|
+
if (getLockEntry(project.lock, target.id, locale2, src.key)?.note) continue;
|
|
4157
|
+
const source = normalize(target.analyze(src.message.value, src).text);
|
|
4158
|
+
const group = `${src.context ?? ""}${source}`;
|
|
4159
|
+
const translation2 = normalize(target.analyze(tgt.message.value, src).text);
|
|
4160
|
+
if (!source || !translation2) continue;
|
|
4161
|
+
const ways = bySource.get(group) ?? /* @__PURE__ */ new Map();
|
|
4162
|
+
const list = ways.get(translation2) ?? [];
|
|
4163
|
+
list.push({ target: target.id, entry: tgt });
|
|
4164
|
+
ways.set(translation2, list);
|
|
4165
|
+
bySource.set(group, ways);
|
|
4166
|
+
}
|
|
4167
|
+
}
|
|
4168
|
+
for (const [group, ways] of bySource) {
|
|
4169
|
+
if (ways.size < 2) continue;
|
|
4170
|
+
const source = group.slice(group.indexOf("") + 1);
|
|
4171
|
+
const sorted = [...ways].sort((a, b) => b[1].length - a[1].length);
|
|
4172
|
+
const odd = sorted.at(-1)?.[1][0];
|
|
4173
|
+
const listed = sorted.slice(0, 3).map(
|
|
4174
|
+
([text, at]) => `"${short(text)}" (${at.map((x) => displayKey(x.entry.key)).join(", ")})`
|
|
4175
|
+
).join(", ");
|
|
4176
|
+
out.push({
|
|
4177
|
+
severity: "warning",
|
|
4178
|
+
code: "inconsistent",
|
|
4179
|
+
message: `"${short(source)}" is translated ${ways.size} ways: ${listed}. If the context differs, add a note to the translation saying how`,
|
|
4180
|
+
locale: locale2,
|
|
4181
|
+
...odd ? { target: odd.target, key: odd.entry.key, file: odd.entry.file, line: odd.entry.line } : {}
|
|
4182
|
+
});
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
return out;
|
|
4186
|
+
}
|
|
4187
|
+
var normalize = (text) => text.trim().replace(/\s+/g, " ");
|
|
4188
|
+
var short = (text) => text.length > 40 ? `${text.slice(0, 39)}\u2026` : text;
|
|
4189
|
+
var ROLES = [
|
|
4190
|
+
["accessibility label", /contentDescription|accessibility(?:Label|Hint)/],
|
|
4191
|
+
["placeholder", /\bplaceholder\b|android:hint|\bhint\s*=|\b(?:Secure)?TextField\(/],
|
|
4192
|
+
// Design systems wrap these (`AppButton`, `SmChip`), so any name ending in the kind counts.
|
|
4193
|
+
["tab", /\b\w*Tab\s*\(|Navigation(?:Bar|Rail)Item|BottomNavigationItem|\.tabItem\b/],
|
|
4194
|
+
["chip", /\b\w*Chip\s*\(/],
|
|
4195
|
+
[
|
|
4196
|
+
"button",
|
|
4197
|
+
/\b\w*Button\s*[({]|<(?:\w+\.)*\w*Button\b|(?:confirm|dismiss)Button|set(?:Positive|Negative|Neutral)Button/
|
|
4198
|
+
],
|
|
4199
|
+
["menu item", /DropdownMenuItem|<item\b[^>]*android:title|\bMenu\s*\{/],
|
|
4200
|
+
["toast", /Toast\.makeText|showSnackbar|Snackbar(?:\.make)?\(/],
|
|
4201
|
+
["notification", /setContent(?:Title|Text)|NotificationCompat|UNMutableNotificationContent/],
|
|
4202
|
+
["app name", /android:label=/],
|
|
4203
|
+
["title", /TopAppBar|\btitle\s*=|\.navigationTitle\(|setTitle\(|\.alert\(/]
|
|
4204
|
+
];
|
|
4205
|
+
function roleOf(usages) {
|
|
4206
|
+
const counts = /* @__PURE__ */ new Map();
|
|
4207
|
+
for (const u of usages) {
|
|
4208
|
+
const lines = u.code.split("\n").reverse();
|
|
4209
|
+
const role = lines.map((line) => ROLES.find(([, pattern]) => pattern.test(line))?.[0]).find((r) => r !== void 0);
|
|
4210
|
+
if (role) counts.set(role, (counts.get(role) ?? 0) + 1);
|
|
4211
|
+
}
|
|
4212
|
+
return [...counts].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
4213
|
+
}
|
|
4214
|
+
var TIGHT = /* @__PURE__ */ new Set(["button", "tab", "chip", "menu item", "app name"]);
|
|
4215
|
+
function lengthCheck(role, s, t) {
|
|
4216
|
+
if (!role || !TIGHT.has(role)) return void 0;
|
|
4217
|
+
const source = width(visible(s).trim());
|
|
4218
|
+
const target = width(visible(t).trim());
|
|
4219
|
+
if (source === 0 || target <= source * 2 || target - source < 6) return void 0;
|
|
4220
|
+
return {
|
|
4221
|
+
severity: "warning",
|
|
4222
|
+
code: "too-long",
|
|
4223
|
+
message: `Used as a ${role}: about ${target} characters wide against ${source} in the source, so it may not fit`
|
|
4224
|
+
};
|
|
4225
|
+
}
|
|
4226
|
+
function width(text) {
|
|
4227
|
+
let w = 0;
|
|
4228
|
+
for (const c of text) {
|
|
4229
|
+
if (new RegExp("\\p{M}|\u200B|\u200C|\u200D", "u").test(c)) continue;
|
|
4230
|
+
w += new RegExp("[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]|\\p{Extended_Pictographic}", "u").test(c) ? 2 : 1;
|
|
4231
|
+
}
|
|
4232
|
+
return w;
|
|
4233
|
+
}
|
|
4234
|
+
|
|
4235
|
+
// src/core/validate.ts
|
|
4236
|
+
function validateProject(project, opts = {}) {
|
|
4237
|
+
const locales = opts.locales ?? projectLocales(project);
|
|
4238
|
+
const strict = opts.strict || opts.requireApproved;
|
|
4239
|
+
const issues = [];
|
|
4240
|
+
if (opts.requireApproved && locales.length === 0) {
|
|
4241
|
+
issues.push({
|
|
4242
|
+
severity: "error",
|
|
4243
|
+
code: "locales-empty",
|
|
4244
|
+
message: "No target locales to check; configure locales or select them with --locale"
|
|
4245
|
+
});
|
|
4246
|
+
}
|
|
4247
|
+
for (const { target: loaded } of project.targets) {
|
|
4248
|
+
issues.push(
|
|
4249
|
+
...loaded.issues.map(
|
|
4250
|
+
(issue) => strict && issue.blocksCompleteness ? { ...issue, severity: "error" } : issue
|
|
4251
|
+
)
|
|
4252
|
+
);
|
|
4253
|
+
const at = (e, locale2) => ({
|
|
4254
|
+
target: loaded.id,
|
|
4255
|
+
locale: locale2,
|
|
4256
|
+
key: e.key,
|
|
4257
|
+
file: e.file,
|
|
4258
|
+
line: e.line
|
|
4259
|
+
});
|
|
4260
|
+
for (const src of loaded.source.entries.values()) {
|
|
4261
|
+
if (!src.translatable) continue;
|
|
4262
|
+
issues.push(
|
|
4263
|
+
...checkSource(loaded, src).map((i) => ({ ...at(src, loaded.source.locale), ...i }))
|
|
4264
|
+
);
|
|
4265
|
+
}
|
|
4266
|
+
for (const locale2 of locales) {
|
|
4267
|
+
const catalog = loaded.locales.get(locale2);
|
|
4268
|
+
if (!catalog) {
|
|
4269
|
+
issues.push({
|
|
4270
|
+
severity: strict ? "error" : "info",
|
|
4271
|
+
code: "locale-missing",
|
|
4272
|
+
message: `No ${locale2} translations yet`,
|
|
4273
|
+
target: loaded.id,
|
|
4274
|
+
locale: locale2
|
|
4275
|
+
});
|
|
4276
|
+
continue;
|
|
4277
|
+
}
|
|
4278
|
+
const spec = loaded.pluralSpec(locale2);
|
|
4279
|
+
const hasPlurals = [...catalog.entries.values()].some((e) => e.message.kind === "plural");
|
|
4280
|
+
if (!spec && hasPlurals) {
|
|
4281
|
+
issues.push({
|
|
4282
|
+
severity: "warning",
|
|
4283
|
+
code: "plural-forms-unknown",
|
|
4284
|
+
message: `Plural rules for ${locale2} are unknown, so plural forms were not checked`,
|
|
4285
|
+
target: loaded.id,
|
|
4286
|
+
locale: locale2
|
|
4287
|
+
});
|
|
4288
|
+
}
|
|
4289
|
+
let missing = 0;
|
|
4290
|
+
for (const src of loaded.source.entries.values()) {
|
|
4291
|
+
if (src.translatable && !catalog.entries.has(src.key)) {
|
|
4292
|
+
missing++;
|
|
4293
|
+
if (strict) {
|
|
4294
|
+
issues.push({
|
|
4295
|
+
...at(src, locale2),
|
|
4296
|
+
severity: "error",
|
|
4297
|
+
code: "missing",
|
|
4298
|
+
message: "Not translated"
|
|
4299
|
+
});
|
|
4300
|
+
}
|
|
4301
|
+
}
|
|
4302
|
+
}
|
|
4303
|
+
if (missing > 0 && !strict) {
|
|
4304
|
+
issues.push({
|
|
4305
|
+
severity: "info",
|
|
4306
|
+
code: "missing",
|
|
4307
|
+
message: `${missing} string${missing === 1 ? "" : "s"} not translated yet`,
|
|
4308
|
+
target: loaded.id,
|
|
4309
|
+
locale: locale2
|
|
4310
|
+
});
|
|
4311
|
+
}
|
|
4312
|
+
for (const tgt of catalog.entries.values()) {
|
|
4313
|
+
const src = loaded.source.entries.get(tgt.key);
|
|
4314
|
+
const where = at(tgt, locale2);
|
|
4315
|
+
if (!src) {
|
|
4316
|
+
issues.push({
|
|
4317
|
+
...where,
|
|
4318
|
+
severity: strict && loaded.format === "android-xml" ? "error" : "warning",
|
|
4319
|
+
code: "orphan",
|
|
4320
|
+
message: loaded.format === "android-xml" ? "No matching default resource in this target's values folder; restore the default, or remove this translation only if the key is no longer used. Check merged resources with Android lint." : "Not in the source strings any more"
|
|
4321
|
+
});
|
|
4322
|
+
continue;
|
|
4323
|
+
}
|
|
4324
|
+
if (!src.translatable && hashMessage(src.message) !== hashMessage(tgt.message)) {
|
|
4325
|
+
issues.push({
|
|
4326
|
+
...where,
|
|
4327
|
+
severity: "warning",
|
|
4328
|
+
code: "not-translatable",
|
|
4329
|
+
message: 'Source is marked translatable="false"; remove this translation'
|
|
4330
|
+
});
|
|
4331
|
+
continue;
|
|
4332
|
+
}
|
|
4333
|
+
if (!src.translatable) continue;
|
|
4334
|
+
if (opts.requireApproved) {
|
|
4335
|
+
const state = entryState(
|
|
4336
|
+
src,
|
|
4337
|
+
tgt,
|
|
4338
|
+
getLockEntry(project.lock, loaded.id, locale2, src.key)
|
|
4339
|
+
);
|
|
4340
|
+
if (state !== "approved") {
|
|
4341
|
+
const reason = state === "stale" ? "Source changed since this translation was recorded" : state === "edited" ? "Translation was edited since it was recorded" : `Translation is ${state}`;
|
|
4342
|
+
issues.push({
|
|
4343
|
+
...where,
|
|
4344
|
+
severity: "error",
|
|
4345
|
+
code: "not-approved",
|
|
4346
|
+
message: `${reason}; review it against the current source and approve it`
|
|
4347
|
+
});
|
|
4348
|
+
}
|
|
4349
|
+
}
|
|
4350
|
+
issues.push(
|
|
4351
|
+
...checkTranslation(loaded, spec, src, tgt, {
|
|
4352
|
+
locale: locale2,
|
|
4353
|
+
sourceLocale: project.config.sourceLocale,
|
|
4354
|
+
glossary: projectGlossary(project),
|
|
4355
|
+
role: opts.usages ? roleOf(opts.usages.find(loaded, src)) : void 0
|
|
4356
|
+
}).map((i) => ({ ...where, ...i }))
|
|
4357
|
+
);
|
|
4358
|
+
}
|
|
4359
|
+
}
|
|
4360
|
+
}
|
|
4361
|
+
issues.push(...inconsistencies(project, locales));
|
|
4362
|
+
return issues;
|
|
4363
|
+
}
|
|
4364
|
+
function checkSource(target, src) {
|
|
4365
|
+
const out = [];
|
|
4366
|
+
const each = (value, form) => {
|
|
4367
|
+
const a = target.analyze(value, src);
|
|
4368
|
+
for (const l of a.lints) out.push({ ...l, ...form ? { form } : {} });
|
|
4369
|
+
const loose = a.placeholders.filter((p) => !p.positional);
|
|
4370
|
+
if (loose.length > 1 && !target.translationsMayReorder) {
|
|
4371
|
+
const printf2 = loose.every((p) => p.raw.startsWith("%") && p.type);
|
|
4372
|
+
out.push({
|
|
4373
|
+
severity: "warning",
|
|
4374
|
+
code: "placeholder-not-positional",
|
|
4375
|
+
message: printf2 ? `Use numbered placeholders (${loose.map((p, i) => `%${i + 1}$${p.type}`).join(", ")}) so translators can reorder them` : `${loose.map((p) => p.raw).join(", ")} can't be reordered; name or number them so translators can`,
|
|
4376
|
+
...form ? { form } : {}
|
|
4377
|
+
});
|
|
4378
|
+
}
|
|
4379
|
+
};
|
|
4380
|
+
const m = src.message;
|
|
4381
|
+
if (m.kind === "text") each(m.value);
|
|
4382
|
+
else if (m.kind === "array") {
|
|
4383
|
+
for (const [i, v] of m.items.entries()) each(v, String(i));
|
|
4384
|
+
} else {
|
|
4385
|
+
if (m.forms.other === void 0 && Object.keys(m.forms).some((k) => !/^\d+$/.test(k))) {
|
|
4386
|
+
out.push({
|
|
4387
|
+
severity: "error",
|
|
4388
|
+
code: "plural-missing-category",
|
|
4389
|
+
message: 'Plural has no "other" form'
|
|
4390
|
+
});
|
|
4391
|
+
}
|
|
4392
|
+
for (const [c, v] of Object.entries(m.forms)) each(v, c);
|
|
4393
|
+
}
|
|
4394
|
+
return out;
|
|
4395
|
+
}
|
|
4396
|
+
function checkTranslation(target, spec, src, tgt, opts) {
|
|
4397
|
+
const { locale: locale2, sourceLocale } = opts;
|
|
3830
4398
|
const t = tgt.message;
|
|
3831
4399
|
const s = src.message.kind === "text" && t.kind === "plural" ? { kind: "plural", forms: { other: src.message.value } } : src.message;
|
|
3832
4400
|
if (s.kind !== t.kind) {
|
|
@@ -3845,7 +4413,8 @@ function checkTranslation(target, spec, src, tgt, opts) {
|
|
|
3845
4413
|
allowed,
|
|
3846
4414
|
strictNewlines: target.strictNewlines,
|
|
3847
4415
|
glossary: opts.glossary,
|
|
3848
|
-
locale: locale2
|
|
4416
|
+
locale: locale2,
|
|
4417
|
+
role: opts.role
|
|
3849
4418
|
}).map((f) => form ? { ...f, form } : f);
|
|
3850
4419
|
if (s.kind === "text" && t.kind === "text") return compare(s.value, t.value);
|
|
3851
4420
|
if (s.kind === "array" && t.kind === "array") {
|
|
@@ -4001,6 +4570,11 @@ function compareValues(s, t, opts = {}) {
|
|
|
4001
4570
|
if (opts.glossary?.length && opts.locale && t.text.trim() !== "") {
|
|
4002
4571
|
out.push(...checkGlossary(opts.glossary, opts.locale, s.text, t.text));
|
|
4003
4572
|
}
|
|
4573
|
+
if (t.text.trim() !== "") {
|
|
4574
|
+
out.push(...textChecks(s, t, { countImplied: opts.countImplied, locale: opts.locale }));
|
|
4575
|
+
const long = lengthCheck(opts.role, s, t);
|
|
4576
|
+
if (long) out.push(long);
|
|
4577
|
+
}
|
|
4004
4578
|
const edge = (x) => [/^\s*/.exec(x)?.[0] ?? "", /\s*$/.exec(x)?.[0] ?? ""];
|
|
4005
4579
|
const [sl, st] = edge(s.text);
|
|
4006
4580
|
const [tl, tt] = edge(t.text);
|
|
@@ -4031,140 +4605,6 @@ function describeNewlines(text) {
|
|
|
4031
4605
|
|
|
4032
4606
|
// src/core/workflow.ts
|
|
4033
4607
|
import { z as z3 } from "zod";
|
|
4034
|
-
|
|
4035
|
-
// src/core/usage.ts
|
|
4036
|
-
import path15 from "path";
|
|
4037
|
-
var MAX_USAGES = 3;
|
|
4038
|
-
var MAX_FILE_BYTES2 = 512 * 1024;
|
|
4039
|
-
var ANDROID_CODE2 = /\.(kt|kts|java|xml)$/;
|
|
4040
|
-
var SWIFT_CODE = /\.(swift|m|mm)$/;
|
|
4041
|
-
var ANDROID_REF = /\bR\.(?:string|plurals|array)\.(\w+)|@(?:string|plurals|array)\/(\w+)/g;
|
|
4042
|
-
var STRING_LITERAL = /@?"((?:[^"\\\n]|\\.)*)"/g;
|
|
4043
|
-
var APPLE_SPEC = /%%|%(?:\d+\$)?[-+ #0]*\d*(?:\.\d+)?(?:hh|h|ll|l|q|z|t|j)?[@dDuUxXoOfeEgGcCsSaAp]/g;
|
|
4044
|
-
var SLOT = "";
|
|
4045
|
-
async function buildUsageIndex(project) {
|
|
4046
|
-
const formats = new Set(project.targets.map((t) => t.target.format));
|
|
4047
|
-
const needsCode = formats.has("android-xml") || formats.has("xcstrings");
|
|
4048
|
-
const files = needsCode ? (await walk(project.fs, project.root)).files : [];
|
|
4049
|
-
const lines = /* @__PURE__ */ new Map();
|
|
4050
|
-
const read2 = async (rel) => {
|
|
4051
|
-
let cached = lines.get(rel);
|
|
4052
|
-
if (!cached) {
|
|
4053
|
-
try {
|
|
4054
|
-
const text = await project.fs.readFile(path15.join(project.root, rel));
|
|
4055
|
-
cached = text.length > MAX_FILE_BYTES2 ? [] : text.split(/\r?\n/);
|
|
4056
|
-
} catch {
|
|
4057
|
-
cached = [];
|
|
4058
|
-
}
|
|
4059
|
-
lines.set(rel, cached);
|
|
4060
|
-
}
|
|
4061
|
-
return cached;
|
|
4062
|
-
};
|
|
4063
|
-
const android = /* @__PURE__ */ new Map();
|
|
4064
|
-
if (formats.has("android-xml")) {
|
|
4065
|
-
for (const rel of files) {
|
|
4066
|
-
if (!ANDROID_CODE2.test(rel) || /(^|\/)res\/values[^/]*\//.test(rel)) continue;
|
|
4067
|
-
const text = await read2(rel);
|
|
4068
|
-
text.forEach((line, i) => {
|
|
4069
|
-
for (const m of line.matchAll(ANDROID_REF)) {
|
|
4070
|
-
const key = m[1] ?? m[2];
|
|
4071
|
-
add(android, key, rel, text, i);
|
|
4072
|
-
}
|
|
4073
|
-
});
|
|
4074
|
-
}
|
|
4075
|
-
}
|
|
4076
|
-
const apple = /* @__PURE__ */ new Map();
|
|
4077
|
-
if (formats.has("xcstrings")) {
|
|
4078
|
-
for (const rel of files) {
|
|
4079
|
-
if (!SWIFT_CODE.test(rel)) continue;
|
|
4080
|
-
const text = await read2(rel);
|
|
4081
|
-
text.forEach((line, i) => {
|
|
4082
|
-
for (const m of line.matchAll(STRING_LITERAL)) {
|
|
4083
|
-
add(apple, normalizeSwiftLiteral(m[1]), rel, text, i);
|
|
4084
|
-
}
|
|
4085
|
-
});
|
|
4086
|
-
}
|
|
4087
|
-
}
|
|
4088
|
-
const po = /* @__PURE__ */ new Map();
|
|
4089
|
-
for (const { target } of project.targets) {
|
|
4090
|
-
if (target.format !== "po") continue;
|
|
4091
|
-
for (const entry of target.source.entries.values()) {
|
|
4092
|
-
const found = [];
|
|
4093
|
-
for (const ref of entry.references ?? []) {
|
|
4094
|
-
if (found.length >= MAX_USAGES) break;
|
|
4095
|
-
const m = /^(.+?):(\d+)$/.exec(ref);
|
|
4096
|
-
const rel = m?.[1] ?? ref;
|
|
4097
|
-
const text = m ? await read2(rel) : [];
|
|
4098
|
-
const i = Number(m?.[2] ?? 0) - 1;
|
|
4099
|
-
found.push(text[i] !== void 0 ? usageAt(rel, text, i) : { at: ref, code: "" });
|
|
4100
|
-
}
|
|
4101
|
-
if (found.length) po.set(`${target.id}\0${entry.key}`, found);
|
|
4102
|
-
}
|
|
4103
|
-
}
|
|
4104
|
-
return {
|
|
4105
|
-
find(target, entry) {
|
|
4106
|
-
switch (target.format) {
|
|
4107
|
-
case "android-xml":
|
|
4108
|
-
return android.get(entry.key) ?? [];
|
|
4109
|
-
case "xcstrings":
|
|
4110
|
-
return apple.get(normalizeCatalogKey(entry.key)) ?? [];
|
|
4111
|
-
case "po":
|
|
4112
|
-
return po.get(`${target.id}\0${entry.key}`) ?? [];
|
|
4113
|
-
default:
|
|
4114
|
-
return [];
|
|
4115
|
-
}
|
|
4116
|
-
}
|
|
4117
|
-
};
|
|
4118
|
-
}
|
|
4119
|
-
function add(index, key, rel, text, i) {
|
|
4120
|
-
const list = index.get(key) ?? [];
|
|
4121
|
-
if (list.length >= MAX_USAGES) return;
|
|
4122
|
-
if (list.some((u) => u.at.startsWith(`${rel}:`))) return;
|
|
4123
|
-
list.push(usageAt(rel, text, i));
|
|
4124
|
-
index.set(key, list);
|
|
4125
|
-
}
|
|
4126
|
-
function usageAt(rel, text, i) {
|
|
4127
|
-
const line = text[i] ?? "";
|
|
4128
|
-
let previous;
|
|
4129
|
-
for (let j = i - 1; j >= Math.max(0, i - 3); j--) {
|
|
4130
|
-
const candidate = text[j];
|
|
4131
|
-
if (candidate.trim() === "") continue;
|
|
4132
|
-
if (/^\s*(\/\/|\*|\/\*|#)/.test(candidate)) break;
|
|
4133
|
-
previous = candidate;
|
|
4134
|
-
break;
|
|
4135
|
-
}
|
|
4136
|
-
const picked = previous !== void 0 ? [previous, line] : [line];
|
|
4137
|
-
const indent = Math.min(...picked.map((l) => /^\s*/.exec(l)?.[0].length ?? 0));
|
|
4138
|
-
const code = picked.map((l) => l.slice(indent).trimEnd()).join("\n").slice(0, 240);
|
|
4139
|
-
return { at: `${rel}:${i + 1}`, code };
|
|
4140
|
-
}
|
|
4141
|
-
function normalizeSwiftLiteral(literal) {
|
|
4142
|
-
let out = "";
|
|
4143
|
-
for (let i = 0; i < literal.length; i++) {
|
|
4144
|
-
const c = literal[i];
|
|
4145
|
-
if (c === "\\" && literal[i + 1] === "(") {
|
|
4146
|
-
let depth = 0;
|
|
4147
|
-
let j = i + 1;
|
|
4148
|
-
for (; j < literal.length; j++) {
|
|
4149
|
-
if (literal[j] === "(") depth++;
|
|
4150
|
-
else if (literal[j] === ")" && --depth === 0) break;
|
|
4151
|
-
}
|
|
4152
|
-
out += SLOT;
|
|
4153
|
-
i = j;
|
|
4154
|
-
} else if (c === "\\" && i + 1 < literal.length) {
|
|
4155
|
-
const n = literal[++i];
|
|
4156
|
-
out += n === "n" ? "\n" : n === "t" ? " " : n;
|
|
4157
|
-
} else {
|
|
4158
|
-
out += c;
|
|
4159
|
-
}
|
|
4160
|
-
}
|
|
4161
|
-
return out;
|
|
4162
|
-
}
|
|
4163
|
-
function normalizeCatalogKey(key) {
|
|
4164
|
-
return key.replace(APPLE_SPEC, (m) => m === "%%" ? "%" : SLOT);
|
|
4165
|
-
}
|
|
4166
|
-
|
|
4167
|
-
// src/core/workflow.ts
|
|
4168
4608
|
function itemId(item) {
|
|
4169
4609
|
return `${item.target}\0${item.locale}\0${item.key}`;
|
|
4170
4610
|
}
|
|
@@ -4190,13 +4630,13 @@ function plainText(target, entry) {
|
|
|
4190
4630
|
const value = entry.message.kind === "plural" ? entry.message.forms.other ?? values.at(-1)?.[1] : values.map(([, v]) => v).join("\n");
|
|
4191
4631
|
return target.analyze(value ?? "", entry).text;
|
|
4192
4632
|
}
|
|
4193
|
-
var
|
|
4633
|
+
var normalize2 = (s) => s.trim().replace(/\s+/g, " ");
|
|
4194
4634
|
function buildMemory(project, locales) {
|
|
4195
4635
|
const memory = /* @__PURE__ */ new Map();
|
|
4196
4636
|
for (const { target } of project.targets) {
|
|
4197
4637
|
for (const src of target.source.entries.values()) {
|
|
4198
4638
|
if (!src.translatable) continue;
|
|
4199
|
-
const text =
|
|
4639
|
+
const text = normalize2(plainText(target, src));
|
|
4200
4640
|
if (!text) continue;
|
|
4201
4641
|
for (const locale2 of locales) {
|
|
4202
4642
|
const tgt = target.locales.get(locale2)?.entries.get(src.key);
|
|
@@ -4211,6 +4651,25 @@ function buildMemory(project, locales) {
|
|
|
4211
4651
|
}
|
|
4212
4652
|
return memory;
|
|
4213
4653
|
}
|
|
4654
|
+
function differentWording(target, src, message, locale2, memory, thisBatch, note) {
|
|
4655
|
+
if (message.kind !== "text" || src.context || note) return void 0;
|
|
4656
|
+
const source = normalize2(plainText(target, src));
|
|
4657
|
+
if (!source) return void 0;
|
|
4658
|
+
const value = normalize2(message.value);
|
|
4659
|
+
const id2 = `${locale2}\0${source}`;
|
|
4660
|
+
const earlier = thisBatch.get(id2);
|
|
4661
|
+
thisBatch.set(id2, earlier ?? { key: displayKey(src.key), value });
|
|
4662
|
+
const existing = (memory.get(source) ?? []).filter(
|
|
4663
|
+
(m) => m.locale === locale2 && (m.target !== target.id || m.key !== src.key) && typeof m.value === "string"
|
|
4664
|
+
).map((m) => ({ key: displayKey(m.key), value: normalize2(m.value) }))[0];
|
|
4665
|
+
const other = existing ?? earlier;
|
|
4666
|
+
if (!other || other.value === value) return void 0;
|
|
4667
|
+
return {
|
|
4668
|
+
severity: "warning",
|
|
4669
|
+
code: "inconsistent",
|
|
4670
|
+
message: `"${source}" is already translated as "${other.value}" (${other.key}). Use the same wording, or add a note saying how the context differs`
|
|
4671
|
+
};
|
|
4672
|
+
}
|
|
4214
4673
|
async function nextBatch(project, opts = {}) {
|
|
4215
4674
|
const usages = opts.usages === void 0 ? await buildUsageIndex(project) : opts.usages;
|
|
4216
4675
|
const locales = opts.locales ?? projectLocales(project);
|
|
@@ -4219,7 +4678,7 @@ async function nextBatch(project, opts = {}) {
|
|
|
4219
4678
|
const offset = opts.offset ?? 0;
|
|
4220
4679
|
const wanted = opts.keys ? new Set(opts.keys) : void 0;
|
|
4221
4680
|
const memory = buildMemory(project, locales);
|
|
4222
|
-
const glossary = project
|
|
4681
|
+
const glossary = projectGlossary(project);
|
|
4223
4682
|
const items = [];
|
|
4224
4683
|
let matched = 0;
|
|
4225
4684
|
for (const locale2 of locales) {
|
|
@@ -4241,6 +4700,8 @@ async function nextBatch(project, opts = {}) {
|
|
|
4241
4700
|
matched++;
|
|
4242
4701
|
if (matched <= offset || items.length >= limit) continue;
|
|
4243
4702
|
const text = plainText(target, src);
|
|
4703
|
+
const found = usages ? usages.find(target, src) : [];
|
|
4704
|
+
const role = roleOf(found);
|
|
4244
4705
|
const placeholders = [
|
|
4245
4706
|
...new Set(
|
|
4246
4707
|
messageValues(src.message).flatMap(
|
|
@@ -4249,7 +4710,7 @@ async function nextBatch(project, opts = {}) {
|
|
|
4249
4710
|
)
|
|
4250
4711
|
];
|
|
4251
4712
|
const hits = glossaryHits(glossary, text);
|
|
4252
|
-
const
|
|
4713
|
+
const matches2 = (memory.get(normalize2(text)) ?? []).filter(
|
|
4253
4714
|
(m) => m.locale === locale2 && !(m.target === target.id && m.key === src.key)
|
|
4254
4715
|
);
|
|
4255
4716
|
const item = {
|
|
@@ -4261,7 +4722,9 @@ async function nextBatch(project, opts = {}) {
|
|
|
4261
4722
|
source: toValue(src.message),
|
|
4262
4723
|
...src.comment ? { comment: src.comment } : {},
|
|
4263
4724
|
...src.context ? { context: src.context } : {},
|
|
4264
|
-
...usageFields(
|
|
4725
|
+
...usageFields(found, src.references),
|
|
4726
|
+
...role ? { role } : {},
|
|
4727
|
+
...tgt && lockEntry?.note ? { note: lockEntry.note } : {},
|
|
4265
4728
|
...tgt ? { current: toValue(tgt.message) } : {},
|
|
4266
4729
|
...state === "stale" && lockEntry?.sourceText !== void 0 ? { previousSource: lockEntry.sourceText } : {},
|
|
4267
4730
|
...handEdited ? { editedByHand: true } : {},
|
|
@@ -4284,8 +4747,8 @@ async function nextBatch(project, opts = {}) {
|
|
|
4284
4747
|
};
|
|
4285
4748
|
})
|
|
4286
4749
|
} : {},
|
|
4287
|
-
...
|
|
4288
|
-
memory:
|
|
4750
|
+
...matches2.length ? {
|
|
4751
|
+
memory: matches2.slice(0, 3).map((m) => ({
|
|
4289
4752
|
value: m.value,
|
|
4290
4753
|
from: `${m.target}:${displayKey(m.key)}`
|
|
4291
4754
|
}))
|
|
@@ -4304,7 +4767,13 @@ async function nextBatch(project, opts = {}) {
|
|
|
4304
4767
|
locales: Object.fromEntries(
|
|
4305
4768
|
usedLocales.map((l) => {
|
|
4306
4769
|
const voice = voiceFor(project.context, l);
|
|
4307
|
-
return [
|
|
4770
|
+
return [
|
|
4771
|
+
l,
|
|
4772
|
+
{
|
|
4773
|
+
language: languageName(l),
|
|
4774
|
+
...voice ? { voice } : { defaultVoice: conventionalVoice(l) }
|
|
4775
|
+
}
|
|
4776
|
+
];
|
|
4308
4777
|
})
|
|
4309
4778
|
),
|
|
4310
4779
|
formats: Object.fromEntries(
|
|
@@ -4332,7 +4801,8 @@ var SubmissionSchema = z3.object({
|
|
|
4332
4801
|
target: z3.string().optional(),
|
|
4333
4802
|
locale: z3.string(),
|
|
4334
4803
|
key: z3.string(),
|
|
4335
|
-
value: MessageValueSchema
|
|
4804
|
+
value: MessageValueSchema,
|
|
4805
|
+
note: z3.string().optional()
|
|
4336
4806
|
});
|
|
4337
4807
|
var SubmitInputSchema = z3.union([
|
|
4338
4808
|
z3.array(SubmissionSchema),
|
|
@@ -4357,10 +4827,21 @@ ${details.join("\n")}`
|
|
|
4357
4827
|
}
|
|
4358
4828
|
var REPLACEABLE = ["missing", "stale", "draft"];
|
|
4359
4829
|
async function submit(project, submissions, opts = {}) {
|
|
4830
|
+
return withProjectWrite(
|
|
4831
|
+
project,
|
|
4832
|
+
opts.dryRun,
|
|
4833
|
+
(fresh) => submitUnlocked(fresh, submissions, opts)
|
|
4834
|
+
);
|
|
4835
|
+
}
|
|
4836
|
+
async function submitUnlocked(project, submissions, opts) {
|
|
4360
4837
|
const result = { accepted: [], rejected: [], files: [], dryRun: !!opts.dryRun };
|
|
4361
4838
|
const configured = projectLocales(project);
|
|
4362
4839
|
const pending = /* @__PURE__ */ new Map();
|
|
4363
4840
|
const seen = /* @__PURE__ */ new Set();
|
|
4841
|
+
const memory = buildMemory(project, configured);
|
|
4842
|
+
const thisBatch = /* @__PURE__ */ new Map();
|
|
4843
|
+
const usages = opts.usages === void 0 ? await buildUsageIndex(project) : opts.usages;
|
|
4844
|
+
const notes = /* @__PURE__ */ new Map();
|
|
4364
4845
|
for (const sub of submissions) {
|
|
4365
4846
|
const reject = (message2, code = "rejected", target2) => result.rejected.push({
|
|
4366
4847
|
...target2 ? { target: target2 } : {},
|
|
@@ -4392,6 +4873,17 @@ async function submit(project, submissions, opts = {}) {
|
|
|
4392
4873
|
continue;
|
|
4393
4874
|
}
|
|
4394
4875
|
const { target, src } = found;
|
|
4876
|
+
const expected = opts.expectedSources?.get(
|
|
4877
|
+
itemId({ target: target.id, locale: locale2, key: displayKey(src.key) })
|
|
4878
|
+
);
|
|
4879
|
+
if (expected !== void 0 && JSON.stringify(expected) !== JSON.stringify(toValue(src.message))) {
|
|
4880
|
+
reject(
|
|
4881
|
+
"The source changed while the model was translating; translate the current source",
|
|
4882
|
+
"source-changed",
|
|
4883
|
+
target.id
|
|
4884
|
+
);
|
|
4885
|
+
continue;
|
|
4886
|
+
}
|
|
4395
4887
|
const id2 = `${target.id}\0${locale2}\0${src.key}`;
|
|
4396
4888
|
if (seen.has(id2)) {
|
|
4397
4889
|
reject("Submitted twice in one batch", "duplicate", target.id);
|
|
@@ -4434,7 +4926,8 @@ async function submit(project, submissions, opts = {}) {
|
|
|
4434
4926
|
...checkTranslation(target, target.pluralSpec(locale2), src, tgt, {
|
|
4435
4927
|
locale: locale2,
|
|
4436
4928
|
sourceLocale: project.config.sourceLocale,
|
|
4437
|
-
glossary: project
|
|
4929
|
+
glossary: projectGlossary(project),
|
|
4930
|
+
role: usages ? roleOf(usages.find(target, src)) : void 0
|
|
4438
4931
|
}).filter((f) => f.code !== "plural-missing-category" && f.code !== "plural-extra-category"),
|
|
4439
4932
|
...checkPluralForms(target, locale2, message)
|
|
4440
4933
|
];
|
|
@@ -4443,6 +4936,16 @@ async function submit(project, submissions, opts = {}) {
|
|
|
4443
4936
|
result.rejected.push({ target: target.id, locale: locale2, key: displayKey(src.key), errors });
|
|
4444
4937
|
continue;
|
|
4445
4938
|
}
|
|
4939
|
+
const differs = differentWording(
|
|
4940
|
+
target,
|
|
4941
|
+
src,
|
|
4942
|
+
message,
|
|
4943
|
+
locale2,
|
|
4944
|
+
memory,
|
|
4945
|
+
thisBatch,
|
|
4946
|
+
sub.note?.trim()
|
|
4947
|
+
);
|
|
4948
|
+
if (differs) findings.push(differs);
|
|
4446
4949
|
result.accepted.push({
|
|
4447
4950
|
target: target.id,
|
|
4448
4951
|
locale: locale2,
|
|
@@ -4454,6 +4957,7 @@ async function submit(project, submissions, opts = {}) {
|
|
|
4454
4957
|
entries.set(src.key, tgt);
|
|
4455
4958
|
byLocale.set(locale2, entries);
|
|
4456
4959
|
pending.set(target, byLocale);
|
|
4960
|
+
notes.set(id2, sub.note?.trim() || null);
|
|
4457
4961
|
}
|
|
4458
4962
|
if (opts.dryRun || pending.size === 0) return result;
|
|
4459
4963
|
const files = /* @__PURE__ */ new Set();
|
|
@@ -4466,7 +4970,12 @@ async function submit(project, submissions, opts = {}) {
|
|
|
4466
4970
|
result.files = [...files].sort();
|
|
4467
4971
|
const keys = [...pending].flatMap(
|
|
4468
4972
|
([target, byLocale]) => [...byLocale].flatMap(
|
|
4469
|
-
([locale2, entries]) => [...entries.keys()].map((key) => ({
|
|
4973
|
+
([locale2, entries]) => [...entries.keys()].map((key) => ({
|
|
4974
|
+
target: target.id,
|
|
4975
|
+
locale: locale2,
|
|
4976
|
+
key,
|
|
4977
|
+
note: notes.get(`${target.id}\0${locale2}\0${key}`) ?? null
|
|
4978
|
+
}))
|
|
4470
4979
|
)
|
|
4471
4980
|
);
|
|
4472
4981
|
await record(project, keys, () => ({
|
|
@@ -4565,11 +5074,13 @@ async function record(project, keys, entry) {
|
|
|
4565
5074
|
const tgt = target?.locales.get(k.locale)?.entries.get(k.key);
|
|
4566
5075
|
if (!src || !tgt) continue;
|
|
4567
5076
|
const previous = getLockEntry(lock, k.target, k.locale, k.key);
|
|
5077
|
+
const note = k.note === void 0 ? previous?.note : k.note ?? void 0;
|
|
4568
5078
|
setLockEntry(lock, k.target, k.locale, k.key, {
|
|
4569
5079
|
source: hashMessage(src.message),
|
|
4570
5080
|
sourceText: toValue(src.message),
|
|
4571
5081
|
target: hashMessage(tgt.message),
|
|
4572
|
-
...entry(previous)
|
|
5082
|
+
...entry(previous),
|
|
5083
|
+
...note ? { note } : {}
|
|
4573
5084
|
});
|
|
4574
5085
|
}
|
|
4575
5086
|
pruneLock(fresh, lock);
|
|
@@ -4591,6 +5102,9 @@ function pruneLock(project, lock) {
|
|
|
4591
5102
|
}
|
|
4592
5103
|
}
|
|
4593
5104
|
async function approve(project, opts = {}) {
|
|
5105
|
+
return withProjectWrite(project, opts.dryRun, (fresh) => approveUnlocked(fresh, opts));
|
|
5106
|
+
}
|
|
5107
|
+
async function approveUnlocked(project, opts) {
|
|
4594
5108
|
const locales = opts.locales ?? projectLocales(project);
|
|
4595
5109
|
const states = opts.states ?? ["draft", "reviewed"];
|
|
4596
5110
|
const to = opts.to ?? "approved";
|
|
@@ -4639,6 +5153,9 @@ async function approve(project, opts = {}) {
|
|
|
4639
5153
|
}
|
|
4640
5154
|
var DISPOSABLE = ["draft", "stale"];
|
|
4641
5155
|
async function removeTranslations(project, opts) {
|
|
5156
|
+
return withProjectWrite(project, opts.dryRun, (fresh) => removeUnlocked(fresh, opts));
|
|
5157
|
+
}
|
|
5158
|
+
async function removeUnlocked(project, opts) {
|
|
4642
5159
|
if (!opts.keys?.length && !opts.states?.length) {
|
|
4643
5160
|
throw new UserError("Say which translations to remove: keys, states (e.g. draft), or both");
|
|
4644
5161
|
}
|
|
@@ -4684,6 +5201,9 @@ async function removeTranslations(project, opts) {
|
|
|
4684
5201
|
return result;
|
|
4685
5202
|
}
|
|
4686
5203
|
async function prune(project, opts = {}) {
|
|
5204
|
+
return withProjectWrite(project, opts.dryRun, (fresh) => pruneUnlocked(fresh, opts));
|
|
5205
|
+
}
|
|
5206
|
+
async function pruneUnlocked(project, opts) {
|
|
4687
5207
|
const result = { removed: [], lockEntries: 0, files: [], dryRun: !!opts.dryRun };
|
|
4688
5208
|
const plan2 = /* @__PURE__ */ new Map();
|
|
4689
5209
|
for (const { target } of project.targets) {
|
|
@@ -4725,29 +5245,6 @@ async function prune(project, opts = {}) {
|
|
|
4725
5245
|
|
|
4726
5246
|
// src/core/plan.ts
|
|
4727
5247
|
import path16 from "path";
|
|
4728
|
-
import { stringify as toYaml } from "yaml";
|
|
4729
|
-
var VOICE_DEFAULTS = {
|
|
4730
|
-
hi: "Address the user as \u0906\u092A (polite). Everyday Hindi over Sanskritized words; common English tech words (app, sync) may stay in English.",
|
|
4731
|
-
ar: "Modern Standard Arabic, addressing the user neutrally. Short sentences.",
|
|
4732
|
-
de: "Address the user as du, as most consumer apps do.",
|
|
4733
|
-
fr: "Address the user as vous.",
|
|
4734
|
-
es: "Address the user as t\xFA. Neutral Spanish that works in Spain and Latin America.",
|
|
4735
|
-
it: "Address the user as tu.",
|
|
4736
|
-
pt: "Address the user as voc\xEA (Brazilian Portuguese conventions).",
|
|
4737
|
-
nl: "Address the user as je.",
|
|
4738
|
-
ru: "Address the user as \u0432\u044B (lowercase).",
|
|
4739
|
-
ja: "Polite \u3067\u3059/\u307E\u3059 form. Short, natural UI phrasing.",
|
|
4740
|
-
ko: "Polite \uD574\uC694\uCCB4. Short, natural UI phrasing.",
|
|
4741
|
-
zh: "Neutral, concise UI language; no honorifics needed.",
|
|
4742
|
-
tr: "Address the user as siz.",
|
|
4743
|
-
pl: "Address the user informally (ty), as most consumer apps do.",
|
|
4744
|
-
id: "Address the user as Anda.",
|
|
4745
|
-
vi: "Address the user as b\u1EA1n.",
|
|
4746
|
-
th: "Polite, neutral tone; use \u0E04\u0E23\u0E31\u0E1A/\u0E04\u0E48\u0E30 only where a sentence needs it."
|
|
4747
|
-
};
|
|
4748
|
-
function defaultVoice(locale2) {
|
|
4749
|
-
return VOICE_DEFAULTS[locale2] ?? VOICE_DEFAULTS[locale2.split("-")[0]] ?? "Polite, neutral tone, as a well-made app in this language would use.";
|
|
4750
|
-
}
|
|
4751
5248
|
var COMMON_LANGUAGES = "af am ar az be bg bn bs ca cs cy da de el en es et eu fa fi fil fr ga gl gu he hi hr hu hy id is it ja ka kk km kn ko ky lo lt lv mk ml mn mr ms my nb ne nl or pa pl ps pt ro ru si sk sl sq sr sv sw ta te th tl tr uk ur uz vi zh zu pt-BR pt-PT zh-Hans zh-Hant es-419 fr-CA en-GB".split(" ");
|
|
4752
5249
|
var NAMES = new Intl.DisplayNames(["en"], { type: "language" });
|
|
4753
5250
|
function resolveLocale(input) {
|
|
@@ -4768,7 +5265,7 @@ function resolveLocale(input) {
|
|
|
4768
5265
|
}
|
|
4769
5266
|
async function appNames(fs, root, result) {
|
|
4770
5267
|
const names = /* @__PURE__ */ new Set();
|
|
4771
|
-
for (const c of result
|
|
5268
|
+
for (const c of result.candidates) {
|
|
4772
5269
|
if (c.target.format !== "android-xml") continue;
|
|
4773
5270
|
try {
|
|
4774
5271
|
const xml = await fs.readFile(path16.join(root, c.target.path, "values", "strings.xml"));
|
|
@@ -4777,12 +5274,6 @@ async function appNames(fs, root, result) {
|
|
|
4777
5274
|
} catch {
|
|
4778
5275
|
}
|
|
4779
5276
|
}
|
|
4780
|
-
try {
|
|
4781
|
-
const readme = await fs.readFile(path16.join(root, "README.md"));
|
|
4782
|
-
const heading = /^#\s+(.+)$/m.exec(readme)?.[1]?.replace(/[^\p{L}\p{N} .-]/gu, "").trim();
|
|
4783
|
-
if (heading && heading.split(/\s+/).length <= 3) names.add(heading);
|
|
4784
|
-
} catch {
|
|
4785
|
-
}
|
|
4786
5277
|
return [...names];
|
|
4787
5278
|
}
|
|
4788
5279
|
async function plan(fs, cwd, opts = {}) {
|
|
@@ -4795,12 +5286,14 @@ async function plan(fs, cwd, opts = {}) {
|
|
|
4795
5286
|
}
|
|
4796
5287
|
return project ? planConfigured(project, requested) : planNew(fs, cwd, requested);
|
|
4797
5288
|
}
|
|
5289
|
+
var EXTRACT = "They won't be translated until they're moved into string resources (the localship-extract skill): part of making the app work in a language, otherwise only when the user asks.";
|
|
4798
5290
|
async function planNew(fs, cwd, requested) {
|
|
4799
5291
|
const result = await detect(fs, path16.resolve(cwd));
|
|
4800
5292
|
const picked = defaultSelection(result).map((n) => result.candidates[n - 1]);
|
|
4801
5293
|
const chosen = picked.filter((c) => c !== void 0);
|
|
4802
5294
|
const locales = requested.length ? requested : result.locales;
|
|
4803
5295
|
const blockers = [];
|
|
5296
|
+
const notes = [];
|
|
4804
5297
|
if (chosen.length === 0) {
|
|
4805
5298
|
const unsupported = result.unsupported.map((u) => u.format).join(", ");
|
|
4806
5299
|
blockers.push(
|
|
@@ -4811,40 +5304,29 @@ async function planNew(fs, cwd, requested) {
|
|
|
4811
5304
|
} : {
|
|
4812
5305
|
kind: "no-translation-files",
|
|
4813
5306
|
message: `No translation files yet. ${result.hints[0] ?? ""}`.trim(),
|
|
4814
|
-
fix: "Move the UI text out of the code into string resources (the localship-extract skill)
|
|
5307
|
+
fix: "Move the UI text out of the code into string resources (the localship-extract skill): part of making the app work in a language, otherwise only when the user asks."
|
|
4815
5308
|
}
|
|
4816
5309
|
);
|
|
4817
5310
|
}
|
|
4818
5311
|
for (const c of chosen) {
|
|
4819
5312
|
if (c.codeLiterals !== void 0 && mostlyHardcoded(c.codeLiterals, c.strings)) {
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
fix: `First move that text into ${c.target.path}/values/strings.xml on a new branch (the localship-extract skill), building as it goes, then translate.`
|
|
4824
|
-
});
|
|
5313
|
+
notes.push(
|
|
5314
|
+
`About ${c.codeLiterals} pieces of UI text are still in the code, against ${c.strings} strings in ${c.target.path}. ${EXTRACT}`
|
|
5315
|
+
);
|
|
4825
5316
|
}
|
|
4826
5317
|
if (c.target.format === "xcstrings" && c.strings === 0) {
|
|
4827
5318
|
blockers.push({
|
|
4828
5319
|
kind: "empty-catalog",
|
|
4829
5320
|
message: `${c.target.path} is empty.`,
|
|
4830
|
-
fix: "Build the
|
|
5321
|
+
fix: "Build the targets that use it, then fill it: Xcode's catalog editor does this with the project open; from the command line, run `xcrun xcstringstool sync <catalog> --stringsdata <each target's .stringsdata in DerivedData>`, since a command-line build alone leaves the catalog unchanged."
|
|
4831
5322
|
});
|
|
4832
5323
|
}
|
|
4833
5324
|
}
|
|
4834
|
-
if (locales.length === 0)
|
|
4835
|
-
|
|
4836
|
-
kind: "no-locales",
|
|
4837
|
-
message: "No languages chosen yet.",
|
|
4838
|
-
fix: "Tell me which languages to translate into (for example: Hindi and Arabic)."
|
|
4839
|
-
});
|
|
4840
|
-
}
|
|
4841
|
-
const strings = chosen.reduce(
|
|
4842
|
-
(n, c) => n + c.strings + (c.codeLiterals !== void 0 && mostlyHardcoded(c.codeLiterals, c.strings) ? c.codeLiterals : 0),
|
|
4843
|
-
0
|
|
4844
|
-
);
|
|
4845
|
-
const keep = await appNames(fs, path16.resolve(cwd), result);
|
|
5325
|
+
if (locales.length === 0) blockers.push(noLocales);
|
|
5326
|
+
const strings = chosen.reduce((n, c) => n + c.strings, 0);
|
|
4846
5327
|
return build({
|
|
4847
5328
|
warnings: result.otherSystems.map((o) => o.message),
|
|
5329
|
+
notes,
|
|
4848
5330
|
configured: false,
|
|
4849
5331
|
sourceLocale: result.sourceLocale.value,
|
|
4850
5332
|
locales: locales.map((code) => ({ code, name: languageName(code), isNew: true })),
|
|
@@ -4855,10 +5337,16 @@ async function planNew(fs, cwd, requested) {
|
|
|
4855
5337
|
})),
|
|
4856
5338
|
work: Object.fromEntries(locales.map((l) => [l, strings])),
|
|
4857
5339
|
blockers,
|
|
4858
|
-
keep,
|
|
4859
|
-
|
|
5340
|
+
keep: await appNames(fs, path16.resolve(cwd), result),
|
|
5341
|
+
brief: false,
|
|
5342
|
+
voice: () => void 0
|
|
4860
5343
|
});
|
|
4861
5344
|
}
|
|
5345
|
+
var noLocales = {
|
|
5346
|
+
kind: "no-locales",
|
|
5347
|
+
message: "No languages chosen yet.",
|
|
5348
|
+
fix: "Ask the user which languages to translate into."
|
|
5349
|
+
};
|
|
4862
5350
|
async function planConfigured(project, requested) {
|
|
4863
5351
|
const configured = projectLocales(project);
|
|
4864
5352
|
const locales = [.../* @__PURE__ */ new Set([...configured, ...requested])].filter(
|
|
@@ -4876,22 +5364,12 @@ async function planConfigured(project, requested) {
|
|
|
4876
5364
|
);
|
|
4877
5365
|
}
|
|
4878
5366
|
}
|
|
4879
|
-
const
|
|
4880
|
-
kind: "mostly-hardcoded",
|
|
4881
|
-
message: n.message,
|
|
4882
|
-
fix: "First move that text into strings.xml on a new branch (the localship-extract skill), then translate."
|
|
4883
|
-
}));
|
|
4884
|
-
if (focus.length === 0) {
|
|
4885
|
-
blockers.push({
|
|
4886
|
-
kind: "no-locales",
|
|
4887
|
-
message: "No languages chosen yet.",
|
|
4888
|
-
fix: "Tell me which languages to translate into."
|
|
4889
|
-
});
|
|
4890
|
-
}
|
|
5367
|
+
const notes = (await hardcodedNotes(project)).map((n) => `${n.message} ${EXTRACT}`);
|
|
4891
5368
|
const { files } = await walk(project.fs, project.root);
|
|
4892
5369
|
const others = await findOtherSystems(project.fs, project.root, files);
|
|
4893
5370
|
return build({
|
|
4894
5371
|
warnings: others.map((o) => o.message),
|
|
5372
|
+
notes,
|
|
4895
5373
|
configured: true,
|
|
4896
5374
|
sourceLocale: project.config.sourceLocale,
|
|
4897
5375
|
locales: focus.map((code) => ({
|
|
@@ -4905,43 +5383,18 @@ async function planConfigured(project, requested) {
|
|
|
4905
5383
|
strings: [...t.target.source.entries.values()].filter((e) => e.translatable).length
|
|
4906
5384
|
})),
|
|
4907
5385
|
work,
|
|
4908
|
-
blockers,
|
|
4909
|
-
keep: project.
|
|
4910
|
-
|
|
4911
|
-
|
|
5386
|
+
blockers: focus.length ? [] : [noLocales],
|
|
5387
|
+
keep: projectGlossary(project).filter((t) => !t.translate).map((t) => t.term),
|
|
5388
|
+
brief: !!project.context.brief,
|
|
5389
|
+
voice: (l) => voiceFor(project.context, l)
|
|
4912
5390
|
});
|
|
4913
5391
|
}
|
|
4914
5392
|
function build(p) {
|
|
4915
5393
|
const voice = Object.fromEntries(
|
|
4916
|
-
p.locales.map((l) =>
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
const total = Math.max(0, ...Object.values(p.work));
|
|
4921
|
-
const steps = [];
|
|
4922
|
-
for (const b of fixFirst) steps.push(b.fix);
|
|
4923
|
-
if (!p.configured) {
|
|
4924
|
-
steps.push(
|
|
4925
|
-
`Set up Localship for ${p.targets.map((t) => t.path).join(", ") || "the app"} with ${names || "your languages"} (localship_start).`
|
|
4926
|
-
);
|
|
4927
|
-
} else if (p.locales.some((l) => l.isNew)) {
|
|
4928
|
-
steps.push(
|
|
4929
|
-
`Add ${p.locales.filter((l) => l.isNew).map((l) => l.name).join(" and ")} (localship_start).`
|
|
4930
|
-
);
|
|
4931
|
-
}
|
|
4932
|
-
if (!p.hasBrief) {
|
|
4933
|
-
steps.push(
|
|
4934
|
-
"Draft a short brief from your README and strings, so translations match the app's tone."
|
|
4935
|
-
);
|
|
4936
|
-
}
|
|
4937
|
-
const about = fixFirst.some((b) => b.kind === "mostly-hardcoded") ? "about " : "";
|
|
4938
|
-
steps.push(
|
|
4939
|
-
`Translate ${total ? `${about}${total}` : "the"} strings${names ? ` into ${names}` : ""}, checking each one (localship_next_batch \u2192 localship_submit until nothing remains).`,
|
|
4940
|
-
"Run all checks (localship_validate) and report what was done.",
|
|
4941
|
-
"Leave everything as drafts for you to review; nothing is approved without you."
|
|
4942
|
-
);
|
|
4943
|
-
const blocked = p.blockers.some(
|
|
4944
|
-
(b) => b.kind === "unsupported-format" || b.kind === "no-locales"
|
|
5394
|
+
p.locales.map((l) => {
|
|
5395
|
+
const own = p.voice(l.code);
|
|
5396
|
+
return [l.code, { text: own ?? conventionalVoice(l.code), fromProject: !!own }];
|
|
5397
|
+
})
|
|
4945
5398
|
);
|
|
4946
5399
|
return {
|
|
4947
5400
|
ready: p.blockers.length === 0,
|
|
@@ -4951,27 +5404,18 @@ function build(p) {
|
|
|
4951
5404
|
targets: p.targets,
|
|
4952
5405
|
work: p.work,
|
|
4953
5406
|
warnings: p.warnings,
|
|
5407
|
+
notes: p.notes,
|
|
4954
5408
|
blockers: p.blockers,
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
brief: p.hasBrief ? "exists" : "will be drafted"
|
|
4959
|
-
},
|
|
4960
|
-
steps,
|
|
4961
|
-
confirm: blocked ? "" : fixFirst.length ? "start" : "start translating"
|
|
5409
|
+
voice,
|
|
5410
|
+
keepAsWritten: p.keep,
|
|
5411
|
+
brief: p.brief
|
|
4962
5412
|
};
|
|
4963
5413
|
}
|
|
4964
5414
|
async function start(fs, cwd, opts) {
|
|
4965
5415
|
const locales = opts.locales.map(resolveLocale);
|
|
4966
5416
|
if (locales.length === 0) throw new UserError("Say which languages to translate into");
|
|
4967
5417
|
const root = path16.resolve(cwd);
|
|
4968
|
-
const result = {
|
|
4969
|
-
config: "",
|
|
4970
|
-
createdConfig: false,
|
|
4971
|
-
addedLocales: [],
|
|
4972
|
-
wrote: [],
|
|
4973
|
-
warnings: []
|
|
4974
|
-
};
|
|
5418
|
+
const result = { config: "", createdConfig: false, addedLocales: [], warnings: [] };
|
|
4975
5419
|
let existing;
|
|
4976
5420
|
try {
|
|
4977
5421
|
existing = (await loadConfig(fs, root)).file;
|
|
@@ -4981,49 +5425,30 @@ async function start(fs, cwd, opts) {
|
|
|
4981
5425
|
if (existing) {
|
|
4982
5426
|
result.config = existing;
|
|
4983
5427
|
result.addedLocales = (await addLocales(fs, existing, locales)).added;
|
|
4984
|
-
|
|
4985
|
-
const out = [];
|
|
4986
|
-
const code = await runInit(
|
|
4987
|
-
fs,
|
|
4988
|
-
{ cwd: root, yes: true, locales },
|
|
4989
|
-
{
|
|
4990
|
-
interactive: false,
|
|
4991
|
-
log: (l) => out.push(l),
|
|
4992
|
-
error: (l) => out.push(l),
|
|
4993
|
-
ask: async (_q, f) => f
|
|
4994
|
-
}
|
|
4995
|
-
);
|
|
4996
|
-
if (code !== 0) throw new UserError(out.join("\n"));
|
|
4997
|
-
result.config = path16.join(root, CONFIG_FILE);
|
|
4998
|
-
result.createdConfig = true;
|
|
4999
|
-
result.addedLocales = locales;
|
|
5000
|
-
result.warnings = out.filter((l) => l.startsWith("\u25B2 ")).map((l) => l.slice(2));
|
|
5001
|
-
}
|
|
5002
|
-
const project = await openProject(fs, { cwd: root });
|
|
5003
|
-
const dir = project.context.dir;
|
|
5004
|
-
const write2 = async (rel, content) => {
|
|
5005
|
-
const file = path16.join(dir, rel);
|
|
5006
|
-
if (await fs.exists(file)) return;
|
|
5007
|
-
await fs.writeFile(file, content.endsWith("\n") ? content : `${content}
|
|
5008
|
-
`);
|
|
5009
|
-
result.wrote.push(rel);
|
|
5010
|
-
};
|
|
5011
|
-
for (const l of projectLocales(project)) {
|
|
5012
|
-
await write2(`${CONTEXT_FILES.voiceDir}/${l}.md`, opts.voice?.[l] ?? defaultVoice(l));
|
|
5013
|
-
}
|
|
5014
|
-
const keep = opts.keepAsWritten ?? await appNames(fs, root, await detect(fs, root));
|
|
5015
|
-
if (keep.length) {
|
|
5016
|
-
await write2(
|
|
5017
|
-
CONTEXT_FILES.glossary,
|
|
5018
|
-
toYaml({ terms: keep.map((term) => ({ term, translate: false })) }, { lineWidth: 0 })
|
|
5019
|
-
);
|
|
5428
|
+
return result;
|
|
5020
5429
|
}
|
|
5430
|
+
const out = [];
|
|
5431
|
+
const code = await runInit(
|
|
5432
|
+
fs,
|
|
5433
|
+
{ cwd: root, yes: true, locales },
|
|
5434
|
+
{
|
|
5435
|
+
interactive: false,
|
|
5436
|
+
log: (l) => out.push(l),
|
|
5437
|
+
error: (l) => out.push(l),
|
|
5438
|
+
ask: async (_q, f) => f
|
|
5439
|
+
}
|
|
5440
|
+
);
|
|
5441
|
+
if (code !== 0) throw new UserError(out.join("\n"));
|
|
5442
|
+
result.config = path16.join(root, CONFIG_FILE);
|
|
5443
|
+
result.createdConfig = true;
|
|
5444
|
+
result.addedLocales = locales;
|
|
5445
|
+
result.warnings = out.filter((l) => l.startsWith("\u25B2 ")).map((l) => l.slice(2));
|
|
5021
5446
|
return result;
|
|
5022
5447
|
}
|
|
5023
5448
|
|
|
5024
5449
|
// src/translate/prompt.ts
|
|
5025
5450
|
var TRANSLATION_RULES = `- Translate the source text of each item into the item's locale. Write what a native speaker would put in a well-made app: natural, concise UI language, not a word-for-word copy.
|
|
5026
|
-
- Follow the brief and the locale's voice. Work out where each text appears and what it does from its comment, context, and usage: the code that shows it, whose file path names the screen and whose code shows whether it is a button, title, label, hint or message.
|
|
5451
|
+
- Follow the brief and the locale's voice (defaultVoice when the project has set none: the usual convention for that language). Work out where each text appears and what it does from its comment, context, and usage: the code that shows it, whose file path names the screen and whose code shows whether it is a button, title, label, hint or message.
|
|
5027
5452
|
- Keep every placeholder exactly as written (%s, %1$s, %d, %@, {name}, %(count)d\u2026). You may move them to fit the grammar; numbered ones (%1$s) may be reordered freely.
|
|
5028
5453
|
- Keep markup and escapes exactly: tags such as <b> or <xliff:g>, entities, \\n, and any leading or trailing spaces and newlines.
|
|
5029
5454
|
- Glossary: a term marked keep stays exactly as written; a term with use must be translated that way.
|
|
@@ -5073,6 +5498,9 @@ function extractJson(reply) {
|
|
|
5073
5498
|
}
|
|
5074
5499
|
}
|
|
5075
5500
|
|
|
5501
|
+
// src/translate/translate.ts
|
|
5502
|
+
import { setTimeout as delay } from "timers/promises";
|
|
5503
|
+
|
|
5076
5504
|
// src/translate/providers.ts
|
|
5077
5505
|
var BUILT_IN = {
|
|
5078
5506
|
anthropic: {
|
|
@@ -5109,6 +5537,10 @@ function createCompletion(spec, opts = {}) {
|
|
|
5109
5537
|
const { provider, model } = parseModel(spec);
|
|
5110
5538
|
const env = opts.env ?? process.env;
|
|
5111
5539
|
const doFetch = opts.fetch ?? fetch;
|
|
5540
|
+
const timeoutMs = opts.timeoutMs ?? 12e4;
|
|
5541
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) {
|
|
5542
|
+
throw new UserError("timeoutMs must be a positive integer no greater than 2147483647");
|
|
5543
|
+
}
|
|
5112
5544
|
const custom = opts.custom?.[provider];
|
|
5113
5545
|
const builtIn = BUILT_IN[provider];
|
|
5114
5546
|
if (!custom && !builtIn) {
|
|
@@ -5123,30 +5555,44 @@ function createCompletion(spec, opts = {}) {
|
|
|
5123
5555
|
if (keyNames.length > 0 && !apiKey) {
|
|
5124
5556
|
throw new UserError(`Set ${keyNames[0]} to use ${provider} models.`);
|
|
5125
5557
|
}
|
|
5126
|
-
const post = async (url, headers, body) => {
|
|
5127
|
-
|
|
5558
|
+
const post = async (url, headers, body, external) => {
|
|
5559
|
+
const deadline = new AbortController();
|
|
5560
|
+
const signal = external ? AbortSignal.any([external, deadline.signal]) : deadline.signal;
|
|
5561
|
+
const timer = setTimeout(() => deadline.abort(), timeoutMs);
|
|
5128
5562
|
try {
|
|
5129
|
-
|
|
5563
|
+
signal.throwIfAborted();
|
|
5564
|
+
const res = await doFetch(url, {
|
|
5130
5565
|
method: "POST",
|
|
5131
5566
|
headers: { "content-type": "application/json", ...headers },
|
|
5132
|
-
body: JSON.stringify(body)
|
|
5567
|
+
body: JSON.stringify(body),
|
|
5568
|
+
signal
|
|
5133
5569
|
});
|
|
5570
|
+
const text = await res.text();
|
|
5571
|
+
signal.throwIfAborted();
|
|
5572
|
+
if (!res.ok) {
|
|
5573
|
+
throw new ProviderError(
|
|
5574
|
+
`${provider} returned ${res.status}: ${text.slice(0, 500)}`,
|
|
5575
|
+
res.status === 429 || res.status >= 500
|
|
5576
|
+
);
|
|
5577
|
+
}
|
|
5578
|
+
try {
|
|
5579
|
+
return JSON.parse(text);
|
|
5580
|
+
} catch {
|
|
5581
|
+
throw new ProviderError(
|
|
5582
|
+
`${provider} returned something that isn't JSON: ${text.slice(0, 200)}`
|
|
5583
|
+
);
|
|
5584
|
+
}
|
|
5134
5585
|
} catch (e) {
|
|
5586
|
+
if (external?.aborted) throw new UserError("Translation cancelled");
|
|
5587
|
+
if (deadline.signal.aborted) {
|
|
5588
|
+
throw new ProviderError(
|
|
5589
|
+
`${provider}: request timed out after ${timeoutMs} ms; retry or increase --timeout-ms`
|
|
5590
|
+
);
|
|
5591
|
+
}
|
|
5592
|
+
if (e instanceof ProviderError) throw e;
|
|
5135
5593
|
throw new ProviderError(`${provider}: could not reach ${baseUrl} (${e.message})`);
|
|
5136
|
-
}
|
|
5137
|
-
|
|
5138
|
-
if (!res.ok) {
|
|
5139
|
-
throw new ProviderError(
|
|
5140
|
-
`${provider} returned ${res.status}: ${text.slice(0, 500)}`,
|
|
5141
|
-
res.status === 429 || res.status >= 500
|
|
5142
|
-
);
|
|
5143
|
-
}
|
|
5144
|
-
try {
|
|
5145
|
-
return JSON.parse(text);
|
|
5146
|
-
} catch {
|
|
5147
|
-
throw new ProviderError(
|
|
5148
|
-
`${provider} returned something that isn't JSON: ${text.slice(0, 200)}`
|
|
5149
|
-
);
|
|
5594
|
+
} finally {
|
|
5595
|
+
clearTimeout(timer);
|
|
5150
5596
|
}
|
|
5151
5597
|
};
|
|
5152
5598
|
if (protocol === "anthropic") {
|
|
@@ -5159,7 +5605,8 @@ function createCompletion(spec, opts = {}) {
|
|
|
5159
5605
|
max_tokens: req.maxTokens ?? 16e3,
|
|
5160
5606
|
system: req.system,
|
|
5161
5607
|
messages: [{ role: "user", content: req.user }]
|
|
5162
|
-
}
|
|
5608
|
+
},
|
|
5609
|
+
req.signal
|
|
5163
5610
|
);
|
|
5164
5611
|
if (data.stop_reason === "max_tokens") {
|
|
5165
5612
|
throw new ProviderError(`${provider}: the response was cut off; use a smaller batch`);
|
|
@@ -5178,7 +5625,8 @@ function createCompletion(spec, opts = {}) {
|
|
|
5178
5625
|
{ role: "system", content: req.system },
|
|
5179
5626
|
{ role: "user", content: req.user }
|
|
5180
5627
|
]
|
|
5181
|
-
}
|
|
5628
|
+
},
|
|
5629
|
+
req.signal
|
|
5182
5630
|
);
|
|
5183
5631
|
const choice = data.choices?.[0];
|
|
5184
5632
|
if (choice?.finish_reason === "length") {
|
|
@@ -5208,15 +5656,38 @@ function modelFor(project, locale2, override) {
|
|
|
5208
5656
|
return model;
|
|
5209
5657
|
}
|
|
5210
5658
|
async function translateProject(start2, opts = {}) {
|
|
5659
|
+
const controller = new AbortController();
|
|
5660
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, controller.signal]) : controller.signal;
|
|
5661
|
+
try {
|
|
5662
|
+
return await runTranslation(start2, { ...opts, signal });
|
|
5663
|
+
} finally {
|
|
5664
|
+
controller.abort();
|
|
5665
|
+
}
|
|
5666
|
+
}
|
|
5667
|
+
async function runTranslation(start2, opts) {
|
|
5668
|
+
const checkCancelled = () => {
|
|
5669
|
+
if (opts.signal?.aborted) throw new UserError("Translation cancelled");
|
|
5670
|
+
};
|
|
5671
|
+
checkCancelled();
|
|
5211
5672
|
const log = opts.log ?? (() => {
|
|
5212
5673
|
});
|
|
5213
5674
|
const locales = opts.locales ?? projectLocales(start2);
|
|
5214
5675
|
if (locales.length === 0)
|
|
5215
5676
|
throw new UserError("No locales to translate. Add `locales:` to the config.");
|
|
5216
5677
|
const batchSize = opts.batchSize ?? 30;
|
|
5217
|
-
const concurrency =
|
|
5218
|
-
const maxAttempts =
|
|
5219
|
-
const
|
|
5678
|
+
const concurrency = opts.concurrency ?? 4;
|
|
5679
|
+
const maxAttempts = opts.attempts ?? 2;
|
|
5680
|
+
const maxPromptBytes = opts.maxPromptBytes ?? 48e3;
|
|
5681
|
+
for (const [name, value] of Object.entries({
|
|
5682
|
+
batchSize,
|
|
5683
|
+
concurrency,
|
|
5684
|
+
attempts: maxAttempts,
|
|
5685
|
+
maxPromptBytes
|
|
5686
|
+
})) {
|
|
5687
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
5688
|
+
throw new UserError(`${name} must be a positive integer`);
|
|
5689
|
+
}
|
|
5690
|
+
const makeCompletion = opts.completion ?? ((model) => createCompletion(model, { custom: start2.config.providers, timeoutMs: opts.timeoutMs }));
|
|
5220
5691
|
const models = Object.fromEntries(locales.map((l) => [l, modelFor(start2, l, opts.model)]));
|
|
5221
5692
|
const clients = /* @__PURE__ */ new Map();
|
|
5222
5693
|
const client = (model) => {
|
|
@@ -5238,9 +5709,11 @@ async function translateProject(start2, opts = {}) {
|
|
|
5238
5709
|
const files = /* @__PURE__ */ new Set();
|
|
5239
5710
|
const tries = /* @__PURE__ */ new Map();
|
|
5240
5711
|
const givenUp = /* @__PURE__ */ new Set();
|
|
5712
|
+
const finished = /* @__PURE__ */ new Set();
|
|
5241
5713
|
let project = start2;
|
|
5242
5714
|
const usages = await buildUsageIndex(start2);
|
|
5243
5715
|
for (; ; ) {
|
|
5716
|
+
checkCancelled();
|
|
5244
5717
|
const round = [];
|
|
5245
5718
|
for (const locale2 of locales) {
|
|
5246
5719
|
let offset = 0;
|
|
@@ -5252,34 +5725,42 @@ async function translateProject(start2, opts = {}) {
|
|
|
5252
5725
|
states: opts.states,
|
|
5253
5726
|
limit: batchSize,
|
|
5254
5727
|
offset,
|
|
5255
|
-
skip:
|
|
5728
|
+
skip: finished,
|
|
5256
5729
|
usages,
|
|
5257
5730
|
skipHandEdited: !opts.force
|
|
5258
5731
|
});
|
|
5259
5732
|
if (batch.items.length === 0) break;
|
|
5260
|
-
|
|
5733
|
+
const retries = /* @__PURE__ */ new Map();
|
|
5734
|
+
for (const item of batch.items) {
|
|
5735
|
+
const retry = tries.get(itemId(item));
|
|
5736
|
+
if (retry) retries.set(item, { value: retry.value, errors: retry.errors });
|
|
5737
|
+
}
|
|
5738
|
+
let user = userPrompt(batch, retries);
|
|
5739
|
+
while (Buffer.byteLength(systemPrompt()) + Buffer.byteLength(user) > maxPromptBytes) {
|
|
5740
|
+
if (batch.items.length === 1) {
|
|
5741
|
+
const item = batch.items[0];
|
|
5742
|
+
throw new UserError(
|
|
5743
|
+
`Prompt for ${item.locale}:${item.key} exceeds maxPromptBytes (${maxPromptBytes}); shorten context or increase --max-prompt-bytes`
|
|
5744
|
+
);
|
|
5745
|
+
}
|
|
5746
|
+
batch.items.pop();
|
|
5747
|
+
user = userPrompt(batch, retries);
|
|
5748
|
+
}
|
|
5749
|
+
round.push({ batch, user });
|
|
5261
5750
|
offset += batch.items.length;
|
|
5262
5751
|
}
|
|
5263
5752
|
if (round.length >= concurrency) break;
|
|
5264
5753
|
}
|
|
5265
5754
|
if (round.length === 0) break;
|
|
5266
5755
|
const replies = await Promise.all(
|
|
5267
|
-
round.map(async (batch) => {
|
|
5756
|
+
round.map(async ({ batch, user }) => {
|
|
5268
5757
|
const locale2 = batch.items[0]?.locale;
|
|
5269
5758
|
const model = models[locale2];
|
|
5270
|
-
const retries = /* @__PURE__ */ new Map();
|
|
5271
|
-
for (const item of batch.items) {
|
|
5272
|
-
const t = tries.get(itemId(item));
|
|
5273
|
-
if (t)
|
|
5274
|
-
retries.set(item, {
|
|
5275
|
-
...t.value !== void 0 ? { value: t.value } : {},
|
|
5276
|
-
errors: t.errors
|
|
5277
|
-
});
|
|
5278
|
-
}
|
|
5279
5759
|
log(`${locale2}: translating ${batch.items.length} strings with ${model}\u2026`);
|
|
5280
5760
|
try {
|
|
5281
5761
|
const reply = await callWithRetry(
|
|
5282
|
-
() => client(model)({ system: systemPrompt(), user:
|
|
5762
|
+
() => client(model)({ system: systemPrompt(), user, signal: opts.signal }),
|
|
5763
|
+
opts.signal
|
|
5283
5764
|
);
|
|
5284
5765
|
return { batch, model, reply };
|
|
5285
5766
|
} catch (e) {
|
|
@@ -5289,12 +5770,16 @@ async function translateProject(start2, opts = {}) {
|
|
|
5289
5770
|
})
|
|
5290
5771
|
);
|
|
5291
5772
|
for (const { batch, model, reply, error } of replies) {
|
|
5773
|
+
checkCancelled();
|
|
5292
5774
|
const byId = new Map(batch.items.map((i) => [itemId(i), i]));
|
|
5293
5775
|
const fail = (item, errors, value) => {
|
|
5294
5776
|
const id2 = itemId(item);
|
|
5295
5777
|
const count = (tries.get(id2)?.count ?? 0) + 1;
|
|
5296
5778
|
tries.set(id2, { count, errors, item, ...value !== void 0 ? { value } : {} });
|
|
5297
|
-
if (count >= maxAttempts)
|
|
5779
|
+
if (count >= maxAttempts) {
|
|
5780
|
+
givenUp.add(id2);
|
|
5781
|
+
finished.add(id2);
|
|
5782
|
+
}
|
|
5298
5783
|
};
|
|
5299
5784
|
if (error !== void 0) {
|
|
5300
5785
|
throw new UserError(error);
|
|
@@ -5325,12 +5810,20 @@ async function translateProject(start2, opts = {}) {
|
|
|
5325
5810
|
const r = await submit(
|
|
5326
5811
|
project,
|
|
5327
5812
|
answers.map((a) => a.sub),
|
|
5328
|
-
{
|
|
5813
|
+
{
|
|
5814
|
+
model,
|
|
5815
|
+
force: opts.force,
|
|
5816
|
+
usages,
|
|
5817
|
+
expectedSources: new Map(batch.items.map((item) => [itemId(item), item.source]))
|
|
5818
|
+
}
|
|
5329
5819
|
);
|
|
5330
5820
|
for (const f of r.files) files.add(f);
|
|
5331
5821
|
result.translated += r.accepted.length;
|
|
5332
5822
|
const accepted = new Set(r.accepted.map((a) => itemId(a)));
|
|
5333
|
-
for (const id2 of accepted)
|
|
5823
|
+
for (const id2 of accepted) {
|
|
5824
|
+
tries.delete(id2);
|
|
5825
|
+
finished.add(id2);
|
|
5826
|
+
}
|
|
5334
5827
|
for (const { item, sub } of answers) {
|
|
5335
5828
|
const id2 = itemId(item);
|
|
5336
5829
|
if (accepted.has(id2)) continue;
|
|
@@ -5365,15 +5858,21 @@ async function translateProject(start2, opts = {}) {
|
|
|
5365
5858
|
});
|
|
5366
5859
|
result.heldForReview = held.items.filter((i) => i.editedByHand).map(({ target, locale: locale2, key }) => ({ target, locale: locale2, key }));
|
|
5367
5860
|
}
|
|
5861
|
+
checkCancelled();
|
|
5368
5862
|
return result;
|
|
5369
5863
|
}
|
|
5370
|
-
async function callWithRetry(fn, tries = 3) {
|
|
5864
|
+
async function callWithRetry(fn, signal, tries = 3) {
|
|
5371
5865
|
for (let i = 1; ; i++) {
|
|
5866
|
+
if (signal?.aborted) throw new UserError("Translation cancelled");
|
|
5372
5867
|
try {
|
|
5373
5868
|
return await fn();
|
|
5374
5869
|
} catch (e) {
|
|
5375
5870
|
if (!(e instanceof ProviderError) || !e.transient || i >= tries) throw e;
|
|
5376
|
-
|
|
5871
|
+
try {
|
|
5872
|
+
await delay(1e3 * 2 ** i, void 0, { signal });
|
|
5873
|
+
} catch {
|
|
5874
|
+
throw new UserError("Translation cancelled");
|
|
5875
|
+
}
|
|
5377
5876
|
}
|
|
5378
5877
|
}
|
|
5379
5878
|
}
|
|
@@ -5400,6 +5899,7 @@ export {
|
|
|
5400
5899
|
printApprove,
|
|
5401
5900
|
printPlan,
|
|
5402
5901
|
runInit,
|
|
5902
|
+
buildUsageIndex,
|
|
5403
5903
|
validateProject,
|
|
5404
5904
|
nextBatch,
|
|
5405
5905
|
SubmissionSchema,
|
|
@@ -5413,4 +5913,4 @@ export {
|
|
|
5413
5913
|
TRANSLATION_RULES,
|
|
5414
5914
|
translateProject
|
|
5415
5915
|
};
|
|
5416
|
-
//# sourceMappingURL=chunk-
|
|
5916
|
+
//# sourceMappingURL=chunk-GNL5EFNP.js.map
|