@vocoder/cli 0.1.1 → 0.1.3

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.
@@ -1,635 +0,0 @@
1
- // src/utils/branch.ts
2
- import { execSync } from "child_process";
3
- function detectBranch(override) {
4
- if (override) {
5
- return override;
6
- }
7
- const envBranch = process.env.GITHUB_REF_NAME || // GitHub Actions
8
- process.env.VERCEL_GIT_COMMIT_REF || // Vercel
9
- process.env.BRANCH || // Netlify, generic
10
- process.env.CI_COMMIT_REF_NAME || // GitLab
11
- process.env.BITBUCKET_BRANCH || // Bitbucket
12
- process.env.CIRCLE_BRANCH;
13
- if (envBranch) {
14
- return envBranch;
15
- }
16
- try {
17
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
18
- encoding: "utf-8",
19
- stdio: ["pipe", "pipe", "ignore"]
20
- }).trim();
21
- return branch;
22
- } catch (error) {
23
- throw new Error(
24
- "Failed to detect git branch. Make sure you are in a git repository or set the --branch flag."
25
- );
26
- }
27
- }
28
- function isTargetBranch(currentBranch, targetBranches) {
29
- return targetBranches.includes(currentBranch);
30
- }
31
-
32
- // src/utils/config.ts
33
- import { config as loadEnv } from "dotenv";
34
- loadEnv();
35
- function getLocalConfig() {
36
- const apiKey = process.env.VOCODER_API_KEY;
37
- if (!apiKey) {
38
- throw new Error(
39
- 'VOCODER_API_KEY is required. Set it in your .env file or environment:\n export VOCODER_API_KEY="your-api-key"\n\nGet your API key from: https://vocoder.app/settings/api-keys'
40
- );
41
- }
42
- return {
43
- apiKey,
44
- apiUrl: process.env.VOCODER_API_URL || "https://vocoder.app"
45
- };
46
- }
47
- function validateLocalConfig(config) {
48
- if (!config.apiKey || config.apiKey.length === 0) {
49
- throw new Error("Invalid API key");
50
- }
51
- if (!config.apiKey.startsWith("vc_")) {
52
- throw new Error("Invalid API key format. Expected format: vc_...");
53
- }
54
- if (!config.apiUrl || !config.apiUrl.startsWith("http")) {
55
- throw new Error("Invalid API URL");
56
- }
57
- }
58
-
59
- // src/commands/sync.ts
60
- import { mkdirSync, writeFileSync } from "fs";
61
- import { join, dirname } from "path";
62
- import chalk from "chalk";
63
- import ora from "ora";
64
-
65
- // src/utils/api.ts
66
- var VocoderAPI = class {
67
- constructor(config) {
68
- this.apiUrl = config.apiUrl;
69
- this.apiKey = config.apiKey;
70
- }
71
- /**
72
- * Fetch project configuration from API
73
- * Project is determined from the API key
74
- */
75
- async getProjectConfig() {
76
- const response = await fetch(
77
- `${this.apiUrl}/api/cli/config`,
78
- {
79
- headers: {
80
- Authorization: `Bearer ${this.apiKey}`
81
- }
82
- }
83
- );
84
- if (!response.ok) {
85
- const error = await response.text();
86
- throw new Error(`Failed to fetch project config: ${error}`);
87
- }
88
- const data = await response.json();
89
- return {
90
- sourceLocale: data.sourceLocale,
91
- targetLocales: data.targetLocales,
92
- targetBranches: data.targetBranches
93
- };
94
- }
95
- /**
96
- * Submit strings for translation
97
- * Project is determined from the API key
98
- */
99
- async submitTranslation(branch, strings, targetLocales) {
100
- const crypto = await import("crypto");
101
- const sortedStrings = [...strings].sort();
102
- const stringsHash = crypto.createHash("sha256").update(JSON.stringify(sortedStrings)).digest("hex");
103
- const response = await fetch(`${this.apiUrl}/api/cli/sync`, {
104
- method: "POST",
105
- headers: {
106
- "Content-Type": "application/json",
107
- Authorization: `Bearer ${this.apiKey}`
108
- },
109
- body: JSON.stringify({
110
- branch,
111
- strings,
112
- targetLocales,
113
- stringsHash
114
- })
115
- });
116
- if (!response.ok) {
117
- const error = await response.text();
118
- throw new Error(`Translation submission failed: ${error}`);
119
- }
120
- return response.json();
121
- }
122
- /**
123
- * Check translation status
124
- */
125
- async getTranslationStatus(batchId) {
126
- const response = await fetch(
127
- `${this.apiUrl}/api/cli/sync/status/${batchId}`,
128
- {
129
- headers: {
130
- Authorization: `Bearer ${this.apiKey}`
131
- }
132
- }
133
- );
134
- if (!response.ok) {
135
- const error = await response.text();
136
- throw new Error(`Failed to check translation status: ${error}`);
137
- }
138
- return response.json();
139
- }
140
- /**
141
- * Wait for translation to complete with polling
142
- */
143
- async waitForCompletion(batchId, timeout = 6e4, onProgress) {
144
- const startTime = Date.now();
145
- const pollInterval = 1e3;
146
- while (Date.now() - startTime < timeout) {
147
- const status = await this.getTranslationStatus(batchId);
148
- if (onProgress) {
149
- onProgress(status.progress);
150
- }
151
- if (status.status === "COMPLETED") {
152
- if (!status.translations) {
153
- throw new Error("Translation completed but no translations returned");
154
- }
155
- return {
156
- translations: status.translations,
157
- localeMetadata: status.localeMetadata
158
- };
159
- }
160
- if (status.status === "FAILED") {
161
- throw new Error(
162
- `Translation failed: ${status.errorMessage || "Unknown error"}`
163
- );
164
- }
165
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
166
- }
167
- throw new Error(`Translation timeout after ${timeout}ms`);
168
- }
169
- };
170
-
171
- // src/utils/extract.ts
172
- import { readFileSync } from "fs";
173
- import { parse } from "@babel/parser";
174
- import babelTraverse from "@babel/traverse";
175
- import { glob } from "glob";
176
- var traverse = babelTraverse.default || babelTraverse;
177
- var StringExtractor = class {
178
- /**
179
- * Extract strings from all files matching the pattern
180
- */
181
- async extractFromProject(pattern, projectRoot = process.cwd()) {
182
- const files = await glob(pattern, {
183
- cwd: projectRoot,
184
- absolute: true,
185
- ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"]
186
- });
187
- const allStrings = [];
188
- for (const file of files) {
189
- try {
190
- const strings = await this.extractFromFile(file);
191
- allStrings.push(...strings);
192
- } catch (error) {
193
- console.warn(`Warning: Failed to extract from ${file}:`, error);
194
- }
195
- }
196
- const unique = this.deduplicateStrings(allStrings);
197
- return unique;
198
- }
199
- /**
200
- * Extract strings from a single file
201
- */
202
- async extractFromFile(filePath) {
203
- const code = readFileSync(filePath, "utf-8");
204
- const strings = [];
205
- try {
206
- const ast = parse(code, {
207
- sourceType: "module",
208
- plugins: ["jsx", "typescript"]
209
- });
210
- const vocoderImports = /* @__PURE__ */ new Map();
211
- const tFunctionNames = /* @__PURE__ */ new Set();
212
- traverse(ast, {
213
- // Track imports of <T> component and t function
214
- ImportDeclaration: (path) => {
215
- const source = path.node.source.value;
216
- if (source === "@vocoder/react") {
217
- path.node.specifiers.forEach((spec) => {
218
- if (spec.type === "ImportSpecifier") {
219
- const imported = spec.imported.type === "Identifier" ? spec.imported.name : null;
220
- const local = spec.local.name;
221
- if (imported === "T") {
222
- vocoderImports.set(local, "T");
223
- }
224
- if (imported === "t") {
225
- tFunctionNames.add(local);
226
- }
227
- if (imported === "useVocoder") {
228
- }
229
- }
230
- });
231
- }
232
- },
233
- // Track destructured 't' from useVocoder hook
234
- VariableDeclarator: (path) => {
235
- const init = path.node.init;
236
- if (init && init.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "useVocoder" && path.node.id.type === "ObjectPattern") {
237
- path.node.id.properties.forEach((prop) => {
238
- if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "t") {
239
- const localName = prop.value.type === "Identifier" ? prop.value.name : "t";
240
- tFunctionNames.add(localName);
241
- }
242
- });
243
- }
244
- },
245
- // Extract from t() function calls
246
- CallExpression: (path) => {
247
- const callee = path.node.callee;
248
- const isTFunction = callee.type === "Identifier" && tFunctionNames.has(callee.name);
249
- if (!isTFunction) return;
250
- const firstArg = path.node.arguments[0];
251
- if (!firstArg) return;
252
- let text = null;
253
- if (firstArg.type === "StringLiteral") {
254
- text = firstArg.value;
255
- } else if (firstArg.type === "TemplateLiteral") {
256
- text = this.extractTemplateText(firstArg);
257
- }
258
- if (!text || text.trim().length === 0) return;
259
- const secondArg = path.node.arguments[1];
260
- let context;
261
- let formality;
262
- if (secondArg && secondArg.type === "ObjectExpression") {
263
- secondArg.properties.forEach((prop) => {
264
- if (prop.type === "ObjectProperty" && prop.key.type === "Identifier") {
265
- if (prop.key.name === "context" && prop.value.type === "StringLiteral") {
266
- context = prop.value.value;
267
- }
268
- if (prop.key.name === "formality" && prop.value.type === "StringLiteral") {
269
- formality = prop.value.value;
270
- }
271
- }
272
- });
273
- }
274
- strings.push({
275
- text: text.trim(),
276
- file: filePath,
277
- line: path.node.loc?.start.line || 0,
278
- context,
279
- formality
280
- });
281
- },
282
- // Extract from JSX elements
283
- JSXElement: (path) => {
284
- const opening = path.node.openingElement;
285
- const tagName = opening.name.type === "JSXIdentifier" ? opening.name.name : null;
286
- if (!tagName) return;
287
- const isTranslationComponent = vocoderImports.has(tagName);
288
- if (!isTranslationComponent) return;
289
- const text = this.extractTextContent(path.node.children);
290
- if (!text || text.trim().length === 0) return;
291
- const context = this.getStringAttribute(opening.attributes, "context");
292
- const formality = this.getStringAttribute(
293
- opening.attributes,
294
- "formality"
295
- );
296
- strings.push({
297
- text: text.trim(),
298
- file: filePath,
299
- line: path.node.loc?.start.line || 0,
300
- context,
301
- formality
302
- });
303
- }
304
- });
305
- } catch (error) {
306
- throw new Error(
307
- `Failed to parse ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`
308
- );
309
- }
310
- return strings;
311
- }
312
- /**
313
- * Extract text from template literal
314
- * Converts template literals like `Hello ${name}` to `Hello {name}`
315
- */
316
- extractTemplateText(node) {
317
- let text = "";
318
- for (let i = 0; i < node.quasis.length; i++) {
319
- const quasi = node.quasis[i];
320
- text += quasi.value.raw;
321
- if (i < node.expressions.length) {
322
- const expr = node.expressions[i];
323
- if (expr.type === "Identifier") {
324
- text += `{${expr.name}}`;
325
- } else {
326
- text += "{value}";
327
- }
328
- }
329
- }
330
- return text;
331
- }
332
- /**
333
- * Extract text content from JSX children
334
- */
335
- extractTextContent(children) {
336
- let text = "";
337
- for (const child of children) {
338
- if (child.type === "JSXText") {
339
- text += child.value;
340
- } else if (child.type === "JSXExpressionContainer") {
341
- const expr = child.expression;
342
- if (expr.type === "Identifier") {
343
- text += `{${expr.name}}`;
344
- } else if (expr.type === "StringLiteral") {
345
- text += expr.value;
346
- } else if (expr.type === "TemplateLiteral") {
347
- text += this.extractTemplateText(expr);
348
- }
349
- }
350
- }
351
- return text;
352
- }
353
- /**
354
- * Get string value from JSX attribute
355
- */
356
- getStringAttribute(attributes, name) {
357
- const attr = attributes.find(
358
- (a) => a.type === "JSXAttribute" && a.name.name === name
359
- );
360
- if (!attr || !attr.value) return void 0;
361
- if (attr.value.type === "StringLiteral") {
362
- return attr.value.value;
363
- }
364
- return void 0;
365
- }
366
- /**
367
- * Deduplicate strings (keep first occurrence)
368
- */
369
- deduplicateStrings(strings) {
370
- const seen = /* @__PURE__ */ new Set();
371
- const unique = [];
372
- for (const str of strings) {
373
- const key = `${str.text}|${str.context || ""}|${str.formality || ""}`;
374
- if (!seen.has(key)) {
375
- seen.add(key);
376
- unique.push(str);
377
- }
378
- }
379
- return unique;
380
- }
381
- };
382
-
383
- // src/commands/sync.ts
384
- function generateIndexFile(locales, translations, localeMetadata) {
385
- const toIdentifier = (locale) => locale.replace(/-/g, "_");
386
- const imports = locales.map(
387
- (locale) => `import ${toIdentifier(locale)} from './${locale}.json';`
388
- ).join("\n");
389
- const translationsObj = locales.map(
390
- (locale) => ` '${locale}': ${toIdentifier(locale)},`
391
- ).join("\n");
392
- const localesObjEntries = locales.map((locale) => {
393
- const metadata = localeMetadata?.[locale];
394
- if (metadata) {
395
- const escapedNativeName = metadata.nativeName.replace(/'/g, "\\'");
396
- const dirProp = metadata.dir ? `, dir: '${metadata.dir}' as const` : "";
397
- return ` '${locale}': { nativeName: '${escapedNativeName}'${dirProp} }`;
398
- } else {
399
- return ` '${locale}': { nativeName: '${locale}' }`;
400
- }
401
- });
402
- const localesObjString = localesObjEntries.join(",\n");
403
- return `// Auto-generated by Vocoder CLI
404
- // This file imports all locale JSON files and exports them as a single object
405
- // Usage: import { translations, locales } from './.vocoder/locales';
406
-
407
- ${imports}
408
-
409
- export const translations = {
410
- ${translationsObj}
411
- };
412
-
413
- /**
414
- * Flat locale metadata map (O(N))
415
- * Structure: locales[localeCode] = { nativeName, dir? }
416
- * - nativeName: Name in the locale's own language (e.g., "Espa\xF1ol", "\u7B80\u4F53\u4E2D\u6587")
417
- * - dir: Optional 'rtl' for right-to-left locales
418
- *
419
- * Translated names are generated at runtime using Intl.DisplayNames:
420
- * Example: new Intl.DisplayNames(['es'], { type: 'language' }).of('en') \u2192 "ingl\xE9s"
421
- * Display format: \`\${getDisplayName(code)} (\${locales[code].nativeName})\` \u2192 "ingl\xE9s (English)"
422
- */
423
- export const locales = {
424
- ${localesObjString}
425
- };
426
-
427
- export type SupportedLocale = ${locales.map((l) => `'${l}'`).join(" | ")};
428
- `;
429
- }
430
- async function sync(options = {}) {
431
- const startTime = Date.now();
432
- const projectRoot = process.cwd();
433
- try {
434
- const spinner = ora("Detecting branch...").start();
435
- const branch = detectBranch(options.branch);
436
- spinner.succeed(`Detected branch: ${chalk.cyan(branch)}`);
437
- spinner.start("Loading project configuration...");
438
- const localConfig = getLocalConfig();
439
- validateLocalConfig(localConfig);
440
- const api = new VocoderAPI(localConfig);
441
- const apiConfig = await api.getProjectConfig();
442
- const config = {
443
- ...localConfig,
444
- ...apiConfig,
445
- extractionPattern: process.env.VOCODER_EXTRACTION_PATTERN || "src/**/*.{tsx,jsx,ts,js}",
446
- outputDir: ".vocoder/locales",
447
- timeout: 6e4
448
- };
449
- spinner.succeed("Project configuration loaded");
450
- if (!options.force && !isTargetBranch(branch, config.targetBranches)) {
451
- console.log(
452
- chalk.yellow(
453
- `\u2139\uFE0F Skipping translations (${branch} is not a target branch)`
454
- )
455
- );
456
- console.log(
457
- chalk.dim(
458
- ` Target branches: ${config.targetBranches.join(", ")}`
459
- )
460
- );
461
- console.log(chalk.dim(` Use --force to translate anyway`));
462
- process.exit(0);
463
- }
464
- spinner.start(`Extracting strings from ${config.extractionPattern}...`);
465
- const extractor = new StringExtractor();
466
- const extractedStrings = await extractor.extractFromProject(
467
- config.extractionPattern,
468
- projectRoot
469
- );
470
- if (extractedStrings.length === 0) {
471
- spinner.warn("No translatable strings found");
472
- console.log(chalk.yellow("Make sure you are using <T> components from @vocoder/react"));
473
- process.exit(0);
474
- }
475
- spinner.succeed(
476
- `Extracted ${chalk.cyan(extractedStrings.length)} strings from ${chalk.cyan(config.extractionPattern)}`
477
- );
478
- if (options.verbose) {
479
- console.log(chalk.dim("\nSample strings:"));
480
- extractedStrings.slice(0, 5).forEach((s) => {
481
- console.log(chalk.dim(` - "${s.text}" (${s.file}:${s.line})`));
482
- });
483
- if (extractedStrings.length > 5) {
484
- console.log(chalk.dim(` ... and ${extractedStrings.length - 5} more`));
485
- }
486
- console.log();
487
- }
488
- if (options.dryRun) {
489
- console.log(chalk.cyan("\n\u{1F4CB} Dry run mode - would translate:"));
490
- console.log(chalk.dim(` Strings: ${extractedStrings.length}`));
491
- console.log(chalk.dim(` Branch: ${branch}`));
492
- console.log(chalk.dim(` Target locales: ${config.targetLocales.join(", ")}`));
493
- console.log(chalk.dim(`
494
- No API calls made.`));
495
- process.exit(0);
496
- }
497
- spinner.start("Submitting strings to Vocoder API...");
498
- const strings = extractedStrings.map((s) => s.text);
499
- const batchResponse = await api.submitTranslation(
500
- branch,
501
- strings,
502
- config.targetLocales
503
- );
504
- spinner.succeed(
505
- `Submitted to API - Batch ID: ${chalk.cyan(batchResponse.batchId)}`
506
- );
507
- if (batchResponse.status === "UP_TO_DATE" && batchResponse.noChanges) {
508
- console.log(chalk.green("\n\u2714 No changes detected - strings are up to date"));
509
- console.log(chalk.dim(" (Files will be written for build environment)\n"));
510
- }
511
- console.log(
512
- chalk.dim(
513
- ` New strings: ${chalk.cyan(batchResponse.newStrings)}`
514
- )
515
- );
516
- if (batchResponse.deletedStrings && batchResponse.deletedStrings > 0) {
517
- console.log(
518
- chalk.dim(
519
- ` Deleted strings: ${chalk.yellow(batchResponse.deletedStrings)} (archived)`
520
- )
521
- );
522
- }
523
- console.log(
524
- chalk.dim(
525
- ` Total strings: ${chalk.cyan(batchResponse.totalStrings)}`
526
- )
527
- );
528
- if (batchResponse.newStrings === 0) {
529
- console.log(
530
- chalk.green("\n\u2705 No new strings - using existing translations")
531
- );
532
- } else {
533
- console.log(
534
- chalk.cyan(
535
- `
536
- \u23F3 Syncing to ${config.targetLocales.length} locales (${config.targetLocales.join(", ")})`
537
- )
538
- );
539
- if (batchResponse.estimatedTime) {
540
- console.log(
541
- chalk.dim(
542
- ` Estimated time: ~${batchResponse.estimatedTime} seconds`
543
- )
544
- );
545
- }
546
- }
547
- spinner.start("Waiting for translations to complete...");
548
- let lastProgress = 0;
549
- const result = await api.waitForCompletion(
550
- batchResponse.batchId,
551
- config.timeout,
552
- (progress) => {
553
- const percent = Math.round(progress * 100);
554
- if (percent > lastProgress) {
555
- spinner.text = `Syncing... ${percent}% complete`;
556
- lastProgress = percent;
557
- }
558
- }
559
- );
560
- const { translations, localeMetadata: apiLocaleMetadata } = result;
561
- spinner.succeed("Translations complete!");
562
- spinner.start(`Writing locale files to ${config.outputDir}...`);
563
- const outputPath = join(projectRoot, config.outputDir);
564
- mkdirSync(outputPath, { recursive: true });
565
- let filesWritten = 0;
566
- const localeNames = [];
567
- for (const [locale, strings2] of Object.entries(translations)) {
568
- const filePath = join(outputPath, `${locale}.json`);
569
- const content = JSON.stringify(strings2, null, 2);
570
- mkdirSync(dirname(filePath), { recursive: true });
571
- writeFileSync(filePath, content, "utf-8");
572
- filesWritten++;
573
- localeNames.push(locale);
574
- const sizeKB = (content.length / 1024).toFixed(1);
575
- console.log(
576
- chalk.dim(` \u2713 Wrote ${locale}.json (${sizeKB}KB)`)
577
- );
578
- }
579
- const indexContent = generateIndexFile(localeNames, translations, apiLocaleMetadata);
580
- const indexPath = join(outputPath, "index.ts");
581
- writeFileSync(indexPath, indexContent, "utf-8");
582
- console.log(chalk.dim(` \u2713 Generated index.ts (with flat locales map)`));
583
- spinner.succeed(`Wrote ${chalk.cyan(filesWritten)} locale files`);
584
- const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
585
- console.log(
586
- chalk.green(`
587
- \u2705 Sync complete! (${duration}s)
588
- `)
589
- );
590
- console.log(chalk.dim("Next steps:"));
591
- console.log(
592
- chalk.dim(
593
- ` 1. Import translations: import { translations } from '${config.outputDir}'`
594
- )
595
- );
596
- console.log(
597
- chalk.dim(
598
- ` 2. Use VocoderProvider: <VocoderProvider translations={translations} defaultLocale="en">`
599
- )
600
- );
601
- console.log(
602
- chalk.dim(
603
- ` 3. Commit ${config.outputDir}/ to your repository`
604
- )
605
- );
606
- } catch (error) {
607
- if (error instanceof Error) {
608
- console.error(chalk.red(`
609
- \u274C Error: ${error.message}
610
- `));
611
- if (error.message.includes("VOCODER_API_KEY")) {
612
- console.log(chalk.yellow("\u{1F4A1} Solution:"));
613
- console.log(chalk.dim(" Set your API key:"));
614
- console.log(chalk.dim(' export VOCODER_API_KEY="your-api-key"'));
615
- console.log(chalk.dim(" or add it to your .env file"));
616
- } else if (error.message.includes("git branch")) {
617
- console.log(chalk.yellow("\u{1F4A1} Solution:"));
618
- console.log(chalk.dim(" Run from a git repository, or use:"));
619
- console.log(chalk.dim(" vocoder translate --branch main"));
620
- }
621
- if (options.verbose) {
622
- console.error(chalk.dim("\nFull error:"), error);
623
- }
624
- }
625
- process.exit(1);
626
- }
627
- }
628
-
629
- export {
630
- detectBranch,
631
- getLocalConfig,
632
- validateLocalConfig,
633
- sync
634
- };
635
- //# sourceMappingURL=chunk-N45Q4R6O.mjs.map