@vocoder/cli 0.1.2 → 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.
package/dist/bin.mjs CHANGED
@@ -1,12 +1,2858 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- sync
4
- } from "./chunk-N45Q4R6O.mjs";
5
2
 
6
3
  // src/bin.ts
7
4
  import { Command } from "commander";
5
+
6
+ // src/commands/init.ts
7
+ import * as p from "@clack/prompts";
8
+
9
+ // src/utils/api.ts
10
+ function isLimitErrorResponse(value) {
11
+ if (!value || typeof value !== "object") {
12
+ return false;
13
+ }
14
+ const candidate = value;
15
+ return typeof candidate.errorCode === "string" && typeof candidate.limitType === "string" && typeof candidate.planId === "string" && typeof candidate.current === "number" && typeof candidate.required === "number" && typeof candidate.upgradeUrl === "string" && typeof candidate.message === "string";
16
+ }
17
+ function isSyncPolicyErrorResponse(value) {
18
+ if (!value || typeof value !== "object") {
19
+ return false;
20
+ }
21
+ const candidate = value;
22
+ return (candidate.errorCode === "BRANCH_NOT_ALLOWED" || candidate.errorCode === "PROJECT_REPOSITORY_MISMATCH") && typeof candidate.message === "string";
23
+ }
24
+ function extractErrorMessage(payload, fallback) {
25
+ if (!payload || typeof payload !== "object") {
26
+ return fallback;
27
+ }
28
+ const candidate = payload;
29
+ if (typeof candidate.message === "string") {
30
+ return candidate.message;
31
+ }
32
+ if (typeof candidate.error === "string") {
33
+ return candidate.error;
34
+ }
35
+ return fallback;
36
+ }
37
+ function parsePayload(raw) {
38
+ if (raw.length === 0) {
39
+ return null;
40
+ }
41
+ try {
42
+ return JSON.parse(raw);
43
+ } catch {
44
+ return { message: raw };
45
+ }
46
+ }
47
+ async function readPayload(response) {
48
+ if (typeof response.text === "function") {
49
+ const raw = await response.text();
50
+ return parsePayload(raw);
51
+ }
52
+ if (typeof response.json === "function") {
53
+ return response.json();
54
+ }
55
+ return null;
56
+ }
57
+ var VocoderAPIError = class extends Error {
58
+ constructor(params) {
59
+ super(params.message);
60
+ this.name = "VocoderAPIError";
61
+ this.status = params.status;
62
+ this.payload = params.payload;
63
+ this.limitError = params.limitError ?? null;
64
+ this.syncPolicyError = params.syncPolicyError ?? null;
65
+ }
66
+ };
67
+ var VocoderAPI = class {
68
+ constructor(config) {
69
+ this.apiUrl = config.apiUrl;
70
+ this.apiKey = config.apiKey;
71
+ }
72
+ async request(path, init2 = {}, errorPrefix) {
73
+ const response = await fetch(`${this.apiUrl}${path}`, {
74
+ ...init2,
75
+ headers: {
76
+ Authorization: `Bearer ${this.apiKey}`,
77
+ ...init2.headers ?? {}
78
+ }
79
+ });
80
+ const payload = await readPayload(response);
81
+ if (!response.ok) {
82
+ const limitError = isLimitErrorResponse(payload) ? payload : null;
83
+ const syncPolicyError = isSyncPolicyErrorResponse(payload) ? payload : null;
84
+ const baseMessage = extractErrorMessage(payload, `Request failed with status ${response.status}`);
85
+ throw new VocoderAPIError({
86
+ message: errorPrefix ? `${errorPrefix}: ${baseMessage}` : baseMessage,
87
+ status: response.status,
88
+ payload,
89
+ limitError,
90
+ syncPolicyError
91
+ });
92
+ }
93
+ return payload;
94
+ }
95
+ /**
96
+ * Fetch project configuration from API
97
+ * Project is determined from the API key
98
+ */
99
+ async getProjectConfig() {
100
+ const data = await this.request("/api/cli/config", {}, "Failed to fetch project config");
101
+ return {
102
+ projectName: data.projectName,
103
+ organizationName: data.organizationName,
104
+ sourceLocale: data.sourceLocale,
105
+ targetLocales: data.targetLocales,
106
+ targetBranches: data.targetBranches,
107
+ syncPolicy: {
108
+ blockingBranches: data.syncPolicy?.blockingBranches ?? ["main", "master"],
109
+ blockingMode: data.syncPolicy?.blockingMode ?? "required",
110
+ nonBlockingMode: data.syncPolicy?.nonBlockingMode ?? "best-effort",
111
+ defaultMaxWaitMs: data.syncPolicy?.defaultMaxWaitMs ?? 6e4
112
+ }
113
+ };
114
+ }
115
+ /**
116
+ * Submit strings for translation
117
+ * Project is determined from the API key
118
+ */
119
+ stableTextKey(text) {
120
+ let hash = 2166136261;
121
+ for (let i = 0; i < text.length; i++) {
122
+ hash ^= text.charCodeAt(i);
123
+ hash = Math.imul(hash, 16777619);
124
+ }
125
+ return `SK_TEXT_${(hash >>> 0).toString(16).toUpperCase().padStart(8, "0")}`;
126
+ }
127
+ normalizeStringEntries(entries) {
128
+ if (entries.length === 0) {
129
+ return [];
130
+ }
131
+ const first = entries[0];
132
+ if (typeof first === "string") {
133
+ return entries.map((text) => ({
134
+ key: this.stableTextKey(text),
135
+ text
136
+ }));
137
+ }
138
+ return entries.map((entry, index) => ({
139
+ key: entry.key || this.stableTextKey(`${entry.text}:${index}`),
140
+ text: entry.text,
141
+ ...entry.context ? { context: entry.context } : {},
142
+ ...entry.formality ? { formality: entry.formality } : {}
143
+ }));
144
+ }
145
+ async submitTranslation(branch, entries, targetLocales, options, repoIdentity) {
146
+ const stringEntries = this.normalizeStringEntries(entries);
147
+ const strings = stringEntries.map((entry) => entry.text);
148
+ const crypto = await import("crypto");
149
+ const sortedStrings = [...strings].sort();
150
+ const stringsHash = crypto.createHash("sha256").update(JSON.stringify(sortedStrings)).digest("hex");
151
+ return this.request("/api/cli/sync", {
152
+ method: "POST",
153
+ headers: {
154
+ "Content-Type": "application/json"
155
+ },
156
+ body: JSON.stringify({
157
+ branch,
158
+ stringEntries,
159
+ targetLocales,
160
+ stringsHash,
161
+ ...options?.requestedMode ? { requestedMode: options.requestedMode } : {},
162
+ ...typeof options?.requestedMaxWaitMs === "number" ? { requestedMaxWaitMs: options.requestedMaxWaitMs } : {},
163
+ ...options?.clientRunId ? { clientRunId: options.clientRunId } : {},
164
+ ...repoIdentity?.repoCanonical ? { repoCanonical: repoIdentity.repoCanonical } : {},
165
+ ...repoIdentity?.repoScopePath !== void 0 ? { repoScopePath: repoIdentity.repoScopePath } : {}
166
+ })
167
+ }, "Translation submission failed");
168
+ }
169
+ /**
170
+ * Check translation status
171
+ */
172
+ async getTranslationStatus(batchId) {
173
+ return this.request(
174
+ `/api/cli/sync/status/${batchId}`,
175
+ {},
176
+ "Failed to check translation status"
177
+ );
178
+ }
179
+ async getTranslationSnapshot(params) {
180
+ const search = new URLSearchParams();
181
+ search.set("branch", params.branch);
182
+ for (const locale of params.targetLocales) {
183
+ search.append("targetLocale", locale);
184
+ }
185
+ return this.request(
186
+ `/api/cli/sync/snapshot?${search.toString()}`,
187
+ {},
188
+ "Failed to fetch translation snapshot"
189
+ );
190
+ }
191
+ /**
192
+ * Wait for translation to complete with polling
193
+ */
194
+ async waitForCompletion(batchId, timeout = 6e4, onProgress) {
195
+ const startTime = Date.now();
196
+ const pollInterval = 1e3;
197
+ while (Date.now() - startTime < timeout) {
198
+ const status = await this.getTranslationStatus(batchId);
199
+ if (onProgress) {
200
+ onProgress(status.progress);
201
+ }
202
+ if (status.status === "COMPLETED") {
203
+ if (!status.translations) {
204
+ throw new Error("Translation completed but no translations returned");
205
+ }
206
+ return {
207
+ translations: status.translations,
208
+ localeMetadata: status.localeMetadata
209
+ };
210
+ }
211
+ if (status.status === "FAILED") {
212
+ throw new Error(
213
+ `Translation failed: ${status.errorMessage || "Unknown error"}`
214
+ );
215
+ }
216
+ await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
217
+ }
218
+ throw new Error(`Translation timeout after ${timeout}ms`);
219
+ }
220
+ async startInitSession(input) {
221
+ const response = await fetch(`${this.apiUrl}/api/cli/init/start`, {
222
+ method: "POST",
223
+ headers: {
224
+ "Content-Type": "application/json"
225
+ },
226
+ body: JSON.stringify(input)
227
+ });
228
+ const payload = await readPayload(response);
229
+ if (!response.ok) {
230
+ throw new VocoderAPIError({
231
+ message: extractErrorMessage(payload, `Failed to start init session (${response.status})`),
232
+ status: response.status,
233
+ payload
234
+ });
235
+ }
236
+ return payload;
237
+ }
238
+ async getInitSessionStatus(params) {
239
+ const response = await fetch(
240
+ `${this.apiUrl}/api/cli/init/status/${params.sessionId}`,
241
+ {
242
+ headers: {
243
+ Authorization: `Bearer ${params.pollToken}`
244
+ }
245
+ }
246
+ );
247
+ const payload = await readPayload(response);
248
+ if (!response.ok) {
249
+ throw new VocoderAPIError({
250
+ message: extractErrorMessage(payload, `Failed to get init status (${response.status})`),
251
+ status: response.status,
252
+ payload
253
+ });
254
+ }
255
+ return payload;
256
+ }
257
+ };
258
+
259
+ // src/commands/init.ts
260
+ import { existsSync, readFileSync, writeFileSync } from "fs";
261
+ import { join } from "path";
262
+
263
+ // src/utils/git-identity.ts
264
+ import { execSync } from "child_process";
265
+ import { relative, resolve } from "path";
266
+ function safeExec(command) {
267
+ try {
268
+ const output = execSync(command, {
269
+ encoding: "utf-8",
270
+ stdio: ["pipe", "pipe", "ignore"]
271
+ }).trim();
272
+ return output.length > 0 ? output : null;
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+ function normalizePath(pathname) {
278
+ const cleaned = pathname.replace(/^\/+/, "").replace(/\.git$/i, "").trim();
279
+ if (!cleaned || !cleaned.includes("/")) {
280
+ return null;
281
+ }
282
+ return cleaned;
283
+ }
284
+ function parseRemoteUrl(remoteUrl) {
285
+ const trimmed = remoteUrl.trim();
286
+ if (!trimmed) {
287
+ return null;
288
+ }
289
+ if (!trimmed.includes("://")) {
290
+ const scpMatch = trimmed.match(/^(?:.+@)?([^:]+):(.+)$/);
291
+ if (scpMatch) {
292
+ const host = (scpMatch[1] || "").toLowerCase();
293
+ const ownerRepoPath = normalizePath(scpMatch[2] || "");
294
+ if (!host || !ownerRepoPath) {
295
+ return null;
296
+ }
297
+ return { host, ownerRepoPath };
298
+ }
299
+ return null;
300
+ }
301
+ try {
302
+ const parsed = new URL(trimmed);
303
+ const host = parsed.hostname.toLowerCase();
304
+ const ownerRepoPath = normalizePath(decodeURIComponent(parsed.pathname));
305
+ if (!host || !ownerRepoPath) {
306
+ return null;
307
+ }
308
+ return { host, ownerRepoPath };
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+ function toCanonical(host, ownerRepoPath) {
314
+ if (host.includes("github.com")) {
315
+ return `github:${ownerRepoPath.toLowerCase()}`;
316
+ }
317
+ if (host.includes("gitlab.com")) {
318
+ return `gitlab:${ownerRepoPath.toLowerCase()}`;
319
+ }
320
+ if (host.includes("bitbucket.org")) {
321
+ return `bitbucket:${ownerRepoPath.toLowerCase()}`;
322
+ }
323
+ return `git:${host}/${ownerRepoPath.toLowerCase()}`;
324
+ }
325
+ function resolveGitRepositoryIdentity() {
326
+ const remoteUrl = safeExec("git config --get remote.origin.url");
327
+ if (!remoteUrl) {
328
+ return null;
329
+ }
330
+ const parsed = parseRemoteUrl(remoteUrl);
331
+ if (!parsed) {
332
+ return null;
333
+ }
334
+ const repositoryRoot = safeExec("git rev-parse --show-toplevel");
335
+ const currentDirectory = process.cwd();
336
+ let repoScopePath = "";
337
+ if (repositoryRoot) {
338
+ const relativePath = relative(resolve(repositoryRoot), resolve(currentDirectory)).replace(/\\/g, "/").trim();
339
+ if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) {
340
+ repoScopePath = relativePath;
341
+ }
342
+ }
343
+ return {
344
+ repoCanonical: toCanonical(parsed.host, parsed.ownerRepoPath),
345
+ repoScopePath
346
+ };
347
+ }
348
+ function resolveGitContext() {
349
+ const warnings = [];
350
+ const identity = resolveGitRepositoryIdentity();
351
+ if (!identity) {
352
+ warnings.push(
353
+ "Could not detect git remote origin. Repo binding will be skipped until sync can detect it."
354
+ );
355
+ }
356
+ return { identity, warnings };
357
+ }
358
+
359
+ // src/commands/init.ts
360
+ import { spawn } from "child_process";
361
+ var SUBSCRIPTION_SETTINGS_PATH = "/dashboard/workspace/settings?tab=subscription";
362
+ function escapeRegExp(value) {
363
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
364
+ }
365
+ function parseTargetLocales(value) {
366
+ if (!value) return void 0;
367
+ const locales = value.split(",").map((locale) => locale.trim()).filter(Boolean);
368
+ return locales.length > 0 ? locales : void 0;
369
+ }
370
+ function getEnvLine(filePath, key) {
371
+ if (!existsSync(filePath)) {
372
+ return null;
373
+ }
374
+ const current = readFileSync(filePath, "utf-8");
375
+ const pattern = new RegExp(`^${escapeRegExp(key)}=.*$`, "m");
376
+ const existingMatch = current.match(pattern);
377
+ return existingMatch?.[0] ?? null;
378
+ }
379
+ function getEnvValue(filePath, key) {
380
+ const line = getEnvLine(filePath, key);
381
+ if (!line) return null;
382
+ const eqIndex = line.indexOf("=");
383
+ if (eqIndex === -1) return null;
384
+ return line.slice(eqIndex + 1);
385
+ }
386
+ function upsertEnvValue(params) {
387
+ const lineValue = `${params.key}=${params.value}`;
388
+ if (!existsSync(params.filePath)) {
389
+ writeFileSync(params.filePath, `${lineValue}
390
+ `, "utf-8");
391
+ return;
392
+ }
393
+ const current = readFileSync(params.filePath, "utf-8");
394
+ const pattern = new RegExp(`^${escapeRegExp(params.key)}=.*$`, "m");
395
+ const existingMatch = current.match(pattern);
396
+ if (existingMatch && existingMatch[0] !== lineValue && !params.allowOverwrite) {
397
+ throw new Error(
398
+ `${params.key} already exists in ${params.filePath}. Re-run with --yes to overwrite.`
399
+ );
400
+ }
401
+ if (existingMatch) {
402
+ const updated = current.replace(pattern, lineValue);
403
+ writeFileSync(params.filePath, updated.endsWith("\n") ? updated : `${updated}
404
+ `, "utf-8");
405
+ return;
406
+ }
407
+ const prefix = current.endsWith("\n") || current.length === 0 ? "" : "\n";
408
+ writeFileSync(params.filePath, `${current}${prefix}${lineValue}
409
+ `, "utf-8");
410
+ }
411
+ async function sleep(ms) {
412
+ await new Promise((resolve2) => setTimeout(resolve2, ms));
413
+ }
414
+ async function tryOpenBrowser(url) {
415
+ if (!process.stdout.isTTY || process.env.CI === "true") {
416
+ return false;
417
+ }
418
+ let command;
419
+ let args;
420
+ if (process.platform === "darwin") {
421
+ command = "open";
422
+ args = [url];
423
+ } else if (process.platform === "win32") {
424
+ command = "rundll32";
425
+ args = ["url.dll,FileProtocolHandler", url];
426
+ } else {
427
+ command = "xdg-open";
428
+ args = [url];
429
+ }
430
+ return await new Promise((resolve2) => {
431
+ try {
432
+ const child = spawn(command, args, {
433
+ detached: true,
434
+ stdio: "ignore",
435
+ windowsHide: true
436
+ });
437
+ let settled = false;
438
+ child.once("spawn", () => {
439
+ if (settled) return;
440
+ settled = true;
441
+ child.unref();
442
+ resolve2(true);
443
+ });
444
+ child.once("error", () => {
445
+ if (settled) return;
446
+ settled = true;
447
+ resolve2(false);
448
+ });
449
+ setTimeout(() => {
450
+ if (settled) return;
451
+ settled = true;
452
+ resolve2(false);
453
+ }, 300);
454
+ } catch {
455
+ resolve2(false);
456
+ }
457
+ });
458
+ }
459
+ function isPlanLimitFailure(message) {
460
+ if (!message) return false;
461
+ return /limit|upgrade/i.test(message);
462
+ }
463
+ function getSubscriptionSettingsUrl(apiUrl) {
464
+ return new URL(SUBSCRIPTION_SETTINGS_PATH, apiUrl).toString();
465
+ }
466
+ function printPlanLimitMessage(apiUrl, message) {
467
+ p.log.error(`You are over your plan limits.
468
+ ${message}`);
469
+ p.log.info(`Manage subscription: ${getSubscriptionSettingsUrl(apiUrl)}`);
470
+ }
471
+ function maskApiKey(key) {
472
+ if (key.length <= 8) return key;
473
+ return `${key.slice(0, 4)}...${key.slice(-4)}`;
474
+ }
475
+ async function init(options = {}) {
476
+ const projectRoot = process.cwd();
477
+ const apiUrl = options.apiUrl || process.env.VOCODER_API_URL || "https://vocoder.app";
478
+ const envPath = join(projectRoot, ".env");
479
+ p.intro("Vocoder Setup");
480
+ const spinner4 = p.spinner();
481
+ try {
482
+ const existingKey = getEnvValue(envPath, "VOCODER_API_KEY");
483
+ if (existingKey && existingKey.startsWith("vc_")) {
484
+ const existingApi = new VocoderAPI({ apiUrl, apiKey: existingKey });
485
+ try {
486
+ const config = await existingApi.getProjectConfig();
487
+ p.log.info("Existing configuration found:");
488
+ p.note(
489
+ [
490
+ `Project: ${config.projectName}`,
491
+ `Workspace: ${config.organizationName}`,
492
+ `Source: ${config.sourceLocale}`,
493
+ `Targets: ${config.targetLocales.join(", ")}`,
494
+ `Key: ${maskApiKey(existingKey)}`
495
+ ].join("\n")
496
+ );
497
+ if (options.yes) {
498
+ p.outro("Configuration unchanged. You're all set!");
499
+ return 0;
500
+ }
501
+ const action = await p.select({
502
+ message: "What would you like to do?",
503
+ options: [
504
+ { value: "keep", label: "Keep current configuration" },
505
+ { value: "reconfigure", label: "Reconfigure (new browser setup)" }
506
+ ]
507
+ });
508
+ if (p.isCancel(action)) {
509
+ p.cancel("Setup cancelled.");
510
+ return 1;
511
+ }
512
+ if (action === "keep") {
513
+ p.outro("Configuration unchanged. You're all set!");
514
+ return 0;
515
+ }
516
+ } catch {
517
+ p.log.warn("Found VOCODER_API_KEY in .env but it appears to be invalid or expired.");
518
+ if (!options.yes) {
519
+ const action = await p.select({
520
+ message: "What would you like to do?",
521
+ options: [
522
+ { value: "reconfigure", label: "Reconfigure (new browser setup)" },
523
+ { value: "keep", label: "Keep current key anyway" }
524
+ ]
525
+ });
526
+ if (p.isCancel(action)) {
527
+ p.cancel("Setup cancelled.");
528
+ return 1;
529
+ }
530
+ if (action === "keep") {
531
+ p.outro("Keeping existing key. You may encounter errors if the key is invalid.");
532
+ return 0;
533
+ }
534
+ }
535
+ }
536
+ }
537
+ spinner4.start("Creating setup session");
538
+ const api = new VocoderAPI({ apiUrl, apiKey: "" });
539
+ const gitContext = resolveGitContext();
540
+ const identity = gitContext.identity;
541
+ const start = await api.startInitSession({
542
+ projectName: options.projectName,
543
+ sourceLocale: options.sourceLocale,
544
+ targetLocales: parseTargetLocales(options.targetLocales),
545
+ ...identity?.repoCanonical ? { repoCanonical: identity.repoCanonical } : {},
546
+ ...identity ? { repoScopePath: identity.repoScopePath } : {}
547
+ });
548
+ spinner4.stop("Setup session created");
549
+ const verificationUrlString = start.verificationUrl;
550
+ if (gitContext.warnings.length > 0) {
551
+ for (const warning of gitContext.warnings) {
552
+ p.log.warn(warning);
553
+ }
554
+ }
555
+ p.note(verificationUrlString, "Authorize in your browser");
556
+ if (process.stdin.isTTY && process.stdout.isTTY && process.env.CI !== "true") {
557
+ const shouldOpen = options.yes ? true : await p.confirm({ message: "Open this URL in your browser?" });
558
+ if (p.isCancel(shouldOpen)) {
559
+ p.cancel("Setup cancelled.");
560
+ return 1;
561
+ }
562
+ if (shouldOpen) {
563
+ const opened = await tryOpenBrowser(verificationUrlString);
564
+ if (opened) {
565
+ p.log.info("Opened your browser for verification.");
566
+ } else {
567
+ p.log.info("Could not open a browser automatically. Use the URL above.");
568
+ }
569
+ }
570
+ }
571
+ const expiresAt = new Date(start.expiresAt).getTime();
572
+ spinner4.start("Waiting for browser authorization...");
573
+ while (Date.now() < expiresAt) {
574
+ const status = await api.getInitSessionStatus({
575
+ sessionId: start.sessionId,
576
+ pollToken: start.poll.token
577
+ });
578
+ if (status.status === "pending") {
579
+ const pendingMessage = status.message?.trim();
580
+ if (pendingMessage) {
581
+ spinner4.message(`Waiting for browser authorization... (${pendingMessage})`);
582
+ }
583
+ await sleep((status.pollIntervalSeconds || start.poll.intervalSeconds) * 1e3);
584
+ continue;
585
+ }
586
+ if (status.status === "failed") {
587
+ spinner4.stop("Setup failed");
588
+ if (isPlanLimitFailure(status.message)) {
589
+ printPlanLimitMessage(apiUrl, status.message);
590
+ } else {
591
+ p.log.error(status.message);
592
+ }
593
+ p.cancel("Setup could not be completed.");
594
+ return 1;
595
+ }
596
+ if (status.status === "completed") {
597
+ spinner4.stop("Authorization complete!");
598
+ const key = "VOCODER_API_KEY";
599
+ const desiredLine = `${key}=${status.credentials.apiKey}`;
600
+ const existingLine = getEnvLine(envPath, key);
601
+ const isAlreadyCurrent = existingLine === desiredLine;
602
+ let didOverwrite = false;
603
+ if (!isAlreadyCurrent) {
604
+ try {
605
+ upsertEnvValue({
606
+ filePath: envPath,
607
+ key,
608
+ value: status.credentials.apiKey,
609
+ allowOverwrite: Boolean(options.yes)
610
+ });
611
+ didOverwrite = Boolean(existingLine);
612
+ } catch (error) {
613
+ const overwriteConflict = error instanceof Error && error.message.includes(`${key} already exists in ${envPath}`);
614
+ if (!overwriteConflict) {
615
+ throw error;
616
+ }
617
+ const shouldOverwrite = await p.confirm({
618
+ message: `${key} already exists in ${envPath}. Overwrite it?`
619
+ });
620
+ if (p.isCancel(shouldOverwrite) || !shouldOverwrite) {
621
+ p.log.warn("Existing VOCODER_API_KEY was not changed.");
622
+ p.log.info("Re-run with --yes to overwrite it without prompting.");
623
+ p.cancel("Setup cancelled.");
624
+ return 1;
625
+ }
626
+ upsertEnvValue({
627
+ filePath: envPath,
628
+ key,
629
+ value: status.credentials.apiKey,
630
+ allowOverwrite: true
631
+ });
632
+ didOverwrite = true;
633
+ }
634
+ }
635
+ if (isAlreadyCurrent) {
636
+ p.log.info(`VOCODER_API_KEY already matches your .env file`);
637
+ } else if (didOverwrite) {
638
+ p.log.success(`Updated VOCODER_API_KEY in .env`);
639
+ } else {
640
+ p.log.success(`Wrote VOCODER_API_KEY to .env`);
641
+ }
642
+ p.outro("Vocoder initialized successfully!");
643
+ p.log.info(`Project: ${status.credentials.projectName}`);
644
+ p.log.info(`Workspace: ${status.credentials.organizationName}`);
645
+ return 0;
646
+ }
647
+ }
648
+ spinner4.stop("Authorization timed out");
649
+ p.log.error("Authorization timed out. Run `vocoder init` again.");
650
+ p.cancel("Setup could not be completed.");
651
+ return 1;
652
+ } catch (error) {
653
+ spinner4.stop();
654
+ if (error instanceof VocoderAPIError && error.limitError) {
655
+ printPlanLimitMessage(apiUrl, error.limitError.message);
656
+ p.log.info(`Current: ${error.limitError.current}`);
657
+ p.log.info(`Required: ${error.limitError.required}`);
658
+ p.log.info(`Upgrade: ${error.limitError.upgradeUrl}`);
659
+ return 1;
660
+ }
661
+ if (error instanceof Error) {
662
+ if (isPlanLimitFailure(error.message)) {
663
+ printPlanLimitMessage(apiUrl, error.message);
664
+ return 1;
665
+ }
666
+ p.log.error(`Error: ${error.message}`);
667
+ } else {
668
+ p.log.error("Unknown setup error");
669
+ }
670
+ return 1;
671
+ }
672
+ }
673
+
674
+ // src/commands/sync.ts
675
+ import * as p2 from "@clack/prompts";
676
+ import { createHash as createHash2, randomUUID } from "crypto";
677
+
678
+ // src/utils/branch.ts
679
+ import { execSync as execSync2 } from "child_process";
680
+ var REGEX_SPECIAL_CHARS = /[.+?^${}()|[\]\\]/g;
681
+ function escapeRegexChar(value) {
682
+ return value.replace(REGEX_SPECIAL_CHARS, "\\$&");
683
+ }
684
+ function detectBranch(override) {
685
+ if (override) {
686
+ return override;
687
+ }
688
+ const envBranch = process.env.GITHUB_HEAD_REF || // GitHub Actions (PR source branch)
689
+ process.env.GITHUB_REF_NAME || // GitHub Actions (push)
690
+ process.env.VERCEL_GIT_COMMIT_REF || // Vercel
691
+ process.env.BRANCH || // Netlify
692
+ process.env.CF_PAGES_BRANCH || // Cloudflare Pages
693
+ process.env.CI_COMMIT_REF_NAME || // GitLab CI
694
+ process.env.BITBUCKET_BRANCH || // Bitbucket Pipelines
695
+ process.env.CIRCLE_BRANCH || // CircleCI
696
+ process.env.RENDER_GIT_BRANCH;
697
+ if (envBranch) {
698
+ return envBranch;
699
+ }
700
+ try {
701
+ const branch = execSync2("git rev-parse --abbrev-ref HEAD", {
702
+ encoding: "utf-8",
703
+ stdio: ["pipe", "pipe", "ignore"]
704
+ }).trim();
705
+ return branch;
706
+ } catch (error) {
707
+ throw new Error(
708
+ "Failed to detect git branch. Make sure you are in a git repository or set the --branch flag."
709
+ );
710
+ }
711
+ }
712
+ function isTargetBranch(currentBranch, targetBranches) {
713
+ return targetBranches.some(
714
+ (pattern) => matchBranchPattern(currentBranch, pattern)
715
+ );
716
+ }
717
+ function matchBranchPattern(branch, pattern) {
718
+ const trimmedPattern = pattern.trim();
719
+ if (!trimmedPattern) {
720
+ return false;
721
+ }
722
+ let regexSource = "^";
723
+ for (let i = 0; i < trimmedPattern.length; i += 1) {
724
+ const char = trimmedPattern[i];
725
+ if (!char) {
726
+ continue;
727
+ }
728
+ if (char === "*") {
729
+ const next = trimmedPattern[i + 1];
730
+ if (next === "*") {
731
+ regexSource += ".*";
732
+ i += 1;
733
+ } else {
734
+ regexSource += "[^/]*";
735
+ }
736
+ continue;
737
+ }
738
+ regexSource += escapeRegexChar(char);
739
+ }
740
+ regexSource += "$";
741
+ return new RegExp(regexSource).test(branch);
742
+ }
743
+
744
+ // src/commands/sync.ts
745
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
746
+
747
+ // src/utils/config.ts
748
+ import chalk from "chalk";
749
+ import { config as loadEnv } from "dotenv";
750
+ loadEnv();
751
+ function validateLocalConfig(config) {
752
+ if (!config.apiKey || config.apiKey.length === 0) {
753
+ throw new Error("VOCODER_API_KEY is required. Set it in your .env file.");
754
+ }
755
+ if (!config.apiKey.startsWith("vc_")) {
756
+ throw new Error("Invalid API key format. Expected format: vc_...");
757
+ }
758
+ if (!config.apiUrl || !config.apiUrl.startsWith("http")) {
759
+ throw new Error("Invalid API URL");
760
+ }
761
+ }
762
+ async function getMergedConfig(cliOptions, verbose = false, _startDir) {
763
+ const configSources = {
764
+ extractionPattern: "default",
765
+ excludePattern: "default",
766
+ apiKey: "environment",
767
+ apiUrl: "default",
768
+ mode: "default",
769
+ maxWaitMs: "default",
770
+ noFallback: "default"
771
+ };
772
+ const defaults = {
773
+ extractionPattern: ["src/**/*.{tsx,jsx,ts,js}"],
774
+ excludePattern: [],
775
+ apiUrl: "https://vocoder.app"
776
+ };
777
+ const envExtractionPattern = process.env.VOCODER_EXTRACTION_PATTERN;
778
+ const envApiUrl = process.env.VOCODER_API_URL;
779
+ const envSyncMode = process.env.VOCODER_SYNC_MODE;
780
+ const envSyncMaxWaitMs = process.env.VOCODER_SYNC_MAX_WAIT_MS;
781
+ const envSyncNoFallback = process.env.VOCODER_SYNC_NO_FALLBACK;
782
+ let extractionPattern;
783
+ if (cliOptions.include && cliOptions.include.length > 0) {
784
+ extractionPattern = cliOptions.include;
785
+ configSources.extractionPattern = "CLI flag";
786
+ } else if (envExtractionPattern) {
787
+ extractionPattern = [envExtractionPattern];
788
+ configSources.extractionPattern = "environment";
789
+ } else {
790
+ extractionPattern = defaults.extractionPattern;
791
+ }
792
+ let excludePattern;
793
+ if (cliOptions.exclude && cliOptions.exclude.length > 0) {
794
+ excludePattern = cliOptions.exclude;
795
+ configSources.excludePattern = "CLI flag";
796
+ } else {
797
+ excludePattern = defaults.excludePattern;
798
+ }
799
+ let apiKey;
800
+ if (process.env.VOCODER_API_KEY) {
801
+ apiKey = process.env.VOCODER_API_KEY;
802
+ configSources.apiKey = "environment";
803
+ }
804
+ let apiUrl;
805
+ if (envApiUrl) {
806
+ apiUrl = envApiUrl;
807
+ configSources.apiUrl = "environment";
808
+ } else {
809
+ apiUrl = defaults.apiUrl;
810
+ }
811
+ const modeCandidates = ["auto", "required", "best-effort"];
812
+ let mode = "auto";
813
+ if (cliOptions.mode && modeCandidates.includes(cliOptions.mode)) {
814
+ mode = cliOptions.mode;
815
+ configSources.mode = "CLI flag";
816
+ } else if (envSyncMode && modeCandidates.includes(envSyncMode)) {
817
+ mode = envSyncMode;
818
+ configSources.mode = "environment";
819
+ }
820
+ let maxWaitMs;
821
+ if (typeof cliOptions.maxWaitMs === "number" && Number.isFinite(cliOptions.maxWaitMs) && cliOptions.maxWaitMs > 0) {
822
+ maxWaitMs = Math.floor(cliOptions.maxWaitMs);
823
+ configSources.maxWaitMs = "CLI flag";
824
+ } else if (envSyncMaxWaitMs) {
825
+ const parsed = Number.parseInt(envSyncMaxWaitMs, 10);
826
+ if (Number.isFinite(parsed) && parsed > 0) {
827
+ maxWaitMs = parsed;
828
+ configSources.maxWaitMs = "environment";
829
+ }
830
+ }
831
+ let noFallback = false;
832
+ if (typeof cliOptions.noFallback === "boolean") {
833
+ noFallback = cliOptions.noFallback;
834
+ configSources.noFallback = "CLI flag";
835
+ } else if (envSyncNoFallback) {
836
+ noFallback = ["1", "true", "yes", "on"].includes(envSyncNoFallback.toLowerCase());
837
+ configSources.noFallback = "environment";
838
+ }
839
+ if (verbose) {
840
+ console.log(chalk.dim("\n Configuration sources:"));
841
+ console.log(chalk.dim(` Include patterns: ${configSources.extractionPattern}`));
842
+ if (excludePattern.length > 0) {
843
+ console.log(chalk.dim(` Exclude patterns: ${configSources.excludePattern}`));
844
+ }
845
+ console.log(chalk.dim(` API key: ${configSources.apiKey}`));
846
+ console.log(chalk.dim(` API URL: ${configSources.apiUrl}
847
+ `));
848
+ console.log(chalk.dim(` Sync mode: ${configSources.mode}`));
849
+ if (maxWaitMs) {
850
+ console.log(chalk.dim(` Max wait: ${configSources.maxWaitMs}`));
851
+ }
852
+ console.log(chalk.dim(` No fallback: ${configSources.noFallback}
853
+ `));
854
+ }
855
+ return {
856
+ extractionPattern,
857
+ excludePattern,
858
+ apiKey,
859
+ apiUrl,
860
+ mode,
861
+ maxWaitMs,
862
+ noFallback,
863
+ configSources
864
+ };
865
+ }
866
+
867
+ // src/utils/extract.ts
868
+ import { createHash } from "crypto";
869
+ import { readFileSync as readFileSync2 } from "fs";
870
+ import { parse } from "@babel/parser";
871
+ import babelTraverse from "@babel/traverse";
872
+ import { glob } from "glob";
873
+ import { relative as pathRelative } from "path";
874
+ var traverse = babelTraverse.default || babelTraverse;
875
+ var StringExtractor = class {
876
+ /**
877
+ * Extract strings from all files matching the pattern(s)
878
+ *
879
+ * @param pattern - Glob pattern(s) to include
880
+ * @param projectRoot - Project root directory
881
+ * @param excludePattern - Glob pattern(s) to exclude (optional)
882
+ */
883
+ async extractFromProject(pattern, projectRoot = process.cwd(), excludePattern) {
884
+ const includePatterns = Array.isArray(pattern) ? pattern : [pattern];
885
+ const defaultIgnore = ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"];
886
+ const ignorePatterns = excludePattern ? [...defaultIgnore, ...Array.isArray(excludePattern) ? excludePattern : [excludePattern]] : defaultIgnore;
887
+ const allFiles = /* @__PURE__ */ new Set();
888
+ for (const includePattern of includePatterns) {
889
+ const files = await glob(includePattern, {
890
+ cwd: projectRoot,
891
+ absolute: true,
892
+ ignore: ignorePatterns
893
+ });
894
+ files.forEach((file) => allFiles.add(file));
895
+ }
896
+ const allStrings = [];
897
+ const sortedFiles = Array.from(allFiles).sort();
898
+ for (const file of sortedFiles) {
899
+ try {
900
+ const strings = await this.extractFromFile(file, projectRoot);
901
+ allStrings.push(...strings);
902
+ } catch (error) {
903
+ console.warn(`Warning: Failed to extract from ${file}:`, error);
904
+ }
905
+ }
906
+ const unique = this.deduplicateStrings(allStrings);
907
+ return unique;
908
+ }
909
+ /**
910
+ * Extract strings from a single file
911
+ */
912
+ async extractFromFile(filePath, projectRoot) {
913
+ const code = readFileSync2(filePath, "utf-8");
914
+ const strings = [];
915
+ const relativeFilePath = pathRelative(projectRoot, filePath).split("\\").join("/");
916
+ try {
917
+ const ast = parse(code, {
918
+ sourceType: "module",
919
+ plugins: ["jsx", "typescript"]
920
+ });
921
+ const vocoderImports = /* @__PURE__ */ new Map();
922
+ const tFunctionNames = /* @__PURE__ */ new Set();
923
+ traverse(ast, {
924
+ // Track imports of <T> component and t function
925
+ ImportDeclaration: (path) => {
926
+ const source = path.node.source.value;
927
+ if (source === "@vocoder/react") {
928
+ path.node.specifiers.forEach((spec) => {
929
+ if (spec.type === "ImportSpecifier") {
930
+ const imported = spec.imported.type === "Identifier" ? spec.imported.name : null;
931
+ const local = spec.local.name;
932
+ if (imported === "T") {
933
+ vocoderImports.set(local, "T");
934
+ }
935
+ if (imported === "t") {
936
+ tFunctionNames.add(local);
937
+ }
938
+ if (imported === "useVocoder") {
939
+ }
940
+ }
941
+ });
942
+ }
943
+ },
944
+ // Track destructured 't' from useVocoder hook
945
+ VariableDeclarator: (path) => {
946
+ const init2 = path.node.init;
947
+ if (init2 && init2.type === "CallExpression" && init2.callee.type === "Identifier" && init2.callee.name === "useVocoder" && path.node.id.type === "ObjectPattern") {
948
+ path.node.id.properties.forEach((prop) => {
949
+ if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "t") {
950
+ const localName = prop.value.type === "Identifier" ? prop.value.name : "t";
951
+ tFunctionNames.add(localName);
952
+ }
953
+ });
954
+ }
955
+ },
956
+ // Extract from t() function calls
957
+ CallExpression: (path) => {
958
+ const callee = path.node.callee;
959
+ const isTFunction = callee.type === "Identifier" && tFunctionNames.has(callee.name);
960
+ if (!isTFunction) return;
961
+ const firstArg = path.node.arguments[0];
962
+ if (!firstArg) return;
963
+ let text = null;
964
+ if (firstArg.type === "StringLiteral") {
965
+ text = firstArg.value;
966
+ } else if (firstArg.type === "TemplateLiteral") {
967
+ text = this.extractTemplateText(firstArg);
968
+ }
969
+ if (!text || text.trim().length === 0) return;
970
+ const secondArg = path.node.arguments[1];
971
+ let context;
972
+ let formality;
973
+ let explicitKey;
974
+ if (secondArg && secondArg.type === "ObjectExpression") {
975
+ secondArg.properties.forEach((prop) => {
976
+ if (prop.type === "ObjectProperty" && prop.key.type === "Identifier") {
977
+ if (prop.key.name === "context" && prop.value.type === "StringLiteral") {
978
+ context = prop.value.value;
979
+ }
980
+ if (prop.key.name === "formality" && prop.value.type === "StringLiteral") {
981
+ formality = prop.value.value;
982
+ }
983
+ if (prop.key.name === "id" && prop.value.type === "StringLiteral") {
984
+ explicitKey = prop.value.value.trim();
985
+ }
986
+ }
987
+ });
988
+ }
989
+ const line = path.node.loc?.start.line || 0;
990
+ const column = path.node.loc?.start.column || 0;
991
+ const key = explicitKey && explicitKey.length > 0 ? explicitKey : this.generateStableKey({
992
+ filePath: relativeFilePath,
993
+ kind: "t-call",
994
+ line,
995
+ column
996
+ });
997
+ strings.push({
998
+ key,
999
+ text: text.trim(),
1000
+ file: filePath,
1001
+ line,
1002
+ context,
1003
+ formality
1004
+ });
1005
+ },
1006
+ // Extract from JSX elements
1007
+ JSXElement: (path) => {
1008
+ const opening = path.node.openingElement;
1009
+ const tagName = opening.name.type === "JSXIdentifier" ? opening.name.name : null;
1010
+ if (!tagName) return;
1011
+ const isTranslationComponent = vocoderImports.has(tagName);
1012
+ if (!isTranslationComponent) return;
1013
+ const msgAttribute = this.getStringAttribute(opening.attributes, "msg");
1014
+ const text = msgAttribute || this.extractTextContent(path.node.children);
1015
+ if (!text || text.trim().length === 0) return;
1016
+ const id = this.getStringAttribute(opening.attributes, "id");
1017
+ const context = this.getStringAttribute(opening.attributes, "context");
1018
+ const formality = this.getStringAttribute(
1019
+ opening.attributes,
1020
+ "formality"
1021
+ );
1022
+ const line = path.node.loc?.start.line || 0;
1023
+ const column = path.node.loc?.start.column || 0;
1024
+ const key = id && id.trim().length > 0 ? id.trim() : this.generateStableKey({
1025
+ filePath: relativeFilePath,
1026
+ kind: "jsx",
1027
+ line,
1028
+ column
1029
+ });
1030
+ strings.push({
1031
+ key,
1032
+ text: text.trim(),
1033
+ file: filePath,
1034
+ line,
1035
+ context,
1036
+ formality
1037
+ });
1038
+ }
1039
+ });
1040
+ } catch (error) {
1041
+ throw new Error(
1042
+ `Failed to parse ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`
1043
+ );
1044
+ }
1045
+ return strings;
1046
+ }
1047
+ /**
1048
+ * Extract text from template literal
1049
+ * Converts template literals like `Hello ${name}` to `Hello {name}`
1050
+ */
1051
+ extractTemplateText(node) {
1052
+ let text = "";
1053
+ for (let i = 0; i < node.quasis.length; i++) {
1054
+ const quasi = node.quasis[i];
1055
+ text += quasi.value.raw;
1056
+ if (i < node.expressions.length) {
1057
+ const expr = node.expressions[i];
1058
+ if (expr.type === "Identifier") {
1059
+ text += `{${expr.name}}`;
1060
+ } else {
1061
+ text += "{value}";
1062
+ }
1063
+ }
1064
+ }
1065
+ return text;
1066
+ }
1067
+ /**
1068
+ * Extract text content from JSX children
1069
+ */
1070
+ extractTextContent(children) {
1071
+ let text = "";
1072
+ for (const child of children) {
1073
+ if (child.type === "JSXText") {
1074
+ text += child.value;
1075
+ } else if (child.type === "JSXExpressionContainer") {
1076
+ const expr = child.expression;
1077
+ if (expr.type === "Identifier") {
1078
+ text += `{${expr.name}}`;
1079
+ } else if (expr.type === "StringLiteral") {
1080
+ text += expr.value;
1081
+ } else if (expr.type === "TemplateLiteral") {
1082
+ text += this.extractTemplateText(expr);
1083
+ }
1084
+ }
1085
+ }
1086
+ return text;
1087
+ }
1088
+ /**
1089
+ * Get string value from JSX attribute
1090
+ * Handles both string literals and template literals
1091
+ */
1092
+ getStringAttribute(attributes, name) {
1093
+ const attr = attributes.find(
1094
+ (a) => a.type === "JSXAttribute" && a.name.name === name
1095
+ );
1096
+ if (!attr || !attr.value) return void 0;
1097
+ if (attr.value.type === "StringLiteral") {
1098
+ return attr.value.value;
1099
+ }
1100
+ if (attr.value.type === "JSXExpressionContainer") {
1101
+ const expr = attr.value.expression;
1102
+ if (expr.type === "TemplateLiteral") {
1103
+ return this.extractTemplateText(expr);
1104
+ }
1105
+ if (expr.type === "StringLiteral") {
1106
+ return expr.value;
1107
+ }
1108
+ }
1109
+ return void 0;
1110
+ }
1111
+ /**
1112
+ * Deduplicate strings (keep first occurrence)
1113
+ */
1114
+ deduplicateStrings(strings) {
1115
+ const seen = /* @__PURE__ */ new Map();
1116
+ const unique = [];
1117
+ for (const str of strings) {
1118
+ const dedupeKey = `${str.text}|${str.context || ""}|${str.formality || ""}`;
1119
+ const existingIndex = seen.get(dedupeKey);
1120
+ if (existingIndex === void 0) {
1121
+ seen.set(dedupeKey, unique.length);
1122
+ unique.push(str);
1123
+ continue;
1124
+ }
1125
+ const existing = unique[existingIndex];
1126
+ if (existing && str.key < existing.key) {
1127
+ existing.key = str.key;
1128
+ }
1129
+ }
1130
+ return unique;
1131
+ }
1132
+ generateStableKey(params) {
1133
+ const payload = `${params.filePath}|${params.kind}|${params.line}:${params.column}`;
1134
+ const digest = createHash("sha1").update(payload).digest("hex");
1135
+ return `SK_${digest.slice(0, 24).toUpperCase()}`;
1136
+ }
1137
+ };
1138
+
1139
+ // src/commands/sync.ts
1140
+ import chalk2 from "chalk";
1141
+ import { join as join2 } from "path";
1142
+ function isRecord(value) {
1143
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1144
+ }
1145
+ function parseLocaleMetadata(value) {
1146
+ if (!isRecord(value)) {
1147
+ return void 0;
1148
+ }
1149
+ const metadata = {};
1150
+ for (const [locale, rawValue] of Object.entries(value)) {
1151
+ if (!isRecord(rawValue)) {
1152
+ continue;
1153
+ }
1154
+ const nativeName = rawValue.nativeName;
1155
+ if (typeof nativeName !== "string" || nativeName.trim().length === 0) {
1156
+ continue;
1157
+ }
1158
+ const entry = { nativeName };
1159
+ if (rawValue.dir === "rtl") {
1160
+ entry.dir = "rtl";
1161
+ }
1162
+ metadata[locale] = entry;
1163
+ }
1164
+ return Object.keys(metadata).length > 0 ? metadata : void 0;
1165
+ }
1166
+ function parseTranslations(value) {
1167
+ if (!isRecord(value)) {
1168
+ return null;
1169
+ }
1170
+ const translations = {};
1171
+ for (const [locale, localeValue] of Object.entries(value)) {
1172
+ if (!isRecord(localeValue)) {
1173
+ continue;
1174
+ }
1175
+ const localeTranslations = {};
1176
+ for (const [source, translated] of Object.entries(localeValue)) {
1177
+ if (typeof translated === "string") {
1178
+ localeTranslations[source] = translated;
1179
+ }
1180
+ }
1181
+ translations[locale] = localeTranslations;
1182
+ }
1183
+ return Object.keys(translations).length > 0 ? translations : null;
1184
+ }
1185
+ function getCacheFilePath(projectRoot, branch) {
1186
+ const slug = branch.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40);
1187
+ const branchHash = createHash2("sha1").update(branch).digest("hex").slice(0, 12);
1188
+ const filename = `${slug || "branch"}-${branchHash}.json`;
1189
+ return join2(projectRoot, ".vocoder", "cache", "sync", filename);
1190
+ }
1191
+ function readLocalSnapshotCache(params) {
1192
+ const candidateBranches = params.branch === "main" ? ["main"] : [params.branch, "main"];
1193
+ for (const candidateBranch of candidateBranches) {
1194
+ const cacheFilePath = getCacheFilePath(params.projectRoot, candidateBranch);
1195
+ if (!existsSync2(cacheFilePath)) {
1196
+ continue;
1197
+ }
1198
+ try {
1199
+ const raw = readFileSync3(cacheFilePath, "utf-8");
1200
+ const parsed = JSON.parse(raw);
1201
+ if (!isRecord(parsed)) {
1202
+ continue;
1203
+ }
1204
+ const translations = parseTranslations(parsed.translations);
1205
+ if (!translations) {
1206
+ continue;
1207
+ }
1208
+ const localeMetadata = parseLocaleMetadata(parsed.localeMetadata);
1209
+ return {
1210
+ source: "local-cache",
1211
+ translations,
1212
+ localeMetadata,
1213
+ snapshotBatchId: typeof parsed.snapshotBatchId === "string" ? parsed.snapshotBatchId : void 0,
1214
+ completedAt: typeof parsed.completedAt === "string" ? parsed.completedAt : null,
1215
+ cacheBranch: candidateBranch
1216
+ };
1217
+ } catch {
1218
+ continue;
1219
+ }
1220
+ }
1221
+ return null;
1222
+ }
1223
+ function writeLocalSnapshotCache(params) {
1224
+ const cacheFilePath = getCacheFilePath(params.projectRoot, params.branch);
1225
+ mkdirSync(join2(params.projectRoot, ".vocoder", "cache", "sync"), {
1226
+ recursive: true
1227
+ });
1228
+ const payload = {
1229
+ version: 1,
1230
+ branch: params.branch,
1231
+ sourceLocale: params.sourceLocale,
1232
+ targetLocales: params.targetLocales,
1233
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
1234
+ ...params.snapshotBatchId ? { snapshotBatchId: params.snapshotBatchId } : {},
1235
+ ...params.completedAt ? { completedAt: params.completedAt } : {},
1236
+ ...params.localeMetadata ? { localeMetadata: params.localeMetadata } : {},
1237
+ translations: params.translations
1238
+ };
1239
+ writeFileSync2(cacheFilePath, JSON.stringify(payload, null, 2), "utf-8");
1240
+ return cacheFilePath;
1241
+ }
1242
+ function resolveEffectiveModeFromPolicy(params) {
1243
+ const { requestedMode, policy, branch } = params;
1244
+ let mode;
1245
+ if (requestedMode === "auto") {
1246
+ const isBlockingBranch = isTargetBranch(branch, policy.blockingBranches);
1247
+ mode = isBlockingBranch ? policy.blockingMode : policy.nonBlockingMode;
1248
+ } else {
1249
+ mode = requestedMode;
1250
+ }
1251
+ return mode;
1252
+ }
1253
+ function resolveWaitTimeoutMs(params) {
1254
+ if (typeof params.requestedMaxWaitMs === "number" && Number.isFinite(params.requestedMaxWaitMs) && params.requestedMaxWaitMs > 0) {
1255
+ return Math.floor(params.requestedMaxWaitMs);
1256
+ }
1257
+ if (typeof params.policyDefaultMaxWaitMs === "number" && Number.isFinite(params.policyDefaultMaxWaitMs) && params.policyDefaultMaxWaitMs > 0) {
1258
+ return Math.floor(params.policyDefaultMaxWaitMs);
1259
+ }
1260
+ return params.fallbackTimeoutMs;
1261
+ }
1262
+ function normalizeTranslations(params) {
1263
+ const merged = {};
1264
+ for (const [locale, values] of Object.entries(params.translations)) {
1265
+ merged[locale] = { ...values };
1266
+ }
1267
+ const expectedLocales = [
1268
+ params.sourceLocale,
1269
+ ...params.targetLocales.filter((locale) => locale !== params.sourceLocale)
1270
+ ];
1271
+ for (const locale of expectedLocales) {
1272
+ if (!merged[locale]) {
1273
+ merged[locale] = {};
1274
+ }
1275
+ }
1276
+ if (!merged[params.sourceLocale]) {
1277
+ merged[params.sourceLocale] = {};
1278
+ }
1279
+ for (const sourceText of params.sourceStrings) {
1280
+ if (!(sourceText in merged[params.sourceLocale])) {
1281
+ merged[params.sourceLocale][sourceText] = sourceText;
1282
+ }
1283
+ }
1284
+ return merged;
1285
+ }
1286
+ function getLimitErrorGuidance(limitError) {
1287
+ if (limitError.limitType === "providers") {
1288
+ return [
1289
+ "Provider setup required.",
1290
+ "Add a DeepL API key in Dashboard -> Workspace Settings -> Providers.",
1291
+ `Open settings: ${limitError.upgradeUrl}`
1292
+ ];
1293
+ }
1294
+ if (limitError.limitType === "translation_chars") {
1295
+ return [
1296
+ "Monthly translation character limit reached.",
1297
+ `Used this month: ${limitError.current.toLocaleString()} chars`,
1298
+ `Requested after sync: ${limitError.required.toLocaleString()} chars`,
1299
+ `Upgrade plan: ${limitError.upgradeUrl}`
1300
+ ];
1301
+ }
1302
+ if (limitError.limitType === "source_strings") {
1303
+ return [
1304
+ "Active source string limit reached.",
1305
+ `Current active strings: ${limitError.current.toLocaleString()}`,
1306
+ `Required for this sync: ${limitError.required.toLocaleString()}`,
1307
+ `Upgrade plan: ${limitError.upgradeUrl}`
1308
+ ];
1309
+ }
1310
+ return [
1311
+ `Plan: ${limitError.planId}`,
1312
+ `Current: ${limitError.current}`,
1313
+ `Required: ${limitError.required}`,
1314
+ `Upgrade: ${limitError.upgradeUrl}`
1315
+ ];
1316
+ }
1317
+ function getSyncPolicyErrorGuidance(error) {
1318
+ if (error.errorCode === "BRANCH_NOT_ALLOWED") {
1319
+ const lines2 = ["This branch is not allowed for this project."];
1320
+ if (error.branch) {
1321
+ lines2.push(`Current branch: ${error.branch}`);
1322
+ }
1323
+ if (error.targetBranches && error.targetBranches.length > 0) {
1324
+ lines2.push(`Allowed branches: ${error.targetBranches.join(", ")}`);
1325
+ }
1326
+ lines2.push("Update your project target branches in the dashboard if needed.");
1327
+ return lines2;
1328
+ }
1329
+ const lines = ["This project is bound to a different repository."];
1330
+ if (error.boundRepoLabel) {
1331
+ lines.push(`Bound repository: ${error.boundRepoLabel}`);
1332
+ }
1333
+ if (error.boundScopePath) {
1334
+ lines.push(`Bound scope: ${error.boundScopePath}`);
1335
+ }
1336
+ lines.push(
1337
+ "Run `vocoder init` from the correct repository or create a separate project."
1338
+ );
1339
+ return lines;
1340
+ }
1341
+ function mergeContext(current, incoming) {
1342
+ if (!incoming) return current;
1343
+ if (!current) return incoming;
1344
+ if (current === incoming) return current;
1345
+ const merged = new Set(
1346
+ [...current.split(" | "), ...incoming.split(" | ")].map((part) => part.trim()).filter(Boolean)
1347
+ );
1348
+ return Array.from(merged).join(" | ");
1349
+ }
1350
+ function buildStringEntries(extractedStrings) {
1351
+ const byText = /* @__PURE__ */ new Map();
1352
+ for (const str of extractedStrings) {
1353
+ const existing = byText.get(str.text);
1354
+ if (!existing) {
1355
+ byText.set(str.text, {
1356
+ key: str.key,
1357
+ text: str.text,
1358
+ ...str.context ? { context: str.context } : {},
1359
+ ...str.formality ? { formality: str.formality } : {}
1360
+ });
1361
+ continue;
1362
+ }
1363
+ existing.context = mergeContext(existing.context, str.context);
1364
+ if (!existing.formality && str.formality) {
1365
+ existing.formality = str.formality;
1366
+ } else if (existing.formality && str.formality && existing.formality !== str.formality) {
1367
+ existing.formality = "auto";
1368
+ }
1369
+ if (str.key < existing.key) {
1370
+ existing.key = str.key;
1371
+ }
1372
+ }
1373
+ return Array.from(byText.values());
1374
+ }
1375
+ async function fetchApiSnapshot(api, params) {
1376
+ const snapshot = await api.getTranslationSnapshot({
1377
+ branch: params.branch,
1378
+ targetLocales: params.targetLocales
1379
+ });
1380
+ if (snapshot.status !== "FOUND" || !snapshot.translations) {
1381
+ return null;
1382
+ }
1383
+ return {
1384
+ source: "api-snapshot",
1385
+ translations: snapshot.translations,
1386
+ localeMetadata: snapshot.localeMetadata,
1387
+ snapshotBatchId: snapshot.snapshotBatchId,
1388
+ completedAt: snapshot.completedAt
1389
+ };
1390
+ }
1391
+ async function sync(options = {}) {
1392
+ const startTime = Date.now();
1393
+ const projectRoot = process.cwd();
1394
+ p2.intro("Vocoder Sync");
1395
+ const spinner4 = p2.spinner();
1396
+ try {
1397
+ spinner4.start("Detecting branch");
1398
+ const branch = detectBranch(options.branch);
1399
+ spinner4.stop(`Branch: ${chalk2.cyan(branch)}`);
1400
+ spinner4.start("Loading project configuration");
1401
+ const mergedConfig = await getMergedConfig(options, options.verbose);
1402
+ const localConfig = {
1403
+ apiKey: mergedConfig.apiKey || "",
1404
+ apiUrl: mergedConfig.apiUrl || "https://vocoder.app"
1405
+ };
1406
+ validateLocalConfig(localConfig);
1407
+ const api = new VocoderAPI(localConfig);
1408
+ const apiConfig = await api.getProjectConfig();
1409
+ const requestedMode = mergedConfig.mode;
1410
+ const waitTimeoutMs = resolveWaitTimeoutMs({
1411
+ requestedMaxWaitMs: mergedConfig.maxWaitMs,
1412
+ policyDefaultMaxWaitMs: apiConfig.syncPolicy.defaultMaxWaitMs,
1413
+ fallbackTimeoutMs: 6e4
1414
+ });
1415
+ const config = {
1416
+ ...localConfig,
1417
+ ...apiConfig,
1418
+ extractionPattern: mergedConfig.extractionPattern,
1419
+ excludePattern: mergedConfig.excludePattern,
1420
+ timeout: waitTimeoutMs
1421
+ };
1422
+ spinner4.stop("Project configuration loaded");
1423
+ if (!options.force && !isTargetBranch(branch, config.targetBranches)) {
1424
+ p2.log.warn(
1425
+ `Skipping translations (${chalk2.cyan(branch)} is not a target branch)`
1426
+ );
1427
+ p2.log.info(`Target branches: ${config.targetBranches.join(", ")}`);
1428
+ p2.log.info("Use --force to translate anyway");
1429
+ p2.outro("");
1430
+ return 0;
1431
+ }
1432
+ const patternsDisplay = Array.isArray(config.extractionPattern) ? config.extractionPattern.join(", ") : config.extractionPattern;
1433
+ spinner4.start(`Extracting strings from ${patternsDisplay}`);
1434
+ const extractor = new StringExtractor();
1435
+ const extractedStrings = await extractor.extractFromProject(
1436
+ config.extractionPattern,
1437
+ projectRoot,
1438
+ config.excludePattern
1439
+ );
1440
+ if (extractedStrings.length === 0) {
1441
+ spinner4.stop("No translatable strings found");
1442
+ p2.log.warn("Make sure you are using <T> components from @vocoder/react");
1443
+ p2.outro("");
1444
+ return 0;
1445
+ }
1446
+ spinner4.stop(
1447
+ `Extracted ${chalk2.cyan(extractedStrings.length)} strings from ${chalk2.cyan(patternsDisplay)}`
1448
+ );
1449
+ if (options.verbose) {
1450
+ const sampleLines = extractedStrings.slice(0, 5).map((s) => ` "${s.text}" (${s.file}:${s.line})`);
1451
+ if (extractedStrings.length > 5) {
1452
+ sampleLines.push(` ... and ${extractedStrings.length - 5} more`);
1453
+ }
1454
+ p2.note(sampleLines.join("\n"), "Sample strings");
1455
+ }
1456
+ if (options.dryRun) {
1457
+ p2.note(
1458
+ [
1459
+ `Strings: ${extractedStrings.length}`,
1460
+ `Branch: ${branch}`,
1461
+ `Target locales: ${config.targetLocales.join(", ")}`,
1462
+ `Requested mode: ${requestedMode}`,
1463
+ `Max wait: ${waitTimeoutMs}ms`,
1464
+ `No fallback: ${mergedConfig.noFallback ? "yes" : "no"}`
1465
+ ].join("\n"),
1466
+ "Dry run - would translate"
1467
+ );
1468
+ p2.outro("No API calls made.");
1469
+ return 0;
1470
+ }
1471
+ const repoIdentity = resolveGitRepositoryIdentity();
1472
+ if (!repoIdentity && options.verbose) {
1473
+ p2.log.warn(
1474
+ "Could not detect git remote origin. Sync will continue without repo metadata."
1475
+ );
1476
+ }
1477
+ const stringEntries = buildStringEntries(extractedStrings);
1478
+ const sourceStrings = stringEntries.map((entry) => entry.text);
1479
+ if (options.verbose && stringEntries.length !== extractedStrings.length) {
1480
+ p2.log.info(
1481
+ `Deduped ${extractedStrings.length} extracted entries into ${stringEntries.length} unique source strings`
1482
+ );
1483
+ }
1484
+ spinner4.start("Submitting strings to Vocoder API");
1485
+ const batchResponse = await api.submitTranslation(
1486
+ branch,
1487
+ stringEntries,
1488
+ config.targetLocales,
1489
+ {
1490
+ requestedMode,
1491
+ requestedMaxWaitMs: waitTimeoutMs,
1492
+ clientRunId: randomUUID()
1493
+ },
1494
+ repoIdentity ?? void 0
1495
+ );
1496
+ spinner4.stop(`Submitted to API - Batch ${chalk2.cyan(batchResponse.batchId)}`);
1497
+ const effectiveMode = batchResponse.effectiveMode ?? resolveEffectiveModeFromPolicy({
1498
+ branch,
1499
+ requestedMode,
1500
+ policy: config.syncPolicy
1501
+ });
1502
+ if (options.verbose) {
1503
+ p2.log.info(`Requested mode: ${requestedMode}`);
1504
+ p2.log.info(`Effective mode: ${effectiveMode}`);
1505
+ p2.log.info(`Wait timeout: ${waitTimeoutMs}ms`);
1506
+ if (batchResponse.queueStatus) {
1507
+ p2.log.info(`Queue status: ${batchResponse.queueStatus}`);
1508
+ }
1509
+ }
1510
+ if (batchResponse.status === "UP_TO_DATE" && batchResponse.noChanges) {
1511
+ p2.log.success("No changes detected - strings are up to date");
1512
+ }
1513
+ p2.log.info(`New strings: ${chalk2.cyan(batchResponse.newStrings)}`);
1514
+ if (batchResponse.deletedStrings && batchResponse.deletedStrings > 0) {
1515
+ p2.log.info(
1516
+ `Deleted strings: ${chalk2.yellow(batchResponse.deletedStrings)} (archived)`
1517
+ );
1518
+ }
1519
+ p2.log.info(`Total strings: ${chalk2.cyan(batchResponse.totalStrings)}`);
1520
+ if (batchResponse.newStrings === 0) {
1521
+ p2.log.success("No new strings - using existing translations");
1522
+ } else {
1523
+ p2.log.info(
1524
+ `Syncing to ${config.targetLocales.length} locales (${config.targetLocales.join(", ")})`
1525
+ );
1526
+ if (batchResponse.estimatedTime) {
1527
+ p2.log.info(`Estimated time: ~${batchResponse.estimatedTime}s`);
1528
+ }
1529
+ }
1530
+ let artifacts = null;
1531
+ if (batchResponse.translations) {
1532
+ artifacts = {
1533
+ source: "fresh",
1534
+ translations: batchResponse.translations
1535
+ };
1536
+ }
1537
+ let waitError = null;
1538
+ if (!artifacts && (effectiveMode === "required" || effectiveMode === "best-effort")) {
1539
+ spinner4.start(`Waiting for translations (max ${waitTimeoutMs}ms)`);
1540
+ let lastProgress = 0;
1541
+ try {
1542
+ const completion = await api.waitForCompletion(
1543
+ batchResponse.batchId,
1544
+ waitTimeoutMs,
1545
+ (progress) => {
1546
+ const percent = Math.round(progress * 100);
1547
+ if (percent > lastProgress) {
1548
+ spinner4.message(`Translating... ${percent}%`);
1549
+ lastProgress = percent;
1550
+ }
1551
+ }
1552
+ );
1553
+ artifacts = {
1554
+ source: "fresh",
1555
+ translations: completion.translations,
1556
+ localeMetadata: completion.localeMetadata
1557
+ };
1558
+ spinner4.stop("Translations complete");
1559
+ } catch (error) {
1560
+ spinner4.stop("Translation wait incomplete");
1561
+ waitError = error instanceof Error ? error : new Error(String(error));
1562
+ if (effectiveMode === "required") {
1563
+ throw waitError;
1564
+ }
1565
+ p2.log.warn(`Best-effort wait ended early: ${waitError.message}`);
1566
+ }
1567
+ }
1568
+ if (!artifacts) {
1569
+ if (mergedConfig.noFallback) {
1570
+ throw new Error(
1571
+ "Fresh translations are not available and fallback is disabled (--no-fallback)."
1572
+ );
1573
+ }
1574
+ spinner4.start("Loading fallback translations");
1575
+ const localFallback = readLocalSnapshotCache({
1576
+ projectRoot,
1577
+ branch
1578
+ });
1579
+ if (localFallback) {
1580
+ artifacts = localFallback;
1581
+ const cacheBranchLabel = localFallback.cacheBranch && localFallback.cacheBranch !== branch ? `${localFallback.cacheBranch} fallback` : localFallback.cacheBranch || branch;
1582
+ spinner4.stop(`Using local cached snapshot (${cacheBranchLabel})`);
1583
+ } else {
1584
+ try {
1585
+ const apiSnapshot = await fetchApiSnapshot(api, {
1586
+ branch,
1587
+ targetLocales: config.targetLocales
1588
+ });
1589
+ if (apiSnapshot) {
1590
+ artifacts = apiSnapshot;
1591
+ spinner4.stop("Using latest completed API snapshot");
1592
+ } else {
1593
+ spinner4.stop("No completed API snapshot available");
1594
+ }
1595
+ } catch (error) {
1596
+ spinner4.stop("Failed to fetch API snapshot");
1597
+ if (options.verbose) {
1598
+ const message = error instanceof Error ? error.message : "Unknown snapshot fetch error";
1599
+ p2.log.warn(`Snapshot fetch error: ${message}`);
1600
+ }
1601
+ }
1602
+ }
1603
+ if (!artifacts) {
1604
+ if (waitError) {
1605
+ throw new Error(
1606
+ `No fallback snapshot available after wait failure: ${waitError.message}`
1607
+ );
1608
+ }
1609
+ throw new Error(
1610
+ "No fallback snapshot available. Try again shortly or run with --mode required."
1611
+ );
1612
+ }
1613
+ }
1614
+ const finalTranslations = normalizeTranslations({
1615
+ sourceLocale: config.sourceLocale,
1616
+ targetLocales: config.targetLocales,
1617
+ sourceStrings,
1618
+ translations: artifacts.translations
1619
+ });
1620
+ try {
1621
+ const cachePath = writeLocalSnapshotCache({
1622
+ projectRoot,
1623
+ branch,
1624
+ sourceLocale: config.sourceLocale,
1625
+ targetLocales: config.targetLocales,
1626
+ translations: finalTranslations,
1627
+ localeMetadata: artifacts.localeMetadata,
1628
+ snapshotBatchId: artifacts.snapshotBatchId ?? (artifacts.source === "fresh" ? batchResponse.batchId : batchResponse.latestCompletedBatchId),
1629
+ completedAt: artifacts.completedAt ?? (artifacts.source === "fresh" ? (/* @__PURE__ */ new Date()).toISOString() : null)
1630
+ });
1631
+ if (options.verbose) {
1632
+ p2.log.info(`Cached snapshot: ${cachePath}`);
1633
+ }
1634
+ } catch (error) {
1635
+ if (options.verbose) {
1636
+ const message = error instanceof Error ? error.message : "Unknown cache write error";
1637
+ p2.log.warn(`Failed to write local snapshot cache: ${message}`);
1638
+ }
1639
+ }
1640
+ if (artifacts.source !== "fresh") {
1641
+ const sourceLabel = artifacts.source === "local-cache" ? "local cached snapshot" : "completed API snapshot";
1642
+ p2.log.warn(
1643
+ `Using ${sourceLabel}. New strings may appear after the background sync completes.`
1644
+ );
1645
+ }
1646
+ const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
1647
+ p2.outro(`Sync complete! (${duration}s)`);
1648
+ p2.log.info("Translations will be injected at build time by @vocoder/unplugin.");
1649
+ p2.log.info("Just use <VocoderProvider> and <T> \u2014 no manual imports needed.");
1650
+ return 0;
1651
+ } catch (error) {
1652
+ spinner4.stop();
1653
+ if (error instanceof VocoderAPIError && error.syncPolicyError) {
1654
+ p2.log.error(error.syncPolicyError.message);
1655
+ const guidance = getSyncPolicyErrorGuidance(error.syncPolicyError);
1656
+ for (const line of guidance) {
1657
+ p2.log.info(line);
1658
+ }
1659
+ return 1;
1660
+ }
1661
+ if (error instanceof VocoderAPIError && error.limitError) {
1662
+ const { limitError } = error;
1663
+ p2.log.error(limitError.message);
1664
+ const guidance = getLimitErrorGuidance(limitError);
1665
+ for (const line of guidance) {
1666
+ p2.log.info(line);
1667
+ }
1668
+ return 1;
1669
+ }
1670
+ if (error instanceof Error) {
1671
+ p2.log.error(error.message);
1672
+ if (error.message.includes("VOCODER_API_KEY")) {
1673
+ p2.log.warn("Set your API key:");
1674
+ p2.log.info(' export VOCODER_API_KEY="your-api-key"');
1675
+ p2.log.info(" or add it to your .env file");
1676
+ } else if (error.message.includes("git branch")) {
1677
+ p2.log.warn("Run from a git repository, or use:");
1678
+ p2.log.info(" vocoder sync --branch main");
1679
+ }
1680
+ if (options.verbose) {
1681
+ p2.log.info(`Full error: ${error.stack ?? error}`);
1682
+ }
1683
+ }
1684
+ return 1;
1685
+ }
1686
+ }
1687
+
1688
+ // src/commands/wrap.ts
1689
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
1690
+ import { relative as relative2 } from "path";
1691
+ import * as p3 from "@clack/prompts";
1692
+ import chalk3 from "chalk";
1693
+
1694
+ // src/utils/wrap/analyzer.ts
1695
+ import { readFileSync as readFileSync4 } from "fs";
1696
+ import { parse as parse2 } from "@babel/parser";
1697
+ import babelTraverse2 from "@babel/traverse";
1698
+ import { glob as glob2 } from "glob";
1699
+
1700
+ // src/utils/wrap/heuristics.ts
1701
+ var URL_REGEX = /^(https?:\/\/|\/\/|mailto:|tel:|ftp:\/\/)/i;
1702
+ var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1703
+ var FILE_PATH_REGEX = /^(\.{0,2}\/|[a-zA-Z]:\\)/;
1704
+ var COLOR_HEX_REGEX = /^#([0-9a-fA-F]{3,8})$/;
1705
+ var COLOR_FUNC_REGEX = /^(rgb|rgba|hsl|hsla)\s*\(/i;
1706
+ var CAMEL_CASE_REGEX = /^[a-z][a-zA-Z0-9]*$/;
1707
+ var PASCAL_CASE_REGEX = /^[A-Z][a-zA-Z0-9]*$/;
1708
+ var SCREAMING_SNAKE_REGEX = /^[A-Z][A-Z0-9_]+$/;
1709
+ var KEBAB_CASE_REGEX = /^[a-z][a-z0-9-]+$/;
1710
+ var MIME_TYPE_REGEX = /^(application|text|image|audio|video|font|multipart)\//;
1711
+ var DATE_FORMAT_REGEX = /^[YMDHhmsaAZz\-\/\.\s:,]+$/;
1712
+ var CSS_UNIT_REGEX = /^\d+(\.\d+)?(px|em|rem|vh|vw|%|ch|ex|pt|pc|in|cm|mm)$/;
1713
+ var TAILWIND_REGEX = /^[a-z][\w-]*(\s+[a-z][\w-]*)*$/;
1714
+ var TAILWIND_PREFIXES = [
1715
+ "flex",
1716
+ "grid",
1717
+ "block",
1718
+ "inline",
1719
+ "hidden",
1720
+ "absolute",
1721
+ "relative",
1722
+ "fixed",
1723
+ "sticky",
1724
+ "top",
1725
+ "bottom",
1726
+ "left",
1727
+ "right",
1728
+ "inset",
1729
+ "w-",
1730
+ "h-",
1731
+ "min-",
1732
+ "max-",
1733
+ "p-",
1734
+ "px-",
1735
+ "py-",
1736
+ "pt-",
1737
+ "pb-",
1738
+ "pl-",
1739
+ "pr-",
1740
+ "m-",
1741
+ "mx-",
1742
+ "my-",
1743
+ "mt-",
1744
+ "mb-",
1745
+ "ml-",
1746
+ "mr-",
1747
+ "text-",
1748
+ "font-",
1749
+ "leading-",
1750
+ "tracking-",
1751
+ "bg-",
1752
+ "border-",
1753
+ "rounded-",
1754
+ "shadow-",
1755
+ "opacity-",
1756
+ "z-",
1757
+ "gap-",
1758
+ "space-",
1759
+ "items-",
1760
+ "justify-",
1761
+ "self-",
1762
+ "place-",
1763
+ "overflow-",
1764
+ "cursor-",
1765
+ "transition-",
1766
+ "duration-",
1767
+ "ease-",
1768
+ "sm:",
1769
+ "md:",
1770
+ "lg:",
1771
+ "xl:",
1772
+ "2xl:",
1773
+ "dark:",
1774
+ "hover:",
1775
+ "focus:",
1776
+ "active:",
1777
+ "group-",
1778
+ "peer-"
1779
+ ];
1780
+ var NON_TRANSLATABLE_ATTRIBUTES = /* @__PURE__ */ new Set([
1781
+ "className",
1782
+ "class",
1783
+ "href",
1784
+ "src",
1785
+ "id",
1786
+ "key",
1787
+ "ref",
1788
+ "style",
1789
+ "data-testid",
1790
+ "data-cy",
1791
+ "data-test",
1792
+ "type",
1793
+ "name",
1794
+ "value",
1795
+ "action",
1796
+ "method",
1797
+ "encType",
1798
+ "target",
1799
+ "rel",
1800
+ "role",
1801
+ "tabIndex",
1802
+ "htmlFor",
1803
+ "for",
1804
+ "width",
1805
+ "height",
1806
+ "viewBox",
1807
+ "xmlns",
1808
+ "fill",
1809
+ "stroke",
1810
+ "onClick",
1811
+ "onChange",
1812
+ "onSubmit",
1813
+ "onBlur",
1814
+ "onFocus",
1815
+ "onKeyDown",
1816
+ "onKeyUp",
1817
+ "onKeyPress",
1818
+ "onMouseEnter",
1819
+ "onMouseLeave"
1820
+ ]);
1821
+ var TRANSLATABLE_ATTRIBUTES = /* @__PURE__ */ new Set([
1822
+ "title",
1823
+ "placeholder",
1824
+ "alt",
1825
+ "aria-label",
1826
+ "aria-description",
1827
+ "aria-placeholder",
1828
+ "aria-roledescription",
1829
+ "aria-valuetext",
1830
+ "label",
1831
+ "description",
1832
+ "message",
1833
+ "heading",
1834
+ "caption",
1835
+ "helperText",
1836
+ "errorMessage",
1837
+ "successMessage",
1838
+ "tooltip"
1839
+ ]);
1840
+ var NON_TRANSLATABLE_CALLS = /* @__PURE__ */ new Set([
1841
+ "console.log",
1842
+ "console.warn",
1843
+ "console.error",
1844
+ "console.info",
1845
+ "console.debug",
1846
+ "require",
1847
+ "import",
1848
+ "addEventListener",
1849
+ "removeEventListener",
1850
+ "querySelector",
1851
+ "querySelectorAll",
1852
+ "getElementById",
1853
+ "getAttribute",
1854
+ "setAttribute",
1855
+ "createElement",
1856
+ "JSON.parse",
1857
+ "JSON.stringify",
1858
+ "parseInt",
1859
+ "parseFloat",
1860
+ "encodeURIComponent",
1861
+ "decodeURIComponent",
1862
+ "encodeURI",
1863
+ "decodeURI",
1864
+ "RegExp"
1865
+ ]);
1866
+ var TRANSLATABLE_VAR_NAMES = /* @__PURE__ */ new Set([
1867
+ "label",
1868
+ "message",
1869
+ "title",
1870
+ "description",
1871
+ "heading",
1872
+ "text",
1873
+ "caption",
1874
+ "subtitle",
1875
+ "tooltip",
1876
+ "errorMessage",
1877
+ "successMessage",
1878
+ "warningMessage",
1879
+ "infoMessage",
1880
+ "placeholder",
1881
+ "helperText",
1882
+ "hint",
1883
+ "buttonText",
1884
+ "linkText",
1885
+ "headerText",
1886
+ "footerText",
1887
+ "confirmText",
1888
+ "cancelText",
1889
+ "submitText",
1890
+ "greeting",
1891
+ "welcome",
1892
+ "instructions"
1893
+ ]);
1894
+ function classifyString(text, context, metadata = {}) {
1895
+ const trimmed = text.trim();
1896
+ if (trimmed.length === 0) {
1897
+ return { translatable: false, confidence: "high", reason: "Empty or whitespace-only" };
1898
+ }
1899
+ if (trimmed.length === 1) {
1900
+ return { translatable: false, confidence: "high", reason: "Single character" };
1901
+ }
1902
+ if (!/[a-zA-Z]/.test(trimmed)) {
1903
+ return { translatable: false, confidence: "high", reason: "No alphabetic characters" };
1904
+ }
1905
+ if (URL_REGEX.test(trimmed)) {
1906
+ return { translatable: false, confidence: "high", reason: "URL" };
1907
+ }
1908
+ if (EMAIL_REGEX.test(trimmed)) {
1909
+ return { translatable: false, confidence: "high", reason: "Email address" };
1910
+ }
1911
+ if (FILE_PATH_REGEX.test(trimmed) && !trimmed.includes(" ")) {
1912
+ return { translatable: false, confidence: "high", reason: "File path" };
1913
+ }
1914
+ if (COLOR_HEX_REGEX.test(trimmed) || COLOR_FUNC_REGEX.test(trimmed)) {
1915
+ return { translatable: false, confidence: "high", reason: "Color code" };
1916
+ }
1917
+ if (CSS_UNIT_REGEX.test(trimmed)) {
1918
+ return { translatable: false, confidence: "high", reason: "CSS unit value" };
1919
+ }
1920
+ if (MIME_TYPE_REGEX.test(trimmed)) {
1921
+ return { translatable: false, confidence: "high", reason: "MIME type" };
1922
+ }
1923
+ if (DATE_FORMAT_REGEX.test(trimmed) && trimmed.length > 1) {
1924
+ return { translatable: false, confidence: "high", reason: "Date format string" };
1925
+ }
1926
+ if (context === "jsx-attribute" && metadata.attributeName) {
1927
+ if (NON_TRANSLATABLE_ATTRIBUTES.has(metadata.attributeName)) {
1928
+ return { translatable: false, confidence: "high", reason: `Non-translatable attribute: ${metadata.attributeName}` };
1929
+ }
1930
+ if (metadata.attributeName.startsWith("data-") && !TRANSLATABLE_ATTRIBUTES.has(metadata.attributeName)) {
1931
+ return { translatable: false, confidence: "high", reason: "data-* attribute" };
1932
+ }
1933
+ if (metadata.attributeName.startsWith("on") && metadata.attributeName.length > 2) {
1934
+ const thirdChar = metadata.attributeName[2];
1935
+ if (thirdChar && thirdChar === thirdChar.toUpperCase()) {
1936
+ return { translatable: false, confidence: "high", reason: "Event handler attribute" };
1937
+ }
1938
+ }
1939
+ if (TRANSLATABLE_ATTRIBUTES.has(metadata.attributeName)) {
1940
+ return { translatable: true, confidence: "high", reason: `Translatable attribute: ${metadata.attributeName}` };
1941
+ }
1942
+ }
1943
+ if (context === "jsx-text") {
1944
+ const hasWords = /[a-zA-Z]{2,}/.test(trimmed);
1945
+ if (hasWords) {
1946
+ return { translatable: true, confidence: "high", reason: "JSX text with words" };
1947
+ }
1948
+ }
1949
+ if (!trimmed.includes(" ") && (CAMEL_CASE_REGEX.test(trimmed) || PASCAL_CASE_REGEX.test(trimmed) || SCREAMING_SNAKE_REGEX.test(trimmed) || KEBAB_CASE_REGEX.test(trimmed))) {
1950
+ return { translatable: false, confidence: "high", reason: "Code identifier" };
1951
+ }
1952
+ if (isTailwindClasses(trimmed)) {
1953
+ return { translatable: false, confidence: "high", reason: "CSS/Tailwind classes" };
1954
+ }
1955
+ if (metadata.isInsideCallExpression) {
1956
+ if (NON_TRANSLATABLE_CALLS.has(metadata.isInsideCallExpression)) {
1957
+ return { translatable: false, confidence: "high", reason: `Inside ${metadata.isInsideCallExpression}()` };
1958
+ }
1959
+ }
1960
+ if (metadata.parentType === "ThrowStatement" || metadata.isInsideCallExpression === "Error") {
1961
+ return { translatable: false, confidence: "high", reason: "Error message" };
1962
+ }
1963
+ if ((context === "string-literal" || context === "template-literal") && metadata.parentType === "VariableDeclarator") {
1964
+ return { translatable: true, confidence: "medium", reason: "String in variable declaration" };
1965
+ }
1966
+ const wordCount = trimmed.split(/\s+/).length;
1967
+ if (wordCount >= 3) {
1968
+ return { translatable: true, confidence: "medium", reason: `Multi-word string (${wordCount} words)` };
1969
+ }
1970
+ if (wordCount === 2 && /[a-zA-Z]{2,}/.test(trimmed)) {
1971
+ return { translatable: true, confidence: "low", reason: "Short phrase (2 words)" };
1972
+ }
1973
+ if (/^[A-Z][a-z]/.test(trimmed) && context !== "string-literal") {
1974
+ return { translatable: true, confidence: "low", reason: "Capitalized word, possibly UI text" };
1975
+ }
1976
+ return { translatable: false, confidence: "low", reason: "Ambiguous single-word string" };
1977
+ }
1978
+ function isTranslatableVarName(name) {
1979
+ const lower = name.toLowerCase();
1980
+ for (const varName of TRANSLATABLE_VAR_NAMES) {
1981
+ if (lower === varName.toLowerCase() || lower.endsWith(varName.toLowerCase())) {
1982
+ return true;
1983
+ }
1984
+ }
1985
+ return false;
1986
+ }
1987
+ function isTailwindClasses(text) {
1988
+ if (!TAILWIND_REGEX.test(text)) return false;
1989
+ const parts = text.split(/\s+/);
1990
+ let tailwindCount = 0;
1991
+ for (const part of parts) {
1992
+ if (TAILWIND_PREFIXES.some((prefix) => part.startsWith(prefix))) {
1993
+ tailwindCount++;
1994
+ }
1995
+ }
1996
+ return tailwindCount > parts.length / 2;
1997
+ }
1998
+
1999
+ // src/utils/wrap/analyzer.ts
2000
+ var traverse2 = babelTraverse2.default || babelTraverse2;
2001
+ var StringAnalyzer = class {
2002
+ constructor(adapter) {
2003
+ this.adapter = adapter;
2004
+ }
2005
+ /**
2006
+ * Analyze all files matching the given patterns and return wrap candidates.
2007
+ */
2008
+ async analyzeProject(options, projectRoot = process.cwd()) {
2009
+ const includePatterns = options.include?.length ? options.include : ["src/**/*.{tsx,jsx,ts,js}"];
2010
+ const defaultIgnore = [
2011
+ "**/node_modules/**",
2012
+ "**/.next/**",
2013
+ "**/dist/**",
2014
+ "**/build/**",
2015
+ "**/*.test.*",
2016
+ "**/*.spec.*",
2017
+ "**/*.stories.*",
2018
+ "**/__tests__/**"
2019
+ ];
2020
+ const ignorePatterns = options.exclude ? [...defaultIgnore, ...options.exclude] : defaultIgnore;
2021
+ const allFiles = /* @__PURE__ */ new Set();
2022
+ for (const pattern of includePatterns) {
2023
+ const files = await glob2(pattern, {
2024
+ cwd: projectRoot,
2025
+ absolute: true,
2026
+ ignore: ignorePatterns
2027
+ });
2028
+ files.forEach((file) => allFiles.add(file));
2029
+ }
2030
+ const allCandidates = [];
2031
+ for (const file of allFiles) {
2032
+ try {
2033
+ const candidates = this.analyzeFile(file);
2034
+ allCandidates.push(...candidates);
2035
+ } catch (error) {
2036
+ if (options.verbose) {
2037
+ const msg = error instanceof Error ? error.message : "Unknown error";
2038
+ console.warn(`Warning: Failed to analyze ${file}: ${msg}`);
2039
+ }
2040
+ }
2041
+ }
2042
+ return allCandidates;
2043
+ }
2044
+ /**
2045
+ * Analyze a single file and return wrap candidates.
2046
+ */
2047
+ analyzeFile(filePath) {
2048
+ const code = readFileSync4(filePath, "utf-8");
2049
+ return this.analyzeCode(code, filePath);
2050
+ }
2051
+ /**
2052
+ * Analyze source code and return wrap candidates.
2053
+ */
2054
+ analyzeCode(code, filePath = "<input>") {
2055
+ const candidates = [];
2056
+ const ast = parse2(code, {
2057
+ sourceType: "module",
2058
+ plugins: ["jsx", "typescript"]
2059
+ });
2060
+ const vocoderImports = /* @__PURE__ */ new Map();
2061
+ const tFunctionNames = /* @__PURE__ */ new Set();
2062
+ traverse2(ast, {
2063
+ // Track imports from @vocoder/react
2064
+ ImportDeclaration: (path) => {
2065
+ const source = path.node.source.value;
2066
+ if (source === this.adapter.importSource) {
2067
+ path.node.specifiers.forEach((spec) => {
2068
+ if (spec.type === "ImportSpecifier") {
2069
+ const imported = spec.imported.type === "Identifier" ? spec.imported.name : null;
2070
+ const local = spec.local.name;
2071
+ if (imported === this.adapter.componentName) {
2072
+ vocoderImports.set(local, this.adapter.componentName);
2073
+ }
2074
+ if (imported === this.adapter.functionName) {
2075
+ tFunctionNames.add(local);
2076
+ }
2077
+ if (imported === this.adapter.hookName) {
2078
+ vocoderImports.set(local, this.adapter.hookName);
2079
+ }
2080
+ }
2081
+ });
2082
+ }
2083
+ },
2084
+ // Track destructured t from useVocoder()
2085
+ VariableDeclarator: (path) => {
2086
+ const init2 = path.node.init;
2087
+ if (init2 && init2.type === "CallExpression" && init2.callee.type === "Identifier" && init2.callee.name === this.adapter.hookName && path.node.id.type === "ObjectPattern") {
2088
+ path.node.id.properties.forEach((prop) => {
2089
+ if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === this.adapter.functionName) {
2090
+ const localName = prop.value.type === "Identifier" ? prop.value.name : this.adapter.functionName;
2091
+ tFunctionNames.add(localName);
2092
+ }
2093
+ });
2094
+ }
2095
+ },
2096
+ // Find bare JSX text
2097
+ JSXText: (path) => {
2098
+ const text = path.node.value;
2099
+ const trimmed = text.trim();
2100
+ if (!trimmed) return;
2101
+ const ancestors = path.getAncestry().map((a) => a.node);
2102
+ if (this.adapter.isAlreadyWrapped(ancestors, vocoderImports)) return;
2103
+ const classification = classifyString(trimmed, "jsx-text", {
2104
+ isInsideComponent: true
2105
+ });
2106
+ if (classification.translatable) {
2107
+ candidates.push({
2108
+ file: filePath,
2109
+ line: path.node.loc?.start.line || 0,
2110
+ column: path.node.loc?.start.column || 0,
2111
+ text: trimmed,
2112
+ confidence: classification.confidence,
2113
+ strategy: "T-component",
2114
+ context: "jsx-text",
2115
+ reason: classification.reason
2116
+ });
2117
+ }
2118
+ },
2119
+ // Find translatable JSX attributes
2120
+ JSXAttribute: (path) => {
2121
+ const attrName = path.node.name?.name;
2122
+ if (!attrName) return;
2123
+ const value = path.node.value;
2124
+ if (!value) return;
2125
+ let text = null;
2126
+ let context = "jsx-attribute";
2127
+ if (value.type === "StringLiteral") {
2128
+ text = value.value;
2129
+ } else if (value.type === "JSXExpressionContainer" && value.expression.type === "StringLiteral") {
2130
+ text = value.expression.value;
2131
+ }
2132
+ if (!text || !text.trim()) return;
2133
+ if (value.type === "JSXExpressionContainer" && value.expression.type === "CallExpression") {
2134
+ if (this.adapter.isAlreadyWrappedCall(value.expression, tFunctionNames)) return;
2135
+ }
2136
+ const classification = classifyString(text.trim(), context, {
2137
+ attributeName: attrName,
2138
+ isInsideComponent: true
2139
+ });
2140
+ if (classification.translatable) {
2141
+ candidates.push({
2142
+ file: filePath,
2143
+ line: path.node.loc?.start.line || 0,
2144
+ column: path.node.loc?.start.column || 0,
2145
+ text: text.trim(),
2146
+ confidence: classification.confidence,
2147
+ strategy: "t-function",
2148
+ context,
2149
+ reason: classification.reason
2150
+ });
2151
+ }
2152
+ },
2153
+ // Find string literals in non-JSX contexts
2154
+ StringLiteral: (path) => {
2155
+ if (path.parent.type === "ImportDeclaration") return;
2156
+ if (path.parent.type === "ExportDeclaration") return;
2157
+ if (path.parent.type === "JSXAttribute") return;
2158
+ if (path.parent.type === "JSXExpressionContainer" && path.parentPath?.parent?.type === "JSXAttribute") return;
2159
+ if (path.parent.type === "JSXExpressionContainer") return;
2160
+ if (path.parent.type === "ObjectProperty" && path.parent.key === path.node) return;
2161
+ if (path.parent.type === "TSLiteralType") return;
2162
+ if (isInsideTCall(path, tFunctionNames)) return;
2163
+ const text = path.node.value;
2164
+ if (!text.trim()) return;
2165
+ const callExpr = getEnclosingCallExpression(path);
2166
+ const parentType = path.parent.type;
2167
+ const classification = classifyString(text.trim(), "string-literal", {
2168
+ parentType,
2169
+ isInsideCallExpression: callExpr,
2170
+ isInsideComponent: false
2171
+ });
2172
+ let { confidence } = classification;
2173
+ if (parentType === "VariableDeclarator" && path.parent.id?.type === "Identifier") {
2174
+ const varName = path.parent.id.name;
2175
+ if (isTranslatableVarName(varName) && classification.translatable) {
2176
+ confidence = "high";
2177
+ }
2178
+ }
2179
+ if (classification.translatable) {
2180
+ candidates.push({
2181
+ file: filePath,
2182
+ line: path.node.loc?.start.line || 0,
2183
+ column: path.node.loc?.start.column || 0,
2184
+ text: text.trim(),
2185
+ confidence,
2186
+ strategy: "t-function",
2187
+ context: "string-literal",
2188
+ reason: classification.reason
2189
+ });
2190
+ }
2191
+ },
2192
+ // Find template literals
2193
+ TemplateLiteral: (path) => {
2194
+ if (path.parent.type === "ImportDeclaration") return;
2195
+ if (path.parent.type === "TaggedTemplateExpression") return;
2196
+ if (isInsideTCall(path, tFunctionNames)) return;
2197
+ const quasis = path.node.quasis;
2198
+ if (quasis.length === 0) return;
2199
+ const parts = [];
2200
+ for (let i = 0; i < quasis.length; i++) {
2201
+ const quasi = quasis[i];
2202
+ parts.push(quasi.value.raw);
2203
+ if (i < path.node.expressions.length) {
2204
+ const expr = path.node.expressions[i];
2205
+ if (expr.type === "Identifier") {
2206
+ parts.push(`{${expr.name}}`);
2207
+ } else {
2208
+ parts.push("{value}");
2209
+ }
2210
+ }
2211
+ }
2212
+ const text = parts.join("").trim();
2213
+ if (!text) return;
2214
+ const callExpr = getEnclosingCallExpression(path);
2215
+ const parentType = path.parent.type;
2216
+ const classification = classifyString(text, "template-literal", {
2217
+ parentType,
2218
+ isInsideCallExpression: callExpr,
2219
+ isInsideComponent: false
2220
+ });
2221
+ if (classification.translatable) {
2222
+ candidates.push({
2223
+ file: filePath,
2224
+ line: path.node.loc?.start.line || 0,
2225
+ column: path.node.loc?.start.column || 0,
2226
+ text,
2227
+ confidence: classification.confidence,
2228
+ strategy: "t-function",
2229
+ context: "template-literal",
2230
+ reason: classification.reason
2231
+ });
2232
+ }
2233
+ }
2234
+ });
2235
+ return candidates;
2236
+ }
2237
+ };
2238
+ function isInsideTCall(path, tNames) {
2239
+ let current = path.parentPath;
2240
+ while (current) {
2241
+ if (current.node.type === "CallExpression") {
2242
+ const callee = current.node.callee;
2243
+ if (callee.type === "Identifier" && tNames.has(callee.name)) {
2244
+ return true;
2245
+ }
2246
+ }
2247
+ current = current.parentPath;
2248
+ }
2249
+ return false;
2250
+ }
2251
+ function getEnclosingCallExpression(path) {
2252
+ let current = path.parentPath;
2253
+ while (current) {
2254
+ if (current.node.type === "CallExpression") {
2255
+ const callee = current.node.callee;
2256
+ if (callee.type === "Identifier") {
2257
+ return callee.name;
2258
+ }
2259
+ if (callee.type === "MemberExpression" && callee.object.type === "Identifier" && callee.property.type === "Identifier") {
2260
+ return `${callee.object.name}.${callee.property.name}`;
2261
+ }
2262
+ }
2263
+ if (current.node.type === "NewExpression") {
2264
+ const callee = current.node.callee;
2265
+ if (callee.type === "Identifier") {
2266
+ return callee.name;
2267
+ }
2268
+ }
2269
+ current = current.parentPath;
2270
+ }
2271
+ return void 0;
2272
+ }
2273
+
2274
+ // src/utils/wrap/transformer.ts
2275
+ import * as recast from "recast";
2276
+ import { parse as babelParse } from "@babel/parser";
2277
+ var babelParser = {
2278
+ parse(source) {
2279
+ return babelParse(source, {
2280
+ sourceType: "module",
2281
+ plugins: ["jsx", "typescript"],
2282
+ tokens: true
2283
+ });
2284
+ }
2285
+ };
2286
+ var StringTransformer = class {
2287
+ constructor(adapter) {
2288
+ this.adapter = adapter;
2289
+ }
2290
+ /**
2291
+ * Transform a file by wrapping the given candidates.
2292
+ * Returns the transformed source code.
2293
+ */
2294
+ transform(code, candidates, filePath = "<input>") {
2295
+ const ast = recast.parse(code, { parser: babelParser });
2296
+ const b = recast.types.builders;
2297
+ const wrapped = [];
2298
+ const skipped = [];
2299
+ const usedStrategies = /* @__PURE__ */ new Set();
2300
+ const componentsNeedingHook = /* @__PURE__ */ new Set();
2301
+ const candidatesByLocation = /* @__PURE__ */ new Map();
2302
+ for (const c of candidates) {
2303
+ candidatesByLocation.set(`${c.line}:${c.column}`, c);
2304
+ }
2305
+ let existingImportDecl = null;
2306
+ const existingSpecifiers = /* @__PURE__ */ new Set();
2307
+ const adapter = this.adapter;
2308
+ recast.visit(ast, {
2309
+ visitImportDeclaration(path) {
2310
+ const source = path.node.source.value;
2311
+ if (source === adapter.importSource) {
2312
+ existingImportDecl = path;
2313
+ for (const spec of path.node.specifiers || []) {
2314
+ if (spec.type === "ImportSpecifier" && spec.imported.type === "Identifier") {
2315
+ existingSpecifiers.add(spec.imported.name);
2316
+ }
2317
+ }
2318
+ }
2319
+ this.traverse(path);
2320
+ },
2321
+ visitJSXText(path) {
2322
+ const loc = path.node.loc;
2323
+ if (!loc) {
2324
+ this.traverse(path);
2325
+ return;
2326
+ }
2327
+ const key = `${loc.start.line}:${loc.start.column}`;
2328
+ const candidate = candidatesByLocation.get(key);
2329
+ if (!candidate || candidate.strategy !== "T-component") {
2330
+ this.traverse(path);
2331
+ return;
2332
+ }
2333
+ const tOpen = b.jsxOpeningElement(
2334
+ b.jsxIdentifier(adapter.componentName),
2335
+ []
2336
+ );
2337
+ const tClose = b.jsxClosingElement(
2338
+ b.jsxIdentifier(adapter.componentName)
2339
+ );
2340
+ const tElement = b.jsxElement(
2341
+ tOpen,
2342
+ tClose,
2343
+ [b.jsxText(candidate.text)]
2344
+ );
2345
+ path.replace(tElement);
2346
+ wrapped.push(candidate);
2347
+ usedStrategies.add("T-component");
2348
+ candidatesByLocation.delete(key);
2349
+ return false;
2350
+ },
2351
+ visitJSXAttribute(path) {
2352
+ const loc = path.node.loc;
2353
+ if (!loc) {
2354
+ this.traverse(path);
2355
+ return;
2356
+ }
2357
+ const key = `${loc.start.line}:${loc.start.column}`;
2358
+ const candidate = candidatesByLocation.get(key);
2359
+ if (!candidate || candidate.strategy !== "t-function") {
2360
+ this.traverse(path);
2361
+ return;
2362
+ }
2363
+ const value = path.node.value;
2364
+ if (!value) {
2365
+ this.traverse(path);
2366
+ return;
2367
+ }
2368
+ const tCall = b.callExpression(
2369
+ b.identifier(adapter.functionName),
2370
+ [b.stringLiteral(candidate.text)]
2371
+ );
2372
+ const exprContainer = b.jsxExpressionContainer(tCall);
2373
+ path.node.value = exprContainer;
2374
+ const componentFunc = findEnclosingComponent(path);
2375
+ if (componentFunc) {
2376
+ componentsNeedingHook.add(componentFunc);
2377
+ }
2378
+ wrapped.push(candidate);
2379
+ usedStrategies.add("t-function");
2380
+ candidatesByLocation.delete(key);
2381
+ this.traverse(path);
2382
+ },
2383
+ visitStringLiteral(path) {
2384
+ const loc = path.node.loc;
2385
+ if (!loc) {
2386
+ this.traverse(path);
2387
+ return;
2388
+ }
2389
+ const key = `${loc.start.line}:${loc.start.column}`;
2390
+ const candidate = candidatesByLocation.get(key);
2391
+ if (!candidate || candidate.strategy !== "t-function") {
2392
+ this.traverse(path);
2393
+ return;
2394
+ }
2395
+ if (path.parent.node.type === "JSXAttribute") {
2396
+ this.traverse(path);
2397
+ return;
2398
+ }
2399
+ const tCall = b.callExpression(
2400
+ b.identifier(adapter.functionName),
2401
+ [b.stringLiteral(candidate.text)]
2402
+ );
2403
+ path.replace(tCall);
2404
+ const componentFunc = findEnclosingComponent(path);
2405
+ if (componentFunc) {
2406
+ componentsNeedingHook.add(componentFunc);
2407
+ }
2408
+ wrapped.push(candidate);
2409
+ usedStrategies.add("t-function");
2410
+ candidatesByLocation.delete(key);
2411
+ return false;
2412
+ }
2413
+ });
2414
+ for (const candidate of candidatesByLocation.values()) {
2415
+ skipped.push(candidate);
2416
+ }
2417
+ if (componentsNeedingHook.size > 0) {
2418
+ this.injectUseVocoderHooks(ast, componentsNeedingHook, b);
2419
+ }
2420
+ this.manageImports(ast, usedStrategies, existingImportDecl, existingSpecifiers, componentsNeedingHook.size > 0, b);
2421
+ const output = recast.print(ast).code;
2422
+ return {
2423
+ file: filePath,
2424
+ output,
2425
+ wrappedCount: wrapped.length,
2426
+ wrapped,
2427
+ skipped
2428
+ };
2429
+ }
2430
+ /**
2431
+ * Inject `const { t } = useVocoder();` at the top of component functions.
2432
+ */
2433
+ injectUseVocoderHooks(ast, componentFuncs, b) {
2434
+ const adapterFunctionName = this.adapter.functionName;
2435
+ const adapterHookName = this.adapter.hookName;
2436
+ const buildHookDecl = () => b.variableDeclaration("const", [
2437
+ b.variableDeclarator(
2438
+ b.objectPattern([
2439
+ b.property.from({
2440
+ kind: "init",
2441
+ key: b.identifier(adapterFunctionName),
2442
+ value: b.identifier(adapterFunctionName),
2443
+ shorthand: true
2444
+ })
2445
+ ]),
2446
+ b.callExpression(b.identifier(adapterHookName), [])
2447
+ )
2448
+ ]);
2449
+ recast.visit(ast, {
2450
+ visitFunction(path) {
2451
+ if (componentFuncs.has(path.node)) {
2452
+ const body = path.node.body;
2453
+ if (body.type === "BlockStatement") {
2454
+ const alreadyHasHook = body.body.some((stmt) => {
2455
+ if (stmt.type !== "VariableDeclaration") return false;
2456
+ return stmt.declarations.some(
2457
+ (decl) => decl.init?.type === "CallExpression" && decl.init.callee?.type === "Identifier" && decl.init.callee.name === "useVocoder"
2458
+ );
2459
+ });
2460
+ if (!alreadyHasHook) {
2461
+ body.body.unshift(buildHookDecl());
2462
+ }
2463
+ }
2464
+ }
2465
+ this.traverse(path);
2466
+ },
2467
+ visitArrowFunctionExpression(path) {
2468
+ if (componentFuncs.has(path.node)) {
2469
+ const body = path.node.body;
2470
+ if (body.type === "BlockStatement") {
2471
+ const alreadyHasHook = body.body.some((stmt) => {
2472
+ if (stmt.type !== "VariableDeclaration") return false;
2473
+ return stmt.declarations.some(
2474
+ (decl) => decl.init?.type === "CallExpression" && decl.init.callee?.type === "Identifier" && decl.init.callee.name === "useVocoder"
2475
+ );
2476
+ });
2477
+ if (!alreadyHasHook) {
2478
+ body.body.unshift(buildHookDecl());
2479
+ }
2480
+ }
2481
+ }
2482
+ this.traverse(path);
2483
+ }
2484
+ });
2485
+ }
2486
+ /**
2487
+ * Add or update @vocoder/react imports.
2488
+ */
2489
+ manageImports(ast, usedStrategies, existingImportPath, existingSpecifiers, needsHook, b) {
2490
+ if (usedStrategies.size === 0) return;
2491
+ const neededSpecifiers = /* @__PURE__ */ new Set();
2492
+ if (usedStrategies.has("T-component")) {
2493
+ neededSpecifiers.add(this.adapter.componentName);
2494
+ }
2495
+ if (usedStrategies.has("t-function") && needsHook) {
2496
+ neededSpecifiers.add(this.adapter.hookName);
2497
+ }
2498
+ const missingSpecifiers = [];
2499
+ for (const spec of neededSpecifiers) {
2500
+ if (!existingSpecifiers.has(spec)) {
2501
+ missingSpecifiers.push(spec);
2502
+ }
2503
+ }
2504
+ if (missingSpecifiers.length === 0) return;
2505
+ if (existingImportPath) {
2506
+ for (const name of missingSpecifiers) {
2507
+ const specifier = b.importSpecifier(b.identifier(name), b.identifier(name));
2508
+ existingImportPath.node.specifiers.push(specifier);
2509
+ }
2510
+ } else {
2511
+ const specifiers = missingSpecifiers.map(
2512
+ (name) => b.importSpecifier(b.identifier(name), b.identifier(name))
2513
+ );
2514
+ const importDecl = b.importDeclaration(
2515
+ specifiers,
2516
+ b.stringLiteral(this.adapter.importSource)
2517
+ );
2518
+ const body = ast.program.body;
2519
+ let lastImportIndex = -1;
2520
+ for (let i = 0; i < body.length; i++) {
2521
+ if (body[i].type === "ImportDeclaration") {
2522
+ lastImportIndex = i;
2523
+ }
2524
+ }
2525
+ if (lastImportIndex >= 0) {
2526
+ body.splice(lastImportIndex + 1, 0, importDecl);
2527
+ } else {
2528
+ body.unshift(importDecl);
2529
+ }
2530
+ }
2531
+ }
2532
+ };
2533
+ function findEnclosingComponent(path) {
2534
+ let current = path.parent;
2535
+ while (current) {
2536
+ const node = current.node;
2537
+ if (node.type === "FunctionDeclaration" && node.id?.name) {
2538
+ const name = node.id.name;
2539
+ if (/^[A-Z]/.test(name)) return node;
2540
+ }
2541
+ if (node.type === "ArrowFunctionExpression") {
2542
+ const parent = current.parent?.node;
2543
+ if (parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") {
2544
+ const name = parent.id.name;
2545
+ if (/^[A-Z]/.test(name)) return node;
2546
+ }
2547
+ }
2548
+ if (node.type === "FunctionExpression") {
2549
+ const parent = current.parent?.node;
2550
+ if (parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") {
2551
+ const name = parent.id.name;
2552
+ if (/^[A-Z]/.test(name)) return node;
2553
+ }
2554
+ }
2555
+ current = current.parent;
2556
+ }
2557
+ return null;
2558
+ }
2559
+
2560
+ // src/utils/wrap/adapters/react.ts
2561
+ var reactAdapter = {
2562
+ name: "react",
2563
+ extensions: [".tsx", ".jsx", ".ts", ".js"],
2564
+ importSource: "@vocoder/react",
2565
+ componentName: "T",
2566
+ functionName: "t",
2567
+ hookName: "useVocoder",
2568
+ translatableAttributes: [
2569
+ "title",
2570
+ "placeholder",
2571
+ "alt",
2572
+ "aria-label",
2573
+ "aria-description",
2574
+ "aria-placeholder",
2575
+ "aria-roledescription",
2576
+ "aria-valuetext",
2577
+ "label",
2578
+ "description",
2579
+ "message",
2580
+ "heading",
2581
+ "caption",
2582
+ "helperText",
2583
+ "errorMessage",
2584
+ "successMessage",
2585
+ "tooltip"
2586
+ ],
2587
+ nonTranslatableAttributes: [
2588
+ "className",
2589
+ "class",
2590
+ "href",
2591
+ "src",
2592
+ "id",
2593
+ "key",
2594
+ "ref",
2595
+ "style",
2596
+ "data-testid",
2597
+ "data-cy",
2598
+ "data-test",
2599
+ "type",
2600
+ "name",
2601
+ "value",
2602
+ "action",
2603
+ "method",
2604
+ "encType",
2605
+ "target",
2606
+ "rel",
2607
+ "role",
2608
+ "tabIndex",
2609
+ "htmlFor",
2610
+ "for",
2611
+ "width",
2612
+ "height",
2613
+ "viewBox",
2614
+ "xmlns",
2615
+ "fill",
2616
+ "stroke"
2617
+ ],
2618
+ isAlreadyWrapped(ancestors, imports) {
2619
+ for (const ancestor of ancestors) {
2620
+ if (ancestor.type === "JSXElement") {
2621
+ const opening = ancestor.openingElement;
2622
+ if (opening && opening.name && opening.name.type === "JSXIdentifier") {
2623
+ const tagName = opening.name.name;
2624
+ if (imports.has(tagName) && imports.get(tagName) === "T") {
2625
+ return true;
2626
+ }
2627
+ }
2628
+ }
2629
+ }
2630
+ return false;
2631
+ },
2632
+ isAlreadyWrappedCall(node, tNames) {
2633
+ if (node.type === "CallExpression") {
2634
+ const callee = node.callee;
2635
+ if (callee.type === "Identifier" && tNames.has(callee.name)) {
2636
+ return true;
2637
+ }
2638
+ }
2639
+ return false;
2640
+ },
2641
+ getRequiredImports(strategies) {
2642
+ const specifiers = [];
2643
+ if (strategies.has("T-component")) {
2644
+ specifiers.push("T");
2645
+ }
2646
+ if (strategies.has("t-function")) {
2647
+ specifiers.push("useVocoder");
2648
+ }
2649
+ return { specifiers, source: "@vocoder/react" };
2650
+ }
2651
+ };
2652
+
2653
+ // src/commands/wrap.ts
2654
+ var CONFIDENCE_ORDER = ["high", "medium", "low"];
2655
+ function meetsConfidenceThreshold(candidate, threshold) {
2656
+ return CONFIDENCE_ORDER.indexOf(candidate) <= CONFIDENCE_ORDER.indexOf(threshold);
2657
+ }
2658
+ async function wrap(options = {}) {
2659
+ const startTime = Date.now();
2660
+ const projectRoot = process.cwd();
2661
+ const confidenceThreshold = options.confidence || "high";
2662
+ p3.intro("Vocoder Wrap");
2663
+ const spinner4 = p3.spinner();
2664
+ try {
2665
+ spinner4.start("Scanning files for unwrapped strings");
2666
+ const analyzer = new StringAnalyzer(reactAdapter);
2667
+ const allCandidates = await analyzer.analyzeProject(options, projectRoot);
2668
+ if (allCandidates.length === 0) {
2669
+ spinner4.stop("No unwrapped strings found");
2670
+ p3.log.info("All user-facing strings appear to be wrapped already.");
2671
+ p3.outro("");
2672
+ return 0;
2673
+ }
2674
+ spinner4.stop(
2675
+ `Found ${chalk3.cyan(allCandidates.length)} candidate strings`
2676
+ );
2677
+ const filtered = allCandidates.filter(
2678
+ (c) => meetsConfidenceThreshold(c.confidence, confidenceThreshold)
2679
+ );
2680
+ if (filtered.length === 0) {
2681
+ p3.log.warn(
2682
+ `No strings meet the ${chalk3.bold(confidenceThreshold)} confidence threshold.`
2683
+ );
2684
+ p3.log.info("Try --confidence medium or --confidence low to see more candidates.");
2685
+ p3.outro("");
2686
+ return 0;
2687
+ }
2688
+ p3.log.info(
2689
+ `${filtered.length} strings meet ${chalk3.bold(confidenceThreshold)} confidence threshold`
2690
+ );
2691
+ const byFile = /* @__PURE__ */ new Map();
2692
+ for (const c of filtered) {
2693
+ const existing = byFile.get(c.file) || [];
2694
+ existing.push(c);
2695
+ byFile.set(c.file, existing);
2696
+ }
2697
+ if (options.dryRun) {
2698
+ const lines = [];
2699
+ for (const [file, candidates] of byFile) {
2700
+ const relPath = relative2(projectRoot, file);
2701
+ lines.push(chalk3.bold(relPath));
2702
+ for (const c of candidates) {
2703
+ const confidenceColor = c.confidence === "high" ? chalk3.green : c.confidence === "medium" ? chalk3.yellow : chalk3.red;
2704
+ const strategyLabel = c.strategy === "T-component" ? "<T>" : "t()";
2705
+ lines.push(
2706
+ ` ${chalk3.dim(`L${c.line}`)} ${confidenceColor(`[${c.confidence}]`)} ${chalk3.cyan(strategyLabel)} "${truncate(c.text, 50)}"`
2707
+ );
2708
+ if (options.verbose) {
2709
+ lines.push(chalk3.dim(` ${c.reason}`));
2710
+ }
2711
+ }
2712
+ lines.push("");
2713
+ }
2714
+ lines.push(summarizeCandidates(filtered));
2715
+ p3.note(lines.join("\n"), "Dry run \u2014 would wrap");
2716
+ p3.outro("Run without --dry-run to apply changes.");
2717
+ return 0;
2718
+ }
2719
+ let accepted;
2720
+ if (options.interactive) {
2721
+ accepted = await interactiveConfirm(byFile, projectRoot);
2722
+ if (accepted.length === 0) {
2723
+ p3.log.warn("No strings selected for wrapping.");
2724
+ p3.outro("");
2725
+ return 0;
2726
+ }
2727
+ } else {
2728
+ accepted = filtered;
2729
+ }
2730
+ spinner4.start("Wrapping strings");
2731
+ const transformer = new StringTransformer(reactAdapter);
2732
+ let totalWrapped = 0;
2733
+ let filesModified = 0;
2734
+ const acceptedByFile = /* @__PURE__ */ new Map();
2735
+ for (const c of accepted) {
2736
+ const existing = acceptedByFile.get(c.file) || [];
2737
+ existing.push(c);
2738
+ acceptedByFile.set(c.file, existing);
2739
+ }
2740
+ for (const [file, candidates] of acceptedByFile) {
2741
+ const code = readFileSync5(file, "utf-8");
2742
+ const result = transformer.transform(code, candidates, file);
2743
+ if (result.wrappedCount > 0) {
2744
+ writeFileSync3(file, result.output, "utf-8");
2745
+ totalWrapped += result.wrappedCount;
2746
+ filesModified++;
2747
+ }
2748
+ if (options.verbose && result.skipped.length > 0) {
2749
+ const relPath = relative2(projectRoot, file);
2750
+ p3.log.info(`Skipped ${result.skipped.length} strings in ${relPath}`);
2751
+ }
2752
+ }
2753
+ spinner4.stop(
2754
+ `Wrapped ${chalk3.cyan(totalWrapped)} strings across ${chalk3.cyan(filesModified)} files`
2755
+ );
2756
+ const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
2757
+ p3.outro(`Done! (${duration}s)`);
2758
+ p3.log.info("Next steps:");
2759
+ p3.log.info(" 1. Review the changes (git diff)");
2760
+ p3.log.info(" 2. Run your tests to verify nothing broke");
2761
+ p3.log.info(' 3. Run "vocoder sync" to extract and translate');
2762
+ return 0;
2763
+ } catch (error) {
2764
+ spinner4.stop();
2765
+ if (error instanceof Error) {
2766
+ p3.log.error(error.message);
2767
+ if (options.verbose) {
2768
+ p3.log.info(`Full error: ${error.stack ?? error}`);
2769
+ }
2770
+ }
2771
+ return 1;
2772
+ }
2773
+ }
2774
+ async function interactiveConfirm(byFile, projectRoot) {
2775
+ const accepted = [];
2776
+ p3.log.info("Interactive mode \u2014 confirm each string:");
2777
+ for (const [file, candidates] of byFile) {
2778
+ const relPath = relative2(projectRoot, file);
2779
+ p3.log.step(chalk3.bold(relPath));
2780
+ let skipFile = false;
2781
+ for (const c of candidates) {
2782
+ if (skipFile) break;
2783
+ const strategyLabel = c.strategy === "T-component" ? "<T>" : "t()";
2784
+ const label = `L${c.line} ${strategyLabel} "${truncate(c.text, 50)}"`;
2785
+ const action = await p3.select({
2786
+ message: label,
2787
+ options: [
2788
+ { value: "yes", label: "Yes, wrap this string" },
2789
+ { value: "no", label: "No, skip" },
2790
+ { value: "all", label: "Accept all remaining" },
2791
+ { value: "skip", label: "Skip this file" },
2792
+ { value: "quit", label: "Quit" }
2793
+ ]
2794
+ });
2795
+ if (p3.isCancel(action) || action === "quit") {
2796
+ return accepted;
2797
+ }
2798
+ if (action === "yes") {
2799
+ accepted.push(c);
2800
+ } else if (action === "all") {
2801
+ accepted.push(c);
2802
+ const remaining = candidates.slice(candidates.indexOf(c) + 1);
2803
+ accepted.push(...remaining);
2804
+ for (const [, moreCandidates] of byFile) {
2805
+ if (moreCandidates !== candidates) {
2806
+ accepted.push(...moreCandidates);
2807
+ }
2808
+ }
2809
+ return accepted;
2810
+ } else if (action === "skip") {
2811
+ skipFile = true;
2812
+ }
2813
+ }
2814
+ }
2815
+ return accepted;
2816
+ }
2817
+ function truncate(text, maxLen) {
2818
+ if (text.length <= maxLen) return text;
2819
+ return text.slice(0, maxLen - 3) + "...";
2820
+ }
2821
+ function summarizeCandidates(candidates) {
2822
+ let high = 0;
2823
+ let medium = 0;
2824
+ let low = 0;
2825
+ let tComponent = 0;
2826
+ let tFunction = 0;
2827
+ for (const c of candidates) {
2828
+ if (c.confidence === "high") high++;
2829
+ else if (c.confidence === "medium") medium++;
2830
+ else low++;
2831
+ if (c.strategy === "T-component") tComponent++;
2832
+ else tFunction++;
2833
+ }
2834
+ const parts = [];
2835
+ if (high > 0) parts.push(chalk3.green(`${high} high`));
2836
+ if (medium > 0) parts.push(chalk3.yellow(`${medium} medium`));
2837
+ if (low > 0) parts.push(chalk3.red(`${low} low`));
2838
+ return `${candidates.length} total (${parts.join(", ")}) | ${tComponent} <T>, ${tFunction} t()`;
2839
+ }
2840
+
2841
+ // src/bin.ts
2842
+ function collect(value, previous = []) {
2843
+ return previous.concat([value]);
2844
+ }
2845
+ async function runCommand(command, options) {
2846
+ const exitCode = await command(options);
2847
+ process.exitCode = exitCode;
2848
+ }
8
2849
  var program = new Command();
9
- program.name("vocoder").description("Vocoder CLI - Sync translations for your application").version("0.1.0");
10
- program.command("sync").description("Extract strings and sync translations").option("--branch <name>", "Override branch detection").option("--force", "Sync even if not a target branch").option("--dry-run", "Show what would be synced without doing it").option("--verbose", "Show detailed progress").option("--max-age <seconds>", "Use cache if younger than this (seconds)", parseInt).action(sync);
2850
+ program.name("vocoder").description("Vocoder CLI - Sync translations for your application").version("0.1.2");
2851
+ program.command("sync").description("Extract strings and sync translations").option("--include <pattern>", "Glob pattern(s) to include (can be used multiple times)", collect, []).option("--exclude <pattern>", "Glob pattern(s) to exclude (can be used multiple times)", collect, []).option("--branch <name>", "Override branch detection").option("--force", "Sync even if not a target branch").option("--mode <mode>", "Sync mode: auto|required|best-effort").option("--max-wait-ms <ms>", "Max wait time before fallback (ms)", (value) => Number.parseInt(value, 10)).option("--no-fallback", "Fail instead of using fallback artifacts").option("--dry-run", "Show what would be synced without doing it").option("--verbose", "Show detailed progress").action((options) => runCommand(sync, {
2852
+ ...options,
2853
+ noFallback: options.noFallback ? true : void 0
2854
+ }));
2855
+ program.command("wrap").description("Auto-wrap strings with <T> and t() for translation").option("--include <pattern>", "Glob pattern(s) to include (can be used multiple times)", collect, []).option("--exclude <pattern>", "Glob pattern(s) to exclude (can be used multiple times)", collect, []).option("--dry-run", "Preview changes without modifying files").option("--interactive", "Confirm each string interactively").option("--confidence <level>", "Minimum confidence: high, medium, low", "high").option("--verbose", "Detailed output").action((options) => runCommand(wrap, options));
2856
+ program.command("init").description("Authenticate and provision Vocoder for this project").option("--api-url <url>", "Override Vocoder API URL").option("--yes", "Allow overwriting existing local config values").option("--project-name <name>", "Starter project name to create").option("--source-locale <locale>", "Source locale for the starter project").option("--target-locales <list>", "Comma-separated target locales (e.g. es,fr,de)").action((options) => runCommand(init, options));
11
2857
  program.parse(process.argv);
12
2858
  //# sourceMappingURL=bin.mjs.map