localship 0.2.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) {
@@ -615,18 +681,40 @@ function inferLocaleStyle(matches2, 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));
@@ -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
 
@@ -3982,6 +4153,7 @@ function inconsistencies(project, locales) {
3982
4153
  if (!src.translatable || src.message.kind !== "text") continue;
3983
4154
  const tgt = catalog.entries.get(src.key);
3984
4155
  if (tgt?.message.kind !== "text") continue;
4156
+ if (getLockEntry(project.lock, target.id, locale2, src.key)?.note) continue;
3985
4157
  const source = normalize(target.analyze(src.message.value, src).text);
3986
4158
  const group = `${src.context ?? ""}${source}`;
3987
4159
  const translation2 = normalize(target.analyze(tgt.message.value, src).text);
@@ -4004,7 +4176,7 @@ function inconsistencies(project, locales) {
4004
4176
  out.push({
4005
4177
  severity: "warning",
4006
4178
  code: "inconsistent",
4007
- message: `"${short(source)}" is translated ${ways.size} ways: ${listed}. Fine if the context differs`,
4179
+ message: `"${short(source)}" is translated ${ways.size} ways: ${listed}. If the context differs, add a note to the translation saying how`,
4008
4180
  locale: locale2,
4009
4181
  ...odd ? { target: odd.target, key: odd.entry.key, file: odd.entry.file, line: odd.entry.line } : {}
4010
4182
  });
@@ -4063,9 +4235,21 @@ function width(text) {
4063
4235
  // src/core/validate.ts
4064
4236
  function validateProject(project, opts = {}) {
4065
4237
  const locales = opts.locales ?? projectLocales(project);
4238
+ const strict = opts.strict || opts.requireApproved;
4066
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
+ }
4067
4247
  for (const { target: loaded } of project.targets) {
4068
- issues.push(...loaded.issues);
4248
+ issues.push(
4249
+ ...loaded.issues.map(
4250
+ (issue) => strict && issue.blocksCompleteness ? { ...issue, severity: "error" } : issue
4251
+ )
4252
+ );
4069
4253
  const at = (e, locale2) => ({
4070
4254
  target: loaded.id,
4071
4255
  locale: locale2,
@@ -4083,7 +4267,7 @@ function validateProject(project, opts = {}) {
4083
4267
  const catalog = loaded.locales.get(locale2);
4084
4268
  if (!catalog) {
4085
4269
  issues.push({
4086
- severity: opts.strict ? "error" : "info",
4270
+ severity: strict ? "error" : "info",
4087
4271
  code: "locale-missing",
4088
4272
  message: `No ${locale2} translations yet`,
4089
4273
  target: loaded.id,
@@ -4106,7 +4290,7 @@ function validateProject(project, opts = {}) {
4106
4290
  for (const src of loaded.source.entries.values()) {
4107
4291
  if (src.translatable && !catalog.entries.has(src.key)) {
4108
4292
  missing++;
4109
- if (opts.strict) {
4293
+ if (strict) {
4110
4294
  issues.push({
4111
4295
  ...at(src, locale2),
4112
4296
  severity: "error",
@@ -4116,7 +4300,7 @@ function validateProject(project, opts = {}) {
4116
4300
  }
4117
4301
  }
4118
4302
  }
4119
- if (missing > 0 && !opts.strict) {
4303
+ if (missing > 0 && !strict) {
4120
4304
  issues.push({
4121
4305
  severity: "info",
4122
4306
  code: "missing",
@@ -4131,9 +4315,9 @@ function validateProject(project, opts = {}) {
4131
4315
  if (!src) {
4132
4316
  issues.push({
4133
4317
  ...where,
4134
- severity: "warning",
4318
+ severity: strict && loaded.format === "android-xml" ? "error" : "warning",
4135
4319
  code: "orphan",
4136
- message: "Not in the source strings any more"
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"
4137
4321
  });
4138
4322
  continue;
4139
4323
  }
@@ -4147,11 +4331,27 @@ function validateProject(project, opts = {}) {
4147
4331
  continue;
4148
4332
  }
4149
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
+ }
4150
4350
  issues.push(
4151
4351
  ...checkTranslation(loaded, spec, src, tgt, {
4152
4352
  locale: locale2,
4153
4353
  sourceLocale: project.config.sourceLocale,
4154
- glossary: project.context.glossary,
4354
+ glossary: projectGlossary(project),
4155
4355
  role: opts.usages ? roleOf(opts.usages.find(loaded, src)) : void 0
4156
4356
  }).map((i) => ({ ...where, ...i }))
4157
4357
  );
@@ -4451,8 +4651,8 @@ function buildMemory(project, locales) {
4451
4651
  }
4452
4652
  return memory;
4453
4653
  }
4454
- function differentWording(target, src, message, locale2, memory, thisBatch) {
4455
- if (message.kind !== "text" || src.context) return void 0;
4654
+ function differentWording(target, src, message, locale2, memory, thisBatch, note) {
4655
+ if (message.kind !== "text" || src.context || note) return void 0;
4456
4656
  const source = normalize2(plainText(target, src));
4457
4657
  if (!source) return void 0;
4458
4658
  const value = normalize2(message.value);
@@ -4467,7 +4667,7 @@ function differentWording(target, src, message, locale2, memory, thisBatch) {
4467
4667
  return {
4468
4668
  severity: "warning",
4469
4669
  code: "inconsistent",
4470
- message: `"${source}" is already translated as "${other.value}" (${other.key}). Use the same wording unless the context differs`
4670
+ message: `"${source}" is already translated as "${other.value}" (${other.key}). Use the same wording, or add a note saying how the context differs`
4471
4671
  };
4472
4672
  }
4473
4673
  async function nextBatch(project, opts = {}) {
@@ -4478,7 +4678,7 @@ async function nextBatch(project, opts = {}) {
4478
4678
  const offset = opts.offset ?? 0;
4479
4679
  const wanted = opts.keys ? new Set(opts.keys) : void 0;
4480
4680
  const memory = buildMemory(project, locales);
4481
- const glossary = project.context.glossary;
4681
+ const glossary = projectGlossary(project);
4482
4682
  const items = [];
4483
4683
  let matched = 0;
4484
4684
  for (const locale2 of locales) {
@@ -4567,7 +4767,13 @@ async function nextBatch(project, opts = {}) {
4567
4767
  locales: Object.fromEntries(
4568
4768
  usedLocales.map((l) => {
4569
4769
  const voice = voiceFor(project.context, l);
4570
- return [l, { language: languageName(l), ...voice ? { voice } : {} }];
4770
+ return [
4771
+ l,
4772
+ {
4773
+ language: languageName(l),
4774
+ ...voice ? { voice } : { defaultVoice: conventionalVoice(l) }
4775
+ }
4776
+ ];
4571
4777
  })
4572
4778
  ),
4573
4779
  formats: Object.fromEntries(
@@ -4621,6 +4827,13 @@ ${details.join("\n")}`
4621
4827
  }
4622
4828
  var REPLACEABLE = ["missing", "stale", "draft"];
4623
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) {
4624
4837
  const result = { accepted: [], rejected: [], files: [], dryRun: !!opts.dryRun };
4625
4838
  const configured = projectLocales(project);
4626
4839
  const pending = /* @__PURE__ */ new Map();
@@ -4660,6 +4873,17 @@ async function submit(project, submissions, opts = {}) {
4660
4873
  continue;
4661
4874
  }
4662
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
+ }
4663
4887
  const id2 = `${target.id}\0${locale2}\0${src.key}`;
4664
4888
  if (seen.has(id2)) {
4665
4889
  reject("Submitted twice in one batch", "duplicate", target.id);
@@ -4702,7 +4926,7 @@ async function submit(project, submissions, opts = {}) {
4702
4926
  ...checkTranslation(target, target.pluralSpec(locale2), src, tgt, {
4703
4927
  locale: locale2,
4704
4928
  sourceLocale: project.config.sourceLocale,
4705
- glossary: project.context.glossary,
4929
+ glossary: projectGlossary(project),
4706
4930
  role: usages ? roleOf(usages.find(target, src)) : void 0
4707
4931
  }).filter((f) => f.code !== "plural-missing-category" && f.code !== "plural-extra-category"),
4708
4932
  ...checkPluralForms(target, locale2, message)
@@ -4712,7 +4936,15 @@ async function submit(project, submissions, opts = {}) {
4712
4936
  result.rejected.push({ target: target.id, locale: locale2, key: displayKey(src.key), errors });
4713
4937
  continue;
4714
4938
  }
4715
- const differs = differentWording(target, src, message, locale2, memory, thisBatch);
4939
+ const differs = differentWording(
4940
+ target,
4941
+ src,
4942
+ message,
4943
+ locale2,
4944
+ memory,
4945
+ thisBatch,
4946
+ sub.note?.trim()
4947
+ );
4716
4948
  if (differs) findings.push(differs);
4717
4949
  result.accepted.push({
4718
4950
  target: target.id,
@@ -4870,6 +5102,9 @@ function pruneLock(project, lock) {
4870
5102
  }
4871
5103
  }
4872
5104
  async function approve(project, opts = {}) {
5105
+ return withProjectWrite(project, opts.dryRun, (fresh) => approveUnlocked(fresh, opts));
5106
+ }
5107
+ async function approveUnlocked(project, opts) {
4873
5108
  const locales = opts.locales ?? projectLocales(project);
4874
5109
  const states = opts.states ?? ["draft", "reviewed"];
4875
5110
  const to = opts.to ?? "approved";
@@ -4918,6 +5153,9 @@ async function approve(project, opts = {}) {
4918
5153
  }
4919
5154
  var DISPOSABLE = ["draft", "stale"];
4920
5155
  async function removeTranslations(project, opts) {
5156
+ return withProjectWrite(project, opts.dryRun, (fresh) => removeUnlocked(fresh, opts));
5157
+ }
5158
+ async function removeUnlocked(project, opts) {
4921
5159
  if (!opts.keys?.length && !opts.states?.length) {
4922
5160
  throw new UserError("Say which translations to remove: keys, states (e.g. draft), or both");
4923
5161
  }
@@ -4963,6 +5201,9 @@ async function removeTranslations(project, opts) {
4963
5201
  return result;
4964
5202
  }
4965
5203
  async function prune(project, opts = {}) {
5204
+ return withProjectWrite(project, opts.dryRun, (fresh) => pruneUnlocked(fresh, opts));
5205
+ }
5206
+ async function pruneUnlocked(project, opts) {
4966
5207
  const result = { removed: [], lockEntries: 0, files: [], dryRun: !!opts.dryRun };
4967
5208
  const plan2 = /* @__PURE__ */ new Map();
4968
5209
  for (const { target } of project.targets) {
@@ -5004,29 +5245,6 @@ async function prune(project, opts = {}) {
5004
5245
 
5005
5246
  // src/core/plan.ts
5006
5247
  import path16 from "path";
5007
- import { stringify as toYaml } from "yaml";
5008
- var VOICE_DEFAULTS = {
5009
- hi: "Address the user as \u0906\u092A (polite). Everyday Hindi over Sanskritized words; common English tech words (app, sync) may stay in English.",
5010
- ar: "Modern Standard Arabic, addressing the user neutrally. Short sentences.",
5011
- de: "Address the user as du, as most consumer apps do.",
5012
- fr: "Address the user as vous.",
5013
- es: "Address the user as t\xFA. Neutral Spanish that works in Spain and Latin America.",
5014
- it: "Address the user as tu.",
5015
- pt: "Address the user as voc\xEA (Brazilian Portuguese conventions).",
5016
- nl: "Address the user as je.",
5017
- ru: "Address the user as \u0432\u044B (lowercase).",
5018
- ja: "Polite \u3067\u3059/\u307E\u3059 form. Short, natural UI phrasing.",
5019
- ko: "Polite \uD574\uC694\uCCB4. Short, natural UI phrasing.",
5020
- zh: "Neutral, concise UI language; no honorifics needed.",
5021
- tr: "Address the user as siz.",
5022
- pl: "Address the user informally (ty), as most consumer apps do.",
5023
- id: "Address the user as Anda.",
5024
- vi: "Address the user as b\u1EA1n.",
5025
- th: "Polite, neutral tone; use \u0E04\u0E23\u0E31\u0E1A/\u0E04\u0E48\u0E30 only where a sentence needs it."
5026
- };
5027
- function defaultVoice(locale2) {
5028
- return VOICE_DEFAULTS[locale2] ?? VOICE_DEFAULTS[locale2.split("-")[0]] ?? "Polite, neutral tone, as a well-made app in this language would use.";
5029
- }
5030
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(" ");
5031
5249
  var NAMES = new Intl.DisplayNames(["en"], { type: "language" });
5032
5250
  function resolveLocale(input) {
@@ -5047,7 +5265,7 @@ function resolveLocale(input) {
5047
5265
  }
5048
5266
  async function appNames(fs, root, result) {
5049
5267
  const names = /* @__PURE__ */ new Set();
5050
- for (const c of result?.candidates ?? []) {
5268
+ for (const c of result.candidates) {
5051
5269
  if (c.target.format !== "android-xml") continue;
5052
5270
  try {
5053
5271
  const xml = await fs.readFile(path16.join(root, c.target.path, "values", "strings.xml"));
@@ -5056,12 +5274,6 @@ async function appNames(fs, root, result) {
5056
5274
  } catch {
5057
5275
  }
5058
5276
  }
5059
- try {
5060
- const readme = await fs.readFile(path16.join(root, "README.md"));
5061
- const heading = /^#\s+(.+)$/m.exec(readme)?.[1]?.replace(/[^\p{L}\p{N} .-]/gu, "").trim();
5062
- if (heading && heading.split(/\s+/).length <= 3) names.add(heading);
5063
- } catch {
5064
- }
5065
5277
  return [...names];
5066
5278
  }
5067
5279
  async function plan(fs, cwd, opts = {}) {
@@ -5074,12 +5286,14 @@ async function plan(fs, cwd, opts = {}) {
5074
5286
  }
5075
5287
  return project ? planConfigured(project, requested) : planNew(fs, cwd, requested);
5076
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.";
5077
5290
  async function planNew(fs, cwd, requested) {
5078
5291
  const result = await detect(fs, path16.resolve(cwd));
5079
5292
  const picked = defaultSelection(result).map((n) => result.candidates[n - 1]);
5080
5293
  const chosen = picked.filter((c) => c !== void 0);
5081
5294
  const locales = requested.length ? requested : result.locales;
5082
5295
  const blockers = [];
5296
+ const notes = [];
5083
5297
  if (chosen.length === 0) {
5084
5298
  const unsupported = result.unsupported.map((u) => u.format).join(", ");
5085
5299
  blockers.push(
@@ -5090,40 +5304,29 @@ async function planNew(fs, cwd, requested) {
5090
5304
  } : {
5091
5305
  kind: "no-translation-files",
5092
5306
  message: `No translation files yet. ${result.hints[0] ?? ""}`.trim(),
5093
- 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."
5094
5308
  }
5095
5309
  );
5096
5310
  }
5097
5311
  for (const c of chosen) {
5098
5312
  if (c.codeLiterals !== void 0 && mostlyHardcoded(c.codeLiterals, c.strings)) {
5099
- blockers.push({
5100
- kind: "mostly-hardcoded",
5101
- 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.`,
5102
- 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.`
5103
- });
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
+ );
5104
5316
  }
5105
5317
  if (c.target.format === "xcstrings" && c.strings === 0) {
5106
5318
  blockers.push({
5107
5319
  kind: "empty-catalog",
5108
5320
  message: `${c.target.path} is empty.`,
5109
- 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."
5110
5322
  });
5111
5323
  }
5112
5324
  }
5113
- if (locales.length === 0) {
5114
- blockers.push({
5115
- kind: "no-locales",
5116
- message: "No languages chosen yet.",
5117
- fix: "Tell me which languages to translate into (for example: Hindi and Arabic)."
5118
- });
5119
- }
5120
- const strings = chosen.reduce(
5121
- (n, c) => n + c.strings + (c.codeLiterals !== void 0 && mostlyHardcoded(c.codeLiterals, c.strings) ? c.codeLiterals : 0),
5122
- 0
5123
- );
5124
- 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);
5125
5327
  return build({
5126
5328
  warnings: result.otherSystems.map((o) => o.message),
5329
+ notes,
5127
5330
  configured: false,
5128
5331
  sourceLocale: result.sourceLocale.value,
5129
5332
  locales: locales.map((code) => ({ code, name: languageName(code), isNew: true })),
@@ -5134,10 +5337,16 @@ async function planNew(fs, cwd, requested) {
5134
5337
  })),
5135
5338
  work: Object.fromEntries(locales.map((l) => [l, strings])),
5136
5339
  blockers,
5137
- keep,
5138
- hasBrief: false
5340
+ keep: await appNames(fs, path16.resolve(cwd), result),
5341
+ brief: false,
5342
+ voice: () => void 0
5139
5343
  });
5140
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
+ };
5141
5350
  async function planConfigured(project, requested) {
5142
5351
  const configured = projectLocales(project);
5143
5352
  const locales = [.../* @__PURE__ */ new Set([...configured, ...requested])].filter(
@@ -5155,22 +5364,12 @@ async function planConfigured(project, requested) {
5155
5364
  );
5156
5365
  }
5157
5366
  }
5158
- const blockers = (await hardcodedNotes(project)).map((n) => ({
5159
- kind: "mostly-hardcoded",
5160
- message: n.message,
5161
- fix: "First move that text into strings.xml on a new branch (the localship-extract skill), then translate."
5162
- }));
5163
- if (focus.length === 0) {
5164
- blockers.push({
5165
- kind: "no-locales",
5166
- message: "No languages chosen yet.",
5167
- fix: "Tell me which languages to translate into."
5168
- });
5169
- }
5367
+ const notes = (await hardcodedNotes(project)).map((n) => `${n.message} ${EXTRACT}`);
5170
5368
  const { files } = await walk(project.fs, project.root);
5171
5369
  const others = await findOtherSystems(project.fs, project.root, files);
5172
5370
  return build({
5173
5371
  warnings: others.map((o) => o.message),
5372
+ notes,
5174
5373
  configured: true,
5175
5374
  sourceLocale: project.config.sourceLocale,
5176
5375
  locales: focus.map((code) => ({
@@ -5184,43 +5383,18 @@ async function planConfigured(project, requested) {
5184
5383
  strings: [...t.target.source.entries.values()].filter((e) => e.translatable).length
5185
5384
  })),
5186
5385
  work,
5187
- blockers,
5188
- keep: project.context.glossary.filter((t) => !t.translate).map((t) => t.term),
5189
- hasBrief: !!project.context.brief,
5190
- 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)
5191
5390
  });
5192
5391
  }
5193
5392
  function build(p) {
5194
5393
  const voice = Object.fromEntries(
5195
- p.locales.map((l) => [l.code, p.voices?.[l.code] ?? defaultVoice(l.code)])
5196
- );
5197
- const fixFirst = p.blockers.filter((b) => b.kind !== "no-locales");
5198
- const names = p.locales.map((l) => l.name).join(" and ");
5199
- const total = Math.max(0, ...Object.values(p.work));
5200
- const steps = [];
5201
- for (const b of fixFirst) steps.push(b.fix);
5202
- if (!p.configured) {
5203
- steps.push(
5204
- `Set up Localship for ${p.targets.map((t) => t.path).join(", ") || "the app"} with ${names || "your languages"} (localship_start).`
5205
- );
5206
- } else if (p.locales.some((l) => l.isNew)) {
5207
- steps.push(
5208
- `Add ${p.locales.filter((l) => l.isNew).map((l) => l.name).join(" and ")} (localship_start).`
5209
- );
5210
- }
5211
- if (!p.hasBrief) {
5212
- steps.push(
5213
- "Draft a short brief from your README and strings, so translations match the app's tone."
5214
- );
5215
- }
5216
- const about = fixFirst.some((b) => b.kind === "mostly-hardcoded") ? "about " : "";
5217
- steps.push(
5218
- `Translate ${total ? `${about}${total}` : "the"} strings${names ? ` into ${names}` : ""}, checking each one (localship_next_batch \u2192 localship_submit until nothing remains).`,
5219
- "Run all checks (localship_validate) and report what was done.",
5220
- "Leave everything as drafts for you to review; nothing is approved without you."
5221
- );
5222
- const blocked = p.blockers.some(
5223
- (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
+ })
5224
5398
  );
5225
5399
  return {
5226
5400
  ready: p.blockers.length === 0,
@@ -5230,27 +5404,18 @@ function build(p) {
5230
5404
  targets: p.targets,
5231
5405
  work: p.work,
5232
5406
  warnings: p.warnings,
5407
+ notes: p.notes,
5233
5408
  blockers: p.blockers,
5234
- defaults: {
5235
- voice,
5236
- keepAsWritten: p.keep,
5237
- brief: p.hasBrief ? "exists" : "will be drafted"
5238
- },
5239
- steps,
5240
- confirm: blocked ? "" : fixFirst.length ? "start" : "start translating"
5409
+ voice,
5410
+ keepAsWritten: p.keep,
5411
+ brief: p.brief
5241
5412
  };
5242
5413
  }
5243
5414
  async function start(fs, cwd, opts) {
5244
5415
  const locales = opts.locales.map(resolveLocale);
5245
5416
  if (locales.length === 0) throw new UserError("Say which languages to translate into");
5246
5417
  const root = path16.resolve(cwd);
5247
- const result = {
5248
- config: "",
5249
- createdConfig: false,
5250
- addedLocales: [],
5251
- wrote: [],
5252
- warnings: []
5253
- };
5418
+ const result = { config: "", createdConfig: false, addedLocales: [], warnings: [] };
5254
5419
  let existing;
5255
5420
  try {
5256
5421
  existing = (await loadConfig(fs, root)).file;
@@ -5260,49 +5425,30 @@ async function start(fs, cwd, opts) {
5260
5425
  if (existing) {
5261
5426
  result.config = existing;
5262
5427
  result.addedLocales = (await addLocales(fs, existing, locales)).added;
5263
- } else {
5264
- const out = [];
5265
- const code = await runInit(
5266
- fs,
5267
- { cwd: root, yes: true, locales },
5268
- {
5269
- interactive: false,
5270
- log: (l) => out.push(l),
5271
- error: (l) => out.push(l),
5272
- ask: async (_q, f) => f
5273
- }
5274
- );
5275
- if (code !== 0) throw new UserError(out.join("\n"));
5276
- result.config = path16.join(root, CONFIG_FILE);
5277
- result.createdConfig = true;
5278
- result.addedLocales = locales;
5279
- result.warnings = out.filter((l) => l.startsWith("\u25B2 ")).map((l) => l.slice(2));
5280
- }
5281
- const project = await openProject(fs, { cwd: root });
5282
- const dir = project.context.dir;
5283
- const write2 = async (rel, content) => {
5284
- const file = path16.join(dir, rel);
5285
- if (await fs.exists(file)) return;
5286
- await fs.writeFile(file, content.endsWith("\n") ? content : `${content}
5287
- `);
5288
- result.wrote.push(rel);
5289
- };
5290
- for (const l of projectLocales(project)) {
5291
- await write2(`${CONTEXT_FILES.voiceDir}/${l}.md`, opts.voice?.[l] ?? defaultVoice(l));
5292
- }
5293
- const keep = opts.keepAsWritten ?? await appNames(fs, root, await detect(fs, root));
5294
- if (keep.length) {
5295
- await write2(
5296
- CONTEXT_FILES.glossary,
5297
- toYaml({ terms: keep.map((term) => ({ term, translate: false })) }, { lineWidth: 0 })
5298
- );
5428
+ return result;
5299
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));
5300
5446
  return result;
5301
5447
  }
5302
5448
 
5303
5449
  // src/translate/prompt.ts
5304
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.
5305
- - 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.
5306
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.
5307
5453
  - Keep markup and escapes exactly: tags such as <b> or <xliff:g>, entities, \\n, and any leading or trailing spaces and newlines.
5308
5454
  - Glossary: a term marked keep stays exactly as written; a term with use must be translated that way.
@@ -5352,6 +5498,9 @@ function extractJson(reply) {
5352
5498
  }
5353
5499
  }
5354
5500
 
5501
+ // src/translate/translate.ts
5502
+ import { setTimeout as delay } from "timers/promises";
5503
+
5355
5504
  // src/translate/providers.ts
5356
5505
  var BUILT_IN = {
5357
5506
  anthropic: {
@@ -5388,6 +5537,10 @@ function createCompletion(spec, opts = {}) {
5388
5537
  const { provider, model } = parseModel(spec);
5389
5538
  const env = opts.env ?? process.env;
5390
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
+ }
5391
5544
  const custom = opts.custom?.[provider];
5392
5545
  const builtIn = BUILT_IN[provider];
5393
5546
  if (!custom && !builtIn) {
@@ -5402,30 +5555,44 @@ function createCompletion(spec, opts = {}) {
5402
5555
  if (keyNames.length > 0 && !apiKey) {
5403
5556
  throw new UserError(`Set ${keyNames[0]} to use ${provider} models.`);
5404
5557
  }
5405
- const post = async (url, headers, body) => {
5406
- 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);
5407
5562
  try {
5408
- res = await doFetch(url, {
5563
+ signal.throwIfAborted();
5564
+ const res = await doFetch(url, {
5409
5565
  method: "POST",
5410
5566
  headers: { "content-type": "application/json", ...headers },
5411
- body: JSON.stringify(body)
5567
+ body: JSON.stringify(body),
5568
+ signal
5412
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
+ }
5413
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;
5414
5593
  throw new ProviderError(`${provider}: could not reach ${baseUrl} (${e.message})`);
5415
- }
5416
- const text = await res.text();
5417
- if (!res.ok) {
5418
- throw new ProviderError(
5419
- `${provider} returned ${res.status}: ${text.slice(0, 500)}`,
5420
- res.status === 429 || res.status >= 500
5421
- );
5422
- }
5423
- try {
5424
- return JSON.parse(text);
5425
- } catch {
5426
- throw new ProviderError(
5427
- `${provider} returned something that isn't JSON: ${text.slice(0, 200)}`
5428
- );
5594
+ } finally {
5595
+ clearTimeout(timer);
5429
5596
  }
5430
5597
  };
5431
5598
  if (protocol === "anthropic") {
@@ -5438,7 +5605,8 @@ function createCompletion(spec, opts = {}) {
5438
5605
  max_tokens: req.maxTokens ?? 16e3,
5439
5606
  system: req.system,
5440
5607
  messages: [{ role: "user", content: req.user }]
5441
- }
5608
+ },
5609
+ req.signal
5442
5610
  );
5443
5611
  if (data.stop_reason === "max_tokens") {
5444
5612
  throw new ProviderError(`${provider}: the response was cut off; use a smaller batch`);
@@ -5457,7 +5625,8 @@ function createCompletion(spec, opts = {}) {
5457
5625
  { role: "system", content: req.system },
5458
5626
  { role: "user", content: req.user }
5459
5627
  ]
5460
- }
5628
+ },
5629
+ req.signal
5461
5630
  );
5462
5631
  const choice = data.choices?.[0];
5463
5632
  if (choice?.finish_reason === "length") {
@@ -5487,15 +5656,38 @@ function modelFor(project, locale2, override) {
5487
5656
  return model;
5488
5657
  }
5489
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();
5490
5672
  const log = opts.log ?? (() => {
5491
5673
  });
5492
5674
  const locales = opts.locales ?? projectLocales(start2);
5493
5675
  if (locales.length === 0)
5494
5676
  throw new UserError("No locales to translate. Add `locales:` to the config.");
5495
5677
  const batchSize = opts.batchSize ?? 30;
5496
- const concurrency = Math.max(1, opts.concurrency ?? 4);
5497
- const maxAttempts = Math.max(1, opts.attempts ?? 2);
5498
- 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 }));
5499
5691
  const models = Object.fromEntries(locales.map((l) => [l, modelFor(start2, l, opts.model)]));
5500
5692
  const clients = /* @__PURE__ */ new Map();
5501
5693
  const client = (model) => {
@@ -5517,9 +5709,11 @@ async function translateProject(start2, opts = {}) {
5517
5709
  const files = /* @__PURE__ */ new Set();
5518
5710
  const tries = /* @__PURE__ */ new Map();
5519
5711
  const givenUp = /* @__PURE__ */ new Set();
5712
+ const finished = /* @__PURE__ */ new Set();
5520
5713
  let project = start2;
5521
5714
  const usages = await buildUsageIndex(start2);
5522
5715
  for (; ; ) {
5716
+ checkCancelled();
5523
5717
  const round = [];
5524
5718
  for (const locale2 of locales) {
5525
5719
  let offset = 0;
@@ -5531,34 +5725,42 @@ async function translateProject(start2, opts = {}) {
5531
5725
  states: opts.states,
5532
5726
  limit: batchSize,
5533
5727
  offset,
5534
- skip: givenUp,
5728
+ skip: finished,
5535
5729
  usages,
5536
5730
  skipHandEdited: !opts.force
5537
5731
  });
5538
5732
  if (batch.items.length === 0) break;
5539
- 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 });
5540
5750
  offset += batch.items.length;
5541
5751
  }
5542
5752
  if (round.length >= concurrency) break;
5543
5753
  }
5544
5754
  if (round.length === 0) break;
5545
5755
  const replies = await Promise.all(
5546
- round.map(async (batch) => {
5756
+ round.map(async ({ batch, user }) => {
5547
5757
  const locale2 = batch.items[0]?.locale;
5548
5758
  const model = models[locale2];
5549
- const retries = /* @__PURE__ */ new Map();
5550
- for (const item of batch.items) {
5551
- const t = tries.get(itemId(item));
5552
- if (t)
5553
- retries.set(item, {
5554
- ...t.value !== void 0 ? { value: t.value } : {},
5555
- errors: t.errors
5556
- });
5557
- }
5558
5759
  log(`${locale2}: translating ${batch.items.length} strings with ${model}\u2026`);
5559
5760
  try {
5560
5761
  const reply = await callWithRetry(
5561
- () => client(model)({ system: systemPrompt(), user: userPrompt(batch, retries) })
5762
+ () => client(model)({ system: systemPrompt(), user, signal: opts.signal }),
5763
+ opts.signal
5562
5764
  );
5563
5765
  return { batch, model, reply };
5564
5766
  } catch (e) {
@@ -5568,12 +5770,16 @@ async function translateProject(start2, opts = {}) {
5568
5770
  })
5569
5771
  );
5570
5772
  for (const { batch, model, reply, error } of replies) {
5773
+ checkCancelled();
5571
5774
  const byId = new Map(batch.items.map((i) => [itemId(i), i]));
5572
5775
  const fail = (item, errors, value) => {
5573
5776
  const id2 = itemId(item);
5574
5777
  const count = (tries.get(id2)?.count ?? 0) + 1;
5575
5778
  tries.set(id2, { count, errors, item, ...value !== void 0 ? { value } : {} });
5576
- if (count >= maxAttempts) givenUp.add(id2);
5779
+ if (count >= maxAttempts) {
5780
+ givenUp.add(id2);
5781
+ finished.add(id2);
5782
+ }
5577
5783
  };
5578
5784
  if (error !== void 0) {
5579
5785
  throw new UserError(error);
@@ -5604,12 +5810,20 @@ async function translateProject(start2, opts = {}) {
5604
5810
  const r = await submit(
5605
5811
  project,
5606
5812
  answers.map((a) => a.sub),
5607
- { 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
+ }
5608
5819
  );
5609
5820
  for (const f of r.files) files.add(f);
5610
5821
  result.translated += r.accepted.length;
5611
5822
  const accepted = new Set(r.accepted.map((a) => itemId(a)));
5612
- for (const id2 of accepted) tries.delete(id2);
5823
+ for (const id2 of accepted) {
5824
+ tries.delete(id2);
5825
+ finished.add(id2);
5826
+ }
5613
5827
  for (const { item, sub } of answers) {
5614
5828
  const id2 = itemId(item);
5615
5829
  if (accepted.has(id2)) continue;
@@ -5644,15 +5858,21 @@ async function translateProject(start2, opts = {}) {
5644
5858
  });
5645
5859
  result.heldForReview = held.items.filter((i) => i.editedByHand).map(({ target, locale: locale2, key }) => ({ target, locale: locale2, key }));
5646
5860
  }
5861
+ checkCancelled();
5647
5862
  return result;
5648
5863
  }
5649
- async function callWithRetry(fn, tries = 3) {
5864
+ async function callWithRetry(fn, signal, tries = 3) {
5650
5865
  for (let i = 1; ; i++) {
5866
+ if (signal?.aborted) throw new UserError("Translation cancelled");
5651
5867
  try {
5652
5868
  return await fn();
5653
5869
  } catch (e) {
5654
5870
  if (!(e instanceof ProviderError) || !e.transient || i >= tries) throw e;
5655
- 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
+ }
5656
5876
  }
5657
5877
  }
5658
5878
  }
@@ -5693,4 +5913,4 @@ export {
5693
5913
  TRANSLATION_RULES,
5694
5914
  translateProject
5695
5915
  };
5696
- //# sourceMappingURL=chunk-QS3UG3KI.js.map
5916
+ //# sourceMappingURL=chunk-GNL5EFNP.js.map