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.
@@ -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
- await fsp.writeFile(tmp, content, "utf8");
352
- await fsp.rename(tmp, file);
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(matches, fallback) {
608
- for (const m of matches) {
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
- return {
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: pluralCategories(locale2).map((id2) => {
628
- const ints = SAMPLE_INTEGERS.filter((n) => rules.select(n) === id2);
629
- const decimals = SAMPLE_DECIMALS.filter((n) => rules.select(n) === id2);
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
- if (locale2 === void 0) continue;
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) locales.set(locale2, catalog);
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, width) {
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 && (width === false || displayWidth(single) <= width)) return [single];
1780
- const pieces = width === false ? segments : segments.flatMap((seg) => wrapSegment(seg, width - 2));
1781
- if (!innerNewline && width !== false && pieces.length === 1 && displayWidth(pieces[0] ?? "") > width - 2) {
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, width) {
1830
- if (width === false) return [`#: ${refs.join(" ")}`];
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) > width) {
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 matches = await matchPattern(fs, root, config.path);
1926
- const style = inferLocaleStyle(matches, "gettext");
1927
- for (const m of matches) {
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((l) => l.substitutions || l.variations?.device);
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
- translatable: key !== "" && info.shouldTranslate !== false && info.extractionState !== "stale" && !variant,
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 app in Xcode once so it collects your strings, then run detect again."
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 xcode = files.some(
3213
- (f) => f.endsWith(".xcodeproj/project.pbxproj") || f === "project.yml" || f.endsWith("/project.yml")
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. Add one in Xcode (File \u203A New \u203A File \u203A String Catalog, named Localizable) and build: Xcode fills it with your SwiftUI Text strings. 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.`
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"} of deleted strings; \`localship prune\` removes ${orphans.length === 1 ? "it" : "them"}`
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 about = p.ready ? "" : "about ";
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
- console.log(bold("Plan"));
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("Defaults") + dim(" (change any of them before starting)"));
3537
- for (const [l, v] of Object.entries(p.defaults.voice)) console.log(` ${l}: ${v}`);
3538
- if (p.defaults.keepAsWritten.length) {
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
- !p.confirm ? yellow("Sort out the item above first.") : p.ready ? green(
3546
- `Ready. Tell your agent "${p.confirm}", or run \`localship start ${codes} --model \u2026\` to translate here.`
3547
- ) : green(`Tell your agent "${p.confirm}" to begin with step 1 (that step needs an agent).`)
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/validate.ts
3698
- function validateProject(project, opts = {}) {
3699
- const locales = opts.locales ?? projectLocales(project);
3700
- const issues = [];
3701
- for (const { target: loaded } of project.targets) {
3702
- issues.push(...loaded.issues);
3703
- const at = (e, locale2) => ({
3704
- target: loaded.id,
3705
- locale: locale2,
3706
- key: e.key,
3707
- file: e.file,
3708
- line: e.line
3709
- });
3710
- for (const src of loaded.source.entries.values()) {
3711
- if (!src.translatable) continue;
3712
- issues.push(
3713
- ...checkSource(loaded, src).map((i) => ({ ...at(src, loaded.source.locale), ...i }))
3714
- );
3715
- }
3716
- for (const locale2 of locales) {
3717
- const catalog = loaded.locales.get(locale2);
3718
- if (!catalog) {
3719
- issues.push({
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
- for (const tgt of catalog.entries.values()) {
3763
- const src = loaded.source.entries.get(tgt.key);
3764
- const where = at(tgt, locale2);
3765
- if (!src) {
3766
- issues.push({
3767
- ...where,
3768
- severity: "warning",
3769
- code: "orphan",
3770
- message: "Not in the source strings any more"
3771
- });
3772
- continue;
3773
- }
3774
- if (!src.translatable && hashMessage(src.message) !== hashMessage(tgt.message)) {
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
- if (!src.translatable) continue;
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
- return issues;
3795
- }
3796
- function checkSource(target, src) {
3797
- const out = [];
3798
- const each = (value, form) => {
3799
- const a = target.analyze(value, src);
3800
- for (const l of a.lints) out.push({ ...l, ...form ? { form } : {} });
3801
- const loose = a.placeholders.filter((p) => !p.positional);
3802
- if (loose.length > 1 && !target.translationsMayReorder) {
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 m = src.message;
3813
- if (m.kind === "text") each(m.value);
3814
- else if (m.kind === "array") {
3815
- for (const [i, v] of m.items.entries()) each(v, String(i));
3816
- } else {
3817
- if (m.forms.other === void 0 && Object.keys(m.forms).some((k) => !/^\d+$/.test(k))) {
3818
- out.push({
3819
- severity: "error",
3820
- code: "plural-missing-category",
3821
- message: 'Plural has no "other" form'
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 out;
3827
- }
3828
- function checkTranslation(target, spec, src, tgt, opts) {
3829
- const { locale: locale2, sourceLocale } = opts;
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 normalize = (s) => s.trim().replace(/\s+/g, " ");
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 = normalize(plainText(target, src));
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.context.glossary;
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 matches = (memory.get(normalize(text)) ?? []).filter(
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(usages ? usages.find(target, src) : [], src.references),
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
- ...matches.length ? {
4288
- memory: matches.slice(0, 3).map((m) => ({
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 [l, { language: languageName(l), ...voice ? { voice } : {} }];
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.context.glossary
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) => ({ target: target.id, locale: locale2, 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?.candidates ?? []) {
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), on a new branch, then set up translation."
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
- blockers.push({
4821
- kind: "mostly-hardcoded",
4822
- message: `About ${c.codeLiterals} pieces of UI text are still in the code, against ${c.strings} in ${c.target.path}. Translating now would leave most of the app in English.`,
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 app in Xcode once so it collects your strings into the catalog."
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
- blockers.push({
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
- hasBrief: false
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 blockers = (await hardcodedNotes(project)).map((n) => ({
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.context.glossary.filter((t) => !t.translate).map((t) => t.term),
4910
- hasBrief: !!project.context.brief,
4911
- voices: project.context.voice
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) => [l.code, p.voices?.[l.code] ?? defaultVoice(l.code)])
4917
- );
4918
- const fixFirst = p.blockers.filter((b) => b.kind !== "no-locales");
4919
- const names = p.locales.map((l) => l.name).join(" and ");
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
- defaults: {
4956
- voice,
4957
- keepAsWritten: p.keep,
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
- } else {
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
- let res;
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
- res = await doFetch(url, {
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
- const text = await res.text();
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 = Math.max(1, opts.concurrency ?? 4);
5218
- const maxAttempts = Math.max(1, opts.attempts ?? 2);
5219
- const makeCompletion = opts.completion ?? ((model) => createCompletion(model, { custom: start2.config.providers }));
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: givenUp,
5728
+ skip: finished,
5256
5729
  usages,
5257
5730
  skipHandEdited: !opts.force
5258
5731
  });
5259
5732
  if (batch.items.length === 0) break;
5260
- round.push(batch);
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: userPrompt(batch, retries) })
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) givenUp.add(id2);
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
- { model, force: opts.force }
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) tries.delete(id2);
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
- await new Promise((r) => setTimeout(r, 1e3 * 2 ** i));
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-OWT47JBL.js.map
5916
+ //# sourceMappingURL=chunk-GNL5EFNP.js.map