i18nsmith 0.1.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 (213) hide show
  1. package/dist/commands/audit.d.ts +3 -0
  2. package/dist/commands/audit.d.ts.map +1 -0
  3. package/dist/commands/audit.js +180 -0
  4. package/dist/commands/audit.js.map +1 -0
  5. package/dist/commands/backup.d.ts +6 -0
  6. package/dist/commands/backup.d.ts.map +1 -0
  7. package/dist/commands/backup.js +85 -0
  8. package/dist/commands/backup.js.map +1 -0
  9. package/dist/commands/check.d.ts +3 -0
  10. package/dist/commands/check.d.ts.map +1 -0
  11. package/dist/commands/check.js +151 -0
  12. package/dist/commands/check.js.map +1 -0
  13. package/dist/commands/config.d.ts +3 -0
  14. package/dist/commands/config.d.ts.map +1 -0
  15. package/dist/commands/config.js +235 -0
  16. package/dist/commands/config.js.map +1 -0
  17. package/dist/commands/debug-patterns.d.ts +3 -0
  18. package/dist/commands/debug-patterns.d.ts.map +1 -0
  19. package/dist/commands/debug-patterns.js +192 -0
  20. package/dist/commands/debug-patterns.js.map +1 -0
  21. package/dist/commands/debug-patterns.test.d.ts +2 -0
  22. package/dist/commands/debug-patterns.test.d.ts.map +1 -0
  23. package/dist/commands/debug-patterns.test.js +109 -0
  24. package/dist/commands/debug-patterns.test.js.map +1 -0
  25. package/dist/commands/diagnose.d.ts +3 -0
  26. package/dist/commands/diagnose.d.ts.map +1 -0
  27. package/dist/commands/diagnose.js +117 -0
  28. package/dist/commands/diagnose.js.map +1 -0
  29. package/dist/commands/init.d.ts +8 -0
  30. package/dist/commands/init.d.ts.map +1 -0
  31. package/dist/commands/init.js +450 -0
  32. package/dist/commands/init.js.map +1 -0
  33. package/dist/commands/init.test.d.ts +2 -0
  34. package/dist/commands/init.test.d.ts.map +1 -0
  35. package/dist/commands/init.test.js +74 -0
  36. package/dist/commands/init.test.js.map +1 -0
  37. package/dist/commands/install-hooks.d.ts +3 -0
  38. package/dist/commands/install-hooks.d.ts.map +1 -0
  39. package/dist/commands/install-hooks.js +52 -0
  40. package/dist/commands/install-hooks.js.map +1 -0
  41. package/dist/commands/preflight.d.ts +7 -0
  42. package/dist/commands/preflight.d.ts.map +1 -0
  43. package/dist/commands/preflight.js +417 -0
  44. package/dist/commands/preflight.js.map +1 -0
  45. package/dist/commands/preflight.test.d.ts +5 -0
  46. package/dist/commands/preflight.test.d.ts.map +1 -0
  47. package/dist/commands/preflight.test.js +108 -0
  48. package/dist/commands/preflight.test.js.map +1 -0
  49. package/dist/commands/rename.d.ts +6 -0
  50. package/dist/commands/rename.d.ts.map +1 -0
  51. package/dist/commands/rename.js +204 -0
  52. package/dist/commands/rename.js.map +1 -0
  53. package/dist/commands/scaffold-adapter.d.ts +3 -0
  54. package/dist/commands/scaffold-adapter.d.ts.map +1 -0
  55. package/dist/commands/scaffold-adapter.js +204 -0
  56. package/dist/commands/scaffold-adapter.js.map +1 -0
  57. package/dist/commands/scaffold-adapter.test.d.ts +2 -0
  58. package/dist/commands/scaffold-adapter.test.d.ts.map +1 -0
  59. package/dist/commands/scaffold-adapter.test.js +102 -0
  60. package/dist/commands/scaffold-adapter.test.js.map +1 -0
  61. package/dist/commands/scan.d.ts +3 -0
  62. package/dist/commands/scan.d.ts.map +1 -0
  63. package/dist/commands/scan.js +93 -0
  64. package/dist/commands/scan.js.map +1 -0
  65. package/dist/commands/sync-seed.test.d.ts +2 -0
  66. package/dist/commands/sync-seed.test.d.ts.map +1 -0
  67. package/dist/commands/sync-seed.test.js +86 -0
  68. package/dist/commands/sync-seed.test.js.map +1 -0
  69. package/dist/commands/sync.d.ts +3 -0
  70. package/dist/commands/sync.d.ts.map +1 -0
  71. package/dist/commands/sync.js +590 -0
  72. package/dist/commands/sync.js.map +1 -0
  73. package/dist/commands/transform.d.ts +3 -0
  74. package/dist/commands/transform.d.ts.map +1 -0
  75. package/dist/commands/transform.js +114 -0
  76. package/dist/commands/transform.js.map +1 -0
  77. package/dist/commands/translate/csv-handler.d.ts +21 -0
  78. package/dist/commands/translate/csv-handler.d.ts.map +1 -0
  79. package/dist/commands/translate/csv-handler.js +270 -0
  80. package/dist/commands/translate/csv-handler.js.map +1 -0
  81. package/dist/commands/translate/executor.d.ts +31 -0
  82. package/dist/commands/translate/executor.d.ts.map +1 -0
  83. package/dist/commands/translate/executor.js +117 -0
  84. package/dist/commands/translate/executor.js.map +1 -0
  85. package/dist/commands/translate/index.d.ts +10 -0
  86. package/dist/commands/translate/index.d.ts.map +1 -0
  87. package/dist/commands/translate/index.js +170 -0
  88. package/dist/commands/translate/index.js.map +1 -0
  89. package/dist/commands/translate/reporter.d.ts +29 -0
  90. package/dist/commands/translate/reporter.d.ts.map +1 -0
  91. package/dist/commands/translate/reporter.js +103 -0
  92. package/dist/commands/translate/reporter.js.map +1 -0
  93. package/dist/commands/translate/types.d.ts +50 -0
  94. package/dist/commands/translate/types.d.ts.map +1 -0
  95. package/dist/commands/translate/types.js +5 -0
  96. package/dist/commands/translate/types.js.map +1 -0
  97. package/dist/commands/translate.d.ts +7 -0
  98. package/dist/commands/translate.d.ts.map +1 -0
  99. package/dist/commands/translate.js +7 -0
  100. package/dist/commands/translate.js.map +1 -0
  101. package/dist/commands/translate.test.d.ts +2 -0
  102. package/dist/commands/translate.test.d.ts.map +1 -0
  103. package/dist/commands/translate.test.js +118 -0
  104. package/dist/commands/translate.test.js.map +1 -0
  105. package/dist/e2e.test.d.ts +6 -0
  106. package/dist/e2e.test.d.ts.map +1 -0
  107. package/dist/e2e.test.js +376 -0
  108. package/dist/e2e.test.js.map +1 -0
  109. package/dist/index.d.ts +4 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +39 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/integration.test.d.ts +6 -0
  114. package/dist/integration.test.d.ts.map +1 -0
  115. package/dist/integration.test.js +320 -0
  116. package/dist/integration.test.js.map +1 -0
  117. package/dist/utils/diagnostics-exit.d.ts +12 -0
  118. package/dist/utils/diagnostics-exit.d.ts.map +1 -0
  119. package/dist/utils/diagnostics-exit.js +49 -0
  120. package/dist/utils/diagnostics-exit.js.map +1 -0
  121. package/dist/utils/diagnostics-exit.test.d.ts +2 -0
  122. package/dist/utils/diagnostics-exit.test.d.ts.map +1 -0
  123. package/dist/utils/diagnostics-exit.test.js +40 -0
  124. package/dist/utils/diagnostics-exit.test.js.map +1 -0
  125. package/dist/utils/diff-utils.d.ts +4 -0
  126. package/dist/utils/diff-utils.d.ts.map +1 -0
  127. package/dist/utils/diff-utils.js +30 -0
  128. package/dist/utils/diff-utils.js.map +1 -0
  129. package/dist/utils/diff-utils.test.d.ts +2 -0
  130. package/dist/utils/diff-utils.test.d.ts.map +1 -0
  131. package/dist/utils/diff-utils.test.js +30 -0
  132. package/dist/utils/diff-utils.test.js.map +1 -0
  133. package/dist/utils/exit-codes.d.ts +142 -0
  134. package/dist/utils/exit-codes.d.ts.map +1 -0
  135. package/dist/utils/exit-codes.js +168 -0
  136. package/dist/utils/exit-codes.js.map +1 -0
  137. package/dist/utils/package-manager.d.ts +4 -0
  138. package/dist/utils/package-manager.d.ts.map +1 -0
  139. package/dist/utils/package-manager.js +40 -0
  140. package/dist/utils/package-manager.js.map +1 -0
  141. package/dist/utils/pkg.d.ts +3 -0
  142. package/dist/utils/pkg.d.ts.map +1 -0
  143. package/dist/utils/pkg.js +24 -0
  144. package/dist/utils/pkg.js.map +1 -0
  145. package/dist/utils/provider-injector.d.ts +36 -0
  146. package/dist/utils/provider-injector.d.ts.map +1 -0
  147. package/dist/utils/provider-injector.js +223 -0
  148. package/dist/utils/provider-injector.js.map +1 -0
  149. package/dist/utils/provider-injector.test.d.ts +2 -0
  150. package/dist/utils/provider-injector.test.d.ts.map +1 -0
  151. package/dist/utils/provider-injector.test.js +67 -0
  152. package/dist/utils/provider-injector.test.js.map +1 -0
  153. package/dist/utils/scaffold.d.ts +20 -0
  154. package/dist/utils/scaffold.d.ts.map +1 -0
  155. package/dist/utils/scaffold.js +197 -0
  156. package/dist/utils/scaffold.js.map +1 -0
  157. package/package.json +35 -0
  158. package/src/commands/audit.ts +234 -0
  159. package/src/commands/backup.ts +96 -0
  160. package/src/commands/check.ts +191 -0
  161. package/src/commands/config.ts +263 -0
  162. package/src/commands/debug-patterns.test.ts +134 -0
  163. package/src/commands/debug-patterns.ts +257 -0
  164. package/src/commands/diagnose.ts +136 -0
  165. package/src/commands/init.test.ts +82 -0
  166. package/src/commands/init.ts +536 -0
  167. package/src/commands/install-hooks.ts +66 -0
  168. package/src/commands/preflight.test.ts +139 -0
  169. package/src/commands/preflight.ts +488 -0
  170. package/src/commands/rename.ts +264 -0
  171. package/src/commands/scaffold-adapter.test.ts +110 -0
  172. package/src/commands/scaffold-adapter.ts +250 -0
  173. package/src/commands/scan.ts +125 -0
  174. package/src/commands/sync-seed.test.ts +116 -0
  175. package/src/commands/sync.ts +736 -0
  176. package/src/commands/transform.ts +151 -0
  177. package/src/commands/translate/README.md +75 -0
  178. package/src/commands/translate/csv-handler.ts +301 -0
  179. package/src/commands/translate/executor.ts +188 -0
  180. package/src/commands/translate/index.ts +220 -0
  181. package/src/commands/translate/reporter.ts +138 -0
  182. package/src/commands/translate/types.ts +56 -0
  183. package/src/commands/translate.test.ts +173 -0
  184. package/src/commands/translate.ts +6 -0
  185. package/src/e2e.test.ts +479 -0
  186. package/src/fixtures/README.md +61 -0
  187. package/src/fixtures/basic-react/i18n.config.json +15 -0
  188. package/src/fixtures/basic-react/locales/de.json +8 -0
  189. package/src/fixtures/basic-react/locales/en.json +8 -0
  190. package/src/fixtures/basic-react/locales/fr.json +8 -0
  191. package/src/fixtures/basic-react/src/App.tsx +15 -0
  192. package/src/fixtures/basic-react/src/Messages.tsx +12 -0
  193. package/src/fixtures/nested-locales/i18n.config.json +9 -0
  194. package/src/fixtures/nested-locales/locales/en.json +23 -0
  195. package/src/fixtures/nested-locales/locales/fr.json +23 -0
  196. package/src/fixtures/nested-locales/src/HomePage.tsx +13 -0
  197. package/src/fixtures/suspicious-keys/i18n.config.json +9 -0
  198. package/src/fixtures/suspicious-keys/locales/en.json +11 -0
  199. package/src/fixtures/suspicious-keys/locales/fr.json +11 -0
  200. package/src/fixtures/suspicious-keys/src/BadKeys.tsx +19 -0
  201. package/src/index.ts +43 -0
  202. package/src/integration.test.ts +438 -0
  203. package/src/utils/diagnostics-exit.test.ts +47 -0
  204. package/src/utils/diagnostics-exit.ts +63 -0
  205. package/src/utils/diff-utils.test.ts +36 -0
  206. package/src/utils/diff-utils.ts +42 -0
  207. package/src/utils/exit-codes.ts +201 -0
  208. package/src/utils/package-manager.ts +44 -0
  209. package/src/utils/pkg.ts +23 -0
  210. package/src/utils/provider-injector.test.ts +79 -0
  211. package/src/utils/provider-injector.ts +315 -0
  212. package/src/utils/scaffold.ts +240 -0
  213. package/tsconfig.json +17 -0
@@ -0,0 +1,264 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import path from 'node:path';
4
+ import { promises as fs } from 'node:fs';
5
+ import { loadConfig, KeyRenamer, type KeyRenameSummary, type KeyRenameBatchSummary, type KeyRenameMapping } from '@i18nsmith/core';
6
+
7
+ interface ScanOptions {
8
+ config: string;
9
+ json?: boolean;
10
+ report?: string;
11
+ }
12
+
13
+ interface RenameMapOptions extends ScanOptions {
14
+ map: string;
15
+ write?: boolean;
16
+ diff?: boolean;
17
+ }
18
+
19
+ /**
20
+ * Registers rename-related commands (rename-key, rename-keys)
21
+ */
22
+ export function registerRename(program: Command): void {
23
+ program
24
+ .command('rename-key')
25
+ .description('Rename translation keys across source files and locale JSON')
26
+ .argument('<oldKey>', 'Existing translation key')
27
+ .argument('<newKey>', 'Replacement translation key')
28
+ .option('-c, --config <path>', 'Path to i18nsmith config file', 'i18n.config.json')
29
+ .option('--json', 'Print raw JSON results', false)
30
+ .option('--report <path>', 'Write JSON summary to a file (for CI or editors)')
31
+ .option('--write', 'Write changes to disk (defaults to dry-run)', false)
32
+ .action(async (oldKey: string, newKey: string, options: ScanOptions & { write?: boolean }) => {
33
+ console.log(chalk.blue(options.write ? 'Renaming translation key...' : 'Planning key rename (dry-run)...'));
34
+
35
+ try {
36
+ const config = await loadConfig(options.config);
37
+ const renamer = new KeyRenamer(config);
38
+ const summary = await renamer.rename(oldKey, newKey, { write: options.write });
39
+
40
+ if (options.report) {
41
+ const outputPath = path.resolve(process.cwd(), options.report);
42
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
43
+ await fs.writeFile(outputPath, JSON.stringify(summary, null, 2));
44
+ console.log(chalk.green(`Rename report written to ${outputPath}`));
45
+ }
46
+
47
+ if (options.json) {
48
+ console.log(JSON.stringify(summary, null, 2));
49
+ return;
50
+ }
51
+
52
+ printRenameSummary(summary);
53
+
54
+ if (!options.write) {
55
+ console.log(chalk.cyan('\nšŸ“‹ DRY RUN - No files were modified'));
56
+ console.log(chalk.yellow('Run again with --write to apply changes.'));
57
+ }
58
+ } catch (error) {
59
+ console.error(chalk.red('Rename failed:'), (error as Error).message);
60
+ process.exitCode = 1;
61
+ }
62
+ });
63
+
64
+ program
65
+ .command('rename-keys')
66
+ .description('Rename multiple translation keys using a mapping file')
67
+ .requiredOption('-m, --map <path>', 'Path to JSON map file (object or array of {"from","to"})')
68
+ .option('-c, --config <path>', 'Path to i18nsmith config file', 'i18n.config.json')
69
+ .option('--json', 'Print raw JSON results', false)
70
+ .option('--report <path>', 'Write JSON summary to a file (for CI or editors)')
71
+ .option('--write', 'Write changes to disk (defaults to dry-run)', false)
72
+ .option('--diff', 'Display unified diffs for files that would change', false)
73
+ .action(async (options: RenameMapOptions) => {
74
+ console.log(
75
+ chalk.blue(options.write ? 'Renaming translation keys from map...' : 'Planning batch rename (dry-run)...')
76
+ );
77
+
78
+ try {
79
+ const config = await loadConfig(options.config);
80
+ const mappings = await loadRenameMappings(options.map);
81
+ const renamer = new KeyRenamer(config);
82
+ const summary = await renamer.renameBatch(mappings, { write: options.write, diff: options.diff });
83
+
84
+ if (options.report) {
85
+ const outputPath = path.resolve(process.cwd(), options.report);
86
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
87
+ await fs.writeFile(outputPath, JSON.stringify(summary, null, 2));
88
+ console.log(chalk.green(`Batch rename report written to ${outputPath}`));
89
+ }
90
+
91
+ if (options.json) {
92
+ console.log(JSON.stringify(summary, null, 2));
93
+ return;
94
+ }
95
+
96
+ printRenameBatchSummary(summary);
97
+
98
+ // Print source file diffs if requested
99
+ if (options.diff && summary.diffs.length > 0) {
100
+ console.log(chalk.blue('\nSource file changes:'));
101
+ for (const diff of summary.diffs) {
102
+ console.log(chalk.cyan(`\n--- ${diff.relativePath} (${diff.changes} change${diff.changes === 1 ? '' : 's'}) ---`));
103
+ console.log(diff.diff);
104
+ }
105
+ }
106
+
107
+ if (!options.write) {
108
+ console.log(chalk.cyan('\nšŸ“‹ DRY RUN - No files were modified'));
109
+ console.log(chalk.yellow('Run again with --write to apply changes.'));
110
+ }
111
+ } catch (error) {
112
+ console.error(chalk.red('Batch rename failed:'), (error as Error).message);
113
+ process.exitCode = 1;
114
+ }
115
+ });
116
+ }
117
+
118
+ function printRenameSummary(summary: KeyRenameSummary) {
119
+ console.log(
120
+ chalk.green(
121
+ `Updated ${summary.occurrences} occurrence${summary.occurrences === 1 ? '' : 's'} across ${summary.filesUpdated.length} file${summary.filesUpdated.length === 1 ? '' : 's'}.`
122
+ )
123
+ );
124
+
125
+ if (summary.filesUpdated.length) {
126
+ console.log(chalk.blue('Files updated:'));
127
+ summary.filesUpdated.forEach((file) => console.log(` • ${file}`));
128
+ }
129
+
130
+ if (summary.localeStats.length) {
131
+ console.log(chalk.blue('Locale updates:'));
132
+ summary.localeStats.forEach((stat) => {
133
+ console.log(
134
+ ` • ${stat.locale}: ${stat.added.length} added, ${stat.updated.length} updated, ${stat.removed.length} removed (total ${stat.totalKeys})`
135
+ );
136
+ });
137
+ } else if (summary.localePreview.length) {
138
+ console.log(chalk.blue('Locale impact preview:'));
139
+ summary.localePreview.forEach((preview) => {
140
+ const status = preview.missing
141
+ ? chalk.yellow('missing source key')
142
+ : preview.duplicate
143
+ ? chalk.red('destination already exists')
144
+ : chalk.green('ready');
145
+ console.log(` • ${preview.locale}: ${status}`);
146
+ });
147
+ }
148
+
149
+ if (summary.missingLocales.length) {
150
+ console.log(
151
+ chalk.yellow(
152
+ `Locales missing the original key: ${summary.missingLocales.join(', ')}. Update them manually if needed.`
153
+ )
154
+ );
155
+ }
156
+ }
157
+
158
+ function printRenameBatchSummary(summary: KeyRenameBatchSummary) {
159
+ console.log(
160
+ chalk.green(
161
+ `Updated ${summary.occurrences} occurrence${summary.occurrences === 1 ? '' : 's'} across ${summary.filesUpdated.length} file${summary.filesUpdated.length === 1 ? '' : 's'}.`
162
+ )
163
+ );
164
+
165
+ if (summary.mappingSummaries.length === 0) {
166
+ console.log(chalk.yellow('No mappings were applied.'));
167
+ } else {
168
+ console.log(chalk.blue('Mappings:'));
169
+ summary.mappingSummaries.slice(0, 50).forEach((mapping) => {
170
+ const refLabel = `${mapping.occurrences} reference${mapping.occurrences === 1 ? '' : 's'}`;
171
+ console.log(` • ${mapping.from} → ${mapping.to} (${refLabel})`);
172
+
173
+ const duplicates = mapping.localePreview
174
+ .filter((preview) => preview.duplicate)
175
+ .map((preview) => preview.locale);
176
+ const missing = mapping.missingLocales;
177
+
178
+ const annotations = [
179
+ missing.length ? `missing locales: ${missing.join(', ')}` : null,
180
+ duplicates.length ? `target already exists in: ${duplicates.join(', ')}` : null,
181
+ ].filter(Boolean);
182
+
183
+ if (annotations.length) {
184
+ console.log(chalk.gray(` ${annotations.join(' Ā· ')}`));
185
+ }
186
+ });
187
+
188
+ if (summary.mappingSummaries.length > 50) {
189
+ console.log(chalk.gray(` ...and ${summary.mappingSummaries.length - 50} more.`));
190
+ }
191
+ }
192
+
193
+ if (summary.filesUpdated.length) {
194
+ console.log(chalk.blue('Files updated:'));
195
+ summary.filesUpdated.forEach((file) => console.log(` • ${file}`));
196
+ }
197
+
198
+ if (summary.localeStats.length) {
199
+ console.log(chalk.blue('Locale updates:'));
200
+ summary.localeStats.forEach((stat) => {
201
+ console.log(
202
+ ` • ${stat.locale}: ${stat.added.length} added, ${stat.updated.length} updated, ${stat.removed.length} removed (total ${stat.totalKeys})`
203
+ );
204
+ });
205
+ }
206
+ }
207
+
208
+ async function loadRenameMappings(mapPath: string): Promise<KeyRenameMapping[]> {
209
+ if (!mapPath) {
210
+ throw new Error('A path to the rename map is required.');
211
+ }
212
+
213
+ const resolvedPath = path.isAbsolute(mapPath) ? mapPath : path.resolve(process.cwd(), mapPath);
214
+ let fileContents: string;
215
+
216
+ try {
217
+ fileContents = await fs.readFile(resolvedPath, 'utf8');
218
+ } catch (error) {
219
+ const err = error as NodeJS.ErrnoException;
220
+ if (err.code === 'ENOENT') {
221
+ throw new Error(`Rename map not found at ${resolvedPath}.`);
222
+ }
223
+ throw new Error(`Unable to read rename map at ${resolvedPath}: ${err.message}`);
224
+ }
225
+
226
+ let parsed: unknown;
227
+ try {
228
+ parsed = JSON.parse(fileContents);
229
+ } catch (error) {
230
+ throw new Error(`Rename map contains invalid JSON: ${(error as Error).message}`);
231
+ }
232
+
233
+ const mappings = normalizeRenameMap(parsed);
234
+ if (!mappings.length) {
235
+ throw new Error('Rename map is empty. Provide at least one {"from": "foo", "to": "bar"} entry.');
236
+ }
237
+
238
+ return mappings;
239
+ }
240
+
241
+ function normalizeRenameMap(input: unknown): KeyRenameMapping[] {
242
+ if (Array.isArray(input)) {
243
+ return input
244
+ .map((item) => {
245
+ if (typeof item !== 'object' || item === null) {
246
+ return undefined;
247
+ }
248
+ const from = 'from' in item ? String((item as Record<string, unknown>).from ?? '') : '';
249
+ const to = 'to' in item ? String((item as Record<string, unknown>).to ?? '') : '';
250
+ return { from: from.trim(), to: to.trim() };
251
+ })
252
+ .filter((entry): entry is KeyRenameMapping =>
253
+ Boolean(entry && entry.from && entry.to && entry.from !== entry.to)
254
+ );
255
+ }
256
+
257
+ if (input && typeof input === 'object') {
258
+ return Object.entries(input as Record<string, unknown>)
259
+ .map(([from, to]) => ({ from: from.trim(), to: typeof to === 'string' ? to.trim() : '' }))
260
+ .filter((entry) => Boolean(entry.from) && Boolean(entry.to) && entry.from !== entry.to);
261
+ }
262
+
263
+ throw new Error('Rename map must be either an object ("old":"new") or an array of {"from","to"}.');
264
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { Command } from 'commander';
3
+ import { registerScaffoldAdapter } from './scaffold-adapter';
4
+ import * as scaffoldModule from '../utils/scaffold.js';
5
+
6
+ vi.mock('@i18nsmith/core', () => ({
7
+ loadConfig: vi.fn().mockResolvedValue({
8
+ version: 1,
9
+ sourceLanguage: 'en',
10
+ targetLanguages: ['es'],
11
+ localesDir: 'locales',
12
+ include: ['src/**/*.{ts,tsx}'],
13
+ exclude: [],
14
+ minTextLength: 1,
15
+ translationAdapter: { module: 'react-i18next', hookName: 'useTranslation' },
16
+ keyGeneration: { namespace: 'common', shortHashLen: 6 },
17
+ seedTargetLocales: false,
18
+ sync: {
19
+ translationIdentifier: 't',
20
+ validateInterpolations: false,
21
+ placeholderFormats: ['doubleCurly', 'percentCurly', 'percentSymbol'],
22
+ emptyValuePolicy: 'warn',
23
+ emptyValueMarkers: ['todo'],
24
+ dynamicKeyAssumptions: [],
25
+ },
26
+ }),
27
+ diagnoseWorkspace: vi.fn().mockResolvedValue({
28
+ localesDir: 'locales',
29
+ localeFiles: [],
30
+ detectedLocales: [],
31
+ runtimePackages: [],
32
+ providerFiles: [],
33
+ adapterFiles: [],
34
+ translationUsage: {
35
+ hookName: 'useTranslation',
36
+ translationIdentifier: 't',
37
+ filesExamined: 0,
38
+ hookOccurrences: 0,
39
+ identifierOccurrences: 0,
40
+ hookExampleFiles: [],
41
+ identifierExampleFiles: [],
42
+ },
43
+ actionableItems: [],
44
+ conflicts: [],
45
+ recommendations: [],
46
+ }),
47
+ }));
48
+
49
+ vi.mock('inquirer', () => ({
50
+ default: {
51
+ prompt: vi.fn().mockResolvedValue({
52
+ type: 'custom',
53
+ sourceLanguage: 'en',
54
+ localesDir: 'locales',
55
+ filePath: 'src/contexts/translation-context.tsx',
56
+ force: false,
57
+ }),
58
+ },
59
+ }));
60
+
61
+ vi.mock('../utils/scaffold.js', () => ({
62
+ scaffoldTranslationContext: vi.fn().mockResolvedValue({
63
+ path: 'src/contexts/translation-context.tsx',
64
+ content: '// mock content',
65
+ written: true,
66
+ }),
67
+ scaffoldI18next: vi.fn().mockResolvedValue({
68
+ i18nPath: 'src/lib/i18n.ts',
69
+ providerPath: 'src/components/i18n-provider.tsx',
70
+ i18nResult: { path: 'src/lib/i18n.ts', content: '// i18n', written: true },
71
+ providerResult: { path: 'src/components/i18n-provider.tsx', content: '// provider', written: true },
72
+ }),
73
+ }));
74
+
75
+ vi.mock('../utils/pkg.js', () => ({
76
+ readPackageJson: vi.fn().mockResolvedValue({}),
77
+ hasDependency: vi.fn().mockReturnValue(false),
78
+ }));
79
+
80
+ describe('scaffold-adapter command', () => {
81
+ it('should register the scaffold-adapter command', () => {
82
+ const program = new Command();
83
+ registerScaffoldAdapter(program);
84
+ const command = program.commands.find((cmd) => cmd.name() === 'scaffold-adapter');
85
+ expect(command).toBeDefined();
86
+ });
87
+
88
+ describe('--dry-run flag', () => {
89
+ beforeEach(() => {
90
+ vi.clearAllMocks();
91
+ });
92
+
93
+ afterEach(() => {
94
+ vi.restoreAllMocks();
95
+ });
96
+
97
+ it('should pass dryRun option to scaffoldTranslationContext', async () => {
98
+ const scaffoldSpy = vi.spyOn(scaffoldModule, 'scaffoldTranslationContext');
99
+ scaffoldSpy.mockResolvedValue({
100
+ path: 'src/contexts/translation-context.tsx',
101
+ content: '// mock dry-run content',
102
+ written: false,
103
+ });
104
+
105
+ // Verify the dryRun parameter is passed correctly through the scaffold options
106
+ expect(scaffoldSpy).not.toHaveBeenCalled();
107
+ // Note: Full integration test would require mocking more dependencies
108
+ });
109
+ });
110
+ });
@@ -0,0 +1,250 @@
1
+ import { Command } from 'commander';
2
+ import inquirer from 'inquirer';
3
+ import chalk from 'chalk';
4
+ import { diagnoseWorkspace, loadConfig } from '@i18nsmith/core';
5
+ import { scaffoldTranslationContext, scaffoldI18next, ScaffoldResult } from '../utils/scaffold.js';
6
+ import { readPackageJson, hasDependency } from '../utils/pkg.js';
7
+ import { detectPackageManager, installDependencies } from '../utils/package-manager.js';
8
+ import { maybeInjectProvider } from '../utils/provider-injector.js';
9
+
10
+ interface ScaffoldCommandOptions {
11
+ type?: 'custom' | 'react-i18next';
12
+ sourceLanguage?: string;
13
+ path?: string;
14
+ i18nPath?: string;
15
+ providerPath?: string;
16
+ localesDir?: string;
17
+ force?: boolean;
18
+ installDeps?: boolean;
19
+ dryRun?: boolean;
20
+ skipIfDetected?: boolean;
21
+ }
22
+
23
+ interface ScaffoldAnswers {
24
+ type: 'custom' | 'react-i18next';
25
+ sourceLanguage: string;
26
+ localesDir: string;
27
+ force: boolean;
28
+ filePath: string;
29
+ i18nPath: string;
30
+ providerPath: string;
31
+ }
32
+
33
+ export function registerScaffoldAdapter(program: Command) {
34
+ program
35
+ .command('scaffold-adapter')
36
+ .description('Scaffold translation adapter files')
37
+ .option('-t, --type <type>', 'Adapter type: custom or react-i18next')
38
+ .option('-l, --source-language <lang>', 'Source language code', 'en')
39
+ .option('-p, --path <path>', 'Path for custom adapter file', 'src/contexts/translation-context.tsx')
40
+ .option('--i18n-path <path>', 'Path for react-i18next initializer', 'src/lib/i18n.ts')
41
+ .option('--provider-path <path>', 'Path for I18nProvider component', 'src/components/i18n-provider.tsx')
42
+ .option('--locales-dir <dir>', 'Locales directory relative to project root', 'locales')
43
+ .option('-f, --force', 'Overwrite files if they already exist', false)
44
+ .option('--install-deps', 'Automatically install adapter dependencies when missing', false)
45
+ .option('--dry-run', 'Preview provider injection changes without modifying files', false)
46
+ .option('--no-skip-if-detected', 'Force scaffolding even if existing adapters/providers are detected')
47
+ .action(async (options: ScaffoldCommandOptions) => {
48
+ console.log(chalk.blue('Scaffolding translation resources...'));
49
+
50
+ const answers = await inquirer.prompt<ScaffoldAnswers>([
51
+ {
52
+ type: 'list',
53
+ name: 'type',
54
+ message: 'Choose adapter type',
55
+ choices: [
56
+ { name: 'Custom context (zero dependencies)', value: 'custom' },
57
+ { name: 'react-i18next (standard)', value: 'react-i18next' },
58
+ ],
59
+ default: options.type || 'custom',
60
+ },
61
+ {
62
+ type: 'input',
63
+ name: 'sourceLanguage',
64
+ message: 'Source language code',
65
+ default: options.sourceLanguage || 'en',
66
+ },
67
+ {
68
+ type: 'input',
69
+ name: 'localesDir',
70
+ message: 'Locales directory (relative to project root)',
71
+ default: options.localesDir || 'locales',
72
+ },
73
+ {
74
+ type: 'input',
75
+ name: 'filePath',
76
+ message: 'Path to scaffold translation context file',
77
+ default: options.path || 'src/contexts/translation-context.tsx',
78
+ when: (answers) => answers.type === 'custom',
79
+ },
80
+ {
81
+ type: 'input',
82
+ name: 'i18nPath',
83
+ message: 'Path for i18next initializer (i18n.ts)',
84
+ default: options.i18nPath || 'src/lib/i18n.ts',
85
+ when: (answers) => answers.type === 'react-i18next',
86
+ },
87
+ {
88
+ type: 'input',
89
+ name: 'providerPath',
90
+ message: 'Path for I18nProvider component',
91
+ default: options.providerPath || 'src/components/i18n-provider.tsx',
92
+ when: (answers) => answers.type === 'react-i18next',
93
+ },
94
+ {
95
+ type: 'confirm',
96
+ name: 'force',
97
+ message: 'Overwrite files if they exist?',
98
+ default: Boolean(options.force),
99
+ },
100
+ ]);
101
+
102
+ if ((options.skipIfDetected ?? true) && !options.force && !answers.force) {
103
+ const detection = await detectExistingRuntime();
104
+ if (detection) {
105
+ console.log(
106
+ chalk.yellow(
107
+ `Existing i18n runtime detected (${detection}). Skipping scaffold. Use --no-skip-if-detected or --force to override.`
108
+ )
109
+ );
110
+ return;
111
+ }
112
+ }
113
+
114
+ try {
115
+ const dryRun = Boolean(options.dryRun);
116
+
117
+ if (answers.type === 'custom') {
118
+ const result = await scaffoldTranslationContext(answers.filePath, answers.sourceLanguage, {
119
+ localesDir: answers.localesDir,
120
+ force: answers.force,
121
+ dryRun,
122
+ });
123
+
124
+ if (dryRun) {
125
+ console.log(chalk.blue('\nšŸ“‹ DRY RUN - No files were modified\n'));
126
+ console.log(chalk.cyan(`Would create: ${result.path}`));
127
+ console.log(chalk.gray('─'.repeat(60)));
128
+ console.log(result.content);
129
+ console.log(chalk.gray('─'.repeat(60)));
130
+ } else {
131
+ console.log(chalk.green(`Translation context scaffolded at ${result.path}`));
132
+ }
133
+
134
+ console.log(chalk.blue('\nUpdate your i18n.config.json:'));
135
+ console.log(`{
136
+ "translationAdapter": {
137
+ "module": "${answers.filePath.replace(/\\\\/g, '/').replace(/\.tsx?$/, '')}",
138
+ "hookName": "useTranslation"
139
+ }
140
+ }`);
141
+ } else if (answers.type === 'react-i18next') {
142
+ const { i18nPath, providerPath, i18nResult, providerResult } = await scaffoldI18next(
143
+ answers.i18nPath,
144
+ answers.providerPath,
145
+ answers.sourceLanguage,
146
+ answers.localesDir,
147
+ { force: answers.force, dryRun }
148
+ );
149
+
150
+ if (dryRun) {
151
+ console.log(chalk.blue('\nšŸ“‹ DRY RUN - No files were modified\n'));
152
+ console.log(chalk.cyan(`Would create: ${i18nResult.path}`));
153
+ console.log(chalk.gray('─'.repeat(60)));
154
+ console.log(i18nResult.content);
155
+ console.log(chalk.gray('─'.repeat(60)));
156
+ console.log(chalk.cyan(`\nWould create: ${providerResult.path}`));
157
+ console.log(chalk.gray('─'.repeat(60)));
158
+ console.log(providerResult.content);
159
+ console.log(chalk.gray('─'.repeat(60)));
160
+ } else {
161
+ console.log(chalk.green('\nScaffolded react-i18next runtime:'));
162
+ console.log(chalk.green(` • ${i18nPath}`));
163
+ console.log(chalk.green(` • ${providerPath}`));
164
+ }
165
+
166
+ const pkg = await readPackageJson();
167
+ const missingDeps = ['react-i18next', 'i18next'].filter((dep) => !hasDependency(pkg, dep));
168
+ if (missingDeps.length) {
169
+ console.log(chalk.yellow('\nDependencies missing:'));
170
+ missingDeps.forEach((dep) => console.log(chalk.yellow(` • ${dep}`)));
171
+
172
+ if (dryRun) {
173
+ console.log(chalk.blue('\nIn write mode, install them with:'));
174
+ console.log(chalk.cyan(' pnpm add react-i18next i18next'));
175
+ } else if (options.installDeps) {
176
+ try {
177
+ const manager = await detectPackageManager();
178
+ console.log(chalk.blue(`\nInstalling dependencies with ${manager}...`));
179
+ await installDependencies(manager, missingDeps);
180
+ console.log(chalk.green('Dependencies installed successfully.'));
181
+ } catch (error) {
182
+ console.error(chalk.red('Failed to install dependencies automatically:'), (error as Error).message);
183
+ console.log(chalk.blue('You can install them manually:'));
184
+ console.log(chalk.cyan(' pnpm add react-i18next i18next'));
185
+ }
186
+ } else {
187
+ console.log(chalk.blue('Install them with:'));
188
+ console.log(chalk.cyan(' pnpm add react-i18next i18next'));
189
+ }
190
+ }
191
+
192
+ console.log(chalk.blue('\nWrap your app with the provider (e.g. Next.js providers.tsx):'));
193
+ console.log(chalk.cyan(`import { I18nProvider } from '${answers.providerPath.replace(/\\\\/g, '/').replace(/\.tsx?$/, '')}';`));
194
+ console.log(chalk.cyan('<I18nProvider>{children}</I18nProvider>'));
195
+
196
+ const injectionResult = await maybeInjectProvider({
197
+ providerComponentPath: providerPath,
198
+ dryRun: Boolean(options.dryRun),
199
+ });
200
+
201
+ if (injectionResult.status === 'injected') {
202
+ console.log(chalk.green(`\nUpdated ${injectionResult.file} to wrap <I18nProvider>.`));
203
+ } else if (injectionResult.status === 'preview') {
204
+ console.log(
205
+ chalk.blue(`\nProvider dry-run for ${injectionResult.file}: changes previewed below (no files modified).`)
206
+ );
207
+ console.log(injectionResult.diff.trimEnd());
208
+ } else if (injectionResult.status === 'skipped') {
209
+ console.log(
210
+ chalk.yellow(`\nProvider file ${injectionResult.file} already uses I18nProvider. Skipping injection.`)
211
+ );
212
+ } else if (injectionResult.status === 'failed') {
213
+ console.log(
214
+ chalk.red(
215
+ `\nCould not safely inject I18nProvider into ${injectionResult.file}: ${injectionResult.reason}\n` +
216
+ 'Please manually wrap your layout or providers file with <I18nProvider> and rerun.'
217
+ )
218
+ );
219
+ } else {
220
+ console.log(chalk.gray('\nNo Next.js provider file detected for automatic injection.'));
221
+ }
222
+ }
223
+ } catch (error) {
224
+ console.error(chalk.red('Failed to scaffold adapter:'), (error as Error).message);
225
+ }
226
+ });
227
+ }
228
+
229
+ async function detectExistingRuntime(): Promise<string | null> {
230
+ try {
231
+ const config = await loadConfig();
232
+ const report = await diagnoseWorkspace(config);
233
+ type AdapterInfo = (typeof report.adapterFiles)[number];
234
+ type ProviderInfo = (typeof report.providerFiles)[number];
235
+
236
+ if (report.adapterFiles.length) {
237
+ return report.adapterFiles.map((adapter: AdapterInfo) => adapter.path).join(', ');
238
+ }
239
+ const provider = report.providerFiles.find((entry: ProviderInfo) => entry.hasI18nProvider);
240
+ if (provider) {
241
+ return provider.relativePath;
242
+ }
243
+ } catch (error) {
244
+ if ((error as Error).message?.includes('Config file not found')) {
245
+ return null;
246
+ }
247
+ console.warn(chalk.gray(`Skipping adapter detection: ${(error as Error).message}`));
248
+ }
249
+ return null;
250
+ }