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,188 @@
1
+ /**
2
+ * Translation execution logic with retry, batching, and placeholder validation
3
+ */
4
+
5
+ import chalk from 'chalk';
6
+ import pLimit from 'p-limit';
7
+ import pRetry from 'p-retry';
8
+ import type { PlaceholderValidator, TranslationPlan, TranslationService, TranslationLocalePlan } from '@i18nsmith/core';
9
+ import { loadTranslator, type Translator, type TranslatorLoadOptions } from '@i18nsmith/translation';
10
+ import type { TranslateLocaleResult, PlaceholderIssue } from './types.js';
11
+
12
+ export interface ExecuteTranslationsInput {
13
+ plan: TranslationPlan;
14
+ translationService: TranslationService;
15
+ provider: {
16
+ name: string;
17
+ loaderOptions: TranslatorLoadOptions;
18
+ };
19
+ overwrite: boolean;
20
+ skipEmpty: boolean;
21
+ placeholderValidator: PlaceholderValidator;
22
+ strictPlaceholders: boolean;
23
+ }
24
+
25
+ export interface ExecuteTranslationsResult {
26
+ results: TranslateLocaleResult[];
27
+ stats: Awaited<ReturnType<TranslationService['flush']>>;
28
+ }
29
+
30
+ /**
31
+ * Execute translations for all locales in the plan
32
+ */
33
+ export async function executeTranslations(
34
+ input: ExecuteTranslationsInput
35
+ ): Promise<ExecuteTranslationsResult> {
36
+ const translator = await loadTranslator(input.provider.loaderOptions);
37
+ const results: TranslateLocaleResult[] = [];
38
+ const limit = pLimit(input.provider.loaderOptions.concurrency ?? 4);
39
+
40
+ try {
41
+ const translationPromises = input.plan.locales.map((localePlan) =>
42
+ limit(async () => {
43
+ const { updates, placeholderIssues } = await translateLocalePlan(
44
+ translator,
45
+ localePlan,
46
+ input.plan.sourceLocale,
47
+ input.provider.loaderOptions.batchSize ?? 25,
48
+ input.placeholderValidator,
49
+ input.strictPlaceholders
50
+ );
51
+ const writeSummary = await input.translationService.writeTranslations(localePlan.locale, updates, {
52
+ overwrite: input.overwrite,
53
+ skipEmpty: input.skipEmpty,
54
+ });
55
+ results.push({
56
+ ...writeSummary,
57
+ characters: localePlan.totalCharacters,
58
+ placeholderIssues,
59
+ });
60
+ })
61
+ );
62
+
63
+ await Promise.all(translationPromises);
64
+
65
+ const stats = await input.translationService.flush();
66
+ // Sort results to match the plan's locale order for consistent output
67
+ results.sort((a, b) => {
68
+ const aIndex = input.plan.locales.findIndex((p) => p.locale === a.locale);
69
+ const bIndex = input.plan.locales.findIndex((p) => p.locale === b.locale);
70
+ return aIndex - bIndex;
71
+ });
72
+ return { results, stats };
73
+ } finally {
74
+ await translator.dispose?.();
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Translate a single locale plan with batching and retry logic
80
+ */
81
+ async function translateLocalePlan(
82
+ translator: Translator,
83
+ localePlan: TranslationLocalePlan,
84
+ sourceLocale: string,
85
+ batchSize: number,
86
+ placeholderValidator: PlaceholderValidator,
87
+ strictPlaceholders: boolean
88
+ ): Promise<{ updates: { key: string; value: string }[]; placeholderIssues: PlaceholderIssue[] }> {
89
+ const updates: { key: string; value: string }[] = [];
90
+ const placeholderIssues: PlaceholderIssue[] = [];
91
+
92
+ for (const chunk of chunkTasks(localePlan.tasks, batchSize)) {
93
+ const translationMap = new Map<string, string>();
94
+
95
+ // Pre-fill with reused values
96
+ chunk.forEach((task) => {
97
+ if (task.reusedValue) {
98
+ translationMap.set(task.key, task.reusedValue.value);
99
+ }
100
+ });
101
+
102
+ const tasksRequiringTranslation = chunk.filter((task) => !task.reusedValue);
103
+ if (tasksRequiringTranslation.length > 0) {
104
+ const sourceTexts = tasksRequiringTranslation.map((task) => task.sourceValue);
105
+ const translated = await pRetry(() => translator.translate(sourceTexts, sourceLocale, localePlan.locale), {
106
+ retries: 3,
107
+ onFailedAttempt: (error) => {
108
+ console.log(
109
+ chalk.yellow(
110
+ `Attempt ${error.attemptNumber} failed translating ${localePlan.locale}. There are ${error.retriesLeft} retries left.`
111
+ )
112
+ );
113
+ },
114
+ });
115
+
116
+ if (!Array.isArray(translated) || translated.length !== sourceTexts.length) {
117
+ throw new Error(
118
+ `Translator returned ${translated.length} result(s) for ${sourceTexts.length} input(s) while translating ${localePlan.locale}.`
119
+ );
120
+ }
121
+
122
+ tasksRequiringTranslation.forEach((task, index) => {
123
+ const candidate = translated[index] ?? '';
124
+ const comparison = placeholderValidator.compare(task.sourceValue, candidate ?? '');
125
+
126
+ if (task.placeholders.length && comparison.missing.length) {
127
+ placeholderIssues.push({
128
+ key: task.key,
129
+ locale: localePlan.locale,
130
+ type: 'missing',
131
+ placeholders: comparison.missing,
132
+ });
133
+ if (!strictPlaceholders) {
134
+ console.log(
135
+ chalk.yellow(
136
+ `Translator output for ${task.key} (${localePlan.locale}) is missing placeholder${
137
+ comparison.missing.length === 1 ? '' : 's'
138
+ }: ${comparison.missing.join(', ')}. Falling back to source text.`
139
+ )
140
+ );
141
+ }
142
+ translationMap.set(task.key, task.sourceValue);
143
+ return;
144
+ }
145
+
146
+ if (comparison.extra.length) {
147
+ placeholderIssues.push({
148
+ key: task.key,
149
+ locale: localePlan.locale,
150
+ type: 'extra',
151
+ placeholders: comparison.extra,
152
+ });
153
+ if (!strictPlaceholders) {
154
+ console.log(
155
+ chalk.yellow(
156
+ `Translator output for ${task.key} (${localePlan.locale}) introduced unexpected placeholder${
157
+ comparison.extra.length === 1 ? '' : 's'
158
+ }: ${comparison.extra.join(', ')}`
159
+ )
160
+ );
161
+ }
162
+ }
163
+
164
+ translationMap.set(task.key, candidate ?? '');
165
+ });
166
+ }
167
+
168
+ chunk.forEach((task) => {
169
+ updates.push({ key: task.key, value: translationMap.get(task.key) ?? '' });
170
+ });
171
+ }
172
+
173
+ return { updates, placeholderIssues };
174
+ }
175
+
176
+ /**
177
+ * Split an array into chunks of a given size
178
+ */
179
+ export function chunkTasks<T>(items: T[], size: number): T[][] {
180
+ if (size <= 0) {
181
+ return [items];
182
+ }
183
+ const result: T[][] = [];
184
+ for (let i = 0; i < items.length; i += size) {
185
+ result.push(items.slice(i, i + size));
186
+ }
187
+ return result;
188
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Translate command - fill missing locale entries via translation adapters
3
+ */
4
+
5
+ import chalk from 'chalk';
6
+ import inquirer from 'inquirer';
7
+ import type { Command } from 'commander';
8
+ import {
9
+ DEFAULT_PLACEHOLDER_FORMATS,
10
+ PlaceholderValidator,
11
+ loadConfig,
12
+ TranslationService,
13
+ TranslationPlan,
14
+ } from '@i18nsmith/core';
15
+ import type { TranslationConfig } from '@i18nsmith/core';
16
+ import { TranslatorLoadError, type TranslatorLoadOptions } from '@i18nsmith/translation';
17
+
18
+ import type { TranslateCommandOptions, TranslateSummary, ProviderSettings } from './types.js';
19
+ import { emitTranslateOutput, maybePrintEstimate } from './reporter.js';
20
+ import { handleCsvExport, handleCsvImport } from './csv-handler.js';
21
+ import { executeTranslations } from './executor.js';
22
+
23
+ // Re-export types for external use
24
+ export * from './types.js';
25
+
26
+ /**
27
+ * Parse comma-separated locale values
28
+ */
29
+ function collectLocales(value: string | string[], previous: string[]): string[] {
30
+ const tokens = (Array.isArray(value) ? value : value.split(','))
31
+ .map((entry) => entry.trim())
32
+ .filter(Boolean);
33
+ return [...previous, ...tokens];
34
+ }
35
+
36
+ /**
37
+ * Resolve provider settings from config and options
38
+ */
39
+ function resolveProviderSettings(
40
+ translationConfig: TranslationConfig | undefined,
41
+ override?: string
42
+ ): ProviderSettings & { loaderOptions: TranslatorLoadOptions } {
43
+ const providerName = (override ?? translationConfig?.provider ?? 'manual').trim();
44
+ const moduleSpecifier = translationConfig?.module;
45
+ let secret: string | undefined;
46
+ if (translationConfig?.secretEnvVar) {
47
+ secret = process.env[translationConfig.secretEnvVar];
48
+ }
49
+
50
+ const loaderOptions: TranslatorLoadOptions = {
51
+ provider: providerName,
52
+ module: moduleSpecifier,
53
+ apiKey: translationConfig?.apiKey,
54
+ secret,
55
+ concurrency: translationConfig?.concurrency,
56
+ batchSize: translationConfig?.batchSize,
57
+ config: translationConfig ? { ...translationConfig } : undefined,
58
+ };
59
+
60
+ return {
61
+ name: providerName,
62
+ loaderOptions,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Prompt for translation confirmation
68
+ */
69
+ async function confirmTranslate(plan: TranslationPlan, providerName: string): Promise<boolean> {
70
+ const { proceed } = await inquirer.prompt<{ proceed: boolean }>([
71
+ {
72
+ type: 'confirm',
73
+ name: 'proceed',
74
+ default: false,
75
+ message: `Translate ${plan.totalTasks} key${plan.totalTasks === 1 ? '' : 's'} (${plan.totalCharacters} chars) across ${plan.locales.length} locale${plan.locales.length === 1 ? '' : 's'} via ${providerName}?`,
76
+ },
77
+ ]);
78
+ return proceed;
79
+ }
80
+
81
+ /**
82
+ * Register the translate command
83
+ */
84
+ export function registerTranslate(program: Command): void {
85
+ program
86
+ .command('translate')
87
+ .description('Fill missing locale entries by invoking configured translation adapters')
88
+ .option('-c, --config <path>', 'Path to i18nsmith config file', 'i18n.config.json')
89
+ .option('--json', 'Print raw JSON results', false)
90
+ .option('--report <path>', 'Write JSON summary to a file')
91
+ .option('--write', 'Write translated values to locale files (defaults to dry-run)', false)
92
+ .option('--locales <codes...>', 'Comma-separated list of locale codes to translate', collectLocales, [])
93
+ .option('--provider <name>', 'Override the translation provider configured in i18n.config.json')
94
+ .option('--force', 'Retranslate keys even if a locale already has a value', false)
95
+ .option('--estimate', 'Attempt to estimate cost when running in dry-run mode', false)
96
+ .option('--no-skip-empty', 'Allow writing empty translator results (default skips them)')
97
+ .option('-y, --yes', 'Skip interactive confirmation when applying translations', false)
98
+ .option('--strict-placeholders', 'Fail if translated output has placeholder mismatches (for CI)', false)
99
+ .option('--export <path>', 'Export missing translations to a CSV file for external translation')
100
+ .option('--import <path>', 'Import translations from a CSV file and merge into locale files')
101
+ .action(async (options: TranslateCommandOptions) => {
102
+ // Handle CSV export mode
103
+ if (options.export) {
104
+ await handleCsvExport(options);
105
+ return;
106
+ }
107
+
108
+ // Handle CSV import mode
109
+ if (options.import) {
110
+ await handleCsvImport(options);
111
+ return;
112
+ }
113
+
114
+ console.log(
115
+ chalk.blue(options.write ? 'Translating locale files...' : 'Planning translations (dry-run)...')
116
+ );
117
+
118
+ try {
119
+ const config = await loadConfig(options.config);
120
+ const translationService = new TranslationService(config);
121
+ const plan = await translationService.buildPlan({
122
+ locales: options.locales,
123
+ force: options.force,
124
+ });
125
+ const placeholderValidator = new PlaceholderValidator(
126
+ config.sync?.placeholderFormats?.length ? config.sync.placeholderFormats : DEFAULT_PLACEHOLDER_FORMATS
127
+ );
128
+
129
+ if (!plan.totalTasks) {
130
+ console.log(chalk.green('✓ No missing translations detected.'));
131
+ return;
132
+ }
133
+
134
+ const providerSettings = resolveProviderSettings(config.translation, options.provider);
135
+ const summary: TranslateSummary = {
136
+ provider: providerSettings.name,
137
+ dryRun: !options.write,
138
+ plan,
139
+ locales: [],
140
+ localeStats: [],
141
+ totalCharacters: plan.totalCharacters,
142
+ };
143
+
144
+ if (!options.write) {
145
+ if (options.estimate && providerSettings.name !== 'manual') {
146
+ await maybePrintEstimate(plan, providerSettings);
147
+ }
148
+
149
+ await emitTranslateOutput(summary, options);
150
+ return;
151
+ }
152
+
153
+ if (providerSettings.name === 'manual') {
154
+ throw new Error(
155
+ 'No translation provider configured. Update "translation.provider" in i18n.config.json or pass --provider.'
156
+ );
157
+ }
158
+
159
+ if (options.write && !options.yes) {
160
+ if (!process.stdout.isTTY) {
161
+ throw new Error('Interactive confirmation required in non-TTY environment. Re-run with --yes to proceed.');
162
+ }
163
+ const confirmed = await confirmTranslate(plan, providerSettings.name);
164
+ if (!confirmed) {
165
+ console.log(chalk.yellow('Translation aborted by user.'));
166
+ return;
167
+ }
168
+ }
169
+
170
+ const skipEmpty = options.skipEmpty !== false;
171
+ const strictPlaceholders = options.strictPlaceholders ?? false;
172
+ const localeResults = await executeTranslations({
173
+ plan,
174
+ translationService,
175
+ provider: providerSettings,
176
+ overwrite: options.force ?? false,
177
+ skipEmpty,
178
+ placeholderValidator,
179
+ strictPlaceholders,
180
+ });
181
+
182
+ summary.locales = localeResults.results;
183
+ summary.localeStats = localeResults.stats;
184
+
185
+ // Check for placeholder issues in strict mode
186
+ const allPlaceholderIssues = localeResults.results.flatMap((r) => r.placeholderIssues);
187
+ if (strictPlaceholders && allPlaceholderIssues.length > 0) {
188
+ console.error(
189
+ chalk.red(
190
+ `\n✗ ${allPlaceholderIssues.length} placeholder issue(s) detected in translated output:`
191
+ )
192
+ );
193
+ allPlaceholderIssues.slice(0, 10).forEach((issue) => {
194
+ console.error(
195
+ chalk.red(
196
+ ` • ${issue.key} (${issue.locale}): ${issue.type} placeholder${
197
+ issue.placeholders.length === 1 ? '' : 's'
198
+ } ${issue.placeholders.join(', ')}`
199
+ )
200
+ );
201
+ });
202
+ if (allPlaceholderIssues.length > 10) {
203
+ console.error(chalk.red(` ... and ${allPlaceholderIssues.length - 10} more issues`));
204
+ }
205
+ process.exitCode = 1;
206
+ }
207
+
208
+ await emitTranslateOutput(summary, options);
209
+ } catch (error: unknown) {
210
+ const translatorError = error instanceof TranslatorLoadError ? error : undefined;
211
+ const normalizedError =
212
+ error instanceof Error
213
+ ? error
214
+ : new Error(typeof error === 'string' ? error : JSON.stringify(error));
215
+
216
+ console.error(chalk.red('Translate failed:'), translatorError?.message ?? normalizedError.message);
217
+ process.exitCode = 1;
218
+ }
219
+ });
220
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Output formatting and reporting utilities for the translate command
3
+ */
4
+
5
+ import fs from 'fs/promises';
6
+ import path from 'path';
7
+ import chalk from 'chalk';
8
+ import type { TranslationPlan } from '@i18nsmith/core';
9
+ import { loadTranslator, type TranslatorLoadOptions } from '@i18nsmith/translation';
10
+ import type {
11
+ TranslateSummary,
12
+ TranslateLocaleResult,
13
+ TranslateCommandOptions,
14
+ ProviderSettings,
15
+ } from './types.js';
16
+
17
+ /**
18
+ * Emit the translation output (report file, JSON, or console)
19
+ */
20
+ export async function emitTranslateOutput(
21
+ summary: TranslateSummary,
22
+ options: TranslateCommandOptions
23
+ ): Promise<void> {
24
+ if (options.report) {
25
+ const outputPath = path.resolve(process.cwd(), options.report);
26
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
27
+ await fs.writeFile(outputPath, JSON.stringify(summary, null, 2));
28
+ console.log(chalk.green(`Translate report written to ${outputPath}`));
29
+ }
30
+
31
+ if (options.json) {
32
+ console.log(JSON.stringify(summary, null, 2));
33
+ return;
34
+ }
35
+
36
+ printPlanSummary(summary.plan);
37
+
38
+ if (!summary.dryRun) {
39
+ printExecutionSummary(summary.locales);
40
+ const mutations = summary.localeStats.reduce(
41
+ (total, stat) => total + stat.added.length + stat.updated.length,
42
+ 0
43
+ );
44
+ if (mutations === 0) {
45
+ console.log(chalk.yellow('No locale files were updated. Translator may have skipped all entries.'));
46
+ }
47
+ } else {
48
+ console.log(chalk.yellow('Run again with --write to apply translations.'));
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Print a summary of the translation plan
54
+ */
55
+ export function printPlanSummary(plan: TranslationPlan): void {
56
+ console.log(
57
+ chalk.green(
58
+ `Source locale ${plan.sourceLocale}; ${plan.totalTasks} missing entr${plan.totalTasks === 1 ? 'y' : 'ies'} across ${plan.locales.length} locale${plan.locales.length === 1 ? '' : 's'}.`
59
+ )
60
+ );
61
+
62
+ if (!plan.locales.length) {
63
+ console.log(chalk.green('All configured locales are up to date.'));
64
+ return;
65
+ }
66
+
67
+ plan.locales.forEach((localePlan) => {
68
+ const reuseCount = localePlan.tasks.filter((task) => task.reusedValue).length;
69
+ const reuseMessage = reuseCount > 0 ? chalk.gray(` (${reuseCount} reusable)`) : '';
70
+ console.log(
71
+ ` • ${localePlan.locale}: ${localePlan.tasks.length} key${
72
+ localePlan.tasks.length === 1 ? '' : 's'
73
+ } (${localePlan.totalCharacters} chars)${reuseMessage}`
74
+ );
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Print the execution summary after translations are applied
80
+ */
81
+ export function printExecutionSummary(results: TranslateLocaleResult[]): void {
82
+ if (!results.length) {
83
+ console.log(chalk.yellow('No translations were written.'));
84
+ return;
85
+ }
86
+
87
+ console.log(chalk.blue('\nTranslation results:'));
88
+ results.forEach((result) => {
89
+ console.log(
90
+ ` • ${result.locale}: ${result.written} written, ${result.skipped} skipped, ${result.emptySkipped} empty (${result.characters} chars)`
91
+ );
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Print cost estimation if available
97
+ */
98
+ export async function maybePrintEstimate(
99
+ plan: TranslationPlan,
100
+ provider: ProviderSettings & { loaderOptions: TranslatorLoadOptions }
101
+ ): Promise<void> {
102
+ console.log(chalk.blue('\n📊 Cost Estimation:'));
103
+ console.log(chalk.gray(` Provider: ${provider.name}`));
104
+ console.log(chalk.gray(` Total characters: ${plan.totalCharacters.toLocaleString()}`));
105
+ console.log(chalk.gray(` Locales: ${plan.locales.length}`));
106
+
107
+ try {
108
+ const translator = await loadTranslator(provider.loaderOptions);
109
+ if (typeof translator.estimateCost === 'function') {
110
+ const estimate = await translator.estimateCost(plan.totalCharacters, { localeCount: plan.locales.length });
111
+ const formattedCost = typeof estimate === 'number'
112
+ ? `$${estimate.toFixed(4)}`
113
+ : String(estimate);
114
+ console.log(chalk.green(` Estimated cost: ${formattedCost}`));
115
+ } else {
116
+ console.log(chalk.yellow(' Provider does not expose cost estimation.'));
117
+ printGenericEstimate(plan);
118
+ }
119
+ await translator.dispose?.();
120
+ } catch (error) {
121
+ console.log(chalk.yellow(` Unable to estimate via provider: ${(error as Error).message}`));
122
+ printGenericEstimate(plan);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Print a generic cost estimate based on common cloud provider rates
128
+ */
129
+ export function printGenericEstimate(plan: TranslationPlan): void {
130
+ // Generic estimation based on common cloud provider rates (rough average)
131
+ // Google/AWS/Azure typically charge ~$20 per million characters
132
+ const ratePerMillion = 20;
133
+ const charsByLocale = plan.totalCharacters;
134
+ const totalChars = charsByLocale * plan.locales.length;
135
+ const estimated = (totalChars / 1_000_000) * ratePerMillion;
136
+ console.log(chalk.gray(` Generic estimate (at ~$20/M chars): $${estimated.toFixed(4)}`));
137
+ console.log(chalk.gray(' (Actual costs vary by provider and tier)'));
138
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Type definitions for the translate command
3
+ */
4
+
5
+ import type { TranslationWriteSummary, TranslationPlan } from '@i18nsmith/core';
6
+ import type { TranslationService } from '@i18nsmith/core';
7
+
8
+ export interface TranslateCommandOptions {
9
+ config?: string;
10
+ json?: boolean;
11
+ report?: string;
12
+ write?: boolean;
13
+ locales?: string[];
14
+ provider?: string;
15
+ force?: boolean;
16
+ estimate?: boolean;
17
+ skipEmpty?: boolean;
18
+ yes?: boolean;
19
+ strictPlaceholders?: boolean;
20
+ export?: string;
21
+ import?: string;
22
+ }
23
+
24
+ export interface TranslateLocaleResult extends TranslationWriteSummary {
25
+ characters: number;
26
+ placeholderIssues: PlaceholderIssue[];
27
+ }
28
+
29
+ export interface PlaceholderIssue {
30
+ key: string;
31
+ locale: string;
32
+ type: 'missing' | 'extra';
33
+ placeholders: string[];
34
+ }
35
+
36
+ export interface TranslateSummary {
37
+ provider: string;
38
+ dryRun: boolean;
39
+ plan: TranslationPlan;
40
+ locales: TranslateLocaleResult[];
41
+ localeStats: Awaited<ReturnType<TranslationService['flush']>>;
42
+ totalCharacters: number;
43
+ }
44
+
45
+ export interface ProviderSettings {
46
+ name: string;
47
+ options?: Record<string, unknown>;
48
+ }
49
+
50
+ export interface CsvRow {
51
+ key: string;
52
+ sourceLocale: string;
53
+ sourceValue: string;
54
+ targetLocale: string;
55
+ targetValue: string;
56
+ }