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
|
+
[](https://www.npmjs.com/package/localship)
|
|
9
10
|
[](https://github.com/jigarfumakiya/localship/actions/workflows/ci.yml)
|
|
10
11
|
[](LICENSE)
|
|
11
12
|
[](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
|
-
- 🤖 **
|
|
35
|
-
|
|
36
|
-
- 🛡️ **Catches broken translations.** Placeholders, plural forms,
|
|
37
|
-
|
|
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,
|
|
41
|
-
|
|
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
|
-
|
|
|
52
|
-
|
|
|
53
|
-
|
|
|
54
|
-
|
|
|
55
|
-
|
|
|
56
|
-
|
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
148
|
+
**Cursor and VS Code**
|
|
149
|
+
|
|
150
|
+
[](https://cursor.com/en/install-mcp?name=localship&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImxvY2Fsc2hpcCIsIm1jcCJdfQ%3D%3D)
|
|
151
|
+
[](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
|
|
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 |
|
|
165
|
+
| Tool | Config file |
|
|
162
166
|
|---|---|
|
|
163
|
-
|
|
|
164
|
-
|
|
|
165
|
-
|
|
|
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(
|
|
608
|
-
for (const m of
|
|
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,
|
|
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 && (
|
|
1780
|
-
const pieces =
|
|
1781
|
-
if (!innerNewline &&
|
|
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,
|
|
1830
|
-
if (
|
|
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) >
|
|
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
|
|
1926
|
-
const style = inferLocaleStyle(
|
|
1927
|
-
for (const m of
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
...
|
|
4288
|
-
memory:
|
|
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) => ({
|
|
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-
|
|
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-
|
|
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.
|
|
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, {
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
316
|
+
//# sourceMappingURL=server-T6GQ6CV5.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "localship",
|
|
3
|
-
"version": "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.
|
|
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
|
|
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.
|
|
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
|