i18nsmith 0.1.8 → 0.2.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/build.mjs +16 -10
- package/dist/commands/audit.d.ts +3 -0
- package/dist/commands/audit.d.ts.map +1 -0
- package/dist/commands/backup.d.ts +6 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.d.ts.map +1 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.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.test.d.ts +2 -0
- package/dist/commands/debug-patterns.test.d.ts.map +1 -0
- package/dist/commands/diagnose.d.ts +3 -0
- package/dist/commands/diagnose.d.ts.map +1 -0
- package/dist/commands/init.d.ts +8 -0
- package/dist/commands/init.d.ts.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/install-hooks.d.ts +3 -0
- package/dist/commands/install-hooks.d.ts.map +1 -0
- package/dist/commands/preflight.d.ts +7 -0
- package/dist/commands/preflight.d.ts.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/rename.d.ts +6 -0
- package/dist/commands/rename.d.ts.map +1 -0
- package/dist/commands/review.d.ts +4 -0
- package/dist/commands/review.d.ts.map +1 -0
- package/dist/commands/review.test.d.ts +2 -0
- package/dist/commands/review.test.d.ts.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.test.d.ts +2 -0
- package/dist/commands/scaffold-adapter.test.d.ts.map +1 -0
- package/dist/commands/scan.d.ts +3 -0
- package/dist/commands/scan.d.ts.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.d.ts +3 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/transform.d.ts +3 -0
- package/dist/commands/transform.d.ts.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/executor.d.ts +31 -0
- package/dist/commands/translate/executor.d.ts.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/reporter.d.ts +29 -0
- package/dist/commands/translate/reporter.d.ts.map +1 -0
- package/dist/commands/translate/types.d.ts +52 -0
- package/dist/commands/translate/types.d.ts.map +1 -0
- package/dist/commands/translate.d.ts +7 -0
- package/dist/commands/translate.d.ts.map +1 -0
- package/dist/commands/translate.test.d.ts +2 -0
- package/dist/commands/translate.test.d.ts.map +1 -0
- package/dist/e2e.test.d.ts +6 -0
- package/dist/e2e.test.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1121 -387
- package/dist/integration.test.d.ts +6 -0
- package/dist/integration.test.d.ts.map +1 -0
- package/dist/test-helpers/ensure-cli-built.d.ts +2 -0
- package/dist/test-helpers/ensure-cli-built.d.ts.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.test.d.ts +2 -0
- package/dist/utils/diagnostics-exit.test.d.ts.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.test.d.ts +2 -0
- package/dist/utils/diff-utils.test.d.ts.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/package-manager.d.ts +4 -0
- package/dist/utils/package-manager.d.ts.map +1 -0
- package/dist/utils/pkg.d.ts +3 -0
- package/dist/utils/pkg.d.ts.map +1 -0
- package/dist/utils/preview.d.ts +13 -0
- package/dist/utils/preview.d.ts.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.test.d.ts +2 -0
- package/dist/utils/provider-injector.test.d.ts.map +1 -0
- package/dist/utils/scaffold.d.ts +20 -0
- package/dist/utils/scaffold.d.ts.map +1 -0
- package/package.json +3 -2
- package/src/commands/debug-patterns.test.ts +6 -1
- package/src/commands/rename.ts +35 -3
- package/src/commands/review.test.ts +12 -0
- package/src/commands/review.ts +228 -0
- package/src/commands/sync-seed.test.ts +9 -3
- package/src/commands/sync.ts +104 -10
- package/src/commands/transform.ts +31 -4
- package/src/commands/translate/index.ts +23 -3
- package/src/commands/translate/types.ts +2 -0
- package/src/commands/translate.test.ts +9 -3
- package/src/e2e.test.ts +3 -11
- package/src/index.ts +2 -0
- package/src/integration.test.ts +12 -1
- package/src/test-helpers/ensure-cli-built.ts +32 -0
- package/src/utils/preview.ts +126 -0
- package/dist/index.js.map +0 -7
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
|
+
import type { Command } from 'commander';
|
|
6
|
+
import { loadConfigWithMeta, Scanner, type ScanCandidate, type ScanSummary } from '@i18nsmith/core';
|
|
7
|
+
|
|
8
|
+
interface ReviewCommandOptions {
|
|
9
|
+
config?: string;
|
|
10
|
+
json?: boolean;
|
|
11
|
+
limit?: number;
|
|
12
|
+
scanCalls?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ReviewAction = 'allow' | 'deny' | 'skip' | 'stop';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_CONFIG_PATH = 'i18n.config.json';
|
|
18
|
+
const DEFAULT_LIMIT = 20;
|
|
19
|
+
|
|
20
|
+
export function literalToRegexPattern(value: string): string {
|
|
21
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
22
|
+
return `^${escaped}$`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeLimit(value?: number): number {
|
|
26
|
+
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) {
|
|
27
|
+
return DEFAULT_LIMIT;
|
|
28
|
+
}
|
|
29
|
+
return Math.min(Math.max(1, Math.floor(value)), 200);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type BucketedScanSummary = ScanSummary & {
|
|
33
|
+
buckets?: {
|
|
34
|
+
needsReview?: ScanCandidate[];
|
|
35
|
+
skipped?: Array<{ reason: string }>;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function summarizeSkipReasons(skipped: Array<{ reason: string }>): string[] {
|
|
40
|
+
if (!skipped.length) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
const counts = new Map<string, number>();
|
|
44
|
+
for (const entry of skipped) {
|
|
45
|
+
counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
|
|
46
|
+
}
|
|
47
|
+
return [...counts.entries()]
|
|
48
|
+
.sort((a, b) => b[1] - a[1])
|
|
49
|
+
.slice(0, 5)
|
|
50
|
+
.map(([reason, count]) => `${reason}: ${count}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function writeExtractionOverrides(
|
|
54
|
+
configPath: string,
|
|
55
|
+
allowPatterns: string[],
|
|
56
|
+
denyPatterns: string[]
|
|
57
|
+
): Promise<{ allowAdded: number; denyAdded: number; wrote: boolean }> {
|
|
58
|
+
if (!allowPatterns.length && !denyPatterns.length) {
|
|
59
|
+
return { allowAdded: 0, denyAdded: 0, wrote: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const raw = await fs.readFile(configPath, 'utf-8');
|
|
63
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
64
|
+
const extraction = (typeof parsed.extraction === 'object' && parsed.extraction !== null)
|
|
65
|
+
? (parsed.extraction as Record<string, unknown>)
|
|
66
|
+
: (parsed.extraction = {});
|
|
67
|
+
|
|
68
|
+
const allowList = Array.isArray(extraction.allowPatterns)
|
|
69
|
+
? [...(extraction.allowPatterns as string[])]
|
|
70
|
+
: [];
|
|
71
|
+
const denyList = Array.isArray(extraction.denyPatterns)
|
|
72
|
+
? [...(extraction.denyPatterns as string[])]
|
|
73
|
+
: [];
|
|
74
|
+
|
|
75
|
+
const allowAdded = appendUnique(allowList, allowPatterns);
|
|
76
|
+
const denyAdded = appendUnique(denyList, denyPatterns);
|
|
77
|
+
|
|
78
|
+
if (allowAdded === 0 && denyAdded === 0) {
|
|
79
|
+
return { allowAdded, denyAdded, wrote: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
extraction.allowPatterns = allowList;
|
|
83
|
+
extraction.denyPatterns = denyList;
|
|
84
|
+
|
|
85
|
+
await fs.writeFile(configPath, JSON.stringify(parsed, null, 2));
|
|
86
|
+
return { allowAdded, denyAdded, wrote: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function appendUnique(target: string[], additions: string[]): number {
|
|
90
|
+
const seen = new Set(target);
|
|
91
|
+
let added = 0;
|
|
92
|
+
for (const next of additions) {
|
|
93
|
+
if (seen.has(next)) continue;
|
|
94
|
+
target.push(next);
|
|
95
|
+
seen.add(next);
|
|
96
|
+
added++;
|
|
97
|
+
}
|
|
98
|
+
return added;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function printCandidate(candidate: ScanCandidate) {
|
|
102
|
+
const location = `${candidate.filePath}:${candidate.position.line}:${candidate.position.column}`;
|
|
103
|
+
console.log(chalk.cyan(`\n${candidate.text}`));
|
|
104
|
+
console.log(chalk.gray(` • Location: ${location}`));
|
|
105
|
+
console.log(chalk.gray(` • Kind: ${candidate.kind}`));
|
|
106
|
+
if (candidate.context) {
|
|
107
|
+
console.log(chalk.gray(` • Context: ${candidate.context}`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function promptAction(): Promise<ReviewAction> {
|
|
112
|
+
const { action } = await inquirer.prompt<{ action: ReviewAction }>([
|
|
113
|
+
{
|
|
114
|
+
type: 'list',
|
|
115
|
+
name: 'action',
|
|
116
|
+
message: 'Choose an action for this string',
|
|
117
|
+
choices: [
|
|
118
|
+
{ name: 'Always translate (add to allowPatterns)', value: 'allow' },
|
|
119
|
+
{ name: 'Always skip (add to denyPatterns)', value: 'deny' },
|
|
120
|
+
{ name: 'Skip for now', value: 'skip' },
|
|
121
|
+
{ name: 'Stop reviewing', value: 'stop' },
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
return action;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function registerReview(program: Command) {
|
|
129
|
+
program
|
|
130
|
+
.command('review')
|
|
131
|
+
.description('Review borderline candidates and persist allow/deny overrides')
|
|
132
|
+
.option('-c, --config <path>', 'Path to i18nsmith config file', DEFAULT_CONFIG_PATH)
|
|
133
|
+
.option('--json', 'Print raw bucket data as JSON', false)
|
|
134
|
+
.option('--limit <n>', 'Limit the number of items per session (default: 20)', (value) => parseInt(value, 10))
|
|
135
|
+
.option('--scan-calls', 'Include translation call arguments', false)
|
|
136
|
+
.action(async (options: ReviewCommandOptions) => {
|
|
137
|
+
try {
|
|
138
|
+
const { config, projectRoot, configPath } = await loadConfigWithMeta(options.config);
|
|
139
|
+
const scanner = new Scanner(config, { workspaceRoot: projectRoot });
|
|
140
|
+
const summary = scanner.scan({ scanCalls: options.scanCalls }) as BucketedScanSummary;
|
|
141
|
+
const buckets = summary.buckets ?? {};
|
|
142
|
+
const needsReview = buckets.needsReview ?? [];
|
|
143
|
+
const skipped = buckets.skipped ?? [];
|
|
144
|
+
|
|
145
|
+
if (options.json) {
|
|
146
|
+
console.log(JSON.stringify({ needsReview, skipped }, null, 2));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!needsReview.length) {
|
|
151
|
+
console.log(chalk.green('No borderline candidates detected.'));
|
|
152
|
+
const reasons = summarizeSkipReasons(skipped);
|
|
153
|
+
if (reasons.length) {
|
|
154
|
+
console.log(chalk.gray('Most common skip reasons:'));
|
|
155
|
+
reasons.forEach((line) => console.log(chalk.gray(` • ${line}`)));
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!process.stdout.isTTY || process.env.CI === 'true') {
|
|
161
|
+
console.log(chalk.red('Interactive review requires a TTY. Use --json for non-interactive output.'));
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const limit = normalizeLimit(options.limit);
|
|
167
|
+
const queue = needsReview.slice(0, limit);
|
|
168
|
+
console.log(
|
|
169
|
+
chalk.blue(
|
|
170
|
+
`Reviewing ${queue.length} of ${needsReview.length} candidate${needsReview.length === 1 ? '' : 's'} (limit=${limit}).`
|
|
171
|
+
)
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const allowPatterns: string[] = [];
|
|
175
|
+
const denyPatterns: string[] = [];
|
|
176
|
+
|
|
177
|
+
for (const candidate of queue) {
|
|
178
|
+
printCandidate(candidate);
|
|
179
|
+
const action = await promptAction();
|
|
180
|
+
if (action === 'stop') {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
if (action === 'skip') {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const pattern = literalToRegexPattern(candidate.text);
|
|
187
|
+
if (action === 'allow') {
|
|
188
|
+
allowPatterns.push(pattern);
|
|
189
|
+
console.log(chalk.green(` → Queued ${pattern} for allowPatterns`));
|
|
190
|
+
} else if (action === 'deny') {
|
|
191
|
+
denyPatterns.push(pattern);
|
|
192
|
+
console.log(chalk.yellow(` → Queued ${pattern} for denyPatterns`));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!allowPatterns.length && !denyPatterns.length) {
|
|
197
|
+
console.log(chalk.gray('No config changes requested.'));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const { allowAdded, denyAdded, wrote } = await writeExtractionOverrides(
|
|
202
|
+
configPath,
|
|
203
|
+
allowPatterns,
|
|
204
|
+
denyPatterns
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (!wrote) {
|
|
208
|
+
console.log(chalk.gray('Patterns already existed; config unchanged.'));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.log(
|
|
213
|
+
chalk.green(
|
|
214
|
+
`Updated ${relativize(configPath)} (${allowAdded} allow, ${denyAdded} deny pattern${
|
|
215
|
+
allowAdded + denyAdded === 1 ? '' : 's'
|
|
216
|
+
} added).`
|
|
217
|
+
)
|
|
218
|
+
);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.error(chalk.red('Review failed:'), (error as Error).message);
|
|
221
|
+
process.exitCode = 1;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function relativize(filePath: string): string {
|
|
227
|
+
return path.relative(process.cwd(), filePath) || filePath;
|
|
228
|
+
}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { execSync } from 'child_process';
|
|
6
|
+
import { ensureCliBuilt } from '../test-helpers/ensure-cli-built';
|
|
7
|
+
|
|
8
|
+
const CLI_PATH = path.resolve(__dirname, '..', '..', 'dist', 'index.js');
|
|
6
9
|
|
|
7
10
|
describe('sync --seed-target-locales', () => {
|
|
8
11
|
let tempDir: string;
|
|
9
12
|
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
await ensureCliBuilt(CLI_PATH);
|
|
15
|
+
});
|
|
16
|
+
|
|
10
17
|
beforeEach(async () => {
|
|
11
18
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18nsmith-seed-test-'));
|
|
12
19
|
|
|
@@ -55,9 +62,8 @@ export function App() {
|
|
|
55
62
|
});
|
|
56
63
|
|
|
57
64
|
const runCli = (args: string): string => {
|
|
58
|
-
const cliPath = path.resolve(__dirname, '..', '..', 'dist', 'index.js');
|
|
59
65
|
try {
|
|
60
|
-
return execSync(`node ${
|
|
66
|
+
return execSync(`node ${CLI_PATH} ${args}`, {
|
|
61
67
|
cwd: tempDir,
|
|
62
68
|
encoding: 'utf8',
|
|
63
69
|
stdio: ['pipe', 'pipe', 'pipe'],
|
package/src/commands/sync.ts
CHANGED
|
@@ -13,11 +13,13 @@ import {
|
|
|
13
13
|
createRenameMappingFile,
|
|
14
14
|
type SyncSummary,
|
|
15
15
|
type KeyRenameBatchSummary,
|
|
16
|
+
type SyncSelection,
|
|
16
17
|
} from '@i18nsmith/core';
|
|
17
18
|
import {
|
|
18
19
|
printLocaleDiffs,
|
|
19
20
|
writeLocaleDiffPatches,
|
|
20
21
|
} from '../utils/diff-utils.js';
|
|
22
|
+
import { applyPreviewFile, writePreviewFile } from '../utils/preview.js';
|
|
21
23
|
import { SYNC_EXIT_CODES } from '../utils/exit-codes.js';
|
|
22
24
|
|
|
23
25
|
interface SyncCommandOptions {
|
|
@@ -49,6 +51,9 @@ interface SyncCommandOptions {
|
|
|
49
51
|
shapeDelimiter?: string;
|
|
50
52
|
seedTargetLocales?: boolean;
|
|
51
53
|
seedValue?: string;
|
|
54
|
+
previewOutput?: string;
|
|
55
|
+
selectionFile?: string;
|
|
56
|
+
applyPreview?: string;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
function collectAssumedKeys(value: string, previous: string[] = []) {
|
|
@@ -90,26 +95,66 @@ export function registerSync(program: Command) {
|
|
|
90
95
|
.option('--shape-delimiter <char>', 'Delimiter for key nesting (default: ".")', '.')
|
|
91
96
|
.option('--seed-target-locales', 'Add missing keys to target locale files with empty or placeholder values', false)
|
|
92
97
|
.option('--seed-value <value>', 'Value to use when seeding target locales (default: empty string)', '')
|
|
98
|
+
.option('--preview-output <path>', 'Write preview summary (JSON) to a file (implies dry-run)')
|
|
99
|
+
.option('--selection-file <path>', 'Path to JSON file with selected missing/unused keys to write (used with --write)')
|
|
100
|
+
.option('--apply-preview <path>', 'Apply a previously saved sync preview JSON file safely')
|
|
93
101
|
.action(async (options: SyncCommandOptions) => {
|
|
102
|
+
if (options.applyPreview) {
|
|
103
|
+
const extraArgs: string[] = [];
|
|
104
|
+
if (options.selectionFile) {
|
|
105
|
+
extraArgs.push('--selection-file', options.selectionFile);
|
|
106
|
+
}
|
|
107
|
+
if (options.prune) {
|
|
108
|
+
extraArgs.push('--prune');
|
|
109
|
+
}
|
|
110
|
+
await applyPreviewFile('sync', options.applyPreview, extraArgs);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
94
114
|
const interactive = Boolean(options.interactive);
|
|
95
115
|
const diffEnabled = Boolean(options.diff || options.patchDir);
|
|
96
116
|
const invalidateCache = Boolean(options.invalidateCache);
|
|
97
|
-
const
|
|
117
|
+
const previewMode = Boolean(options.previewOutput);
|
|
118
|
+
const diffRequested = diffEnabled || Boolean(options.json) || previewMode;
|
|
119
|
+
|
|
98
120
|
if (interactive && options.json) {
|
|
99
121
|
console.error(chalk.red('--interactive cannot be combined with --json output.'));
|
|
100
122
|
process.exitCode = 1;
|
|
101
123
|
return;
|
|
102
124
|
}
|
|
103
125
|
|
|
104
|
-
|
|
105
|
-
chalk.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
126
|
+
if (previewMode && interactive) {
|
|
127
|
+
console.error(chalk.red('--preview-output cannot be combined with --interactive.'));
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const writeEnabled = Boolean(options.write) && !previewMode;
|
|
133
|
+
if (previewMode && options.write) {
|
|
134
|
+
console.log(
|
|
135
|
+
chalk.yellow('Preview requested; ignoring --write and running in dry-run mode.')
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
options.write = writeEnabled;
|
|
139
|
+
|
|
140
|
+
if (options.selectionFile && !options.write) {
|
|
141
|
+
console.error(chalk.red('--selection-file requires --write (or --apply-preview) to take effect.'));
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const selectionFromFile = options.selectionFile
|
|
147
|
+
? await loadSelectionFile(options.selectionFile)
|
|
148
|
+
: undefined;
|
|
149
|
+
|
|
150
|
+
const banner = previewMode
|
|
151
|
+
? 'Generating sync preview...'
|
|
152
|
+
: interactive
|
|
153
|
+
? 'Interactive sync (dry-run first)...'
|
|
154
|
+
: writeEnabled
|
|
155
|
+
? 'Syncing locale files...'
|
|
156
|
+
: 'Checking locale drift...';
|
|
157
|
+
console.log(chalk.blue(banner));
|
|
113
158
|
|
|
114
159
|
try {
|
|
115
160
|
const { config, projectRoot, configPath } = await loadConfigWithMeta(options.config);
|
|
@@ -202,11 +247,17 @@ export function registerSync(program: Command) {
|
|
|
202
247
|
validateInterpolations: options.validateInterpolations,
|
|
203
248
|
emptyValuePolicy: options.emptyValues === false ? 'fail' : undefined,
|
|
204
249
|
assumedKeys: options.assume,
|
|
250
|
+
selection: selectionFromFile,
|
|
205
251
|
diff: diffRequested,
|
|
206
252
|
invalidateCache,
|
|
207
253
|
targets: options.target,
|
|
208
254
|
});
|
|
209
255
|
|
|
256
|
+
if (previewMode && options.previewOutput) {
|
|
257
|
+
const savedPath = await writePreviewFile('sync', summary, options.previewOutput);
|
|
258
|
+
console.log(chalk.green(`Preview written to ${path.relative(process.cwd(), savedPath)}`));
|
|
259
|
+
}
|
|
260
|
+
|
|
210
261
|
// Show backup info if created
|
|
211
262
|
if (summary.backup) {
|
|
212
263
|
console.log(chalk.blue(`\n📦 ${summary.backup.summary}`));
|
|
@@ -685,6 +736,49 @@ async function runInteractiveSync(syncer: Syncer, options: SyncCommandOptions) {
|
|
|
685
736
|
}
|
|
686
737
|
}
|
|
687
738
|
|
|
739
|
+
async function loadSelectionFile(filePath: string): Promise<SyncSelection> {
|
|
740
|
+
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
|
|
741
|
+
let raw: string;
|
|
742
|
+
try {
|
|
743
|
+
raw = await fs.readFile(resolvedPath, 'utf8');
|
|
744
|
+
} catch (error) {
|
|
745
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
746
|
+
throw new Error(`Unable to read selection file at ${resolvedPath}: ${message}`);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
let parsed: unknown;
|
|
750
|
+
try {
|
|
751
|
+
parsed = JSON.parse(raw);
|
|
752
|
+
} catch (error) {
|
|
753
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
754
|
+
throw new Error(`Selection file ${resolvedPath} contains invalid JSON: ${message}`);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const selection: SyncSelection = {};
|
|
758
|
+
const missing = (parsed as { missing?: unknown }).missing;
|
|
759
|
+
const unused = (parsed as { unused?: unknown }).unused;
|
|
760
|
+
|
|
761
|
+
if (Array.isArray(missing)) {
|
|
762
|
+
const normalized = missing.map((key) => String(key).trim()).filter(Boolean);
|
|
763
|
+
if (normalized.length) {
|
|
764
|
+
selection.missing = normalized;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (Array.isArray(unused)) {
|
|
769
|
+
const normalized = unused.map((key) => String(key).trim()).filter(Boolean);
|
|
770
|
+
if (normalized.length) {
|
|
771
|
+
selection.unused = normalized;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (!selection.missing?.length && !selection.unused?.length) {
|
|
776
|
+
throw new Error(`Selection file ${resolvedPath} must include at least one "missing" or "unused" entry.`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return selection;
|
|
780
|
+
}
|
|
781
|
+
|
|
688
782
|
function printRenameBatchSummary(summary: KeyRenameBatchSummary) {
|
|
689
783
|
console.log(
|
|
690
784
|
chalk.green(
|
|
@@ -6,6 +6,7 @@ import { loadConfigWithMeta } from '@i18nsmith/core';
|
|
|
6
6
|
import { Transformer } from '@i18nsmith/transformer';
|
|
7
7
|
import type { TransformSummary } from '@i18nsmith/transformer';
|
|
8
8
|
import { printLocaleDiffs, writeLocaleDiffPatches } from '../utils/diff-utils.js';
|
|
9
|
+
import { applyPreviewFile, writePreviewFile } from '../utils/preview.js';
|
|
9
10
|
|
|
10
11
|
interface TransformOptions {
|
|
11
12
|
config?: string;
|
|
@@ -17,6 +18,8 @@ interface TransformOptions {
|
|
|
17
18
|
diff?: boolean;
|
|
18
19
|
patchDir?: string;
|
|
19
20
|
migrateTextKeys?: boolean;
|
|
21
|
+
previewOutput?: string;
|
|
22
|
+
applyPreview?: string;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
const collectTargetPatterns = (value: string | string[], previous: string[]) => {
|
|
@@ -83,11 +86,30 @@ export function registerTransform(program: Command) {
|
|
|
83
86
|
.option('--patch-dir <path>', 'Write locale diffs to .patch files in the specified directory')
|
|
84
87
|
.option('--target <pattern...>', 'Limit scanning to specific files or glob patterns', collectTargetPatterns, [])
|
|
85
88
|
.option('--migrate-text-keys', 'Migrate existing t("Text") calls to structured keys')
|
|
89
|
+
.option('--preview-output <path>', 'Write preview summary (JSON) to a file (implies dry-run)')
|
|
90
|
+
.option('--apply-preview <path>', 'Apply a previously saved transform preview JSON file safely')
|
|
86
91
|
.action(async (options: TransformOptions) => {
|
|
92
|
+
if (options.applyPreview) {
|
|
93
|
+
await applyPreviewFile('transform', options.applyPreview);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
const diffEnabled = Boolean(options.diff || options.patchDir);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
);
|
|
98
|
+
const previewMode = Boolean(options.previewOutput);
|
|
99
|
+
const diffRequested = diffEnabled || previewMode;
|
|
100
|
+
const writeEnabled = Boolean(options.write) && !previewMode;
|
|
101
|
+
|
|
102
|
+
if (previewMode && options.write) {
|
|
103
|
+
console.log(chalk.yellow('Preview requested; ignoring --write and running in dry-run mode.'));
|
|
104
|
+
}
|
|
105
|
+
options.write = writeEnabled;
|
|
106
|
+
|
|
107
|
+
const banner = previewMode
|
|
108
|
+
? 'Generating transform preview...'
|
|
109
|
+
: writeEnabled
|
|
110
|
+
? 'Running transform (write mode)...'
|
|
111
|
+
: 'Planning transform (dry-run)...';
|
|
112
|
+
console.log(chalk.blue(banner));
|
|
91
113
|
|
|
92
114
|
try {
|
|
93
115
|
const { config, projectRoot, configPath } = await loadConfigWithMeta(options.config);
|
|
@@ -103,10 +125,15 @@ export function registerTransform(program: Command) {
|
|
|
103
125
|
const summary = await transformer.run({
|
|
104
126
|
write: options.write,
|
|
105
127
|
targets: options.target,
|
|
106
|
-
diff:
|
|
128
|
+
diff: diffRequested,
|
|
107
129
|
migrateTextKeys: options.migrateTextKeys,
|
|
108
130
|
});
|
|
109
131
|
|
|
132
|
+
if (previewMode && options.previewOutput) {
|
|
133
|
+
const savedPath = await writePreviewFile('transform', summary, options.previewOutput);
|
|
134
|
+
console.log(chalk.green(`Preview written to ${path.relative(process.cwd(), savedPath)}`));
|
|
135
|
+
}
|
|
136
|
+
|
|
110
137
|
if (options.report) {
|
|
111
138
|
const outputPath = path.resolve(process.cwd(), options.report);
|
|
112
139
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import inquirer from 'inquirer';
|
|
7
|
+
import path from 'path';
|
|
7
8
|
import type { Command } from 'commander';
|
|
8
9
|
import {
|
|
9
10
|
DEFAULT_PLACEHOLDER_FORMATS,
|
|
@@ -19,6 +20,7 @@ import type { TranslateCommandOptions, TranslateSummary, ProviderSettings } from
|
|
|
19
20
|
import { emitTranslateOutput, maybePrintEstimate } from './reporter.js';
|
|
20
21
|
import { handleCsvExport, handleCsvImport } from './csv-handler.js';
|
|
21
22
|
import { executeTranslations } from './executor.js';
|
|
23
|
+
import { applyPreviewFile, writePreviewFile } from '../../utils/preview.js';
|
|
22
24
|
|
|
23
25
|
// Re-export types for external use
|
|
24
26
|
export * from './types.js';
|
|
@@ -98,7 +100,14 @@ export function registerTranslate(program: Command): void {
|
|
|
98
100
|
.option('--strict-placeholders', 'Fail if translated output has placeholder mismatches (for CI)', false)
|
|
99
101
|
.option('--export <path>', 'Export missing translations to a CSV file for external translation')
|
|
100
102
|
.option('--import <path>', 'Import translations from a CSV file and merge into locale files')
|
|
103
|
+
.option('--preview-output <path>', 'Write preview summary (JSON) to a file (implies dry-run)')
|
|
104
|
+
.option('--apply-preview <path>', 'Apply a previously saved translate preview JSON file safely')
|
|
101
105
|
.action(async (options: TranslateCommandOptions) => {
|
|
106
|
+
if (options.applyPreview) {
|
|
107
|
+
await applyPreviewFile('translate', options.applyPreview, ['--yes']);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
102
111
|
// Handle CSV export mode
|
|
103
112
|
if (options.export) {
|
|
104
113
|
await handleCsvExport(options);
|
|
@@ -111,9 +120,14 @@ export function registerTranslate(program: Command): void {
|
|
|
111
120
|
return;
|
|
112
121
|
}
|
|
113
122
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
)
|
|
123
|
+
const previewMode = Boolean(options.previewOutput);
|
|
124
|
+
const writeEnabled = Boolean(options.write) && !previewMode;
|
|
125
|
+
if (previewMode && options.write) {
|
|
126
|
+
console.log(chalk.yellow('Preview requested; ignoring --write and running in dry-run mode.'));
|
|
127
|
+
}
|
|
128
|
+
options.write = writeEnabled;
|
|
129
|
+
|
|
130
|
+
console.log(chalk.blue(writeEnabled ? 'Translating locale files...' : 'Planning translations (dry-run)...'));
|
|
117
131
|
|
|
118
132
|
try {
|
|
119
133
|
const config = await loadConfig(options.config);
|
|
@@ -142,6 +156,12 @@ export function registerTranslate(program: Command): void {
|
|
|
142
156
|
};
|
|
143
157
|
|
|
144
158
|
if (!options.write) {
|
|
159
|
+
if (previewMode && options.previewOutput) {
|
|
160
|
+
const savedPath = await writePreviewFile('translate', summary, options.previewOutput);
|
|
161
|
+
console.log(chalk.green(`Preview written to ${path.relative(process.cwd(), savedPath)}`));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
if (options.estimate && providerSettings.name !== 'manual') {
|
|
146
166
|
await maybePrintEstimate(plan, providerSettings);
|
|
147
167
|
}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { execSync } from 'child_process';
|
|
6
|
+
import { ensureCliBuilt } from '../test-helpers/ensure-cli-built';
|
|
7
|
+
|
|
8
|
+
const CLI_PATH = path.resolve(__dirname, '..', '..', 'dist', 'index.js');
|
|
6
9
|
|
|
7
10
|
describe('translate command', () => {
|
|
8
11
|
let tempDir: string;
|
|
9
12
|
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
await ensureCliBuilt(CLI_PATH);
|
|
15
|
+
});
|
|
16
|
+
|
|
10
17
|
beforeEach(async () => {
|
|
11
18
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18nsmith-translate-test-'));
|
|
12
19
|
|
|
@@ -55,9 +62,8 @@ export function App() {
|
|
|
55
62
|
});
|
|
56
63
|
|
|
57
64
|
const runCli = (args: string): string => {
|
|
58
|
-
const cliPath = path.resolve(__dirname, '..', '..', 'dist', 'index.js');
|
|
59
65
|
try {
|
|
60
|
-
return execSync(`node ${
|
|
66
|
+
return execSync(`node ${CLI_PATH} ${args}`, {
|
|
61
67
|
cwd: tempDir,
|
|
62
68
|
encoding: 'utf8',
|
|
63
69
|
stdio: ['pipe', 'pipe', 'pipe'],
|
package/src/e2e.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import path from 'path';
|
|
|
9
9
|
import os from 'os';
|
|
10
10
|
import { spawnSync } from 'child_process';
|
|
11
11
|
import { fileURLToPath } from 'url';
|
|
12
|
+
import { ensureCliBuilt } from './test-helpers/ensure-cli-built';
|
|
12
13
|
|
|
13
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
15
|
const __dirname = path.dirname(__filename);
|
|
@@ -84,17 +85,8 @@ function extractJson<T>(output: string): T {
|
|
|
84
85
|
return JSON.parse(jsonMatch[0]);
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
beforeAll(() => {
|
|
88
|
-
|
|
89
|
-
const result = spawnSync('pnpm', ['build'], {
|
|
90
|
-
cwd: cliRoot,
|
|
91
|
-
stdio: 'inherit',
|
|
92
|
-
env: { ...process.env },
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
if (result.status !== 0) {
|
|
96
|
-
throw new Error('Failed to build CLI before running E2E tests');
|
|
97
|
-
}
|
|
88
|
+
beforeAll(async () => {
|
|
89
|
+
await ensureCliBuilt(CLI_PATH);
|
|
98
90
|
});
|
|
99
91
|
|
|
100
92
|
describe('E2E Fixture Tests', () => {
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { registerBackup } from './commands/backup.js';
|
|
|
15
15
|
import { registerRename } from './commands/rename.js';
|
|
16
16
|
import { registerInstallHooks } from './commands/install-hooks.js';
|
|
17
17
|
import { registerConfig } from './commands/config.js';
|
|
18
|
+
import { registerReview } from './commands/review.js';
|
|
18
19
|
|
|
19
20
|
export const program = new Command();
|
|
20
21
|
|
|
@@ -38,6 +39,7 @@ registerBackup(program);
|
|
|
38
39
|
registerRename(program);
|
|
39
40
|
registerInstallHooks(program);
|
|
40
41
|
registerConfig(program);
|
|
42
|
+
registerReview(program);
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
program.parse();
|
package/src/integration.test.ts
CHANGED
|
@@ -3,17 +3,19 @@
|
|
|
3
3
|
* These tests run the actual CLI commands against real file systems
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
|
|
7
7
|
import fs from 'fs/promises';
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import os from 'os';
|
|
10
10
|
import { spawnSync } from 'child_process';
|
|
11
11
|
import { fileURLToPath } from 'url';
|
|
12
|
+
import { ensureCliBuilt } from './test-helpers/ensure-cli-built';
|
|
12
13
|
|
|
13
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
15
|
const __dirname = path.dirname(__filename);
|
|
15
16
|
|
|
16
17
|
// Get the correct CLI path regardless of where tests are run from
|
|
18
|
+
// During tests, __dirname points to src/, so we go up one level to find dist/
|
|
17
19
|
const CLI_PATH = path.resolve(__dirname, '../dist/index.js');
|
|
18
20
|
|
|
19
21
|
// Helper to run CLI commands
|
|
@@ -33,6 +35,11 @@ function runCli(
|
|
|
33
35
|
},
|
|
34
36
|
});
|
|
35
37
|
|
|
38
|
+
// Log errors for debugging
|
|
39
|
+
if (result.error) {
|
|
40
|
+
console.error('CLI execution error:', result.error);
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
const stdout = result.stdout ?? '';
|
|
37
44
|
const stderr = result.stderr ?? '';
|
|
38
45
|
|
|
@@ -56,6 +63,10 @@ function extractJson<T>(output: string): T {
|
|
|
56
63
|
describe('CLI Integration Tests', () => {
|
|
57
64
|
let tmpDir: string;
|
|
58
65
|
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
await ensureCliBuilt(CLI_PATH);
|
|
68
|
+
});
|
|
69
|
+
|
|
59
70
|
beforeEach(async () => {
|
|
60
71
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18nsmith-cli-integration-'));
|
|
61
72
|
});
|