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.
- package/dist/commands/audit.d.ts +3 -0
- package/dist/commands/audit.d.ts.map +1 -0
- package/dist/commands/audit.js +180 -0
- package/dist/commands/audit.js.map +1 -0
- package/dist/commands/backup.d.ts +6 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/backup.js +85 -0
- package/dist/commands/backup.js.map +1 -0
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.d.ts.map +1 -0
- package/dist/commands/check.js +151 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +235 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/debug-patterns.d.ts +3 -0
- package/dist/commands/debug-patterns.d.ts.map +1 -0
- package/dist/commands/debug-patterns.js +192 -0
- package/dist/commands/debug-patterns.js.map +1 -0
- package/dist/commands/debug-patterns.test.d.ts +2 -0
- package/dist/commands/debug-patterns.test.d.ts.map +1 -0
- package/dist/commands/debug-patterns.test.js +109 -0
- package/dist/commands/debug-patterns.test.js.map +1 -0
- package/dist/commands/diagnose.d.ts +3 -0
- package/dist/commands/diagnose.d.ts.map +1 -0
- package/dist/commands/diagnose.js +117 -0
- package/dist/commands/diagnose.js.map +1 -0
- package/dist/commands/init.d.ts +8 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +450 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/init.test.d.ts +2 -0
- package/dist/commands/init.test.d.ts.map +1 -0
- package/dist/commands/init.test.js +74 -0
- package/dist/commands/init.test.js.map +1 -0
- package/dist/commands/install-hooks.d.ts +3 -0
- package/dist/commands/install-hooks.d.ts.map +1 -0
- package/dist/commands/install-hooks.js +52 -0
- package/dist/commands/install-hooks.js.map +1 -0
- package/dist/commands/preflight.d.ts +7 -0
- package/dist/commands/preflight.d.ts.map +1 -0
- package/dist/commands/preflight.js +417 -0
- package/dist/commands/preflight.js.map +1 -0
- package/dist/commands/preflight.test.d.ts +5 -0
- package/dist/commands/preflight.test.d.ts.map +1 -0
- package/dist/commands/preflight.test.js +108 -0
- package/dist/commands/preflight.test.js.map +1 -0
- package/dist/commands/rename.d.ts +6 -0
- package/dist/commands/rename.d.ts.map +1 -0
- package/dist/commands/rename.js +204 -0
- package/dist/commands/rename.js.map +1 -0
- package/dist/commands/scaffold-adapter.d.ts +3 -0
- package/dist/commands/scaffold-adapter.d.ts.map +1 -0
- package/dist/commands/scaffold-adapter.js +204 -0
- package/dist/commands/scaffold-adapter.js.map +1 -0
- package/dist/commands/scaffold-adapter.test.d.ts +2 -0
- package/dist/commands/scaffold-adapter.test.d.ts.map +1 -0
- package/dist/commands/scaffold-adapter.test.js +102 -0
- package/dist/commands/scaffold-adapter.test.js.map +1 -0
- package/dist/commands/scan.d.ts +3 -0
- package/dist/commands/scan.d.ts.map +1 -0
- package/dist/commands/scan.js +93 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/sync-seed.test.d.ts +2 -0
- package/dist/commands/sync-seed.test.d.ts.map +1 -0
- package/dist/commands/sync-seed.test.js +86 -0
- package/dist/commands/sync-seed.test.js.map +1 -0
- package/dist/commands/sync.d.ts +3 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +590 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/transform.d.ts +3 -0
- package/dist/commands/transform.d.ts.map +1 -0
- package/dist/commands/transform.js +114 -0
- package/dist/commands/transform.js.map +1 -0
- package/dist/commands/translate/csv-handler.d.ts +21 -0
- package/dist/commands/translate/csv-handler.d.ts.map +1 -0
- package/dist/commands/translate/csv-handler.js +270 -0
- package/dist/commands/translate/csv-handler.js.map +1 -0
- package/dist/commands/translate/executor.d.ts +31 -0
- package/dist/commands/translate/executor.d.ts.map +1 -0
- package/dist/commands/translate/executor.js +117 -0
- package/dist/commands/translate/executor.js.map +1 -0
- package/dist/commands/translate/index.d.ts +10 -0
- package/dist/commands/translate/index.d.ts.map +1 -0
- package/dist/commands/translate/index.js +170 -0
- package/dist/commands/translate/index.js.map +1 -0
- package/dist/commands/translate/reporter.d.ts +29 -0
- package/dist/commands/translate/reporter.d.ts.map +1 -0
- package/dist/commands/translate/reporter.js +103 -0
- package/dist/commands/translate/reporter.js.map +1 -0
- package/dist/commands/translate/types.d.ts +50 -0
- package/dist/commands/translate/types.d.ts.map +1 -0
- package/dist/commands/translate/types.js +5 -0
- package/dist/commands/translate/types.js.map +1 -0
- package/dist/commands/translate.d.ts +7 -0
- package/dist/commands/translate.d.ts.map +1 -0
- package/dist/commands/translate.js +7 -0
- package/dist/commands/translate.js.map +1 -0
- package/dist/commands/translate.test.d.ts +2 -0
- package/dist/commands/translate.test.d.ts.map +1 -0
- package/dist/commands/translate.test.js +118 -0
- package/dist/commands/translate.test.js.map +1 -0
- package/dist/e2e.test.d.ts +6 -0
- package/dist/e2e.test.d.ts.map +1 -0
- package/dist/e2e.test.js +376 -0
- package/dist/e2e.test.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.test.d.ts +6 -0
- package/dist/integration.test.d.ts.map +1 -0
- package/dist/integration.test.js +320 -0
- package/dist/integration.test.js.map +1 -0
- package/dist/utils/diagnostics-exit.d.ts +12 -0
- package/dist/utils/diagnostics-exit.d.ts.map +1 -0
- package/dist/utils/diagnostics-exit.js +49 -0
- package/dist/utils/diagnostics-exit.js.map +1 -0
- package/dist/utils/diagnostics-exit.test.d.ts +2 -0
- package/dist/utils/diagnostics-exit.test.d.ts.map +1 -0
- package/dist/utils/diagnostics-exit.test.js +40 -0
- package/dist/utils/diagnostics-exit.test.js.map +1 -0
- package/dist/utils/diff-utils.d.ts +4 -0
- package/dist/utils/diff-utils.d.ts.map +1 -0
- package/dist/utils/diff-utils.js +30 -0
- package/dist/utils/diff-utils.js.map +1 -0
- package/dist/utils/diff-utils.test.d.ts +2 -0
- package/dist/utils/diff-utils.test.d.ts.map +1 -0
- package/dist/utils/diff-utils.test.js +30 -0
- package/dist/utils/diff-utils.test.js.map +1 -0
- package/dist/utils/exit-codes.d.ts +142 -0
- package/dist/utils/exit-codes.d.ts.map +1 -0
- package/dist/utils/exit-codes.js +168 -0
- package/dist/utils/exit-codes.js.map +1 -0
- package/dist/utils/package-manager.d.ts +4 -0
- package/dist/utils/package-manager.d.ts.map +1 -0
- package/dist/utils/package-manager.js +40 -0
- package/dist/utils/package-manager.js.map +1 -0
- package/dist/utils/pkg.d.ts +3 -0
- package/dist/utils/pkg.d.ts.map +1 -0
- package/dist/utils/pkg.js +24 -0
- package/dist/utils/pkg.js.map +1 -0
- package/dist/utils/provider-injector.d.ts +36 -0
- package/dist/utils/provider-injector.d.ts.map +1 -0
- package/dist/utils/provider-injector.js +223 -0
- package/dist/utils/provider-injector.js.map +1 -0
- package/dist/utils/provider-injector.test.d.ts +2 -0
- package/dist/utils/provider-injector.test.d.ts.map +1 -0
- package/dist/utils/provider-injector.test.js +67 -0
- package/dist/utils/provider-injector.test.js.map +1 -0
- package/dist/utils/scaffold.d.ts +20 -0
- package/dist/utils/scaffold.d.ts.map +1 -0
- package/dist/utils/scaffold.js +197 -0
- package/dist/utils/scaffold.js.map +1 -0
- package/package.json +35 -0
- package/src/commands/audit.ts +234 -0
- package/src/commands/backup.ts +96 -0
- package/src/commands/check.ts +191 -0
- package/src/commands/config.ts +263 -0
- package/src/commands/debug-patterns.test.ts +134 -0
- package/src/commands/debug-patterns.ts +257 -0
- package/src/commands/diagnose.ts +136 -0
- package/src/commands/init.test.ts +82 -0
- package/src/commands/init.ts +536 -0
- package/src/commands/install-hooks.ts +66 -0
- package/src/commands/preflight.test.ts +139 -0
- package/src/commands/preflight.ts +488 -0
- package/src/commands/rename.ts +264 -0
- package/src/commands/scaffold-adapter.test.ts +110 -0
- package/src/commands/scaffold-adapter.ts +250 -0
- package/src/commands/scan.ts +125 -0
- package/src/commands/sync-seed.test.ts +116 -0
- package/src/commands/sync.ts +736 -0
- package/src/commands/transform.ts +151 -0
- package/src/commands/translate/README.md +75 -0
- package/src/commands/translate/csv-handler.ts +301 -0
- package/src/commands/translate/executor.ts +188 -0
- package/src/commands/translate/index.ts +220 -0
- package/src/commands/translate/reporter.ts +138 -0
- package/src/commands/translate/types.ts +56 -0
- package/src/commands/translate.test.ts +173 -0
- package/src/commands/translate.ts +6 -0
- package/src/e2e.test.ts +479 -0
- package/src/fixtures/README.md +61 -0
- package/src/fixtures/basic-react/i18n.config.json +15 -0
- package/src/fixtures/basic-react/locales/de.json +8 -0
- package/src/fixtures/basic-react/locales/en.json +8 -0
- package/src/fixtures/basic-react/locales/fr.json +8 -0
- package/src/fixtures/basic-react/src/App.tsx +15 -0
- package/src/fixtures/basic-react/src/Messages.tsx +12 -0
- package/src/fixtures/nested-locales/i18n.config.json +9 -0
- package/src/fixtures/nested-locales/locales/en.json +23 -0
- package/src/fixtures/nested-locales/locales/fr.json +23 -0
- package/src/fixtures/nested-locales/src/HomePage.tsx +13 -0
- package/src/fixtures/suspicious-keys/i18n.config.json +9 -0
- package/src/fixtures/suspicious-keys/locales/en.json +11 -0
- package/src/fixtures/suspicious-keys/locales/fr.json +11 -0
- package/src/fixtures/suspicious-keys/src/BadKeys.tsx +19 -0
- package/src/index.ts +43 -0
- package/src/integration.test.ts +438 -0
- package/src/utils/diagnostics-exit.test.ts +47 -0
- package/src/utils/diagnostics-exit.ts +63 -0
- package/src/utils/diff-utils.test.ts +36 -0
- package/src/utils/diff-utils.ts +42 -0
- package/src/utils/exit-codes.ts +201 -0
- package/src/utils/package-manager.ts +44 -0
- package/src/utils/pkg.ts +23 -0
- package/src/utils/provider-injector.test.ts +79 -0
- package/src/utils/provider-injector.ts +315 -0
- package/src/utils/scaffold.ts +240 -0
- 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
|
+
}
|