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.
- package/dist/commands/rename.d.ts.map +1 -1
- 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/sync.d.ts.map +1 -1
- package/dist/commands/transform.d.ts.map +1 -1
- package/dist/commands/translate/index.d.ts.map +1 -1
- package/dist/commands/translate/types.d.ts +2 -0
- package/dist/commands/translate/types.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1240 -387
- 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/preview.d.ts +13 -0
- package/dist/utils/preview.d.ts.map +1 -0
- package/dist/utils/preview.test.d.ts +2 -0
- package/dist/utils/preview.test.d.ts.map +1 -0
- package/package.json +1 -1
- 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 +114 -6
- 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 +47 -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.test.ts +94 -0
- package/src/utils/preview.ts +126 -0
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"file":"preview.test.d.ts","sourceRoot":"","sources":["../../src/utils/preview.test.ts"],"names":[],"mappings":""}
|
package/package.json
CHANGED
|
@@ -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
|
|
package/src/commands/rename.ts
CHANGED
|
@@ -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
|
-
.
|
|
33
|
-
|
|
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, {
|
|
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 ${
|
|
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(
|