localship 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  Free and open source · No account, no server · Any AI model · Every translation checked
8
8
 
9
+ [![npm](https://img.shields.io/npm/v/localship?color=cb3837&logo=npm)](https://www.npmjs.com/package/localship)
9
10
  [![CI](https://github.com/jigarfumakiya/localship/actions/workflows/ci.yml/badge.svg)](https://github.com/jigarfumakiya/localship/actions/workflows/ci.yml)
10
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
11
12
  [![MCP](https://img.shields.io/badge/MCP-server-8A2BE2)](https://modelcontextprotocol.io)
@@ -31,29 +32,28 @@ no server, no monthly bill.
31
32
 
32
33
  - 🆓 **Free and open source.** MIT-licensed. You pay only for your own AI usage, or nothing with a
33
34
  local model.
34
- - 🤖 **Works with the AI you already use.** Your coding agent's own model, or Claude, GPT, Gemini,
35
- OpenRouter, or a local model through Ollama.
36
- - 🛡️ **Catches broken translations.** Placeholders, plural forms, Android escaping and markup are
37
- checked in code before anything is written. Failures go back to the AI with the reason.
35
+ - 🤖 **Your AI, your model.** Your coding agent's own model, or Claude, GPT, Gemini, OpenRouter
36
+ or Ollama.
37
+ - 🛡️ **Catches broken translations.** Placeholders, plural forms, escaping and markup are checked
38
+ in code before anything is written. Failures go back to the AI with the reason.
38
39
  - 🧠 **More context for the AI.** Each string comes with the code that uses it, its comment, your
39
40
  glossary and voice notes, and earlier translations of the same text.
40
- - 🔒 **Stays in your repo.** No server, no account, no upload. Your files, written the way Android
41
- Studio and Xcode write them.
41
+ - 🔒 **Stays in your repo.** No server, no account, no upload. Your own translation files, formatted
42
+ the way your tools write them.
42
43
  - 💻 **Runs anywhere.** Claude Code, Cursor, VS Code, Codex, Gemini CLI, Claude Desktop or the
43
44
  terminal, on macOS, Linux and Windows.
44
45
 
45
46
  ### 🤔 Why use Localship?
46
47
 
47
- Your AI can already translate a strings file if you ask. Here's what changes with Localship:
48
-
49
48
  | | ❌ Asking an AI directly | ✅ With Localship |
50
49
  |---|---|---|
51
- | Placeholders like `%1$s` | Sometimes dropped, and the app crashes on that screen | Checked before writing; sent back to fix |
52
- | Plural forms | Arabic often gets 2 forms instead of 6 | The right forms for every language, from Unicode CLDR |
53
- | Android escaping | An unescaped apostrophe breaks the build | Escaped for you |
54
- | Consistent terms | "Budget" translated three ways | Glossary, voice notes and earlier translations |
55
- | Changing copy | Everything is translated again | Only changed strings are redone, with the old and new text |
56
- | Your translator's fixes | Overwritten | Kept unless you ask |
50
+ | Context | One string at a time | Code, comments, brief and glossary |
51
+ | Placeholders | Sometimes dropped | Checked before writing |
52
+ | Plural forms | Often incomplete | Every form each language needs |
53
+ | Escaping and markup | Can break the build | Checked and escaped |
54
+ | Terms and tone | Drift across strings | Same glossary and voice everywhere |
55
+ | Changed copy | Everything redone | Only what changed |
56
+ | Your edits | Overwritten | Kept |
57
57
 
58
58
  ## 🎬 See it in action
59
59
 
@@ -129,45 +129,44 @@ all supported.
129
129
 
130
130
  ## 🚀 Install
131
131
 
132
- Needs [Node.js](https://nodejs.org) 22 or newer.
133
-
134
- ### ⭐ The easiest way
135
-
136
- **Claude Code:** install the plugin, which includes the server and the skills.
132
+ **Claude Code plugin**
137
133
 
138
134
  ```
139
135
  /plugin marketplace add jigarfumakiya/localship
140
136
  /plugin install localship@localship
141
137
  ```
142
138
 
143
- **Codex, Cursor, Gemini CLI and other agents:** one command adds the Localship skills,
144
- step-by-step playbooks your agent follows. The first time, it offers to install the Localship
145
- command line it uses.
139
+ **Claude Code, Codex, Gemini CLI and other CLIs:** add the server, then the skills.
146
140
 
147
141
  ```sh
148
- npx skills add jigarfumakiya/localship
142
+ claude mcp add localship -- npx -y localship mcp # Claude Code
143
+ codex mcp add localship -- npx -y localship mcp # Codex
144
+ gemini mcp add localship npx -y localship mcp # Gemini CLI
145
+ npx skills add jigarfumakiya/localship # the skills, for any agent
149
146
  ```
150
147
 
151
- Then open your app and ask: *"Translate this app into Hindi and Arabic."*
148
+ **Cursor and VS Code**
149
+
150
+ [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=localship&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImxvY2Fsc2hpcCIsIm1jcCJdfQ%3D%3D)
151
+ [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_server-0098FF?logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522localship%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522localship%2522%252C%2522mcp%2522%255D%257D)
152
+
153
+ Add the skills too: `npx skills add jigarfumakiya/localship`.
154
+
155
+ Now ask your agent: *"Translate this app into Hindi and Arabic."* Needs Node.js 22 or newer.
152
156
 
153
157
  ### 🔌 Other tools
154
158
 
155
- Add Localship as an MCP server.
159
+ Add this MCP server to your tool's config:
156
160
 
157
161
  ```json
158
162
  { "mcpServers": { "localship": { "command": "npx", "args": ["-y", "localship", "mcp"] } } }
159
163
  ```
160
164
 
161
- | Tool | Where the config goes |
165
+ | Tool | Config file |
162
166
  |---|---|
163
- | Claude Code (without the plugin) | `claude mcp add localship -- npx -y localship mcp` |
164
- | Cursor | `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` |
165
- | VS Code | `.vscode/mcp.json`, with `"servers"` instead of `"mcpServers"` and `"type": "stdio"` added |
166
- | Gemini CLI | `.gemini/settings.json` |
167
- | Codex | `~/.codex/config.toml`: `[mcp_servers.localship]` with `command = "npx"` and `args = ["-y", "localship", "mcp"]` |
168
- | Claude Desktop | `claude_desktop_config.json`, with `"-C", "/path/to/your/app"` added before `"mcp"` |
169
-
170
- The first start takes a minute while it installs.
167
+ | Cursor | `.cursor/mcp.json` |
168
+ | VS Code | `.vscode/mcp.json` (use `"servers"`) |
169
+ | Claude Desktop | `claude_desktop_config.json` (add `"-C", "/path/to/app"` before `"mcp"`) |
171
170
 
172
171
  <details>
173
172
  <summary><b>MCP tools</b></summary>
@@ -604,8 +604,8 @@ async function matchPattern(fs, root, pattern) {
604
604
  }
605
605
  return out.sort((a, b) => a.locale.localeCompare(b.locale));
606
606
  }
607
- function inferLocaleStyle(matches, fallback) {
608
- for (const m of matches) {
607
+ function inferLocaleStyle(matches2, fallback) {
608
+ for (const m of matches2) {
609
609
  if (/[_@]/.test(m.segment)) return "gettext";
610
610
  if (m.segment.includes("-")) return "bcp47";
611
611
  }
@@ -1771,14 +1771,14 @@ function escapeString(s) {
1771
1771
  for (const c of s) out += ESCAPES[c] ?? c;
1772
1772
  return out;
1773
1773
  }
1774
- function formatString(keyword, value, width) {
1774
+ function formatString(keyword, value, width2) {
1775
1775
  const segments = value.split(/(?<=\n)/).map(escapeString);
1776
1776
  if (segments.length === 0) segments.push("");
1777
1777
  const single = `${keyword} "${segments.join("")}"`;
1778
1778
  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) {
1779
+ if (!innerNewline && (width2 === false || displayWidth(single) <= width2)) return [single];
1780
+ const pieces = width2 === false ? segments : segments.flatMap((seg) => wrapSegment(seg, width2 - 2));
1781
+ if (!innerNewline && width2 !== false && pieces.length === 1 && displayWidth(pieces[0] ?? "") > width2 - 2) {
1782
1782
  return [single];
1783
1783
  }
1784
1784
  return [`${keyword} ""`, ...pieces.map((s) => `"${s}"`)];
@@ -1826,12 +1826,12 @@ function charWidth(c) {
1826
1826
  if (ZERO_WIDTH.test(c)) return 0;
1827
1827
  return WIDE.test(c) ? 2 : 1;
1828
1828
  }
1829
- function wrapReferences(refs, width) {
1830
- if (width === false) return [`#: ${refs.join(" ")}`];
1829
+ function wrapReferences(refs, width2) {
1830
+ if (width2 === false) return [`#: ${refs.join(" ")}`];
1831
1831
  const lines = [];
1832
1832
  let line = "#:";
1833
1833
  for (const r of refs) {
1834
- if (line !== "#:" && displayWidth(line) + 1 + displayWidth(r) > width) {
1834
+ if (line !== "#:" && displayWidth(line) + 1 + displayWidth(r) > width2) {
1835
1835
  lines.push(line);
1836
1836
  line = "#:";
1837
1837
  }
@@ -1922,9 +1922,9 @@ var poAdapter = {
1922
1922
  }
1923
1923
  return byKey;
1924
1924
  };
1925
- const matches = await matchPattern(fs, root, config.path);
1926
- const style = inferLocaleStyle(matches, "gettext");
1927
- for (const m of matches) {
1925
+ const matches2 = await matchPattern(fs, root, config.path);
1926
+ const style = inferLocaleStyle(matches2, "gettext");
1927
+ for (const m of matches2) {
1928
1928
  const po = await read2(m.file, m.locale);
1929
1929
  if (po) files.set(m.locale, { file: m.file, po, byKey: indexEntries(po, m.file, m.locale) });
1930
1930
  }
@@ -3694,6 +3694,372 @@ async function runInit(fs, opts, io) {
3694
3694
  }
3695
3695
  }
3696
3696
 
3697
+ // src/core/usage.ts
3698
+ import path15 from "path";
3699
+ var MAX_USAGES = 3;
3700
+ var MAX_FILE_BYTES2 = 512 * 1024;
3701
+ var ANDROID_CODE2 = /\.(kt|kts|java|xml)$/;
3702
+ var SWIFT_CODE = /\.(swift|m|mm)$/;
3703
+ var ANDROID_REF = /\bR\.(?:string|plurals|array)\.(\w+)|@(?:string|plurals|array)\/(\w+)/g;
3704
+ var STRING_LITERAL = /@?"((?:[^"\\\n]|\\.)*)"/g;
3705
+ var APPLE_SPEC = /%%|%(?:\d+\$)?[-+ #0]*\d*(?:\.\d+)?(?:hh|h|ll|l|q|z|t|j)?[@dDuUxXoOfeEgGcCsSaAp]/g;
3706
+ var SLOT = "";
3707
+ async function buildUsageIndex(project) {
3708
+ const formats = new Set(project.targets.map((t) => t.target.format));
3709
+ const needsCode = formats.has("android-xml") || formats.has("xcstrings");
3710
+ const files = needsCode ? (await walk(project.fs, project.root)).files : [];
3711
+ const lines = /* @__PURE__ */ new Map();
3712
+ const read2 = async (rel) => {
3713
+ let cached = lines.get(rel);
3714
+ if (!cached) {
3715
+ try {
3716
+ const text = await project.fs.readFile(path15.join(project.root, rel));
3717
+ cached = text.length > MAX_FILE_BYTES2 ? [] : text.split(/\r?\n/);
3718
+ } catch {
3719
+ cached = [];
3720
+ }
3721
+ lines.set(rel, cached);
3722
+ }
3723
+ return cached;
3724
+ };
3725
+ const android = /* @__PURE__ */ new Map();
3726
+ if (formats.has("android-xml")) {
3727
+ for (const rel of files) {
3728
+ if (!ANDROID_CODE2.test(rel) || /(^|\/)res\/values[^/]*\//.test(rel)) continue;
3729
+ const text = await read2(rel);
3730
+ text.forEach((line, i) => {
3731
+ for (const m of line.matchAll(ANDROID_REF)) {
3732
+ const key = m[1] ?? m[2];
3733
+ add(android, key, rel, text, i);
3734
+ }
3735
+ });
3736
+ }
3737
+ }
3738
+ const apple = /* @__PURE__ */ new Map();
3739
+ if (formats.has("xcstrings")) {
3740
+ for (const rel of files) {
3741
+ if (!SWIFT_CODE.test(rel)) continue;
3742
+ const text = await read2(rel);
3743
+ text.forEach((line, i) => {
3744
+ for (const m of line.matchAll(STRING_LITERAL)) {
3745
+ add(apple, normalizeSwiftLiteral(m[1]), rel, text, i);
3746
+ }
3747
+ });
3748
+ }
3749
+ }
3750
+ const po = /* @__PURE__ */ new Map();
3751
+ for (const { target } of project.targets) {
3752
+ if (target.format !== "po") continue;
3753
+ for (const entry of target.source.entries.values()) {
3754
+ const found = [];
3755
+ for (const ref of entry.references ?? []) {
3756
+ if (found.length >= MAX_USAGES) break;
3757
+ const m = /^(.+?):(\d+)$/.exec(ref);
3758
+ const rel = m?.[1] ?? ref;
3759
+ const text = m ? await read2(rel) : [];
3760
+ const i = Number(m?.[2] ?? 0) - 1;
3761
+ found.push(text[i] !== void 0 ? usageAt(rel, text, i) : { at: ref, code: "" });
3762
+ }
3763
+ if (found.length) po.set(`${target.id}\0${entry.key}`, found);
3764
+ }
3765
+ }
3766
+ return {
3767
+ find(target, entry) {
3768
+ switch (target.format) {
3769
+ case "android-xml":
3770
+ return android.get(entry.key) ?? [];
3771
+ case "xcstrings":
3772
+ return apple.get(normalizeCatalogKey(entry.key)) ?? [];
3773
+ case "po":
3774
+ return po.get(`${target.id}\0${entry.key}`) ?? [];
3775
+ default:
3776
+ return [];
3777
+ }
3778
+ }
3779
+ };
3780
+ }
3781
+ function add(index, key, rel, text, i) {
3782
+ const list = index.get(key) ?? [];
3783
+ if (list.length >= MAX_USAGES) return;
3784
+ if (list.some((u) => u.at.startsWith(`${rel}:`))) return;
3785
+ list.push(usageAt(rel, text, i));
3786
+ index.set(key, list);
3787
+ }
3788
+ function usageAt(rel, text, i) {
3789
+ const line = text[i] ?? "";
3790
+ let previous;
3791
+ for (let j = i - 1; j >= Math.max(0, i - 3); j--) {
3792
+ const candidate = text[j];
3793
+ if (candidate.trim() === "") continue;
3794
+ if (/^\s*(\/\/|\*|\/\*|#)/.test(candidate)) break;
3795
+ previous = candidate;
3796
+ break;
3797
+ }
3798
+ const picked = previous !== void 0 ? [previous, line] : [line];
3799
+ const indent = Math.min(...picked.map((l) => /^\s*/.exec(l)?.[0].length ?? 0));
3800
+ const code = picked.map((l) => l.slice(indent).trimEnd()).join("\n").slice(0, 240);
3801
+ return { at: `${rel}:${i + 1}`, code };
3802
+ }
3803
+ function normalizeSwiftLiteral(literal) {
3804
+ let out = "";
3805
+ for (let i = 0; i < literal.length; i++) {
3806
+ const c = literal[i];
3807
+ if (c === "\\" && literal[i + 1] === "(") {
3808
+ let depth = 0;
3809
+ let j = i + 1;
3810
+ for (; j < literal.length; j++) {
3811
+ if (literal[j] === "(") depth++;
3812
+ else if (literal[j] === ")" && --depth === 0) break;
3813
+ }
3814
+ out += SLOT;
3815
+ i = j;
3816
+ } else if (c === "\\" && i + 1 < literal.length) {
3817
+ const n = literal[++i];
3818
+ out += n === "n" ? "\n" : n === "t" ? " " : n;
3819
+ } else {
3820
+ out += c;
3821
+ }
3822
+ }
3823
+ return out;
3824
+ }
3825
+ function normalizeCatalogKey(key) {
3826
+ return key.replace(APPLE_SPEC, (m) => m === "%%" ? "%" : SLOT);
3827
+ }
3828
+
3829
+ // src/core/qa.ts
3830
+ function textChecks(s, t, opts = {}) {
3831
+ const out = [];
3832
+ const source = visible(s);
3833
+ const target = visible(t);
3834
+ const language = opts.locale?.split("-")[0] ?? "";
3835
+ if (!opts.countImplied) {
3836
+ const have = numbers(target);
3837
+ for (const n of numbers(source)) {
3838
+ if (DUAL.has(language) && (n === "1" || n === "2")) continue;
3839
+ const i = have.indexOf(n);
3840
+ if (i >= 0) have.splice(i, 1);
3841
+ else
3842
+ out.push({
3843
+ severity: "warning",
3844
+ code: "number-mismatch",
3845
+ message: `The number ${n} from the source is missing`
3846
+ });
3847
+ }
3848
+ }
3849
+ const ending = closing(source);
3850
+ if (ending && !NO_QUESTION_MARK.has(language) && !ending.marks.some((m) => endsWith(target, m))) {
3851
+ out.push({
3852
+ severity: "warning",
3853
+ code: "punctuation-end",
3854
+ message: `The source ends with "${ending.shown}"; the translation doesn't`
3855
+ });
3856
+ }
3857
+ if (isAllCaps(source) && readsLowercase(withoutKeptWords(target, source))) {
3858
+ out.push({
3859
+ severity: "warning",
3860
+ code: "case-mismatch",
3861
+ message: "The source is in capitals (a label or heading); the translation isn't"
3862
+ });
3863
+ }
3864
+ for (const token of [...matches(source, URL), ...matches(source, EMAIL)]) {
3865
+ if (!target.includes(token))
3866
+ out.push({
3867
+ severity: "warning",
3868
+ code: "token-missing",
3869
+ message: `${token} from the source is missing or changed`
3870
+ });
3871
+ }
3872
+ const emoji = matches(target, EMOJI);
3873
+ for (const e of matches(source, EMOJI)) {
3874
+ const i = emoji.indexOf(e);
3875
+ if (i >= 0) emoji.splice(i, 1);
3876
+ else
3877
+ out.push({
3878
+ severity: "warning",
3879
+ code: "token-missing",
3880
+ message: `The emoji ${e} from the source is missing`
3881
+ });
3882
+ }
3883
+ return out;
3884
+ }
3885
+ function visible(a) {
3886
+ let text = a.text;
3887
+ for (const p of a.placeholders) text = text.split(p.raw).join(" ");
3888
+ return text;
3889
+ }
3890
+ function numbers(text) {
3891
+ const out = [];
3892
+ for (const m of text.matchAll(new RegExp("\\p{Nd}+(?:[.,\xA0\u202F'\u2019]\\p{Nd}+)*", "gu"))) {
3893
+ const digits = [...m[0]].filter((c) => new RegExp("\\p{Nd}", "u").test(c)).map(digitValue);
3894
+ out.push(digits.join(""));
3895
+ }
3896
+ return out;
3897
+ }
3898
+ var ZEROS = [
3899
+ 48,
3900
+ 1632,
3901
+ 1776,
3902
+ 1984,
3903
+ 2406,
3904
+ 2534,
3905
+ 2662,
3906
+ 2790,
3907
+ 2918,
3908
+ 3046,
3909
+ 3174,
3910
+ 3302,
3911
+ 3430,
3912
+ 3558,
3913
+ 3664,
3914
+ 3792,
3915
+ 3872,
3916
+ 4160,
3917
+ 4240,
3918
+ 6112,
3919
+ 6160,
3920
+ 6470,
3921
+ 6608,
3922
+ 43216,
3923
+ 43264,
3924
+ 65296
3925
+ ];
3926
+ function digitValue(c) {
3927
+ const cp = c.codePointAt(0) ?? 0;
3928
+ const zero = ZEROS.find((z4) => cp >= z4 && cp <= z4 + 9);
3929
+ return zero === void 0 ? c : String(cp - zero);
3930
+ }
3931
+ var ENDINGS = [
3932
+ { shown: "?", marks: ["?", "\uFF1F", "\u061F", ";"] },
3933
+ { shown: "!", marks: ["!", "\uFF01"] },
3934
+ { shown: ":", marks: [":", "\uFF1A", "\uFE55"] },
3935
+ { shown: "\u2026", marks: ["\u2026", "...", "\u22EF", "\u2025"] }
3936
+ ];
3937
+ var DUAL = /* @__PURE__ */ new Set(["ar", "he", "iw"]);
3938
+ var NO_QUESTION_MARK = /* @__PURE__ */ new Set(["th", "lo", "km", "my"]);
3939
+ function closing(text) {
3940
+ const end = trimEnd(text);
3941
+ if (end.endsWith("...")) return ENDINGS[3];
3942
+ return ENDINGS.find((e) => end.endsWith(e.shown));
3943
+ }
3944
+ function endsWith(text, mark) {
3945
+ return trimEnd(text).endsWith(mark);
3946
+ }
3947
+ function trimEnd(text) {
3948
+ return text.replace(/[\s"'”’»)\]}」』]+$/u, "");
3949
+ }
3950
+ function withoutKeptWords(text, source) {
3951
+ const kept = new Set(source.toLowerCase().match(new RegExp("\\p{L}+", "gu")) ?? []);
3952
+ return text.replace(new RegExp("\\p{L}+", "gu"), (w) => kept.has(w.toLowerCase()) ? " " : w);
3953
+ }
3954
+ function isAllCaps(text) {
3955
+ const cased = [...text].filter(hasCase);
3956
+ return cased.length >= 2 && cased.every((c) => c === c.toUpperCase());
3957
+ }
3958
+ function readsLowercase(text) {
3959
+ const letters = [...text].filter((c) => new RegExp("\\p{L}", "u").test(c));
3960
+ const cased = letters.filter(hasCase);
3961
+ if (cased.length < 2 || cased.length * 2 < letters.length) return false;
3962
+ const lower = cased.filter((c) => c !== c.toUpperCase()).length;
3963
+ return lower > cased.length - lower;
3964
+ }
3965
+ function hasCase(c) {
3966
+ return c.toLowerCase() !== c.toUpperCase();
3967
+ }
3968
+ var URL = /https?:\/\/[^\s<>"')\]]+[^\s<>"')\].,;:!?]/gu;
3969
+ var EMAIL = /[\p{L}\p{N}._%+-]+@[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+/gu;
3970
+ var EMOJI = new RegExp("\\p{Extended_Pictographic}", "gu");
3971
+ function matches(text, pattern) {
3972
+ return [...text.matchAll(pattern)].map((m) => m[0]);
3973
+ }
3974
+ function inconsistencies(project, locales) {
3975
+ const out = [];
3976
+ for (const locale2 of locales) {
3977
+ const bySource = /* @__PURE__ */ new Map();
3978
+ for (const { target } of project.targets) {
3979
+ const catalog = target.locales.get(locale2);
3980
+ if (!catalog) continue;
3981
+ for (const src of target.source.entries.values()) {
3982
+ if (!src.translatable || src.message.kind !== "text") continue;
3983
+ const tgt = catalog.entries.get(src.key);
3984
+ if (tgt?.message.kind !== "text") continue;
3985
+ const source = normalize(target.analyze(src.message.value, src).text);
3986
+ const group = `${src.context ?? ""}${source}`;
3987
+ const translation2 = normalize(target.analyze(tgt.message.value, src).text);
3988
+ if (!source || !translation2) continue;
3989
+ const ways = bySource.get(group) ?? /* @__PURE__ */ new Map();
3990
+ const list = ways.get(translation2) ?? [];
3991
+ list.push({ target: target.id, entry: tgt });
3992
+ ways.set(translation2, list);
3993
+ bySource.set(group, ways);
3994
+ }
3995
+ }
3996
+ for (const [group, ways] of bySource) {
3997
+ if (ways.size < 2) continue;
3998
+ const source = group.slice(group.indexOf("") + 1);
3999
+ const sorted = [...ways].sort((a, b) => b[1].length - a[1].length);
4000
+ const odd = sorted.at(-1)?.[1][0];
4001
+ const listed = sorted.slice(0, 3).map(
4002
+ ([text, at]) => `"${short(text)}" (${at.map((x) => displayKey(x.entry.key)).join(", ")})`
4003
+ ).join(", ");
4004
+ out.push({
4005
+ severity: "warning",
4006
+ code: "inconsistent",
4007
+ message: `"${short(source)}" is translated ${ways.size} ways: ${listed}. Fine if the context differs`,
4008
+ locale: locale2,
4009
+ ...odd ? { target: odd.target, key: odd.entry.key, file: odd.entry.file, line: odd.entry.line } : {}
4010
+ });
4011
+ }
4012
+ }
4013
+ return out;
4014
+ }
4015
+ var normalize = (text) => text.trim().replace(/\s+/g, " ");
4016
+ var short = (text) => text.length > 40 ? `${text.slice(0, 39)}\u2026` : text;
4017
+ var ROLES = [
4018
+ ["accessibility label", /contentDescription|accessibility(?:Label|Hint)/],
4019
+ ["placeholder", /\bplaceholder\b|android:hint|\bhint\s*=|\b(?:Secure)?TextField\(/],
4020
+ // Design systems wrap these (`AppButton`, `SmChip`), so any name ending in the kind counts.
4021
+ ["tab", /\b\w*Tab\s*\(|Navigation(?:Bar|Rail)Item|BottomNavigationItem|\.tabItem\b/],
4022
+ ["chip", /\b\w*Chip\s*\(/],
4023
+ [
4024
+ "button",
4025
+ /\b\w*Button\s*[({]|<(?:\w+\.)*\w*Button\b|(?:confirm|dismiss)Button|set(?:Positive|Negative|Neutral)Button/
4026
+ ],
4027
+ ["menu item", /DropdownMenuItem|<item\b[^>]*android:title|\bMenu\s*\{/],
4028
+ ["toast", /Toast\.makeText|showSnackbar|Snackbar(?:\.make)?\(/],
4029
+ ["notification", /setContent(?:Title|Text)|NotificationCompat|UNMutableNotificationContent/],
4030
+ ["app name", /android:label=/],
4031
+ ["title", /TopAppBar|\btitle\s*=|\.navigationTitle\(|setTitle\(|\.alert\(/]
4032
+ ];
4033
+ function roleOf(usages) {
4034
+ const counts = /* @__PURE__ */ new Map();
4035
+ for (const u of usages) {
4036
+ const lines = u.code.split("\n").reverse();
4037
+ const role = lines.map((line) => ROLES.find(([, pattern]) => pattern.test(line))?.[0]).find((r) => r !== void 0);
4038
+ if (role) counts.set(role, (counts.get(role) ?? 0) + 1);
4039
+ }
4040
+ return [...counts].sort((a, b) => b[1] - a[1])[0]?.[0];
4041
+ }
4042
+ var TIGHT = /* @__PURE__ */ new Set(["button", "tab", "chip", "menu item", "app name"]);
4043
+ function lengthCheck(role, s, t) {
4044
+ if (!role || !TIGHT.has(role)) return void 0;
4045
+ const source = width(visible(s).trim());
4046
+ const target = width(visible(t).trim());
4047
+ if (source === 0 || target <= source * 2 || target - source < 6) return void 0;
4048
+ return {
4049
+ severity: "warning",
4050
+ code: "too-long",
4051
+ message: `Used as a ${role}: about ${target} characters wide against ${source} in the source, so it may not fit`
4052
+ };
4053
+ }
4054
+ function width(text) {
4055
+ let w = 0;
4056
+ for (const c of text) {
4057
+ if (new RegExp("\\p{M}|\u200B|\u200C|\u200D", "u").test(c)) continue;
4058
+ 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;
4059
+ }
4060
+ return w;
4061
+ }
4062
+
3697
4063
  // src/core/validate.ts
3698
4064
  function validateProject(project, opts = {}) {
3699
4065
  const locales = opts.locales ?? projectLocales(project);
@@ -3785,12 +4151,14 @@ function validateProject(project, opts = {}) {
3785
4151
  ...checkTranslation(loaded, spec, src, tgt, {
3786
4152
  locale: locale2,
3787
4153
  sourceLocale: project.config.sourceLocale,
3788
- glossary: project.context.glossary
4154
+ glossary: project.context.glossary,
4155
+ role: opts.usages ? roleOf(opts.usages.find(loaded, src)) : void 0
3789
4156
  }).map((i) => ({ ...where, ...i }))
3790
4157
  );
3791
4158
  }
3792
4159
  }
3793
4160
  }
4161
+ issues.push(...inconsistencies(project, locales));
3794
4162
  return issues;
3795
4163
  }
3796
4164
  function checkSource(target, src) {
@@ -3845,7 +4213,8 @@ function checkTranslation(target, spec, src, tgt, opts) {
3845
4213
  allowed,
3846
4214
  strictNewlines: target.strictNewlines,
3847
4215
  glossary: opts.glossary,
3848
- locale: locale2
4216
+ locale: locale2,
4217
+ role: opts.role
3849
4218
  }).map((f) => form ? { ...f, form } : f);
3850
4219
  if (s.kind === "text" && t.kind === "text") return compare(s.value, t.value);
3851
4220
  if (s.kind === "array" && t.kind === "array") {
@@ -4001,6 +4370,11 @@ function compareValues(s, t, opts = {}) {
4001
4370
  if (opts.glossary?.length && opts.locale && t.text.trim() !== "") {
4002
4371
  out.push(...checkGlossary(opts.glossary, opts.locale, s.text, t.text));
4003
4372
  }
4373
+ if (t.text.trim() !== "") {
4374
+ out.push(...textChecks(s, t, { countImplied: opts.countImplied, locale: opts.locale }));
4375
+ const long = lengthCheck(opts.role, s, t);
4376
+ if (long) out.push(long);
4377
+ }
4004
4378
  const edge = (x) => [/^\s*/.exec(x)?.[0] ?? "", /\s*$/.exec(x)?.[0] ?? ""];
4005
4379
  const [sl, st] = edge(s.text);
4006
4380
  const [tl, tt] = edge(t.text);
@@ -4031,140 +4405,6 @@ function describeNewlines(text) {
4031
4405
 
4032
4406
  // src/core/workflow.ts
4033
4407
  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
4408
  function itemId(item) {
4169
4409
  return `${item.target}\0${item.locale}\0${item.key}`;
4170
4410
  }
@@ -4190,13 +4430,13 @@ function plainText(target, entry) {
4190
4430
  const value = entry.message.kind === "plural" ? entry.message.forms.other ?? values.at(-1)?.[1] : values.map(([, v]) => v).join("\n");
4191
4431
  return target.analyze(value ?? "", entry).text;
4192
4432
  }
4193
- var normalize = (s) => s.trim().replace(/\s+/g, " ");
4433
+ var normalize2 = (s) => s.trim().replace(/\s+/g, " ");
4194
4434
  function buildMemory(project, locales) {
4195
4435
  const memory = /* @__PURE__ */ new Map();
4196
4436
  for (const { target } of project.targets) {
4197
4437
  for (const src of target.source.entries.values()) {
4198
4438
  if (!src.translatable) continue;
4199
- const text = normalize(plainText(target, src));
4439
+ const text = normalize2(plainText(target, src));
4200
4440
  if (!text) continue;
4201
4441
  for (const locale2 of locales) {
4202
4442
  const tgt = target.locales.get(locale2)?.entries.get(src.key);
@@ -4211,6 +4451,25 @@ function buildMemory(project, locales) {
4211
4451
  }
4212
4452
  return memory;
4213
4453
  }
4454
+ function differentWording(target, src, message, locale2, memory, thisBatch) {
4455
+ if (message.kind !== "text" || src.context) return void 0;
4456
+ const source = normalize2(plainText(target, src));
4457
+ if (!source) return void 0;
4458
+ const value = normalize2(message.value);
4459
+ const id2 = `${locale2}\0${source}`;
4460
+ const earlier = thisBatch.get(id2);
4461
+ thisBatch.set(id2, earlier ?? { key: displayKey(src.key), value });
4462
+ const existing = (memory.get(source) ?? []).filter(
4463
+ (m) => m.locale === locale2 && (m.target !== target.id || m.key !== src.key) && typeof m.value === "string"
4464
+ ).map((m) => ({ key: displayKey(m.key), value: normalize2(m.value) }))[0];
4465
+ const other = existing ?? earlier;
4466
+ if (!other || other.value === value) return void 0;
4467
+ return {
4468
+ severity: "warning",
4469
+ code: "inconsistent",
4470
+ message: `"${source}" is already translated as "${other.value}" (${other.key}). Use the same wording unless the context differs`
4471
+ };
4472
+ }
4214
4473
  async function nextBatch(project, opts = {}) {
4215
4474
  const usages = opts.usages === void 0 ? await buildUsageIndex(project) : opts.usages;
4216
4475
  const locales = opts.locales ?? projectLocales(project);
@@ -4241,6 +4500,8 @@ async function nextBatch(project, opts = {}) {
4241
4500
  matched++;
4242
4501
  if (matched <= offset || items.length >= limit) continue;
4243
4502
  const text = plainText(target, src);
4503
+ const found = usages ? usages.find(target, src) : [];
4504
+ const role = roleOf(found);
4244
4505
  const placeholders = [
4245
4506
  ...new Set(
4246
4507
  messageValues(src.message).flatMap(
@@ -4249,7 +4510,7 @@ async function nextBatch(project, opts = {}) {
4249
4510
  )
4250
4511
  ];
4251
4512
  const hits = glossaryHits(glossary, text);
4252
- const matches = (memory.get(normalize(text)) ?? []).filter(
4513
+ const matches2 = (memory.get(normalize2(text)) ?? []).filter(
4253
4514
  (m) => m.locale === locale2 && !(m.target === target.id && m.key === src.key)
4254
4515
  );
4255
4516
  const item = {
@@ -4261,7 +4522,9 @@ async function nextBatch(project, opts = {}) {
4261
4522
  source: toValue(src.message),
4262
4523
  ...src.comment ? { comment: src.comment } : {},
4263
4524
  ...src.context ? { context: src.context } : {},
4264
- ...usageFields(usages ? usages.find(target, src) : [], src.references),
4525
+ ...usageFields(found, src.references),
4526
+ ...role ? { role } : {},
4527
+ ...tgt && lockEntry?.note ? { note: lockEntry.note } : {},
4265
4528
  ...tgt ? { current: toValue(tgt.message) } : {},
4266
4529
  ...state === "stale" && lockEntry?.sourceText !== void 0 ? { previousSource: lockEntry.sourceText } : {},
4267
4530
  ...handEdited ? { editedByHand: true } : {},
@@ -4284,8 +4547,8 @@ async function nextBatch(project, opts = {}) {
4284
4547
  };
4285
4548
  })
4286
4549
  } : {},
4287
- ...matches.length ? {
4288
- memory: matches.slice(0, 3).map((m) => ({
4550
+ ...matches2.length ? {
4551
+ memory: matches2.slice(0, 3).map((m) => ({
4289
4552
  value: m.value,
4290
4553
  from: `${m.target}:${displayKey(m.key)}`
4291
4554
  }))
@@ -4332,7 +4595,8 @@ var SubmissionSchema = z3.object({
4332
4595
  target: z3.string().optional(),
4333
4596
  locale: z3.string(),
4334
4597
  key: z3.string(),
4335
- value: MessageValueSchema
4598
+ value: MessageValueSchema,
4599
+ note: z3.string().optional()
4336
4600
  });
4337
4601
  var SubmitInputSchema = z3.union([
4338
4602
  z3.array(SubmissionSchema),
@@ -4361,6 +4625,10 @@ async function submit(project, submissions, opts = {}) {
4361
4625
  const configured = projectLocales(project);
4362
4626
  const pending = /* @__PURE__ */ new Map();
4363
4627
  const seen = /* @__PURE__ */ new Set();
4628
+ const memory = buildMemory(project, configured);
4629
+ const thisBatch = /* @__PURE__ */ new Map();
4630
+ const usages = opts.usages === void 0 ? await buildUsageIndex(project) : opts.usages;
4631
+ const notes = /* @__PURE__ */ new Map();
4364
4632
  for (const sub of submissions) {
4365
4633
  const reject = (message2, code = "rejected", target2) => result.rejected.push({
4366
4634
  ...target2 ? { target: target2 } : {},
@@ -4434,7 +4702,8 @@ async function submit(project, submissions, opts = {}) {
4434
4702
  ...checkTranslation(target, target.pluralSpec(locale2), src, tgt, {
4435
4703
  locale: locale2,
4436
4704
  sourceLocale: project.config.sourceLocale,
4437
- glossary: project.context.glossary
4705
+ glossary: project.context.glossary,
4706
+ role: usages ? roleOf(usages.find(target, src)) : void 0
4438
4707
  }).filter((f) => f.code !== "plural-missing-category" && f.code !== "plural-extra-category"),
4439
4708
  ...checkPluralForms(target, locale2, message)
4440
4709
  ];
@@ -4443,6 +4712,8 @@ async function submit(project, submissions, opts = {}) {
4443
4712
  result.rejected.push({ target: target.id, locale: locale2, key: displayKey(src.key), errors });
4444
4713
  continue;
4445
4714
  }
4715
+ const differs = differentWording(target, src, message, locale2, memory, thisBatch);
4716
+ if (differs) findings.push(differs);
4446
4717
  result.accepted.push({
4447
4718
  target: target.id,
4448
4719
  locale: locale2,
@@ -4454,6 +4725,7 @@ async function submit(project, submissions, opts = {}) {
4454
4725
  entries.set(src.key, tgt);
4455
4726
  byLocale.set(locale2, entries);
4456
4727
  pending.set(target, byLocale);
4728
+ notes.set(id2, sub.note?.trim() || null);
4457
4729
  }
4458
4730
  if (opts.dryRun || pending.size === 0) return result;
4459
4731
  const files = /* @__PURE__ */ new Set();
@@ -4466,7 +4738,12 @@ async function submit(project, submissions, opts = {}) {
4466
4738
  result.files = [...files].sort();
4467
4739
  const keys = [...pending].flatMap(
4468
4740
  ([target, byLocale]) => [...byLocale].flatMap(
4469
- ([locale2, entries]) => [...entries.keys()].map((key) => ({ target: target.id, locale: locale2, key }))
4741
+ ([locale2, entries]) => [...entries.keys()].map((key) => ({
4742
+ target: target.id,
4743
+ locale: locale2,
4744
+ key,
4745
+ note: notes.get(`${target.id}\0${locale2}\0${key}`) ?? null
4746
+ }))
4470
4747
  )
4471
4748
  );
4472
4749
  await record(project, keys, () => ({
@@ -4565,11 +4842,13 @@ async function record(project, keys, entry) {
4565
4842
  const tgt = target?.locales.get(k.locale)?.entries.get(k.key);
4566
4843
  if (!src || !tgt) continue;
4567
4844
  const previous = getLockEntry(lock, k.target, k.locale, k.key);
4845
+ const note = k.note === void 0 ? previous?.note : k.note ?? void 0;
4568
4846
  setLockEntry(lock, k.target, k.locale, k.key, {
4569
4847
  source: hashMessage(src.message),
4570
4848
  sourceText: toValue(src.message),
4571
4849
  target: hashMessage(tgt.message),
4572
- ...entry(previous)
4850
+ ...entry(previous),
4851
+ ...note ? { note } : {}
4573
4852
  });
4574
4853
  }
4575
4854
  pruneLock(fresh, lock);
@@ -5400,6 +5679,7 @@ export {
5400
5679
  printApprove,
5401
5680
  printPlan,
5402
5681
  runInit,
5682
+ buildUsageIndex,
5403
5683
  validateProject,
5404
5684
  nextBatch,
5405
5685
  SubmissionSchema,
@@ -5413,4 +5693,4 @@ export {
5413
5693
  TRANSLATION_RULES,
5414
5694
  translateProject
5415
5695
  };
5416
- //# sourceMappingURL=chunk-OWT47JBL.js.map
5696
+ //# sourceMappingURL=chunk-QS3UG3KI.js.map
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  UserError,
5
5
  addLocales,
6
6
  approve,
7
+ buildUsageIndex,
7
8
  computeStatus,
8
9
  describeDetect,
9
10
  describeNone,
@@ -29,13 +30,13 @@ import {
29
30
  submit,
30
31
  translateProject,
31
32
  validateProject
32
- } from "./chunk-OWT47JBL.js";
33
+ } from "./chunk-QS3UG3KI.js";
33
34
 
34
35
  // src/cli/main.ts
35
36
  import path from "path";
36
37
  import { createInterface } from "readline/promises";
37
38
  import { Command, InvalidArgumentError } from "commander";
38
- var VERSION = true ? "0.1.0" : "dev";
39
+ var VERSION = true ? "0.2.0" : "dev";
39
40
  var program = new Command().name("localship").description("Local-first, agent-native localization for apps").version(VERSION).option("-c, --config <file>", "path to the config (default: the nearest .localship/config.yaml)").option("-C, --cwd <dir>", "directory to run in", process.cwd());
40
41
  var globals = () => program.opts();
41
42
  var locales = (v) => v.split(",").map((s) => s.trim()).filter(Boolean);
@@ -75,7 +76,11 @@ program.command("status").description("Translation progress per locale").option(
75
76
  });
76
77
  program.command("validate").description("Check source strings and translations; exits 1 on errors").option("-l, --locale <list>", "comma-separated locales", locales).option("--strict", "untranslated strings and missing locales are errors").option("--json", "machine-readable output").action(async (opts) => {
77
78
  const project = await openProject(nodeFs, globals());
78
- const issues = validateProject(project, { locales: opts.locale, strict: opts.strict });
79
+ const issues = validateProject(project, {
80
+ locales: opts.locale,
81
+ strict: opts.strict,
82
+ usages: await buildUsageIndex(project)
83
+ });
79
84
  const errors = issues.filter((i) => i.severity === "error").length;
80
85
  if (opts.json) {
81
86
  console.log(JSON.stringify({ ok: errors === 0, issues }, null, 2));
@@ -291,7 +296,7 @@ program.command("translate").description("Translate missing and stale strings wi
291
296
  }
292
297
  );
293
298
  program.command("mcp").description("Run the MCP server over stdio, for Claude, Cursor, Codex and other MCP clients").action(async () => {
294
- const { runServer } = await import("./server-GZNS57EQ.js");
299
+ const { runServer } = await import("./server-T6GQ6CV5.js");
295
300
  const explicit = program.getOptionValueSource("cwd") !== "default";
296
301
  const cwd = explicit ? globals().cwd : process.env.CLAUDE_PROJECT_DIR ?? globals().cwd;
297
302
  await runServer({ cwd, version: VERSION });
@@ -7,6 +7,7 @@ import {
7
7
  UserError,
8
8
  addLocales,
9
9
  approve,
10
+ buildUsageIndex,
10
11
  computeStatus,
11
12
  detect,
12
13
  hardcodedNotes,
@@ -24,7 +25,7 @@ import {
24
25
  translateProject,
25
26
  validateProject,
26
27
  writeContext
27
- } from "./chunk-OWT47JBL.js";
28
+ } from "./chunk-QS3UG3KI.js";
28
29
 
29
30
  // src/mcp/server.ts
30
31
  import { existsSync, readFileSync } from "fs";
@@ -112,7 +113,8 @@ function createServer(opts) {
112
113
  strict: z.boolean().optional().describe("Untranslated strings and missing locales are errors")
113
114
  },
114
115
  async ({ cwd, locales, strict }) => {
115
- const issues = validateProject(await project(cwd), { locales, strict });
116
+ const p = await project(cwd);
117
+ const issues = validateProject(p, { locales, strict, usages: await buildUsageIndex(p) });
116
118
  return { ok: !issues.some((i) => i.severity === "error"), issues };
117
119
  },
118
120
  { readOnlyHint: true }
@@ -212,7 +214,7 @@ ${TRANSLATION_RULES}`,
212
214
  );
213
215
  tool(
214
216
  "localship_submit",
215
- "Check translations and write the ones without errors as drafts. Rejected ones come back with the errors to fix; resubmit just those. value is a string, an object of plural forms, or an array, matching the item's kind.",
217
+ "Check translations and write the ones without errors as drafts. Rejected ones come back with the errors to fix; resubmit just those. value is a string, an object of plural forms, or an array, matching the item's kind. Add a short note only when the source is ambiguous, saying how you read it.",
216
218
  {
217
219
  cwd: cwdArg,
218
220
  translations: z.array(SubmissionSchema),
@@ -311,4 +313,4 @@ export {
311
313
  createServer,
312
314
  runServer
313
315
  };
314
- //# sourceMappingURL=server-GZNS57EQ.js.map
316
+ //# sourceMappingURL=server-T6GQ6CV5.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "localship",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Translate your app with the AI you already use. An MCP server, agent skills and CLI that check every translation before it's written. Android, Apple String Catalogs and gettext.",
5
5
  "keywords": [
6
6
  "localization",
@@ -23,7 +23,8 @@ installed, ask to install it once with `npm install -g localship`.
23
23
  localship next-batch --locale hi --states draft
24
24
  ```
25
25
  Each item shows `source` and the translation as `current`, with the comment, plural forms
26
- and glossary. Read `.localship/brief.md` and the locale's voice first.
26
+ and glossary. A `note` says how the translator read an ambiguous source: check that reading
27
+ first, and mention it to the user if you're not sure. `role` says where it appears. Read `.localship/brief.md` and the locale's voice first.
27
28
  To review translations made before Localship, use `--states untracked`; for hand edits,
28
29
  `--states edited`. Fixing those replaces a person's work, so ask before resubmitting, and
29
30
  pass `--force` only then.
@@ -64,7 +64,11 @@ come out generic.
64
64
  one).
65
65
  - Arrays: same number of items, same order.
66
66
  - Reuse `memory` unless the context differs. Follow the glossary.
67
- - Keep it about as long as the source; buttons and tabs have little room.
67
+ - Keep it about as long as the source. Items with a `role` of button, tab, chip or menu item
68
+ have little room: prefer the shortest natural wording.
69
+ - When the source could mean two things ("Open" as a button or a status, "Balance" as money
70
+ or equilibrium) and the context doesn't settle it, add a short `note` saying how you read
71
+ it. Reviewers see it. Skip notes when the meaning is clear.
68
72
  - Android values: write plain text; apostrophes, quotes and `&` are escaped for you.
69
73
 
70
74
  3. Submit, using a file for anything non-trivial:
@@ -77,7 +81,8 @@ come out generic.
77
81
  {"target": "android", "locale": "hi", "key": "days_left", "value": {"one": "%d दिन बाकी", "other": "%d दिन बाकी"}}
78
82
  ]}
79
83
  ```
80
- Copy `target`, `locale` and `key` from the batch items. Delete the file afterwards.
84
+ Copy `target`, `locale` and `key` from the batch items. Add `"note"` to an entry only for an
85
+ ambiguous source. Delete the file afterwards.
81
86
 
82
87
  4. Read the result. Rejected entries list their errors: fix exactly those and resubmit only
83
88
  them. Accepted entries may have warnings (glossary, untranslated, whitespace): fix the