nativtongue-cli 0.0.1
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 +98 -0
- package/dist/chunk-QQ3TF54X.js +214 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +179 -0
- package/dist/tongue-sync.d.ts +10 -0
- package/dist/tongue-sync.js +120 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# nativtongue-cli
|
|
2
|
+
|
|
3
|
+
Dev tooling for [nativtongue](../nativtongue) — extracts translation keys from your source code, generates translation files, and auto-translates via Claude.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -D nativtongue-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires [Claude Code](https://claude.ai/code) installed and authenticated for auto-translation.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx nativtongue init fr es de
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This:
|
|
20
|
+
- Creates `.nativtonguerc.json` with your locales
|
|
21
|
+
- Patches `vite.config.ts` to add the `tongueSync()` plugin
|
|
22
|
+
- Creates the `public/i18n/` directory
|
|
23
|
+
- Adds `.nativtongue/` to `.gitignore`
|
|
24
|
+
|
|
25
|
+
Then start your dev server. Translations sync automatically as you write code.
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
### `nativtongue init <locales...>`
|
|
30
|
+
|
|
31
|
+
Set up nativtongue in your project.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
nativtongue init fr # French only
|
|
35
|
+
nativtongue init fr es de ja # Multiple locales
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### `nativtongue sync`
|
|
39
|
+
|
|
40
|
+
Extract keys from source files and update translation files.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
nativtongue sync # Extract keys, add empty values for new ones
|
|
44
|
+
nativtongue sync --translate # Extract + auto-translate missing keys via Claude
|
|
45
|
+
nativtongue sync --prune # Remove stale keys no longer in source
|
|
46
|
+
nativtongue sync --translate --prune # Both
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Vite plugin
|
|
50
|
+
|
|
51
|
+
The `tongueSync()` plugin watches your source files during development and automatically extracts keys + translates on save.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// vite.config.ts
|
|
55
|
+
import { tongueSync } from 'nativtongue-cli/sync'
|
|
56
|
+
|
|
57
|
+
export default defineConfig({
|
|
58
|
+
plugins: [tongueSync()],
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### How it works
|
|
63
|
+
|
|
64
|
+
1. On dev server start, scans all source files and extracts translation keys
|
|
65
|
+
2. On file save, re-extracts keys from the changed file (5s debounce)
|
|
66
|
+
3. Diffs against the last known key set — if nothing changed, does nothing
|
|
67
|
+
4. Translates new keys via `claude -p` (parallel across locales)
|
|
68
|
+
5. Writes updated locale files (e.g. `public/i18n/fr.json`)
|
|
69
|
+
|
|
70
|
+
### Options
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
tongueSync({
|
|
74
|
+
srcDir: './src', // default: from .nativtonguerc.json or './src'
|
|
75
|
+
i18nDir: './public/i18n', // default: from .nativtonguerc.json or './public/i18n'
|
|
76
|
+
locales: ['fr', 'es'], // default: from .nativtonguerc.json
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## What gets extracted
|
|
81
|
+
|
|
82
|
+
The static extractor finds:
|
|
83
|
+
- Plain text inside JSX elements (`<p>Hello</p>`, `<h1>Welcome</h1>`)
|
|
84
|
+
- `t("...")` calls from the `useT()` hook
|
|
85
|
+
- Translatable attributes (`aria-label`, `placeholder`, `title`, `alt`)
|
|
86
|
+
- Scoped keys inside `<TranslationScope>` (e.g. `navigation::Back`)
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
`.nativtonguerc.json` in your project root:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"srcDir": "./src",
|
|
95
|
+
"i18nDir": "./public/i18n",
|
|
96
|
+
"locales": ["fr", "es", "de"]
|
|
97
|
+
}
|
|
98
|
+
```
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// src/extractor.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
var T_FUNCTION_RE = /\bt\(\s*["']([^"']+)["']\s*\)/g;
|
|
4
|
+
var JSX_TEXT_RE = /<(h[1-6]|p|span|div|li|td|th|label|a|strong|em)(?:\s[^>]*)?>([^<{]+?)<\/\1>/gs;
|
|
5
|
+
var ATTR_RE = /(?:aria-label|placeholder|title|alt)=["']([^"']+)["']/g;
|
|
6
|
+
var SCOPE_RE = /<TranslationScope\s+context=["']([^"']+)["']\s*>/g;
|
|
7
|
+
var SCOPE_CLOSE_RE = /<\/TranslationScope>/g;
|
|
8
|
+
function isTranslatableText(text) {
|
|
9
|
+
if (!text || text.length < 2) return false;
|
|
10
|
+
if (text.includes("${")) return false;
|
|
11
|
+
if (/^[\d\s.,!?;:'"()-]+$/.test(text)) return false;
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
function extractKeysFromSource(source) {
|
|
15
|
+
const keys = /* @__PURE__ */ new Set();
|
|
16
|
+
const events = [];
|
|
17
|
+
for (const match of source.matchAll(SCOPE_RE)) {
|
|
18
|
+
events.push({ pos: match.index, type: "open", scope: match[1] });
|
|
19
|
+
}
|
|
20
|
+
for (const match of source.matchAll(SCOPE_CLOSE_RE)) {
|
|
21
|
+
events.push({ pos: match.index, type: "close" });
|
|
22
|
+
}
|
|
23
|
+
events.sort((a, b) => a.pos - b.pos);
|
|
24
|
+
const scopeStack = [];
|
|
25
|
+
const regions = [];
|
|
26
|
+
for (const event of events) {
|
|
27
|
+
if (event.type === "open") {
|
|
28
|
+
scopeStack.push({ pos: event.pos, scope: event.scope });
|
|
29
|
+
} else if (scopeStack.length > 0) {
|
|
30
|
+
const open = scopeStack.pop();
|
|
31
|
+
regions.push({ scope: open.scope, start: open.pos, end: event.pos });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function isInScope(pos) {
|
|
35
|
+
return regions.some((r) => pos >= r.start && pos < r.end);
|
|
36
|
+
}
|
|
37
|
+
for (const match of source.matchAll(T_FUNCTION_RE)) {
|
|
38
|
+
if (!isInScope(match.index)) keys.add(match[1]);
|
|
39
|
+
}
|
|
40
|
+
for (const match of source.matchAll(JSX_TEXT_RE)) {
|
|
41
|
+
if (!isInScope(match.index)) {
|
|
42
|
+
const text = match[2].trim();
|
|
43
|
+
if (isTranslatableText(text)) keys.add(text);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const match of source.matchAll(ATTR_RE)) {
|
|
47
|
+
if (!isInScope(match.index)) {
|
|
48
|
+
const text = match[1].trim();
|
|
49
|
+
if (isTranslatableText(text)) keys.add(text);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const { scope, start, end } of regions) {
|
|
53
|
+
const region = source.slice(start, end);
|
|
54
|
+
for (const match of region.matchAll(T_FUNCTION_RE)) {
|
|
55
|
+
keys.add(`${scope}::${match[1]}`);
|
|
56
|
+
}
|
|
57
|
+
for (const match of region.matchAll(JSX_TEXT_RE)) {
|
|
58
|
+
const text = match[2].trim();
|
|
59
|
+
if (isTranslatableText(text)) {
|
|
60
|
+
keys.add(`${scope}::${text}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const match of region.matchAll(ATTR_RE)) {
|
|
64
|
+
const text = match[1].trim();
|
|
65
|
+
if (isTranslatableText(text)) {
|
|
66
|
+
keys.add(`${scope}::${text}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return Array.from(keys);
|
|
71
|
+
}
|
|
72
|
+
function extractKeysFromFile(filePath) {
|
|
73
|
+
const source = fs.readFileSync(filePath, "utf-8");
|
|
74
|
+
return extractKeysFromSource(source);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/sync-locale.ts
|
|
78
|
+
import fs2 from "fs";
|
|
79
|
+
import path from "path";
|
|
80
|
+
async function syncLocaleFile(options) {
|
|
81
|
+
const { locale, i18nDir, allKeys, provider, prune = false, retranslateEmpty = false, signal } = options;
|
|
82
|
+
const localePath = path.join(i18nDir, `${locale}.json`);
|
|
83
|
+
let existing = {};
|
|
84
|
+
if (fs2.existsSync(localePath)) {
|
|
85
|
+
const raw = JSON.parse(fs2.readFileSync(localePath, "utf-8"));
|
|
86
|
+
delete raw._disclaimer;
|
|
87
|
+
existing = raw;
|
|
88
|
+
}
|
|
89
|
+
const allKeysSet = new Set(allKeys);
|
|
90
|
+
const missingKeys = retranslateEmpty ? allKeys.filter((k) => !(k in existing) || !existing[k]) : allKeys.filter((k) => !(k in existing));
|
|
91
|
+
const staleKeys = Object.keys(existing).filter((k) => !allKeysSet.has(k));
|
|
92
|
+
let newTranslations = {};
|
|
93
|
+
if (provider && missingKeys.length > 0) {
|
|
94
|
+
console.log(`[nativtongue] Translating ${missingKeys.length} keys to ${locale}...`);
|
|
95
|
+
try {
|
|
96
|
+
newTranslations = await provider.translate(missingKeys, locale, signal);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (err?.name === "AbortError") throw err;
|
|
99
|
+
console.warn(`[nativtongue] Translation failed for ${locale}:`, err?.message ?? err);
|
|
100
|
+
for (const k of missingKeys) newTranslations[k] = "";
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
for (const k of missingKeys) newTranslations[k] = "";
|
|
104
|
+
}
|
|
105
|
+
if (signal?.aborted) return;
|
|
106
|
+
const merged = {};
|
|
107
|
+
for (const key of allKeys) {
|
|
108
|
+
if (key in existing && existing[key]) {
|
|
109
|
+
merged[key] = existing[key];
|
|
110
|
+
} else if (key in newTranslations) {
|
|
111
|
+
merged[key] = newTranslations[key];
|
|
112
|
+
} else {
|
|
113
|
+
merged[key] = "";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!prune) {
|
|
117
|
+
for (const key of staleKeys) merged[key] = existing[key];
|
|
118
|
+
}
|
|
119
|
+
const output = {
|
|
120
|
+
_disclaimer: "Translations generated by Claude. Review before shipping to production.",
|
|
121
|
+
...merged
|
|
122
|
+
};
|
|
123
|
+
fs2.writeFileSync(localePath, JSON.stringify(output, null, 2) + "\n");
|
|
124
|
+
const staleMsg = staleKeys.length > 0 ? prune ? `, ${staleKeys.length} removed (${staleKeys.join(", ")})` : `, ${staleKeys.length} stale (kept: ${staleKeys.join(", ")}). Run with --prune to remove.` : "";
|
|
125
|
+
console.log(`[nativtongue] ${locale}.json \u2014 ${missingKeys.length} added${staleMsg}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/locale-names.ts
|
|
129
|
+
var LOCALE_NAMES = {
|
|
130
|
+
fr: "French",
|
|
131
|
+
es: "Spanish",
|
|
132
|
+
de: "German",
|
|
133
|
+
it: "Italian",
|
|
134
|
+
pt: "Portuguese",
|
|
135
|
+
ja: "Japanese",
|
|
136
|
+
ko: "Korean",
|
|
137
|
+
zh: "Chinese",
|
|
138
|
+
ar: "Arabic",
|
|
139
|
+
ru: "Russian",
|
|
140
|
+
nl: "Dutch",
|
|
141
|
+
sv: "Swedish",
|
|
142
|
+
da: "Danish",
|
|
143
|
+
no: "Norwegian",
|
|
144
|
+
fi: "Finnish",
|
|
145
|
+
pl: "Polish",
|
|
146
|
+
cs: "Czech",
|
|
147
|
+
tr: "Turkish",
|
|
148
|
+
th: "Thai",
|
|
149
|
+
vi: "Vietnamese",
|
|
150
|
+
hi: "Hindi",
|
|
151
|
+
he: "Hebrew",
|
|
152
|
+
uk: "Ukrainian",
|
|
153
|
+
ro: "Romanian",
|
|
154
|
+
hu: "Hungarian"
|
|
155
|
+
};
|
|
156
|
+
var LOCALE_RE = /^[a-z]{2,3}(-[A-Z]{2,3})?$/;
|
|
157
|
+
function localeName(code) {
|
|
158
|
+
return LOCALE_NAMES[code] ?? code;
|
|
159
|
+
}
|
|
160
|
+
function isValidLocale(locale) {
|
|
161
|
+
return LOCALE_RE.test(locale);
|
|
162
|
+
}
|
|
163
|
+
function parseJsonResponse(raw) {
|
|
164
|
+
let cleaned = raw.trim();
|
|
165
|
+
if (cleaned.startsWith("```"))
|
|
166
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
|
|
167
|
+
return JSON.parse(cleaned);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/translate-provider.ts
|
|
171
|
+
import { execFile } from "child_process";
|
|
172
|
+
import { promisify } from "util";
|
|
173
|
+
var execFileAsync = promisify(execFile);
|
|
174
|
+
function buildPrompt(batch, targetLocale) {
|
|
175
|
+
return `Translate the following UI strings to ${localeName(targetLocale)} (${targetLocale}). Return ONLY a JSON object mapping each original string to its translation. No markdown, no explanation, no wrapping.
|
|
176
|
+
|
|
177
|
+
Some keys use "scope::text" format (e.g. "navigation::Back"). The scope is context, not text to translate. Translate only the part after "::" but keep the full key in the output. For example, "navigation::Back" should be translated based on the word "Back" in a navigation context.
|
|
178
|
+
|
|
179
|
+
Strings to translate:
|
|
180
|
+
${JSON.stringify(batch, null, 2)}`;
|
|
181
|
+
}
|
|
182
|
+
var BATCH_SIZE = 50;
|
|
183
|
+
function createClaudeCliProvider() {
|
|
184
|
+
return {
|
|
185
|
+
async translate(keys, targetLocale, signal) {
|
|
186
|
+
const result = {};
|
|
187
|
+
for (let i = 0; i < keys.length; i += BATCH_SIZE) {
|
|
188
|
+
const batch = keys.slice(i, i + BATCH_SIZE);
|
|
189
|
+
const prompt = buildPrompt(batch, targetLocale);
|
|
190
|
+
try {
|
|
191
|
+
const { stdout } = await execFileAsync("claude", ["-p", prompt], {
|
|
192
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
193
|
+
signal
|
|
194
|
+
});
|
|
195
|
+
const parsed = parseJsonResponse(stdout);
|
|
196
|
+
Object.assign(result, parsed);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (err?.name === "AbortError") throw err;
|
|
199
|
+
console.warn(`[nativtongue] Claude CLI failed for batch ${i / BATCH_SIZE + 1}:`, err);
|
|
200
|
+
for (const k of batch) result[k] = "";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export {
|
|
209
|
+
extractKeysFromSource,
|
|
210
|
+
extractKeysFromFile,
|
|
211
|
+
syncLocaleFile,
|
|
212
|
+
isValidLocale,
|
|
213
|
+
createClaudeCliProvider
|
|
214
|
+
};
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
createClaudeCliProvider,
|
|
4
|
+
extractKeysFromFile,
|
|
5
|
+
isValidLocale,
|
|
6
|
+
syncLocaleFile
|
|
7
|
+
} from "./chunk-QQ3TF54X.js";
|
|
8
|
+
|
|
9
|
+
// src/cli.ts
|
|
10
|
+
import fs3 from "fs";
|
|
11
|
+
import path2 from "path";
|
|
12
|
+
|
|
13
|
+
// src/sync.ts
|
|
14
|
+
import fs from "fs";
|
|
15
|
+
import { glob } from "glob";
|
|
16
|
+
async function sync(options) {
|
|
17
|
+
const { srcDir, i18nDir, locales, provider, prune = false } = options;
|
|
18
|
+
const files = await glob("**/*.{tsx,jsx,ts,js}", {
|
|
19
|
+
cwd: srcDir,
|
|
20
|
+
absolute: true,
|
|
21
|
+
ignore: ["**/node_modules/**", "**/dist/**"]
|
|
22
|
+
});
|
|
23
|
+
const staticKeys = /* @__PURE__ */ new Set();
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
const keys = extractKeysFromFile(file);
|
|
26
|
+
keys.forEach((k) => staticKeys.add(k));
|
|
27
|
+
}
|
|
28
|
+
console.log(`[nativtongue] Found ${staticKeys.size} keys from static analysis`);
|
|
29
|
+
const allKeys = Array.from(staticKeys).sort();
|
|
30
|
+
fs.mkdirSync(i18nDir, { recursive: true });
|
|
31
|
+
await Promise.allSettled(
|
|
32
|
+
locales.map(
|
|
33
|
+
(locale) => syncLocaleFile({ locale, i18nDir, allKeys, provider, prune })
|
|
34
|
+
)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/init.ts
|
|
39
|
+
import fs2 from "fs";
|
|
40
|
+
import path from "path";
|
|
41
|
+
var VITE_CONFIG_NAMES = [
|
|
42
|
+
"vite.config.ts",
|
|
43
|
+
"vite.config.js",
|
|
44
|
+
"vite.config.mts",
|
|
45
|
+
"vite.config.mjs"
|
|
46
|
+
];
|
|
47
|
+
function init(locales) {
|
|
48
|
+
const cwd = process.cwd();
|
|
49
|
+
if (locales.length === 0) {
|
|
50
|
+
console.error("[nativtongue] Specify at least one locale: nativtongue init fr es de");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const rcPath = path.join(cwd, ".nativtonguerc.json");
|
|
54
|
+
if (fs2.existsSync(rcPath)) {
|
|
55
|
+
console.log("[nativtongue] .nativtonguerc.json already exists, skipping");
|
|
56
|
+
} else {
|
|
57
|
+
const rc = { srcDir: "./src", i18nDir: "./public/i18n", locales };
|
|
58
|
+
fs2.writeFileSync(rcPath, JSON.stringify(rc, null, 2) + "\n");
|
|
59
|
+
console.log("[nativtongue] Created .nativtonguerc.json");
|
|
60
|
+
}
|
|
61
|
+
const i18nDir = path.join(cwd, "public/i18n");
|
|
62
|
+
fs2.mkdirSync(i18nDir, { recursive: true });
|
|
63
|
+
const viteConfig = VITE_CONFIG_NAMES.map((name) => path.join(cwd, name)).find((p) => fs2.existsSync(p));
|
|
64
|
+
if (viteConfig) {
|
|
65
|
+
let source = fs2.readFileSync(viteConfig, "utf-8");
|
|
66
|
+
if (source.includes("tongueSync")) {
|
|
67
|
+
console.log("[nativtongue] Vite config already has tongueSync plugin, skipping");
|
|
68
|
+
} else {
|
|
69
|
+
const importLine = "import { tongueSync } from 'nativtongue-cli/sync'\n";
|
|
70
|
+
const lastImportIdx = source.lastIndexOf("\nimport ");
|
|
71
|
+
if (lastImportIdx !== -1) {
|
|
72
|
+
const lineEnd = source.indexOf("\n", lastImportIdx + 1);
|
|
73
|
+
source = source.slice(0, lineEnd + 1) + importLine + source.slice(lineEnd + 1);
|
|
74
|
+
} else {
|
|
75
|
+
source = importLine + source;
|
|
76
|
+
}
|
|
77
|
+
const pluginsMatch = source.match(/plugins\s*:\s*\[/);
|
|
78
|
+
if (pluginsMatch && pluginsMatch.index !== void 0) {
|
|
79
|
+
const insertAt = pluginsMatch.index + pluginsMatch[0].length;
|
|
80
|
+
source = source.slice(0, insertAt) + "\n tongueSync()," + source.slice(insertAt);
|
|
81
|
+
} else {
|
|
82
|
+
console.warn("[nativtongue] Could not find plugins array in vite config \u2014 add tongueSync() manually");
|
|
83
|
+
}
|
|
84
|
+
fs2.writeFileSync(viteConfig, source);
|
|
85
|
+
console.log(`[nativtongue] Patched ${path.basename(viteConfig)}`);
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
console.warn("[nativtongue] No vite.config found \u2014 add tongueSync() plugin manually");
|
|
89
|
+
}
|
|
90
|
+
const gitignorePath = path.join(cwd, ".gitignore");
|
|
91
|
+
if (fs2.existsSync(gitignorePath)) {
|
|
92
|
+
const gitignore = fs2.readFileSync(gitignorePath, "utf-8");
|
|
93
|
+
if (!gitignore.includes(".nativtongue")) {
|
|
94
|
+
fs2.appendFileSync(gitignorePath, "\n# nativtongue\n.nativtongue/\n");
|
|
95
|
+
console.log("[nativtongue] Added .nativtongue/ to .gitignore");
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
fs2.writeFileSync(gitignorePath, "# nativtongue\n.nativtongue/\n");
|
|
99
|
+
console.log("[nativtongue] Created .gitignore");
|
|
100
|
+
}
|
|
101
|
+
console.log(`
|
|
102
|
+
Done! Next steps:
|
|
103
|
+
1. Wrap your root component:
|
|
104
|
+
|
|
105
|
+
import { TranslationProvider } from 'nativtongue'
|
|
106
|
+
|
|
107
|
+
<TranslationProvider defaultLocale="en">
|
|
108
|
+
<App />
|
|
109
|
+
</TranslationProvider>
|
|
110
|
+
|
|
111
|
+
2. Start your dev server \u2014 translations sync automatically.
|
|
112
|
+
`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/cli.ts
|
|
116
|
+
function loadConfig() {
|
|
117
|
+
const configPath = path2.resolve(process.cwd(), ".nativtonguerc.json");
|
|
118
|
+
if (!fs3.existsSync(configPath)) return {};
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(fs3.readFileSync(configPath, "utf-8"));
|
|
121
|
+
} catch {
|
|
122
|
+
console.error(`[nativtongue] Invalid JSON in ${configPath}`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function main() {
|
|
127
|
+
const command = process.argv[2];
|
|
128
|
+
if (!command || command === "help" || command === "--help") {
|
|
129
|
+
console.log(`
|
|
130
|
+
nativtongue \u2014 translation sync CLI
|
|
131
|
+
|
|
132
|
+
Commands:
|
|
133
|
+
init <locales...> Set up nativtongue in your project
|
|
134
|
+
Example: nativtongue init fr es de
|
|
135
|
+
|
|
136
|
+
sync [options] Extract keys and update translation files
|
|
137
|
+
--translate Auto-fill missing translations
|
|
138
|
+
--prune Remove stale keys from translation files
|
|
139
|
+
|
|
140
|
+
Translation uses your local Claude Code installation (no API key needed).
|
|
141
|
+
`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (command === "init") {
|
|
145
|
+
const locales = process.argv.slice(3).filter((a) => !a.startsWith("-"));
|
|
146
|
+
init(locales);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (command === "sync") {
|
|
150
|
+
const config = loadConfig();
|
|
151
|
+
const shouldTranslate = process.argv.includes("--translate");
|
|
152
|
+
const shouldPrune = process.argv.includes("--prune");
|
|
153
|
+
const srcDir = path2.resolve(process.cwd(), config.srcDir ?? "./src");
|
|
154
|
+
const i18nDir = path2.resolve(process.cwd(), config.i18nDir ?? "./public/i18n");
|
|
155
|
+
const rawLocales = config.locales ?? [];
|
|
156
|
+
const invalid = rawLocales.filter((l) => !isValidLocale(l));
|
|
157
|
+
if (invalid.length > 0) {
|
|
158
|
+
console.error(`[nativtongue] Invalid locale codes: ${invalid.join(", ")}`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
const locales = rawLocales;
|
|
162
|
+
if (locales.length === 0) {
|
|
163
|
+
console.error('[nativtongue] No locales configured. Add "locales" to .nativtonguerc.json');
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
let provider = void 0;
|
|
167
|
+
if (shouldTranslate) {
|
|
168
|
+
provider = createClaudeCliProvider();
|
|
169
|
+
}
|
|
170
|
+
await sync({ srcDir, i18nDir, locales, provider, prune: shouldPrune });
|
|
171
|
+
} else {
|
|
172
|
+
console.error(`Unknown command: ${command}`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
main().catch((err) => {
|
|
177
|
+
console.error(err);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createClaudeCliProvider,
|
|
3
|
+
extractKeysFromSource,
|
|
4
|
+
isValidLocale,
|
|
5
|
+
syncLocaleFile
|
|
6
|
+
} from "./chunk-QQ3TF54X.js";
|
|
7
|
+
|
|
8
|
+
// src/tongue-sync.ts
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { glob } from "glob";
|
|
12
|
+
function tongueSync(options) {
|
|
13
|
+
const keyCache = /* @__PURE__ */ new Map();
|
|
14
|
+
let lastSyncedSnapshot = "";
|
|
15
|
+
let debounceTimer = null;
|
|
16
|
+
let inFlightAbort = null;
|
|
17
|
+
let syncing = false;
|
|
18
|
+
let srcDir;
|
|
19
|
+
let i18nDir;
|
|
20
|
+
let locales;
|
|
21
|
+
const provider = createClaudeCliProvider();
|
|
22
|
+
function loadRcConfig() {
|
|
23
|
+
const rcPath = path.resolve(process.cwd(), ".nativtonguerc.json");
|
|
24
|
+
if (fs.existsSync(rcPath)) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(fs.readFileSync(rcPath, "utf-8"));
|
|
27
|
+
} catch {
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
function getAllKeys() {
|
|
33
|
+
const all = /* @__PURE__ */ new Set();
|
|
34
|
+
for (const keys of keyCache.values()) keys.forEach((k) => all.add(k));
|
|
35
|
+
return Array.from(all).sort();
|
|
36
|
+
}
|
|
37
|
+
function updateFileCache(filePath) {
|
|
38
|
+
try {
|
|
39
|
+
const source = fs.readFileSync(filePath, "utf-8");
|
|
40
|
+
keyCache.set(filePath, extractKeysFromSource(source));
|
|
41
|
+
} catch {
|
|
42
|
+
keyCache.delete(filePath);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function initialScan() {
|
|
46
|
+
const files = await glob("**/*.{tsx,jsx,ts,js}", {
|
|
47
|
+
cwd: srcDir,
|
|
48
|
+
absolute: true,
|
|
49
|
+
ignore: ["**/node_modules/**", "**/dist/**"]
|
|
50
|
+
});
|
|
51
|
+
for (const file of files) updateFileCache(file);
|
|
52
|
+
}
|
|
53
|
+
function scheduleSync() {
|
|
54
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
55
|
+
debounceTimer = setTimeout(() => runSync(), 5e3);
|
|
56
|
+
}
|
|
57
|
+
async function runSync() {
|
|
58
|
+
if (locales.length === 0) return;
|
|
59
|
+
const allKeys = getAllKeys();
|
|
60
|
+
const snapshot = JSON.stringify(allKeys);
|
|
61
|
+
if (snapshot === lastSyncedSnapshot) return;
|
|
62
|
+
lastSyncedSnapshot = snapshot;
|
|
63
|
+
if (inFlightAbort) {
|
|
64
|
+
inFlightAbort.abort();
|
|
65
|
+
inFlightAbort = null;
|
|
66
|
+
}
|
|
67
|
+
syncing = true;
|
|
68
|
+
const abort = new AbortController();
|
|
69
|
+
inFlightAbort = abort;
|
|
70
|
+
try {
|
|
71
|
+
fs.mkdirSync(i18nDir, { recursive: true });
|
|
72
|
+
await Promise.allSettled(
|
|
73
|
+
locales.map(
|
|
74
|
+
(locale) => syncLocaleFile({
|
|
75
|
+
locale,
|
|
76
|
+
i18nDir,
|
|
77
|
+
allKeys,
|
|
78
|
+
provider,
|
|
79
|
+
prune: true,
|
|
80
|
+
retranslateEmpty: true,
|
|
81
|
+
signal: abort.signal
|
|
82
|
+
})
|
|
83
|
+
)
|
|
84
|
+
);
|
|
85
|
+
} finally {
|
|
86
|
+
syncing = false;
|
|
87
|
+
inFlightAbort = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
name: "nativtongue",
|
|
92
|
+
configResolved() {
|
|
93
|
+
const rc = loadRcConfig();
|
|
94
|
+
srcDir = path.resolve(process.cwd(), options?.srcDir ?? rc.srcDir ?? "./src");
|
|
95
|
+
i18nDir = path.resolve(process.cwd(), options?.i18nDir ?? rc.i18nDir ?? "./public/i18n");
|
|
96
|
+
const rawLocales = options?.locales ?? rc.locales ?? [];
|
|
97
|
+
const invalid = rawLocales.filter((l) => !isValidLocale(l));
|
|
98
|
+
if (invalid.length > 0) {
|
|
99
|
+
console.warn(`[nativtongue] Invalid locale codes ignored: ${invalid.join(", ")}`);
|
|
100
|
+
}
|
|
101
|
+
locales = rawLocales.filter((l) => isValidLocale(l));
|
|
102
|
+
},
|
|
103
|
+
configureServer() {
|
|
104
|
+
initialScan().then(() => {
|
|
105
|
+
if (locales.length > 0) runSync();
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
handleHotUpdate({ file }) {
|
|
109
|
+
if (!file.startsWith(srcDir)) return;
|
|
110
|
+
if (!file.match(/\.(tsx?|jsx?)$/)) return;
|
|
111
|
+
if (file.startsWith(i18nDir)) return;
|
|
112
|
+
if (syncing) return;
|
|
113
|
+
updateFileCache(file);
|
|
114
|
+
scheduleSync();
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export {
|
|
119
|
+
tongueSync
|
|
120
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nativtongue-cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "CLI and dev tooling for nativtongue — extract, sync, and translate",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/cli.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/cli.js",
|
|
10
|
+
"./sync": {
|
|
11
|
+
"types": "./dist/tongue-sync.d.ts",
|
|
12
|
+
"import": "./dist/tongue-sync.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"bin": {
|
|
19
|
+
"nativtongue": "./dist/cli.js"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "rm -rf dist && tsup src/cli.ts src/tongue-sync.ts --format esm --dts --external vite",
|
|
23
|
+
"dev": "tsup src/cli.ts src/tongue-sync.ts --format esm --dts --external vite --watch",
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"glob": "^11.0.2"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^24.12.2",
|
|
31
|
+
"tsup": "^8.5.1",
|
|
32
|
+
"typescript": "^5.9.3",
|
|
33
|
+
"vite": "^8.0.8",
|
|
34
|
+
"vitest": "^4.1.4"
|
|
35
|
+
},
|
|
36
|
+
"packageManager": "pnpm@10.33.0"
|
|
37
|
+
}
|