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 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,10 @@
1
+ import { Plugin } from 'vite';
2
+
3
+ interface TongueSyncOptions {
4
+ srcDir?: string;
5
+ i18nDir?: string;
6
+ locales?: string[];
7
+ }
8
+ declare function tongueSync(options?: TongueSyncOptions): Plugin;
9
+
10
+ export { type TongueSyncOptions, tongueSync };
@@ -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
+ }