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,151 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import type { Command } from 'commander';
5
+ import { loadConfigWithMeta } from '@i18nsmith/core';
6
+ import { Transformer } from '@i18nsmith/transformer';
7
+ import type { TransformSummary } from '@i18nsmith/transformer';
8
+ import { printLocaleDiffs, writeLocaleDiffPatches } from '../utils/diff-utils.js';
9
+
10
+ interface TransformOptions {
11
+ config?: string;
12
+ json?: boolean;
13
+ target?: string[];
14
+ report?: string;
15
+ write?: boolean;
16
+ check?: boolean;
17
+ diff?: boolean;
18
+ patchDir?: string;
19
+ migrateTextKeys?: boolean;
20
+ }
21
+
22
+ const collectTargetPatterns = (value: string | string[], previous: string[]) => {
23
+ const list = Array.isArray(value) ? value : [value];
24
+ const tokens = list
25
+ .flatMap((entry) => entry.split(','))
26
+ .map((token) => token.trim())
27
+ .filter(Boolean);
28
+ return [...previous, ...tokens];
29
+ };
30
+
31
+ function printTransformSummary(summary: TransformSummary) {
32
+ console.log(
33
+ chalk.green(
34
+ `Scanned ${summary.filesScanned} file${summary.filesScanned === 1 ? '' : 's'}; ` +
35
+ `${summary.candidates.length} candidate${summary.candidates.length === 1 ? '' : 's'} processed.`
36
+ )
37
+ );
38
+
39
+ const preview = summary.candidates.slice(0, 50).map((candidate) => ({
40
+ File: candidate.filePath,
41
+ Line: candidate.position.line,
42
+ Kind: candidate.kind,
43
+ Status: candidate.status,
44
+ Key: candidate.suggestedKey,
45
+ Preview:
46
+ candidate.text.length > 40
47
+ ? `${candidate.text.slice(0, 37)}...`
48
+ : candidate.text,
49
+ }));
50
+
51
+ console.table(preview);
52
+
53
+ if (summary.filesChanged.length) {
54
+ console.log(chalk.blue(`Files changed (${summary.filesChanged.length}):`));
55
+ summary.filesChanged.forEach((file) => console.log(` • ${file}`));
56
+ }
57
+
58
+ if (summary.localeStats.length) {
59
+ console.log(chalk.blue('Locale updates:'));
60
+ summary.localeStats.forEach((stat) => {
61
+ console.log(
62
+ ` • ${stat.locale}: ${stat.added.length} added, ${stat.updated.length} updated, ${stat.removed.length} removed (total ${stat.totalKeys})`
63
+ );
64
+ });
65
+ }
66
+
67
+ if (summary.skippedFiles.length) {
68
+ console.log(chalk.yellow('Skipped items:'));
69
+ summary.skippedFiles.forEach((item) => console.log(` • ${item.filePath}: ${item.reason}`));
70
+ }
71
+ }
72
+
73
+ export function registerTransform(program: Command) {
74
+ program
75
+ .command('transform')
76
+ .description('Scan project and apply i18n transformations')
77
+ .option('-c, --config <path>', 'Path to i18nsmith config file', 'i18n.config.json')
78
+ .option('--json', 'Print raw JSON results', false)
79
+ .option('--report <path>', 'Write JSON summary to a file (for CI or editors)')
80
+ .option('--write', 'Write changes to disk (defaults to dry-run)', false)
81
+ .option('--check', 'Exit with error code if changes are needed', false)
82
+ .option('--diff', 'Display unified diffs for locale files that would change', false)
83
+ .option('--patch-dir <path>', 'Write locale diffs to .patch files in the specified directory')
84
+ .option('--target <pattern...>', 'Limit scanning to specific files or glob patterns', collectTargetPatterns, [])
85
+ .option('--migrate-text-keys', 'Migrate existing t("Text") calls to structured keys')
86
+ .action(async (options: TransformOptions) => {
87
+ const diffEnabled = Boolean(options.diff || options.patchDir);
88
+ console.log(
89
+ chalk.blue(options.write ? 'Running transform (write mode)...' : 'Planning transform (dry-run)...')
90
+ );
91
+
92
+ try {
93
+ const { config, projectRoot, configPath } = await loadConfigWithMeta(options.config);
94
+
95
+ // Inform user if config was found in a parent directory
96
+ const cwd = process.cwd();
97
+ if (projectRoot !== cwd) {
98
+ console.log(chalk.gray(`Config found at ${path.relative(cwd, configPath)}`));
99
+ console.log(chalk.gray(`Using project root: ${projectRoot}\n`));
100
+ }
101
+
102
+ const transformer = new Transformer(config, { workspaceRoot: projectRoot });
103
+ const summary = await transformer.run({
104
+ write: options.write,
105
+ targets: options.target,
106
+ diff: diffEnabled,
107
+ migrateTextKeys: options.migrateTextKeys,
108
+ });
109
+
110
+ if (options.report) {
111
+ const outputPath = path.resolve(process.cwd(), options.report);
112
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
113
+ await fs.writeFile(outputPath, JSON.stringify(summary, null, 2));
114
+ console.log(chalk.green(`Transform report written to ${outputPath}`));
115
+ }
116
+
117
+ if (options.json) {
118
+ console.log(JSON.stringify(summary, null, 2));
119
+ return;
120
+ }
121
+
122
+ printTransformSummary(summary);
123
+
124
+ if (diffEnabled) {
125
+ printLocaleDiffs(summary.diffs);
126
+ }
127
+ if (options.patchDir) {
128
+ await writeLocaleDiffPatches(summary.diffs, options.patchDir);
129
+ }
130
+
131
+ if (options.check && summary.candidates.some((candidate) => candidate.status === 'pending')) {
132
+ console.error(chalk.red('\nCheck failed: Pending translations found. Run with --write to fix.'));
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+
137
+ if (!options.write && summary.candidates.some((candidate) => candidate.status === 'pending')) {
138
+ console.log(chalk.cyan('\n📋 DRY RUN - No files were modified'));
139
+ console.log(chalk.yellow('Run again with --write to apply these changes.'));
140
+ }
141
+ } catch (error) {
142
+ const errorMessage = (error as Error).message;
143
+ if (options.json) {
144
+ console.log(JSON.stringify({ ok: false, error: { message: errorMessage } }, null, 2));
145
+ } else {
146
+ console.error(chalk.red('Transform failed:'), errorMessage);
147
+ }
148
+ process.exitCode = 1;
149
+ }
150
+ });
151
+ }
@@ -0,0 +1,75 @@
1
+ # Translate Command Module
2
+
3
+ This module handles the `i18nsmith translate` command for automated translations.
4
+
5
+ ## Structure
6
+
7
+ ```
8
+ translate/
9
+ ├── index.ts # Main command registration and orchestration
10
+ ├── types.ts # Type definitions (TranslateOptions, TranslateSummary, etc.)
11
+ ├── reporter.ts # Progress and result reporting
12
+ ├── executor.ts # Translation execution logic
13
+ └── csv-handler.ts # CSV import/export functionality
14
+ ```
15
+
16
+ ## Public API
17
+
18
+ ### Functions
19
+
20
+ - `registerTranslate(program)` — Register the translate command with Commander
21
+
22
+ ### Types
23
+
24
+ - `TranslateOptions` — Command-line options
25
+ - `TranslateSummary` — Translation operation summary
26
+ - `TranslationPlan` — Plan for translation operations
27
+ - `TranslationResult` — Result of translation operations
28
+
29
+ ## Usage
30
+
31
+ The module is registered automatically by the CLI entry point:
32
+
33
+ ```typescript
34
+ import { program } from 'commander';
35
+ import { registerTranslate } from './commands/translate/index.js';
36
+
37
+ registerTranslate(program);
38
+ ```
39
+
40
+ ## Module Responsibilities
41
+
42
+ | Module | Responsibility |
43
+ |--------|----------------|
44
+ | `index.ts` | Command registration, option parsing, workflow orchestration |
45
+ | `types.ts` | TypeScript type definitions |
46
+ | `reporter.ts` | Console output formatting, progress indicators |
47
+ | `executor.ts` | Translation API calls, batch processing |
48
+ | `csv-handler.ts` | CSV export/import for manual translation workflows |
49
+
50
+ ## CLI Usage
51
+
52
+ ```bash
53
+ # Preview missing translations (dry-run)
54
+ i18nsmith translate
55
+
56
+ # Translate and write results
57
+ i18nsmith translate --write
58
+
59
+ # Translate specific locales
60
+ i18nsmith translate --locales fr de --write
61
+
62
+ # Export to CSV for manual translation
63
+ i18nsmith translate --export missing.csv
64
+
65
+ # Import translated CSV
66
+ i18nsmith translate --import filled.csv --write
67
+ ```
68
+
69
+ ## Backwards Compatibility
70
+
71
+ The parent `translate.ts` file re-exports from this module:
72
+
73
+ ```typescript
74
+ import { registerTranslate } from './translate/index.js';
75
+ ```
@@ -0,0 +1,301 @@
1
+ /**
2
+ * CSV export/import utilities for translator handoff
3
+ */
4
+
5
+ import fs from 'fs/promises';
6
+ import path from 'path';
7
+ import chalk from 'chalk';
8
+ import {
9
+ DEFAULT_PLACEHOLDER_FORMATS,
10
+ PlaceholderValidator,
11
+ loadConfig,
12
+ TranslationService,
13
+ } from '@i18nsmith/core';
14
+ import type { TranslateCommandOptions, CsvRow } from './types.js';
15
+
16
+ /**
17
+ * Escape a field for CSV output
18
+ */
19
+ export function escapeCsvField(field: string): string {
20
+ if (field.includes(',') || field.includes('"') || field.includes('\n') || field.includes('\r')) {
21
+ return `"${field.replace(/"/g, '""')}"`;
22
+ }
23
+ return field;
24
+ }
25
+
26
+ /**
27
+ * Parse a CSV line into fields (handles quoted fields with commas and escaped quotes)
28
+ */
29
+ export function parseCsvLine(line: string): string[] {
30
+ const fields: string[] = [];
31
+ let current = '';
32
+ let inQuotes = false;
33
+ let i = 0;
34
+
35
+ while (i < line.length) {
36
+ const char = line[i];
37
+ if (inQuotes) {
38
+ if (char === '"') {
39
+ if (line[i + 1] === '"') {
40
+ current += '"';
41
+ i += 2;
42
+ } else {
43
+ inQuotes = false;
44
+ i++;
45
+ }
46
+ } else {
47
+ current += char;
48
+ i++;
49
+ }
50
+ } else {
51
+ if (char === '"') {
52
+ inQuotes = true;
53
+ i++;
54
+ } else if (char === ',') {
55
+ fields.push(current);
56
+ current = '';
57
+ i++;
58
+ } else {
59
+ current += char;
60
+ i++;
61
+ }
62
+ }
63
+ }
64
+ fields.push(current);
65
+ return fields;
66
+ }
67
+
68
+ /**
69
+ * Export missing translations to a CSV file for external translation
70
+ */
71
+ export async function handleCsvExport(options: TranslateCommandOptions): Promise<void> {
72
+ const exportPath = options.export!;
73
+ console.log(chalk.blue(`Exporting missing translations to ${exportPath}...`));
74
+
75
+ try {
76
+ const config = await loadConfig(options.config);
77
+ const translationService = new TranslationService(config);
78
+ const plan = await translationService.buildPlan({
79
+ locales: options.locales,
80
+ force: options.force,
81
+ });
82
+
83
+ if (!plan.totalTasks) {
84
+ console.log(chalk.green('✓ No missing translations to export.'));
85
+ return;
86
+ }
87
+
88
+ // Build CSV rows
89
+ const rows: CsvRow[] = [];
90
+ for (const localePlan of plan.locales) {
91
+ for (const task of localePlan.tasks) {
92
+ rows.push({
93
+ key: task.key,
94
+ sourceLocale: plan.sourceLocale,
95
+ sourceValue: task.sourceValue,
96
+ targetLocale: localePlan.locale,
97
+ targetValue: '',
98
+ });
99
+ }
100
+ }
101
+
102
+ // Generate CSV content
103
+ const header = 'key,sourceLocale,sourceValue,targetLocale,translatedValue';
104
+ const csvLines = [header];
105
+ for (const row of rows) {
106
+ csvLines.push([
107
+ escapeCsvField(row.key),
108
+ escapeCsvField(row.sourceLocale),
109
+ escapeCsvField(row.sourceValue),
110
+ escapeCsvField(row.targetLocale),
111
+ escapeCsvField(row.targetValue),
112
+ ].join(','));
113
+ }
114
+ const csvContent = csvLines.join('\n') + '\n';
115
+
116
+ // Write CSV file
117
+ const resolvedPath = path.resolve(process.cwd(), exportPath);
118
+ await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
119
+ await fs.writeFile(resolvedPath, csvContent, 'utf8');
120
+
121
+ console.log(chalk.green(`✓ Exported ${rows.length} missing translation(s) to ${exportPath}`));
122
+ console.log(chalk.gray(` Source locale: ${plan.sourceLocale}`));
123
+ console.log(chalk.gray(` Target locales: ${plan.locales.map(l => l.locale).join(', ')}`));
124
+ console.log(chalk.gray('\nFill in the "translatedValue" column and import with:'));
125
+ console.log(chalk.cyan(` i18nsmith translate --import ${exportPath} --write`));
126
+ } catch (error) {
127
+ console.error(chalk.red('Export failed:'), (error as Error).message);
128
+ process.exitCode = 1;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Import translations from a CSV file and merge into locale files
134
+ */
135
+ export async function handleCsvImport(options: TranslateCommandOptions): Promise<void> {
136
+ const importPath = options.import!;
137
+ const dryRun = !options.write;
138
+ console.log(chalk.blue(`${dryRun ? 'Previewing' : 'Importing'} translations from ${importPath}...`));
139
+
140
+ try {
141
+ const config = await loadConfig(options.config);
142
+ const translationService = new TranslationService(config);
143
+ const placeholderValidator = new PlaceholderValidator(
144
+ config.sync?.placeholderFormats?.length ? config.sync.placeholderFormats : DEFAULT_PLACEHOLDER_FORMATS
145
+ );
146
+
147
+ // Read and parse CSV
148
+ const resolvedPath = path.resolve(process.cwd(), importPath);
149
+ let csvContent: string;
150
+ try {
151
+ csvContent = await fs.readFile(resolvedPath, 'utf8');
152
+ } catch (err) {
153
+ const error = err as NodeJS.ErrnoException;
154
+ if (error.code === 'ENOENT') {
155
+ throw new Error(`CSV file not found: ${resolvedPath}`);
156
+ }
157
+ throw error;
158
+ }
159
+
160
+ const lines = csvContent.split(/\r?\n/).filter(line => line.trim().length > 0);
161
+ if (lines.length < 2) {
162
+ throw new Error('CSV file is empty or has no data rows.');
163
+ }
164
+
165
+ // Parse header
166
+ const headerFields = parseCsvLine(lines[0]);
167
+ const keyIdx = headerFields.indexOf('key');
168
+ const sourceLocaleIdx = headerFields.indexOf('sourceLocale');
169
+ const sourceValueIdx = headerFields.indexOf('sourceValue');
170
+ const targetLocaleIdx = headerFields.indexOf('targetLocale');
171
+ const translatedValueIdx = headerFields.indexOf('translatedValue');
172
+
173
+ if (keyIdx === -1 || targetLocaleIdx === -1 || translatedValueIdx === -1) {
174
+ throw new Error('CSV must have columns: key, targetLocale, translatedValue');
175
+ }
176
+
177
+ // Parse data rows
178
+ const updates = new Map<string, { key: string; value: string }[]>();
179
+ const placeholderIssues: { key: string; locale: string; issue: string }[] = [];
180
+ let skipped = 0;
181
+ let total = 0;
182
+
183
+ for (let i = 1; i < lines.length; i++) {
184
+ const fields = parseCsvLine(lines[i]);
185
+ const key = fields[keyIdx]?.trim();
186
+ const targetLocale = fields[targetLocaleIdx]?.trim();
187
+ const translatedValue = fields[translatedValueIdx]?.trim();
188
+ const sourceValue = sourceValueIdx >= 0 ? fields[sourceValueIdx]?.trim() : undefined;
189
+
190
+ if (!key || !targetLocale) {
191
+ skipped++;
192
+ continue;
193
+ }
194
+
195
+ total++;
196
+
197
+ if (!translatedValue) {
198
+ skipped++;
199
+ continue;
200
+ }
201
+
202
+ // Validate placeholders if we have source value
203
+ if (sourceValue) {
204
+ const comparison = placeholderValidator.compare(sourceValue, translatedValue);
205
+ if (comparison.missing.length > 0) {
206
+ placeholderIssues.push({
207
+ key,
208
+ locale: targetLocale,
209
+ issue: `Missing placeholders: ${comparison.missing.join(', ')}`,
210
+ });
211
+ if (options.strictPlaceholders) {
212
+ skipped++;
213
+ continue;
214
+ }
215
+ }
216
+ if (comparison.extra.length > 0) {
217
+ placeholderIssues.push({
218
+ key,
219
+ locale: targetLocale,
220
+ issue: `Extra placeholders: ${comparison.extra.join(', ')}`,
221
+ });
222
+ }
223
+ }
224
+
225
+ if (!updates.has(targetLocale)) {
226
+ updates.set(targetLocale, []);
227
+ }
228
+ updates.get(targetLocale)!.push({ key, value: translatedValue });
229
+ }
230
+
231
+ // Print summary
232
+ const totalUpdates = Array.from(updates.values()).reduce((sum, arr) => sum + arr.length, 0);
233
+ console.log(chalk.green(`Parsed ${total} row(s): ${totalUpdates} with translations, ${skipped} skipped (empty)`));
234
+
235
+ if (placeholderIssues.length > 0) {
236
+ console.log(chalk.yellow(`\n⚠️ ${placeholderIssues.length} placeholder issue(s):`));
237
+ for (const issue of placeholderIssues.slice(0, 10)) {
238
+ console.log(chalk.yellow(` • ${issue.key} (${issue.locale}): ${issue.issue}`));
239
+ }
240
+ if (placeholderIssues.length > 10) {
241
+ console.log(chalk.gray(` ... and ${placeholderIssues.length - 10} more`));
242
+ }
243
+ }
244
+
245
+ if (options.strictPlaceholders && placeholderIssues.length > 0) {
246
+ console.error(chalk.red('\n✗ Aborting due to placeholder issues (--strict-placeholders mode)'));
247
+ process.exitCode = 1;
248
+ return;
249
+ }
250
+
251
+ if (totalUpdates === 0) {
252
+ console.log(chalk.yellow('No translations to import. Fill in the "translatedValue" column.'));
253
+ return;
254
+ }
255
+
256
+ // Apply updates
257
+ if (dryRun) {
258
+ console.log(chalk.blue('\nDry-run preview:'));
259
+ for (const [locale, localeUpdates] of updates) {
260
+ console.log(` • ${locale}: ${localeUpdates.length} translation(s)`);
261
+ }
262
+ console.log(chalk.cyan('\n📋 DRY RUN - No files were modified'));
263
+ console.log(chalk.yellow('Run again with --write to apply changes.'));
264
+ } else {
265
+ for (const [locale, localeUpdates] of updates) {
266
+ const result = await translationService.writeTranslations(locale, localeUpdates, {
267
+ overwrite: options.force ?? false,
268
+ skipEmpty: options.skipEmpty !== false,
269
+ });
270
+ console.log(` • ${locale}: ${result.written} written, ${result.skipped} skipped`);
271
+ }
272
+
273
+ const stats = await translationService.flush();
274
+ console.log(chalk.green(`\n✓ Imported translations from ${importPath}`));
275
+ for (const stat of stats) {
276
+ console.log(chalk.gray(` ${stat.locale}: ${stat.added.length} added, ${stat.updated.length} updated`));
277
+ }
278
+ }
279
+
280
+ // Write report if requested
281
+ if (options.report) {
282
+ const report = {
283
+ source: importPath,
284
+ dryRun,
285
+ totalRows: total,
286
+ skipped,
287
+ updates: Object.fromEntries(
288
+ Array.from(updates.entries()).map(([locale, arr]) => [locale, arr.length])
289
+ ),
290
+ placeholderIssues,
291
+ };
292
+ const outputPath = path.resolve(process.cwd(), options.report);
293
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
294
+ await fs.writeFile(outputPath, JSON.stringify(report, null, 2));
295
+ console.log(chalk.green(`Import report written to ${options.report}`));
296
+ }
297
+ } catch (error) {
298
+ console.error(chalk.red('Import failed:'), (error as Error).message);
299
+ process.exitCode = 1;
300
+ }
301
+ }