i18nsmith 0.3.3 → 0.4.0

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.
Files changed (36) hide show
  1. package/build.mjs +1 -1
  2. package/dist/commands/detect.d.ts +3 -0
  3. package/dist/commands/detect.d.ts.map +1 -0
  4. package/dist/commands/init.d.ts.map +1 -1
  5. package/dist/commands/rename.d.ts.map +1 -1
  6. package/dist/commands/scan.d.ts.map +1 -1
  7. package/dist/commands/sync.d.ts.map +1 -1
  8. package/dist/commands/transform.d.ts.map +1 -1
  9. package/dist/commands/translate/csv-handler.d.ts.map +1 -1
  10. package/dist/index.cjs +47711 -42734
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/test-helpers/ensure-cli-built.d.ts.map +1 -1
  13. package/dist/utils/adapter-preflight.d.ts +10 -0
  14. package/dist/utils/adapter-preflight.d.ts.map +1 -0
  15. package/i18n.config.json +14 -0
  16. package/package.json +4 -2
  17. package/src/commands/detect.ts +342 -0
  18. package/src/commands/init.test.ts +208 -1
  19. package/src/commands/init.ts +472 -195
  20. package/src/commands/rename.ts +13 -0
  21. package/src/commands/review.ts +1 -1
  22. package/src/commands/scan.ts +4 -1
  23. package/src/commands/sync.ts +23 -3
  24. package/src/commands/transform.ts +54 -2
  25. package/src/commands/translate/csv-handler.ts +2 -1
  26. package/src/e2e.test.ts +4 -4
  27. package/src/fixtures/suspicious-keys/locales/en.json +8 -8
  28. package/src/fixtures/suspicious-keys/locales/fr.json +8 -8
  29. package/src/fixtures/suspicious-keys/preview.json +419 -0
  30. package/src/fixtures/suspicious-keys/src/BadKeys.tsx.backup +19 -0
  31. package/src/index.ts +3 -1
  32. package/src/integration.test.ts +2 -6
  33. package/src/rename-suspicious.test.ts +3 -3
  34. package/src/test-helpers/ensure-cli-built.ts +18 -0
  35. package/src/utils/adapter-preflight.ts +53 -0
  36. package/test.vue +33 -0
@@ -3,9 +3,10 @@ import inquirer from 'inquirer';
3
3
  import fs from 'fs/promises';
4
4
  import path from 'path';
5
5
  import chalk from 'chalk';
6
- import { diagnoseWorkspace, I18nConfig, TranslationConfig, ensureGitignore } from '@i18nsmith/core';
6
+ import { diagnoseWorkspace, I18nConfig, TranslationConfig, ensureGitignore, ProjectIntelligenceService, type ProjectIntelligence, type SuggestedConfig, Scanner, KeyGenerator } from '@i18nsmith/core';
7
7
  import { scaffoldTranslationContext, scaffoldI18next } from '../utils/scaffold.js';
8
8
  import { hasDependency, readPackageJson } from '../utils/pkg.js';
9
+ import { detectPackageManager, installDependencies } from '../utils/package-manager.js';
9
10
  import { CliError, withErrorHandling } from '../utils/errors.js';
10
11
 
11
12
  /**
@@ -41,9 +42,14 @@ export function parseGlobList(value: string): string[] {
41
42
  interface InitCommandOptions {
42
43
  merge?: boolean;
43
44
  yes?: boolean;
45
+ template?: string;
46
+ scaffold?: boolean;
44
47
  }
45
48
 
46
49
  interface InitAnswers {
50
+ setupMode?: 'auto' | 'template' | 'manual';
51
+ template?: string;
52
+ confirmAuto?: boolean;
47
53
  sourceLanguage: string;
48
54
  targetLanguages: string;
49
55
  localesDir: string;
@@ -65,9 +71,22 @@ interface InitAnswers {
65
71
  seedTargetLocales: boolean;
66
72
  }
67
73
 
74
+ /**
75
+ * Run project intelligence detection to gather smart defaults.
76
+ */
77
+ async function detectProjectIntelligence(workspaceRoot: string): Promise<ProjectIntelligence | null> {
78
+ try {
79
+ const service = new ProjectIntelligenceService();
80
+ const result = await service.analyze({ workspaceRoot });
81
+ return result;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
68
87
  /**
69
88
  * Run init in non-interactive mode with sensible defaults.
70
- * Auto-detects existing adapters and locales.
89
+ * Uses ProjectIntelligenceService for smart detection.
71
90
  */
72
91
  async function runNonInteractiveInit(commandOptions: InitCommandOptions): Promise<void> {
73
92
  const workspaceRoot = process.cwd();
@@ -85,97 +104,92 @@ async function runNonInteractiveInit(commandOptions: InitCommandOptions): Promis
85
104
  // Config doesn't exist, proceed
86
105
  }
87
106
 
88
- // Create a minimal config for diagnostics
89
- const minimalConfig: I18nConfig = {
90
- version: 1 as const,
91
- sourceLanguage: 'en',
92
- targetLanguages: [],
93
- localesDir: 'locales',
94
- include: ['src/**/*.{ts,tsx,js,jsx}'],
95
- exclude: ['node_modules/**'],
96
- minTextLength: 1,
97
- translation: { provider: 'manual' },
98
- translationAdapter: { module: 'react-i18next', hookName: 'useTranslation' },
99
- keyGeneration: { namespace: 'common', shortHashLen: 6 },
100
- seedTargetLocales: false,
101
- };
107
+ console.log(chalk.blue('🔍 Detecting project configuration...'));
102
108
 
103
- // Try to detect existing setup
104
- let detectedAdapter: string | undefined;
105
- let detectedLocales: string[] = [];
109
+ // Use ProjectIntelligenceService for detection
110
+ const intelligence = await detectProjectIntelligence(workspaceRoot);
106
111
 
107
- try {
108
- const report = await diagnoseWorkspace(minimalConfig, { workspaceRoot });
112
+ let suggestedConfig: SuggestedConfig;
113
+
114
+ if (intelligence) {
115
+ const { framework, locales, filePatterns, confidence } = intelligence;
109
116
 
110
- // Detect adapter files
111
- if (report.adapterFiles.length > 0) {
112
- detectedAdapter = report.adapterFiles[0].path;
113
- console.log(chalk.blue(`Detected adapter: ${detectedAdapter}`));
117
+ // Report detection results
118
+ if (framework.type !== 'unknown') {
119
+ console.log(chalk.green(` ✓ Framework: ${framework.type}`));
114
120
  }
115
-
116
- // Detect existing locales
117
- const existingLocales = report.localeFiles.filter(
118
- (entry) => !entry.missing && !entry.parseError
119
- );
120
- if (existingLocales.length > 0) {
121
- detectedLocales = existingLocales.map((entry) => entry.locale);
122
- console.log(chalk.blue(`Detected locales: ${detectedLocales.join(', ')}`));
121
+ if (framework.adapter) {
122
+ console.log(chalk.green(` ✓ i18n Adapter: ${framework.adapter}`));
123
+ }
124
+ if (locales.existingFiles.length > 0) {
125
+ const langs = [locales.sourceLanguage, ...locales.targetLanguages].filter(Boolean);
126
+ console.log(chalk.green(` ✓ Locales: ${langs.join(', ')}`));
127
+ }
128
+ if (filePatterns.sourceDirectories.length > 0) {
129
+ console.log(chalk.green(` ✓ Source directories: ${filePatterns.sourceDirectories.slice(0, 3).join(', ')}${filePatterns.sourceDirectories.length > 3 ? '...' : ''}`));
123
130
  }
124
- } catch (error) {
125
- console.log(chalk.dim(`Could not run diagnostics: ${(error as Error).message}`));
126
- }
127
131
 
128
- // Determine source and target languages from detected locales
129
- let sourceLanguage = 'en';
130
- let targetLanguages: string[] = [];
131
-
132
- if (detectedLocales.length > 0) {
133
- // Use 'en' as source if present, otherwise first detected locale
134
- if (detectedLocales.includes('en')) {
135
- sourceLanguage = 'en';
136
- targetLanguages = detectedLocales.filter((l) => l !== 'en');
132
+ // Use template if specified, otherwise use detected config
133
+ if (commandOptions.template) {
134
+ const service = new ProjectIntelligenceService();
135
+ suggestedConfig = service.applyTemplate(commandOptions.template, intelligence);
136
+ console.log(chalk.green(` ✓ Template: ${commandOptions.template}`));
137
137
  } else {
138
- sourceLanguage = detectedLocales[0];
139
- targetLanguages = detectedLocales.slice(1);
138
+ suggestedConfig = intelligence.suggestedConfig;
140
139
  }
140
+
141
+ // Report confidence
142
+ const confidencePercent = Math.round(intelligence.confidence.overall * 100);
143
+ const confidenceColor = intelligence.confidence.level === 'high' ? chalk.green : intelligence.confidence.level === 'medium' ? chalk.yellow : chalk.red;
144
+ console.log(confidenceColor(` Detection confidence: ${confidencePercent}% (${intelligence.confidence.level})`));
145
+
146
+ // Check for low confidence in non-interactive mode
147
+ if (intelligence.confidence.level === 'low' || intelligence.confidence.level === 'uncertain') {
148
+ console.log(chalk.red('\n❌ Detection confidence is too low for automatic setup.'));
149
+ console.log(chalk.dim('This could lead to incorrect configuration choices.'));
150
+ console.log(chalk.blue('\nSuggestions:'));
151
+ console.log(chalk.cyan(' • Use --template <name> to specify your framework explicitly'));
152
+ console.log(chalk.cyan(' • Run without --yes for interactive setup'));
153
+ console.log(chalk.cyan(' • Check that your project has clear framework indicators (package.json, config files)'));
154
+ throw new CliError('Detection confidence too low for non-interactive mode. Use --template or run interactively.');
155
+ }
156
+ } else {
157
+ // Fallback when detection fails completely
158
+ console.log(chalk.red('❌ Project analysis failed.'));
159
+ console.log(chalk.dim('Unable to detect project configuration automatically.'));
160
+ console.log(chalk.blue('\nSuggestions:'));
161
+ console.log(chalk.cyan(' • Use --template <name> to specify your framework explicitly'));
162
+ console.log(chalk.cyan(' • Run without --yes for interactive setup'));
163
+ console.log(chalk.cyan(' • Ensure package.json exists and contains framework dependencies'));
164
+ throw new CliError('Project analysis failed. Use --template or run interactively.');
141
165
  }
142
166
 
143
- // Build the config
167
+ // Build config from suggested values
144
168
  const config: I18nConfig = {
145
169
  version: 1 as const,
146
- sourceLanguage,
147
- targetLanguages,
148
- localesDir: 'locales',
149
- include: [
150
- 'src/**/*.{ts,tsx,js,jsx}',
151
- 'app/**/*.{ts,tsx,js,jsx}',
152
- 'pages/**/*.{ts,tsx,js,jsx}',
153
- 'components/**/*.{ts,tsx,js,jsx}',
154
- ],
155
- exclude: ['node_modules/**', '**/*.test.*'],
170
+ sourceLanguage: suggestedConfig.sourceLanguage,
171
+ targetLanguages: suggestedConfig.targetLanguages,
172
+ localesDir: suggestedConfig.localesDir,
173
+ include: suggestedConfig.include,
174
+ exclude: suggestedConfig.exclude,
156
175
  minTextLength: 1,
157
176
  translation: { provider: 'manual' },
158
177
  translationAdapter: {
159
- module: detectedAdapter ?? 'react-i18next',
160
- hookName: 'useTranslation',
161
- },
162
- keyGeneration: {
163
- namespace: 'common',
164
- shortHashLen: 6,
178
+ module: suggestedConfig.translationAdapter.module,
179
+ hookName: suggestedConfig.translationAdapter.hookName,
165
180
  },
181
+ keyGeneration: suggestedConfig.keyGeneration,
166
182
  seedTargetLocales: false,
167
183
  };
168
184
 
169
185
  try {
170
186
  await fs.writeFile(configPath, JSON.stringify(config, null, 2));
171
187
  console.log(chalk.green(`\n✓ Configuration created at ${configPath}`));
172
- console.log(chalk.dim(' Source language: ' + sourceLanguage));
173
- if (targetLanguages.length > 0) {
174
- console.log(chalk.dim(' Target languages: ' + targetLanguages.join(', ')));
175
- }
176
- if (detectedAdapter) {
177
- console.log(chalk.dim(' Adapter: ' + detectedAdapter));
188
+ console.log(chalk.dim(' Source language: ' + config.sourceLanguage));
189
+ if (config.targetLanguages.length > 0) {
190
+ console.log(chalk.dim(' Target languages: ' + config.targetLanguages.join(', ')));
178
191
  }
192
+ console.log(chalk.dim(' Adapter: ' + (config.translationAdapter?.module ?? 'react-i18next')));
179
193
 
180
194
  // Ensure .gitignore has i18nsmith artifacts
181
195
  const gitignoreResult = await ensureGitignore(workspaceRoot);
@@ -183,6 +197,149 @@ async function runNonInteractiveInit(commandOptions: InitCommandOptions): Promis
183
197
  console.log(chalk.green(`✓ Updated .gitignore with i18nsmith artifacts`));
184
198
  }
185
199
 
200
+ // Create a minimal source locale file to avoid immediate "missing source locale" diagnostics
201
+ try {
202
+ const localesDirPath = path.join(workspaceRoot, config.localesDir || 'locales');
203
+ await fs.mkdir(localesDirPath, { recursive: true });
204
+ const sourceLocalePath = path.join(localesDirPath, `${config.sourceLanguage}.json`);
205
+ // Only create if it doesn't already exist
206
+ try {
207
+ await fs.access(sourceLocalePath);
208
+ } catch {
209
+ await fs.writeFile(sourceLocalePath, JSON.stringify({}, null, 2));
210
+ console.log(chalk.green(`✓ Created source locale file at ${sourceLocalePath}`));
211
+ }
212
+ } catch (err) {
213
+ console.log(chalk.yellow('Could not create source locale file automatically. You can create it at <localesDir>/<sourceLanguage>.json'));
214
+ }
215
+
216
+ // Seed source locale with detected keys to avoid immediate "missing-key" diagnostics
217
+ try {
218
+ console.log(chalk.blue('🔍 Scanning for existing hardcoded text...'));
219
+ const scanner = await Scanner.create(config, { workspaceRoot });
220
+ const scanResult = await scanner.scan();
221
+
222
+ if (scanResult.buckets.highConfidence.length > 0) {
223
+ const sourceLocalePath = path.join(workspaceRoot, config.localesDir || 'locales', `${config.sourceLanguage}.json`);
224
+
225
+ // Read existing content or create empty object
226
+ let existingKeys: Record<string, string> = {};
227
+ try {
228
+ const content = await fs.readFile(sourceLocalePath, 'utf-8');
229
+ existingKeys = JSON.parse(content);
230
+ } catch {
231
+ // File doesn't exist or is invalid, start with empty
232
+ }
233
+
234
+ // Generate keys for high-confidence candidates
235
+ const keyGenerator = new KeyGenerator({
236
+ namespace: config.keyGeneration?.namespace || 'common',
237
+ hashLength: config.keyGeneration?.shortHashLen || 6,
238
+ workspaceRoot,
239
+ });
240
+
241
+ let keysAdded = 0;
242
+ for (const candidate of scanResult.buckets.highConfidence) {
243
+ const generated = keyGenerator.generate(candidate.text, {
244
+ filePath: candidate.filePath,
245
+ kind: candidate.kind,
246
+ context: candidate.context,
247
+ });
248
+
249
+ if (!existingKeys[generated.key]) {
250
+ existingKeys[generated.key] = candidate.text;
251
+ keysAdded++;
252
+ }
253
+ }
254
+
255
+ if (keysAdded > 0) {
256
+ await fs.writeFile(sourceLocalePath, JSON.stringify(existingKeys, null, 2));
257
+ console.log(chalk.green(`✓ Seeded source locale with ${keysAdded} detected keys`));
258
+ } else {
259
+ console.log(chalk.dim(' No new keys to seed (all detected keys already exist)'));
260
+ }
261
+ } else {
262
+ console.log(chalk.dim(' No high-confidence hardcoded text detected'));
263
+ }
264
+ } catch (error) {
265
+ console.log(chalk.yellow(`Could not seed locale with detected keys: ${(error as Error).message}`));
266
+ }
267
+
268
+ // Auto-scaffold adapter if requested and conditions are met
269
+ let scaffolded = false;
270
+ if (commandOptions.scaffold && intelligence && intelligence.confidence.level === 'high' && intelligence.existingSetup.runtimePackages.length === 0) {
271
+ console.log(chalk.blue('\n🔧 Auto-scaffolding translation adapter...'));
272
+
273
+ try {
274
+ const adapterType = suggestedConfig.translationAdapter.module === 'react-i18next' ? 'react-i18next' : 'custom';
275
+
276
+ if (adapterType === 'react-i18next') {
277
+ const i18nPath = 'src/lib/i18n.ts';
278
+ const providerPath = 'src/components/i18n-provider.tsx';
279
+
280
+ const { i18nResult, providerResult } = await scaffoldI18next(
281
+ i18nPath,
282
+ providerPath,
283
+ config.sourceLanguage,
284
+ config.localesDir || 'locales',
285
+ { workspaceRoot }
286
+ );
287
+
288
+ console.log(chalk.green('✓ Scaffolded react-i18next runtime:'));
289
+ console.log(chalk.green(` • ${i18nResult.path}`));
290
+ console.log(chalk.green(` • ${providerResult.path}`));
291
+
292
+ // Try to install dependencies
293
+ const pkg = await readPackageJson();
294
+ const missingDeps = ['react-i18next', 'i18next'].filter((dep) => !hasDependency(pkg, dep));
295
+ if (missingDeps.length) {
296
+ try {
297
+ const manager = await detectPackageManager();
298
+ console.log(chalk.blue(`Installing dependencies with ${manager}...`));
299
+ await installDependencies(manager, missingDeps);
300
+ console.log(chalk.green('✓ Dependencies installed successfully.'));
301
+ } catch (error) {
302
+ console.log(chalk.yellow('Could not install dependencies automatically. Install them manually:'));
303
+ console.log(chalk.cyan(` pnpm add ${missingDeps.join(' ')}`));
304
+ }
305
+ }
306
+
307
+ scaffolded = true;
308
+ } else {
309
+ // Custom adapter for other frameworks
310
+ const contextPath = `src/contexts/translation-context.tsx`;
311
+ const result = await scaffoldTranslationContext(contextPath, config.sourceLanguage, {
312
+ localesDir: config.localesDir || 'locales',
313
+ workspaceRoot
314
+ });
315
+
316
+ console.log(chalk.green(`✓ Scaffolded custom translation context at ${result.path}`));
317
+
318
+ // Update config to use the custom adapter
319
+ config.translationAdapter = {
320
+ module: contextPath.replace(/\.tsx?$/, ''),
321
+ hookName: 'useTranslation',
322
+ };
323
+
324
+ // Rewrite config file with updated adapter
325
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2));
326
+
327
+ scaffolded = true;
328
+ }
329
+ } catch (error) {
330
+ console.log(chalk.yellow(`Could not auto-scaffold adapter: ${(error as Error).message}`));
331
+ }
332
+ }
333
+
334
+ // Inform user about next steps
335
+ console.log(chalk.blue('\nNext steps:'));
336
+ if (!scaffolded) {
337
+ console.log(chalk.dim(' • Install and scaffold a runtime (e.g. react-i18next or vue-i18n) to enable in-app translations.'));
338
+ console.log(chalk.cyan(` i18nsmith scaffold-adapter --type ${suggestedConfig.translationAdapter.module} --install-deps`));
339
+ } else {
340
+ console.log(chalk.dim(' • Your translation adapter is ready! Import and use it in your components.'));
341
+ }
342
+
186
343
  console.log(chalk.blue('\nRun "i18nsmith check" to verify your setup.'));
187
344
  } catch (error) {
188
345
  const message = error instanceof Error ? error.message : String(error);
@@ -196,6 +353,8 @@ export function registerInit(program: Command) {
196
353
  .description('Initialize i18nsmith configuration')
197
354
  .option('--merge', 'Merge with existing locales/runtimes when detected', false)
198
355
  .option('-y, --yes', 'Skip prompts and use defaults (non-interactive mode)', false)
356
+ .option('--template <template>', 'Use a preset template (react, next-app, next-pages, vue3, nuxt3, svelte, minimal)', undefined)
357
+ .option('--scaffold', 'Automatically scaffold translation adapter when confidence is high and runtime is missing', false)
199
358
  .action(
200
359
  withErrorHandling(async (commandOptions: InitCommandOptions) => {
201
360
  console.log(chalk.blue('Initializing i18nsmith configuration...'));
@@ -206,41 +365,83 @@ export function registerInit(program: Command) {
206
365
  return;
207
366
  }
208
367
 
368
+ const workspaceRoot = process.cwd();
369
+ let config: I18nConfig | undefined;
370
+
371
+ // Detect project intelligence early to use for suggestions
372
+ const intelligence = await detectProjectIntelligence(workspaceRoot);
373
+ const suggestedValues = intelligence?.suggestedConfig;
374
+
209
375
  const answers = await inquirer.prompt<InitAnswers>([
376
+ {
377
+ type: 'list',
378
+ name: 'setupMode',
379
+ message: 'How would you like to set up i18nsmith?',
380
+ choices: [
381
+ { name: 'Auto-detect (recommended) - Analyze your project and suggest optimal configuration', value: 'auto' },
382
+ { name: 'Use template - Choose from popular framework presets', value: 'template' },
383
+ { name: 'Manual setup - Configure everything manually', value: 'manual' },
384
+ ],
385
+ default: 'auto',
386
+ },
387
+ {
388
+ type: 'list',
389
+ name: 'template',
390
+ message: 'Which template matches your project?',
391
+ when: (answers) => answers.setupMode === 'template',
392
+ choices: [
393
+ { name: 'React with react-i18next', value: 'react' },
394
+ { name: 'Next.js App Router', value: 'next-app' },
395
+ { name: 'Next.js Pages Router', value: 'next-pages' },
396
+ { name: 'Vue 3 with vue-i18n', value: 'vue3' },
397
+ { name: 'Nuxt 3', value: 'nuxt3' },
398
+ { name: 'Svelte/SvelteKit', value: 'svelte' },
399
+ { name: 'Minimal setup', value: 'minimal' },
400
+ ],
401
+ default: 'react',
402
+ },
403
+
404
+ // Manual Configuration Questions with Smart Suggestions
210
405
  {
211
406
  type: 'input',
212
407
  name: 'sourceLanguage',
213
408
  message: 'What is the source language?',
214
- default: 'en',
409
+ when: (answers) => answers.setupMode === 'manual',
410
+ default: suggestedValues?.sourceLanguage || 'en',
215
411
  },
216
412
  {
217
413
  type: 'input',
218
414
  name: 'targetLanguages',
219
415
  message: 'Which target languages do you need? (comma separated)',
220
- default: 'fr',
416
+ when: (answers) => answers.setupMode === 'manual',
417
+ default: suggestedValues?.targetLanguages?.join(', ') || 'fr',
221
418
  },
222
419
  {
223
420
  type: 'input',
224
421
  name: 'localesDir',
225
422
  message: 'Where should locale files be stored?',
226
- default: 'locales',
423
+ when: (answers) => answers.setupMode === 'manual',
424
+ default: suggestedValues?.localesDir || 'locales',
227
425
  },
228
426
  {
229
427
  type: 'input',
230
428
  name: 'include',
231
429
  message: 'Which files should be scanned? (comma separated glob patterns)',
232
- default: 'src/**/*.{ts,tsx,js,jsx}, app/**/*.{ts,tsx,js,jsx}, pages/**/*.{ts,tsx,js,jsx}, components/**/*.{ts,tsx,js,jsx}',
430
+ when: (answers) => answers.setupMode === 'manual',
431
+ default: suggestedValues?.include?.join(', ') || 'src/**/*.{ts,tsx,js,jsx}, app/**/*.{ts,tsx,js,jsx}, pages/**/*.{ts,tsx,js,jsx}, components/**/*.{ts,tsx,js,jsx}',
233
432
  },
234
433
  {
235
434
  type: 'input',
236
435
  name: 'exclude',
237
436
  message: 'Which files should be excluded? (comma separated glob patterns)',
238
- default: 'node_modules/**,**/*.test.*',
437
+ when: (answers) => answers.setupMode === 'manual',
438
+ default: suggestedValues?.exclude?.join(', ') || 'node_modules/**,**/*.test.*',
239
439
  },
240
440
  {
241
441
  type: 'input',
242
442
  name: 'minTextLength',
243
443
  message: 'Minimum length for translatable text?',
444
+ when: (answers) => answers.setupMode === 'manual',
244
445
  default: '1',
245
446
  validate: (input) => {
246
447
  const num = parseInt(input, 10);
@@ -251,6 +452,7 @@ export function registerInit(program: Command) {
251
452
  type: 'list',
252
453
  name: 'service',
253
454
  message: 'Which translation service do you want to use?',
455
+ when: (answers) => answers.setupMode === 'manual',
254
456
  choices: ['google', 'deepl', 'manual'],
255
457
  default: 'google',
256
458
  },
@@ -258,7 +460,7 @@ export function registerInit(program: Command) {
258
460
  type: 'input',
259
461
  name: 'translationSecretEnvVar',
260
462
  message: 'Name of the environment variable containing your translation API key',
261
- when: (answers) => answers.service !== 'manual',
463
+ when: (answers) => answers.setupMode === 'manual' && answers.service !== 'manual',
262
464
  default: (answers: InitAnswers) =>
263
465
  answers.service === 'deepl' ? 'DEEPL_API_KEY' : 'GOOGLE_TRANSLATE_API_KEY',
264
466
  },
@@ -266,168 +468,243 @@ export function registerInit(program: Command) {
266
468
  type: 'list',
267
469
  name: 'adapterPreset',
268
470
  message: 'How should transformed components access translations?',
471
+ when: (answers) => answers.setupMode === 'manual',
269
472
  choices: [
270
473
  { name: 'react-i18next (default)', value: 'react-i18next' },
474
+ { name: 'vue-i18n', value: 'vue-i18n' },
475
+ { name: 'svelte-i18n', value: 'svelte-i18n' },
476
+ { name: 'next-intl', value: 'next-intl' },
271
477
  { name: 'Custom hook/module', value: 'custom' },
272
478
  ],
273
- default: 'react-i18next',
479
+ default: suggestedValues?.translationAdapter?.module || 'react-i18next',
274
480
  },
275
481
  {
276
482
  type: 'input',
277
483
  name: 'customAdapterModule',
278
484
  message: 'Provide the module specifier for your translation hook (e.g. "@/contexts/translation-context")',
279
- when: (answers) => answers.adapterPreset === 'custom',
485
+ when: (answers) => answers.setupMode === 'manual' && answers.adapterPreset === 'custom',
280
486
  validate: (input) => (input && input.trim().length > 0 ? true : 'Module specifier cannot be empty'),
281
487
  },
282
488
  {
283
489
  type: 'input',
284
490
  name: 'customAdapterHook',
285
491
  message: 'Name of the hook/function to import (default: useTranslation)',
286
- when: (answers) => answers.adapterPreset === 'custom',
492
+ when: (answers) => answers.setupMode === 'manual' && answers.adapterPreset === 'custom',
287
493
  default: 'useTranslation',
288
494
  },
289
- {
290
- type: 'confirm',
291
- name: 'scaffoldAdapter',
292
- message: 'Scaffold a lightweight translation context file?',
293
- when: (answers) => answers.adapterPreset === 'custom',
294
- default: true,
295
- },
296
- {
297
- type: 'input',
298
- name: 'scaffoldAdapterPath',
299
- message: 'Path to scaffold the translation context file (relative to project root)',
300
- when: (answers) => answers.scaffoldAdapter,
301
- default: 'src/contexts/translation-context.tsx',
302
- },
303
- {
304
- type: 'confirm',
305
- name: 'scaffoldReactRuntime',
306
- message: 'Scaffold i18next initializer and provider?',
307
- when: (answers) => answers.adapterPreset === 'react-i18next',
308
- default: true,
309
- },
310
- {
311
- type: 'input',
312
- name: 'reactI18nPath',
313
- message: 'Path for i18next initializer (e.g. src/lib/i18n.ts)',
314
- when: (answers) => answers.scaffoldReactRuntime,
315
- default: 'src/lib/i18n.ts',
316
- },
317
- {
318
- type: 'input',
319
- name: 'reactProviderPath',
320
- message: 'Path for I18nProvider component (e.g. src/components/i18n-provider.tsx)',
321
- when: (answers) => answers.scaffoldReactRuntime,
322
- default: 'src/components/i18n-provider.tsx',
323
- },
324
495
  {
325
496
  type: 'input',
326
497
  name: 'keyNamespace',
327
498
  message: 'Namespace prefix for generated keys',
328
- default: 'common',
499
+ when: (answers) => answers.setupMode === 'manual',
500
+ default: suggestedValues?.keyGeneration?.namespace || 'common',
329
501
  },
330
502
  {
331
503
  type: 'input',
332
504
  name: 'shortHashLen',
333
505
  message: 'Length of short hash suffix for keys',
334
- default: '6',
506
+ when: (answers) => answers.setupMode === 'manual',
507
+ default: suggestedValues?.keyGeneration?.shortHashLen?.toString() || '6',
335
508
  validate: (input) => {
336
509
  const num = parseInt(input, 10);
337
510
  return !isNaN(num) && num > 0 ? true : 'Please enter a positive number';
338
511
  },
339
512
  },
340
- {
341
- type: 'confirm',
342
- name: 'seedTargetLocales',
343
- message: 'Seed target locale files with empty values?',
344
- default: false,
345
- },
346
513
  ]);
347
514
 
348
- const adapterModule =
349
- answers.adapterPreset === 'custom'
350
- ? answers.customAdapterModule?.trim()
351
- : 'react-i18next';
352
- const adapterHook =
353
- answers.adapterPreset === 'custom'
354
- ? (answers.customAdapterHook?.trim() || 'useTranslation')
355
- : 'useTranslation';
356
-
357
- const translationConfig: TranslationConfig =
358
- answers.service === 'manual'
359
- ? { provider: 'manual' }
360
- : {
361
- provider: answers.service,
362
- secretEnvVar: answers.translationSecretEnvVar?.trim() || undefined,
363
- concurrency: 5,
364
- };
365
-
366
- const config: I18nConfig = {
367
- version: 1 as const,
368
- sourceLanguage: answers.sourceLanguage,
369
- targetLanguages: parseGlobList(answers.targetLanguages),
370
- localesDir: answers.localesDir,
371
- include: parseGlobList(answers.include),
372
- exclude: parseGlobList(answers.exclude),
373
- minTextLength: parseInt(answers.minTextLength, 10),
374
- translation: translationConfig,
375
- translationAdapter: {
376
- module: adapterModule ?? 'react-i18next',
377
- hookName: adapterHook,
378
- },
379
- keyGeneration: {
380
- namespace: answers.keyNamespace,
381
- shortHashLen: parseInt(answers.shortHashLen, 10),
382
- },
383
- seedTargetLocales: answers.seedTargetLocales,
384
- };
385
515
 
386
- const workspaceRoot = process.cwd();
387
- const mergeDecision = await maybePromptMergeStrategy(config, workspaceRoot, Boolean(commandOptions.merge));
388
- if (mergeDecision?.aborted) {
389
- console.log(chalk.yellow('Aborting init to avoid overwriting existing i18n assets. Re-run with --merge to bypass.'));
516
+ if (answers.setupMode === 'auto') {
517
+ if (!intelligence) {
518
+ console.log(chalk.yellow('Could not analyze project. Please use Manual setup.'));
390
519
  return;
520
+ } else {
521
+ const { framework, locales, filePatterns, confidence } = intelligence;
522
+
523
+ // Report detection results
524
+ if (framework.type !== 'unknown') {
525
+ console.log(chalk.green(` ✓ Framework: ${framework.type}`));
526
+ }
527
+ if (framework.adapter) {
528
+ console.log(chalk.green(` ✓ i18n Adapter: ${framework.adapter}`));
529
+ }
530
+ if (locales.existingFiles.length > 0) {
531
+ const langs = [locales.sourceLanguage, ...locales.targetLanguages].filter(Boolean);
532
+ console.log(chalk.green(` ✓ Locales: ${langs.join(', ')}`));
533
+ }
534
+
535
+ const confidencePercent = Math.round(confidence.overall * 100);
536
+ const confidenceColor = confidence.level === 'high' ? chalk.green : confidence.level === 'medium' ? chalk.yellow : chalk.red;
537
+ console.log(confidenceColor(` Detection confidence: ${confidencePercent}% (${confidence.level})`));
538
+
539
+ // Create config from intelligence
540
+ config = {
541
+ version: 1 as const,
542
+ sourceLanguage: locales.sourceLanguage || 'en',
543
+ targetLanguages: locales.targetLanguages,
544
+ localesDir: locales.localesDir || 'locales',
545
+ include: filePatterns.include,
546
+ exclude: filePatterns.exclude,
547
+ minTextLength: 1,
548
+ translation: { provider: 'manual' },
549
+ translationAdapter: {
550
+ module: framework.adapter || 'react-i18next',
551
+ hookName: framework.hookName || 'useTranslation',
552
+ },
553
+ keyGeneration: {
554
+ namespace: 'common',
555
+ shortHashLen: 6,
556
+ },
557
+ seedTargetLocales: false,
558
+ };
391
559
  }
560
+ }
561
+
562
+ if (answers.setupMode === 'template') {
563
+ // Use template
564
+ console.log(chalk.blue(`📋 Applying ${answers.template} template...`));
565
+ const service = new ProjectIntelligenceService();
566
+ // Uses pre-detected intelligence from outer scope
567
+ const suggestedConfig = service.applyTemplate(answers.template!, intelligence || {
568
+ framework: { type: 'unknown', adapter: 'react-i18next', hookName: 'useTranslation', features: [], confidence: 0, evidence: [] },
569
+ locales: { sourceLanguage: 'en', targetLanguages: [], localesDir: 'locales', format: 'flat', existingFiles: [], existingKeyCount: 0, confidence: 0 },
570
+ filePatterns: { include: ['**/*.{ts,tsx,js,jsx}'], exclude: ['node_modules/**', 'dist/**'], sourceDirectories: [], hasTypeScript: false, hasJsx: false, hasVue: false, hasSvelte: false, sourceFileCount: 0, confidence: 0 },
571
+ existingSetup: { hasExistingConfig: false, hasExistingLocales: false, hasI18nProvider: false, runtimePackages: [], translationUsage: { hookName: 'useTranslation', translationIdentifier: 't', filesWithHooks: 0, translationCalls: 0, exampleFiles: [] } },
572
+ confidence: { framework: 0, filePatterns: 0, existingSetup: 0, locales: 0, overall: 0, level: 'low' },
573
+ warnings: [],
574
+ recommendations: [],
575
+ suggestedConfig: {
576
+ sourceLanguage: 'en',
577
+ targetLanguages: [],
578
+ localesDir: 'locales',
579
+ include: ['**/*.{ts,tsx,js,jsx}'],
580
+ exclude: ['node_modules/**', 'dist/**'],
581
+ translationAdapter: { module: 'react-i18next', hookName: 'useTranslation' },
582
+ keyGeneration: { namespace: 'common', shortHashLen: 6 }
583
+ },
584
+ });
585
+
586
+ config = {
587
+ version: 1 as const,
588
+ ...suggestedConfig,
589
+ minTextLength: 1,
590
+ translation: { provider: 'manual' },
591
+ seedTargetLocales: false,
592
+ };
593
+ }
392
594
 
393
- const configPath = path.join(workspaceRoot, 'i18n.config.json');
595
+ if (answers.setupMode === 'manual' || !config) {
596
+ // Manual setup - use the existing prompts
597
+ console.log(chalk.blue('🔧 Manual configuration...'));
598
+
599
+ // Map the selected adapter preset to the correct module name and hook.
600
+ // The prompt offers 'react-i18next', 'vue-i18n', 'svelte-i18n', 'next-intl', and 'custom'.
601
+ // Only 'custom' uses the user-provided module specifier; all known presets
602
+ // are used directly as the adapter module name.
603
+ const adapterModule =
604
+ answers.adapterPreset === 'custom'
605
+ ? answers.customAdapterModule?.trim()
606
+ : answers.adapterPreset; // Use actual selection (vue-i18n, svelte-i18n, etc.)
607
+ const ADAPTER_HOOKS: Record<string, string> = {
608
+ 'react-i18next': 'useTranslation',
609
+ 'vue-i18n': 'useI18n',
610
+ 'svelte-i18n': 't',
611
+ 'next-intl': 'useTranslations',
612
+ };
613
+ const adapterHook =
614
+ answers.adapterPreset === 'custom'
615
+ ? (answers.customAdapterHook?.trim() || 'useTranslation')
616
+ : ADAPTER_HOOKS[answers.adapterPreset] || 'useTranslation';
617
+
618
+ const translationConfig: TranslationConfig =
619
+ answers.service === 'manual'
620
+ ? { provider: 'manual' }
621
+ : {
622
+ provider: answers.service,
623
+ secretEnvVar: answers.translationSecretEnvVar?.trim() || undefined,
624
+ concurrency: 5,
625
+ };
626
+
627
+ config = {
628
+ version: 1 as const,
629
+ sourceLanguage: answers.sourceLanguage,
630
+ targetLanguages: parseGlobList(answers.targetLanguages),
631
+ localesDir: answers.localesDir,
632
+ include: parseGlobList(answers.include),
633
+ exclude: parseGlobList(answers.exclude),
634
+ minTextLength: parseInt(answers.minTextLength, 10),
635
+ translation: translationConfig,
636
+ translationAdapter: {
637
+ module: adapterModule ?? 'react-i18next',
638
+ hookName: adapterHook,
639
+ },
640
+ keyGeneration: {
641
+ namespace: answers.keyNamespace,
642
+ shortHashLen: parseInt(answers.shortHashLen, 10),
643
+ },
644
+ seedTargetLocales: answers.seedTargetLocales,
645
+ };
646
+ }
394
647
 
395
- try {
396
- await fs.writeFile(configPath, JSON.stringify(config, null, 2));
397
- console.log(chalk.green(`\nConfiguration created at ${configPath}`));
648
+ const mergeDecision = await maybePromptMergeStrategy(config, workspaceRoot, Boolean(commandOptions.merge));
649
+ if (mergeDecision?.aborted) {
650
+ console.log(chalk.yellow('Aborting init to avoid overwriting existing i18n assets. Re-run with --merge to bypass.'));
651
+ return;
652
+ }
398
653
 
399
- // Ensure .gitignore has i18nsmith artifacts
400
- const gitignoreResult = await ensureGitignore(workspaceRoot);
401
- if (gitignoreResult.updated) {
402
- console.log(chalk.green(`Updated .gitignore with i18nsmith artifacts`));
654
+ const configPath = path.join(workspaceRoot, 'i18n.config.json');
655
+
656
+ try {
657
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2));
658
+ console.log(chalk.green(`\nConfiguration created at ${configPath}`));
659
+
660
+ // Ensure .gitignore has i18nsmith artifacts
661
+ const gitignoreResult = await ensureGitignore(workspaceRoot);
662
+ if (gitignoreResult.updated) {
663
+ console.log(chalk.green(`Updated .gitignore with i18nsmith artifacts`));
664
+ }
665
+
666
+ // Create locales directory and minimal source locale file so
667
+ // `i18nsmith check` doesn't immediately report missing source locale.
668
+ try {
669
+ const localesDirPath = path.join(workspaceRoot, config!.localesDir || 'locales');
670
+ await fs.mkdir(localesDirPath, { recursive: true });
671
+ const sourceLocalePath = path.join(localesDirPath, `${config!.sourceLanguage}.json`);
672
+ try {
673
+ await fs.access(sourceLocalePath);
674
+ } catch {
675
+ await fs.writeFile(sourceLocalePath, JSON.stringify({}, null, 2));
676
+ console.log(chalk.green(`✓ Created source locale file at ${sourceLocalePath}`));
403
677
  }
678
+ } catch {
679
+ console.log(chalk.yellow('Could not create source locale file automatically.'));
680
+ }
404
681
 
405
- if (answers.scaffoldAdapter && answers.scaffoldAdapterPath) {
406
- try {
407
- await scaffoldTranslationContext(answers.scaffoldAdapterPath, answers.sourceLanguage, {
408
- localesDir: answers.localesDir,
409
- });
410
- console.log(chalk.green(`Translation context scaffolded at ${answers.scaffoldAdapterPath}`));
411
- } catch (error) {
412
- console.warn(chalk.yellow(`Skipping adapter scaffold: ${(error as Error).message}`));
413
- }
682
+ if (answers.scaffoldAdapter && answers.scaffoldAdapterPath) {
683
+ try {
684
+ await scaffoldTranslationContext(answers.scaffoldAdapterPath, answers.sourceLanguage, {
685
+ localesDir: answers.localesDir,
686
+ });
687
+ console.log(chalk.green(`Translation context scaffolded at ${answers.scaffoldAdapterPath}`));
688
+ } catch (error) {
689
+ console.warn(chalk.yellow(`Skipping adapter scaffold: ${(error as Error).message}`));
414
690
  }
691
+ }
415
692
 
416
- if (
417
- answers.adapterPreset === 'react-i18next' &&
418
- answers.scaffoldReactRuntime &&
419
- answers.reactI18nPath &&
420
- answers.reactProviderPath
421
- ) {
422
- try {
423
- await scaffoldI18next(
424
- answers.reactI18nPath,
425
- answers.reactProviderPath,
426
- answers.sourceLanguage,
427
- answers.localesDir
428
- );
429
- console.log(chalk.green('react-i18next runtime scaffolded:'));
430
- console.log(chalk.green(` • ${answers.reactI18nPath}`));
693
+ if (
694
+ answers.adapterPreset === 'react-i18next' &&
695
+ answers.scaffoldReactRuntime &&
696
+ answers.reactI18nPath &&
697
+ answers.reactProviderPath
698
+ ) {
699
+ try {
700
+ await scaffoldI18next(
701
+ answers.reactI18nPath,
702
+ answers.reactProviderPath,
703
+ answers.sourceLanguage,
704
+ answers.localesDir
705
+ );
706
+ console.log(chalk.green('react-i18next runtime scaffolded:'));
707
+ console.log(chalk.green(` • ${answers.reactI18nPath}`));
431
708
  console.log(chalk.green(` • ${answers.reactProviderPath}`));
432
709
  console.log(chalk.blue('\nWrap your app with the provider (e.g. Next.js providers.tsx):'));
433
710
  console.log(