@vocoder/cli 0.1.2 → 0.1.4

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