i18nsmith 0.1.9 → 0.2.1

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 (35) hide show
  1. package/dist/commands/rename.d.ts.map +1 -1
  2. package/dist/commands/review.d.ts +4 -0
  3. package/dist/commands/review.d.ts.map +1 -0
  4. package/dist/commands/review.test.d.ts +2 -0
  5. package/dist/commands/review.test.d.ts.map +1 -0
  6. package/dist/commands/sync.d.ts.map +1 -1
  7. package/dist/commands/transform.d.ts.map +1 -1
  8. package/dist/commands/translate/index.d.ts.map +1 -1
  9. package/dist/commands/translate/types.d.ts +2 -0
  10. package/dist/commands/translate/types.d.ts.map +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1240 -387
  13. package/dist/test-helpers/ensure-cli-built.d.ts +2 -0
  14. package/dist/test-helpers/ensure-cli-built.d.ts.map +1 -0
  15. package/dist/utils/preview.d.ts +13 -0
  16. package/dist/utils/preview.d.ts.map +1 -0
  17. package/dist/utils/preview.test.d.ts +2 -0
  18. package/dist/utils/preview.test.d.ts.map +1 -0
  19. package/package.json +1 -1
  20. package/src/commands/debug-patterns.test.ts +6 -1
  21. package/src/commands/rename.ts +35 -3
  22. package/src/commands/review.test.ts +12 -0
  23. package/src/commands/review.ts +228 -0
  24. package/src/commands/sync-seed.test.ts +9 -3
  25. package/src/commands/sync.ts +104 -10
  26. package/src/commands/transform.ts +114 -6
  27. package/src/commands/translate/index.ts +23 -3
  28. package/src/commands/translate/types.ts +2 -0
  29. package/src/commands/translate.test.ts +9 -3
  30. package/src/e2e.test.ts +47 -11
  31. package/src/index.ts +2 -0
  32. package/src/integration.test.ts +12 -1
  33. package/src/test-helpers/ensure-cli-built.ts +32 -0
  34. package/src/utils/preview.test.ts +94 -0
  35. package/src/utils/preview.ts +126 -0
@@ -0,0 +1,2 @@
1
+ export declare function ensureCliBuilt(cliPath: string): Promise<void>;
2
+ //# sourceMappingURL=ensure-cli-built.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ensure-cli-built.d.ts","sourceRoot":"","sources":["../../src/test-helpers/ensure-cli-built.ts"],"names":[],"mappings":"AAUA,wBAAsB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBnE"}
@@ -0,0 +1,13 @@
1
+ export interface PreviewPayload<TSummary> {
2
+ type: string;
3
+ version: number;
4
+ command: string;
5
+ args: string[];
6
+ timestamp: string;
7
+ summary: TSummary;
8
+ }
9
+ export type PreviewKind = 'sync' | 'transform' | 'rename-key' | 'translate';
10
+ export declare function writePreviewFile<TSummary>(kind: PreviewKind, summary: TSummary, outputPath: string): Promise<string>;
11
+ export declare function readPreviewFile<TSummary>(expectedKind: PreviewKind, previewPath: string): Promise<PreviewPayload<TSummary>>;
12
+ export declare function applyPreviewFile(expectedKind: PreviewKind, previewPath: string, extraArgs?: string[]): Promise<void>;
13
+ //# sourceMappingURL=preview.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preview.d.ts","sourceRoot":"","sources":["../../src/utils/preview.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,cAAc,CAAC,QAAQ;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,QAAQ,CAAC;CACnB;AAED,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,WAAW,GAAG,YAAY,GAAG,WAAW,CAAC;AAE5E,wBAAsB,gBAAgB,CAAC,QAAQ,EAC7C,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,QAAQ,EACjB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,CAAC,CAcjB;AAED,wBAAsB,eAAe,CAAC,QAAQ,EAC5C,YAAY,EAAE,WAAW,EACzB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC,CAWnC;AAED,wBAAsB,gBAAgB,CACpC,YAAY,EAAE,WAAW,EACzB,WAAW,EAAE,MAAM,EACnB,SAAS,GAAE,MAAM,EAAO,GACvB,OAAO,CAAC,IAAI,CAAC,CAuBf"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=preview.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preview.test.d.ts","sourceRoot":"","sources":["../../src/utils/preview.test.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18nsmith",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
4
4
  "description": "CLI for i18nsmith",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,9 +1,10 @@
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 { spawnSync } from 'child_process';
6
6
  import { fileURLToPath } from 'url';
7
+ import { ensureCliBuilt } from '../test-helpers/ensure-cli-built';
7
8
 
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = path.dirname(__filename);
@@ -33,6 +34,10 @@ function runCli(args: string[], options: { cwd?: string } = {}) {
33
34
  describe('debug-patterns command', () => {
34
35
  let tmpDir: string;
35
36
 
37
+ beforeAll(async () => {
38
+ await ensureCliBuilt(CLI_PATH);
39
+ });
40
+
36
41
  beforeEach(async () => {
37
42
  tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18nsmith-debug-patterns-'));
38
43
 
@@ -3,6 +3,7 @@ import chalk from 'chalk';
3
3
  import path from 'node:path';
4
4
  import { promises as fs } from 'node:fs';
5
5
  import { loadConfig, KeyRenamer, type KeyRenameSummary, type KeyRenameBatchSummary, type KeyRenameMapping } from '@i18nsmith/core';
6
+ import { applyPreviewFile, writePreviewFile } from '../utils/preview.js';
6
7
 
7
8
  interface ScanOptions {
8
9
  config: string;
@@ -10,6 +11,13 @@ interface ScanOptions {
10
11
  report?: string;
11
12
  }
12
13
 
14
+ interface RenameKeyOptions extends ScanOptions {
15
+ write?: boolean;
16
+ diff?: boolean;
17
+ previewOutput?: string;
18
+ applyPreview?: string;
19
+ }
20
+
13
21
  interface RenameMapOptions extends ScanOptions {
14
22
  map: string;
15
23
  write?: boolean;
@@ -29,13 +37,37 @@ export function registerRename(program: Command): void {
29
37
  .option('--json', 'Print raw JSON results', false)
30
38
  .option('--report <path>', 'Write JSON summary to a file (for CI or editors)')
31
39
  .option('--write', 'Write changes to disk (defaults to dry-run)', false)
32
- .action(async (oldKey: string, newKey: string, options: ScanOptions & { write?: boolean }) => {
33
- console.log(chalk.blue(options.write ? 'Renaming translation key...' : 'Planning key rename (dry-run)...'));
40
+ .option('--diff', 'Display unified diffs for files that would change', false)
41
+ .option('--preview-output <path>', 'Write preview summary (JSON) to a file (implies dry-run)')
42
+ .option('--apply-preview <path>', 'Apply a previously saved rename preview JSON file safely')
43
+ .action(async (oldKey: string, newKey: string, options: RenameKeyOptions) => {
44
+ if (options.applyPreview) {
45
+ await applyPreviewFile('rename-key', options.applyPreview);
46
+ return;
47
+ }
48
+
49
+ const previewMode = Boolean(options.previewOutput);
50
+ const writeEnabled = Boolean(options.write) && !previewMode;
51
+ if (previewMode && options.write) {
52
+ console.log(chalk.yellow('Preview requested; ignoring --write and running in dry-run mode.'));
53
+ }
54
+ options.write = writeEnabled;
55
+
56
+ console.log(chalk.blue(writeEnabled ? 'Renaming translation key...' : 'Planning key rename (dry-run)...'));
34
57
 
35
58
  try {
36
59
  const config = await loadConfig(options.config);
37
60
  const renamer = new KeyRenamer(config);
38
- const summary = await renamer.rename(oldKey, newKey, { write: options.write });
61
+ const summary = await renamer.rename(oldKey, newKey, {
62
+ write: options.write,
63
+ diff: options.diff || previewMode,
64
+ });
65
+
66
+ if (previewMode && options.previewOutput) {
67
+ const savedPath = await writePreviewFile('rename-key', summary, options.previewOutput);
68
+ console.log(chalk.green(`Preview written to ${path.relative(process.cwd(), savedPath)}`));
69
+ return;
70
+ }
39
71
 
40
72
  if (options.report) {
41
73
  const outputPath = path.resolve(process.cwd(), options.report);
@@ -0,0 +1,12 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { literalToRegexPattern } from './review.js';
3
+
4
+ describe('literalToRegexPattern', () => {
5
+ it('anchors the value and escapes regex metacharacters', () => {
6
+ expect(literalToRegexPattern('Price (USD)+?')).toBe('^Price \\(USD\\)\\+\\?$');
7
+ });
8
+
9
+ it('preserves whitespace and newline characters', () => {
10
+ expect(literalToRegexPattern('Multi\nLine')).toBe('^Multi\nLine$');
11
+ });
12
+ });
@@ -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 ${cliPath} ${args}`, {
66
+ return execSync(`node ${CLI_PATH} ${args}`, {
61
67
  cwd: tempDir,
62
68
  encoding: 'utf8',
63
69
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -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 diffRequested = diffEnabled || Boolean(options.json);
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
- console.log(
105
- chalk.blue(
106
- interactive
107
- ? 'Interactive sync (dry-run first)...'
108
- : options.write
109
- ? 'Syncing locale files...'
110
- : 'Checking locale drift...'
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(