i18nsmith 0.2.0 → 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/transform.d.ts.map +1 -1
- package/dist/index.js +121 -4
- 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/transform.ts +83 -2
- package/src/e2e.test.ts +44 -0
- package/src/utils/preview.test.ts +94 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transform.d.ts","sourceRoot":"","sources":["../../src/commands/transform.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"transform.d.ts","sourceRoot":"","sources":["../../src/commands/transform.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAsJzC,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,QAyGjD"}
|
package/dist/index.js
CHANGED
|
@@ -108399,6 +108399,9 @@ async function formatFileWithPrettier(filePath, loadPrettier = defaultLoader) {
|
|
|
108399
108399
|
var Transformer = class _Transformer {
|
|
108400
108400
|
constructor(config, options8 = {}) {
|
|
108401
108401
|
this.config = config;
|
|
108402
|
+
// Per-run dedupe state. Must be cleared at the beginning of each run.
|
|
108403
|
+
// Keeping it on the instance caused subsequent runs to mislabel candidates as duplicates
|
|
108404
|
+
// and could lead to confusing multi-pass behavior/reporting.
|
|
108402
108405
|
this.seenHashes = /* @__PURE__ */ new Map();
|
|
108403
108406
|
this.workspaceRoot = options8.workspaceRoot ?? process.cwd();
|
|
108404
108407
|
this.project = options8.project ?? new Project6({ skipAddingFilesFromTsConfig: true });
|
|
@@ -108418,6 +108421,7 @@ var Transformer = class _Transformer {
|
|
|
108418
108421
|
this.translationAdapter = this.normalizeTranslationAdapter(config.translationAdapter);
|
|
108419
108422
|
}
|
|
108420
108423
|
async run(runOptions = {}) {
|
|
108424
|
+
this.seenHashes.clear();
|
|
108421
108425
|
const write = runOptions.write ?? this.defaultWrite;
|
|
108422
108426
|
const generateDiffs = runOptions.diff ?? false;
|
|
108423
108427
|
if (write) {
|
|
@@ -108439,15 +108443,47 @@ var Transformer = class _Transformer {
|
|
|
108439
108443
|
scanCalls: runOptions.migrateTextKeys
|
|
108440
108444
|
});
|
|
108441
108445
|
const enriched = await this.enrichCandidates(summary.detailedCandidates);
|
|
108446
|
+
const transformableCandidates = enriched.filter(
|
|
108447
|
+
(candidate) => candidate.status === "pending" || candidate.status === "existing"
|
|
108448
|
+
);
|
|
108442
108449
|
const filesChanged = /* @__PURE__ */ new Map();
|
|
108443
108450
|
const skippedFiles = [];
|
|
108444
108451
|
const shouldProcess = write || generateDiffs;
|
|
108452
|
+
const enableProgress = Boolean(write && runOptions.onProgress && transformableCandidates.length > 0);
|
|
108453
|
+
const progressState = {
|
|
108454
|
+
processed: 0,
|
|
108455
|
+
applied: 0,
|
|
108456
|
+
skipped: 0,
|
|
108457
|
+
errors: 0,
|
|
108458
|
+
total: transformableCandidates.length
|
|
108459
|
+
};
|
|
108460
|
+
const emitProgress = () => {
|
|
108461
|
+
if (!enableProgress || !runOptions.onProgress) {
|
|
108462
|
+
return;
|
|
108463
|
+
}
|
|
108464
|
+
const percent = progressState.total === 0 ? 100 : Math.min(100, Math.round(progressState.processed / progressState.total * 100));
|
|
108465
|
+
const payload = {
|
|
108466
|
+
stage: "apply",
|
|
108467
|
+
processed: progressState.processed,
|
|
108468
|
+
total: progressState.total,
|
|
108469
|
+
percent,
|
|
108470
|
+
applied: progressState.applied,
|
|
108471
|
+
skipped: progressState.skipped,
|
|
108472
|
+
remaining: Math.max(progressState.total - progressState.processed, 0),
|
|
108473
|
+
errors: progressState.errors,
|
|
108474
|
+
message: "Applying transformations"
|
|
108475
|
+
};
|
|
108476
|
+
runOptions.onProgress(payload);
|
|
108477
|
+
};
|
|
108478
|
+
emitProgress();
|
|
108445
108479
|
if (shouldProcess) {
|
|
108446
108480
|
const fileImportCache = /* @__PURE__ */ new Map();
|
|
108447
108481
|
for (const candidate of enriched) {
|
|
108448
108482
|
if (candidate.status !== "pending" && candidate.status !== "existing") {
|
|
108449
108483
|
continue;
|
|
108450
108484
|
}
|
|
108485
|
+
progressState.processed += 1;
|
|
108486
|
+
let skipCounted = false;
|
|
108451
108487
|
const wasExisting = candidate.status === "existing";
|
|
108452
108488
|
try {
|
|
108453
108489
|
const sourceFile = candidate.raw.sourceFile;
|
|
@@ -108478,6 +108514,9 @@ var Transformer = class _Transformer {
|
|
|
108478
108514
|
candidate.status = "skipped";
|
|
108479
108515
|
candidate.reason = "No React component/function scope found";
|
|
108480
108516
|
skippedFiles.push({ filePath: candidate.filePath, reason: candidate.reason });
|
|
108517
|
+
progressState.skipped += 1;
|
|
108518
|
+
skipCounted = true;
|
|
108519
|
+
emitProgress();
|
|
108481
108520
|
continue;
|
|
108482
108521
|
}
|
|
108483
108522
|
if (write) {
|
|
@@ -108486,6 +108525,7 @@ var Transformer = class _Transformer {
|
|
|
108486
108525
|
this.applyCandidate(candidate);
|
|
108487
108526
|
candidate.status = "applied";
|
|
108488
108527
|
filesChanged.set(candidate.filePath, sourceFile);
|
|
108528
|
+
progressState.applied += 1;
|
|
108489
108529
|
}
|
|
108490
108530
|
if (!wasExisting) {
|
|
108491
108531
|
const sourceValue = await this.findLegacyLocaleValue(this.sourceLocale, candidate.text) ?? candidate.text ?? generateValueFromKey(candidate.suggestedKey);
|
|
@@ -108502,15 +108542,30 @@ var Transformer = class _Transformer {
|
|
|
108502
108542
|
candidate.status = "skipped";
|
|
108503
108543
|
candidate.reason = error.message;
|
|
108504
108544
|
skippedFiles.push({ filePath: candidate.filePath, reason: candidate.reason });
|
|
108545
|
+
progressState.skipped += 1;
|
|
108546
|
+
progressState.errors += 1;
|
|
108547
|
+
skipCounted = true;
|
|
108548
|
+
}
|
|
108549
|
+
if (candidate.status === "skipped" && !skipCounted) {
|
|
108550
|
+
progressState.skipped += 1;
|
|
108551
|
+
skipCounted = true;
|
|
108505
108552
|
}
|
|
108553
|
+
emitProgress();
|
|
108506
108554
|
}
|
|
108507
108555
|
if (write) {
|
|
108556
|
+
const changedFiles = Array.from(filesChanged.values());
|
|
108508
108557
|
await Promise.all(
|
|
108509
|
-
|
|
108558
|
+
changedFiles.map(async (file) => {
|
|
108510
108559
|
await file.save();
|
|
108511
108560
|
await formatFileWithPrettier(file.getFilePath());
|
|
108512
108561
|
})
|
|
108513
108562
|
);
|
|
108563
|
+
for (const file of changedFiles) {
|
|
108564
|
+
try {
|
|
108565
|
+
file.refreshFromFileSystemSync();
|
|
108566
|
+
} catch {
|
|
108567
|
+
}
|
|
108568
|
+
}
|
|
108514
108569
|
}
|
|
108515
108570
|
}
|
|
108516
108571
|
const localeStats = write ? await this.localeStore.flush() : [];
|
|
@@ -108581,7 +108636,7 @@ var Transformer = class _Transformer {
|
|
|
108581
108636
|
const generated = this.keyGenerator.generate(candidate.text, this.buildContext(candidate));
|
|
108582
108637
|
let status = "pending";
|
|
108583
108638
|
let reason;
|
|
108584
|
-
const { node
|
|
108639
|
+
const { node, sourceFile, ...serializableCandidate } = candidate;
|
|
108585
108640
|
const validation = this.keyValidator.validate(generated.key, candidate.text);
|
|
108586
108641
|
if (!validation.valid) {
|
|
108587
108642
|
status = "skipped";
|
|
@@ -108711,9 +108766,21 @@ var collectTargetPatterns3 = (value, previous) => {
|
|
|
108711
108766
|
return [...previous, ...tokens];
|
|
108712
108767
|
};
|
|
108713
108768
|
function printTransformSummary(summary) {
|
|
108769
|
+
const counts = summary.candidates.reduce(
|
|
108770
|
+
(acc, c7) => {
|
|
108771
|
+
acc.total += 1;
|
|
108772
|
+
if (c7.status === "applied") acc.applied += 1;
|
|
108773
|
+
else if (c7.status === "pending") acc.pending += 1;
|
|
108774
|
+
else if (c7.status === "duplicate") acc.duplicate += 1;
|
|
108775
|
+
else if (c7.status === "existing") acc.existing += 1;
|
|
108776
|
+
else if (c7.status === "skipped") acc.skipped += 1;
|
|
108777
|
+
return acc;
|
|
108778
|
+
},
|
|
108779
|
+
{ total: 0, applied: 0, pending: 0, duplicate: 0, existing: 0, skipped: 0 }
|
|
108780
|
+
);
|
|
108714
108781
|
console.log(
|
|
108715
108782
|
chalk14.green(
|
|
108716
|
-
`Scanned ${summary.filesScanned} file${summary.filesScanned === 1 ? "" : "s"}; ${
|
|
108783
|
+
`Scanned ${summary.filesScanned} file${summary.filesScanned === 1 ? "" : "s"}; ${counts.total} candidate${counts.total === 1 ? "" : "s"} found (applied: ${counts.applied}, pending: ${counts.pending}, duplicates: ${counts.duplicate}, existing: ${counts.existing}, skipped: ${counts.skipped}).`
|
|
108717
108784
|
)
|
|
108718
108785
|
);
|
|
108719
108786
|
const preview = summary.candidates.slice(0, 50).map((candidate) => ({
|
|
@@ -108725,6 +108792,15 @@ function printTransformSummary(summary) {
|
|
|
108725
108792
|
Preview: candidate.text.length > 40 ? `${candidate.text.slice(0, 37)}...` : candidate.text
|
|
108726
108793
|
}));
|
|
108727
108794
|
console.table(preview);
|
|
108795
|
+
const pending = summary.candidates.filter((candidate) => candidate.status === "pending").length;
|
|
108796
|
+
if (pending > 0) {
|
|
108797
|
+
console.log(
|
|
108798
|
+
chalk14.yellow(
|
|
108799
|
+
`
|
|
108800
|
+
${pending} candidate${pending === 1 ? "" : "s"} still pending. This can happen when candidates are filtered for safety (e.g., not in a React scope). Re-run with --write after reviewing skipped reasons if you want to keep iterating.`
|
|
108801
|
+
)
|
|
108802
|
+
);
|
|
108803
|
+
}
|
|
108728
108804
|
if (summary.filesChanged.length) {
|
|
108729
108805
|
console.log(chalk14.blue(`Files changed (${summary.filesChanged.length}):`));
|
|
108730
108806
|
summary.filesChanged.forEach((file) => console.log(` \u2022 ${file}`));
|
|
@@ -108742,6 +108818,44 @@ function printTransformSummary(summary) {
|
|
|
108742
108818
|
summary.skippedFiles.forEach((item) => console.log(` \u2022 ${item.filePath}: ${item.reason}`));
|
|
108743
108819
|
}
|
|
108744
108820
|
}
|
|
108821
|
+
function createProgressLogger() {
|
|
108822
|
+
let lastPercent = -1;
|
|
108823
|
+
let lastLogTime = 0;
|
|
108824
|
+
let pendingCarriageReturn = false;
|
|
108825
|
+
const writeLine = (line3, final = false) => {
|
|
108826
|
+
if (process.stdout.isTTY) {
|
|
108827
|
+
process.stdout.write(`\r${line3}`);
|
|
108828
|
+
pendingCarriageReturn = !final;
|
|
108829
|
+
if (final) {
|
|
108830
|
+
process.stdout.write("\n");
|
|
108831
|
+
}
|
|
108832
|
+
} else {
|
|
108833
|
+
console.log(line3);
|
|
108834
|
+
}
|
|
108835
|
+
};
|
|
108836
|
+
return {
|
|
108837
|
+
emit(progress) {
|
|
108838
|
+
if (progress.stage !== "apply" || progress.total === 0) {
|
|
108839
|
+
return;
|
|
108840
|
+
}
|
|
108841
|
+
const now = Date.now();
|
|
108842
|
+
const shouldLog = progress.processed === progress.total || progress.percent === 0 || progress.percent >= lastPercent + 2 || now - lastLogTime > 1500;
|
|
108843
|
+
if (!shouldLog) {
|
|
108844
|
+
return;
|
|
108845
|
+
}
|
|
108846
|
+
lastPercent = progress.percent;
|
|
108847
|
+
lastLogTime = now;
|
|
108848
|
+
const line3 = `Applying transforms ${progress.processed}/${progress.total} (${progress.percent}%) | applied: ${progress.applied ?? 0} | skipped: ${progress.skipped ?? 0}` + (progress.errors ? ` | errors: ${progress.errors}` : "") + ` | remaining: ${progress.remaining ?? progress.total - progress.processed}`;
|
|
108849
|
+
writeLine(line3, progress.processed === progress.total);
|
|
108850
|
+
},
|
|
108851
|
+
flush() {
|
|
108852
|
+
if (pendingCarriageReturn && process.stdout.isTTY) {
|
|
108853
|
+
process.stdout.write("\n");
|
|
108854
|
+
pendingCarriageReturn = false;
|
|
108855
|
+
}
|
|
108856
|
+
}
|
|
108857
|
+
};
|
|
108858
|
+
}
|
|
108745
108859
|
function registerTransform(program2) {
|
|
108746
108860
|
program2.command("transform").description("Scan project and apply i18n transformations").option("-c, --config <path>", "Path to i18nsmith config file", "i18n.config.json").option("--json", "Print raw JSON results", false).option("--report <path>", "Write JSON summary to a file (for CI or editors)").option("--write", "Write changes to disk (defaults to dry-run)", false).option("--check", "Exit with error code if changes are needed", false).option("--diff", "Display unified diffs for locale files that would change", false).option("--patch-dir <path>", "Write locale diffs to .patch files in the specified directory").option("--target <pattern...>", "Limit scanning to specific files or glob patterns", collectTargetPatterns3, []).option("--migrate-text-keys", 'Migrate existing t("Text") calls to structured keys').option("--preview-output <path>", "Write preview summary (JSON) to a file (implies dry-run)").option("--apply-preview <path>", "Apply a previously saved transform preview JSON file safely").action(async (options8) => {
|
|
108747
108861
|
if (options8.applyPreview) {
|
|
@@ -108767,12 +108881,15 @@ function registerTransform(program2) {
|
|
|
108767
108881
|
`));
|
|
108768
108882
|
}
|
|
108769
108883
|
const transformer = new Transformer(config, { workspaceRoot: projectRoot });
|
|
108884
|
+
const progressLogger = createProgressLogger();
|
|
108770
108885
|
const summary = await transformer.run({
|
|
108771
108886
|
write: options8.write,
|
|
108772
108887
|
targets: options8.target,
|
|
108773
108888
|
diff: diffRequested,
|
|
108774
|
-
migrateTextKeys: options8.migrateTextKeys
|
|
108889
|
+
migrateTextKeys: options8.migrateTextKeys,
|
|
108890
|
+
onProgress: progressLogger.emit
|
|
108775
108891
|
});
|
|
108892
|
+
progressLogger.flush();
|
|
108776
108893
|
if (previewMode && options8.previewOutput) {
|
|
108777
108894
|
const savedPath = await writePreviewFile("transform", summary, options8.previewOutput);
|
|
108778
108895
|
console.log(chalk14.green(`Preview written to ${path33.relative(process.cwd(), savedPath)}`));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preview.test.d.ts","sourceRoot":"","sources":["../../src/utils/preview.test.ts"],"names":[],"mappings":""}
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ import chalk from 'chalk';
|
|
|
4
4
|
import type { Command } from 'commander';
|
|
5
5
|
import { loadConfigWithMeta } from '@i18nsmith/core';
|
|
6
6
|
import { Transformer } from '@i18nsmith/transformer';
|
|
7
|
-
import type { TransformSummary } from '@i18nsmith/transformer';
|
|
7
|
+
import type { TransformProgress, TransformSummary } from '@i18nsmith/transformer';
|
|
8
8
|
import { printLocaleDiffs, writeLocaleDiffPatches } from '../utils/diff-utils.js';
|
|
9
9
|
import { applyPreviewFile, writePreviewFile } from '../utils/preview.js';
|
|
10
10
|
|
|
@@ -32,10 +32,24 @@ const collectTargetPatterns = (value: string | string[], previous: string[]) =>
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
function printTransformSummary(summary: TransformSummary) {
|
|
35
|
+
const counts = summary.candidates.reduce(
|
|
36
|
+
(acc, c) => {
|
|
37
|
+
acc.total += 1;
|
|
38
|
+
if (c.status === 'applied') acc.applied += 1;
|
|
39
|
+
else if (c.status === 'pending') acc.pending += 1;
|
|
40
|
+
else if (c.status === 'duplicate') acc.duplicate += 1;
|
|
41
|
+
else if (c.status === 'existing') acc.existing += 1;
|
|
42
|
+
else if (c.status === 'skipped') acc.skipped += 1;
|
|
43
|
+
return acc;
|
|
44
|
+
},
|
|
45
|
+
{ total: 0, applied: 0, pending: 0, duplicate: 0, existing: 0, skipped: 0 }
|
|
46
|
+
);
|
|
47
|
+
|
|
35
48
|
console.log(
|
|
36
49
|
chalk.green(
|
|
37
50
|
`Scanned ${summary.filesScanned} file${summary.filesScanned === 1 ? '' : 's'}; ` +
|
|
38
|
-
`${
|
|
51
|
+
`${counts.total} candidate${counts.total === 1 ? '' : 's'} found ` +
|
|
52
|
+
`(applied: ${counts.applied}, pending: ${counts.pending}, duplicates: ${counts.duplicate}, existing: ${counts.existing}, skipped: ${counts.skipped}).`
|
|
39
53
|
)
|
|
40
54
|
);
|
|
41
55
|
|
|
@@ -53,6 +67,17 @@ function printTransformSummary(summary: TransformSummary) {
|
|
|
53
67
|
|
|
54
68
|
console.table(preview);
|
|
55
69
|
|
|
70
|
+
const pending = summary.candidates.filter((candidate) => candidate.status === 'pending').length;
|
|
71
|
+
if (pending > 0) {
|
|
72
|
+
console.log(
|
|
73
|
+
chalk.yellow(
|
|
74
|
+
`\n${pending} candidate${pending === 1 ? '' : 's'} still pending. ` +
|
|
75
|
+
`This can happen when candidates are filtered for safety (e.g., not in a React scope). ` +
|
|
76
|
+
`Re-run with --write after reviewing skipped reasons if you want to keep iterating.`
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
56
81
|
if (summary.filesChanged.length) {
|
|
57
82
|
console.log(chalk.blue(`Files changed (${summary.filesChanged.length}):`));
|
|
58
83
|
summary.filesChanged.forEach((file) => console.log(` • ${file}`));
|
|
@@ -73,6 +98,59 @@ function printTransformSummary(summary: TransformSummary) {
|
|
|
73
98
|
}
|
|
74
99
|
}
|
|
75
100
|
|
|
101
|
+
function createProgressLogger() {
|
|
102
|
+
let lastPercent = -1;
|
|
103
|
+
let lastLogTime = 0;
|
|
104
|
+
let pendingCarriageReturn = false;
|
|
105
|
+
|
|
106
|
+
const writeLine = (line: string, final = false) => {
|
|
107
|
+
if (process.stdout.isTTY) {
|
|
108
|
+
process.stdout.write(`\r${line}`);
|
|
109
|
+
pendingCarriageReturn = !final;
|
|
110
|
+
if (final) {
|
|
111
|
+
process.stdout.write('\n');
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
console.log(line);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
emit(progress: TransformProgress) {
|
|
120
|
+
if (progress.stage !== 'apply' || progress.total === 0) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const shouldLog =
|
|
126
|
+
progress.processed === progress.total ||
|
|
127
|
+
progress.percent === 0 ||
|
|
128
|
+
progress.percent >= lastPercent + 2 ||
|
|
129
|
+
now - lastLogTime > 1500;
|
|
130
|
+
|
|
131
|
+
if (!shouldLog) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
lastPercent = progress.percent;
|
|
136
|
+
lastLogTime = now;
|
|
137
|
+
const line =
|
|
138
|
+
`Applying transforms ${progress.processed}/${progress.total} (${progress.percent}%)` +
|
|
139
|
+
` | applied: ${progress.applied ?? 0}` +
|
|
140
|
+
` | skipped: ${progress.skipped ?? 0}` +
|
|
141
|
+
(progress.errors ? ` | errors: ${progress.errors}` : '') +
|
|
142
|
+
` | remaining: ${progress.remaining ?? progress.total - progress.processed}`;
|
|
143
|
+
writeLine(line, progress.processed === progress.total);
|
|
144
|
+
},
|
|
145
|
+
flush() {
|
|
146
|
+
if (pendingCarriageReturn && process.stdout.isTTY) {
|
|
147
|
+
process.stdout.write('\n');
|
|
148
|
+
pendingCarriageReturn = false;
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
76
154
|
export function registerTransform(program: Command) {
|
|
77
155
|
program
|
|
78
156
|
.command('transform')
|
|
@@ -122,12 +200,15 @@ export function registerTransform(program: Command) {
|
|
|
122
200
|
}
|
|
123
201
|
|
|
124
202
|
const transformer = new Transformer(config, { workspaceRoot: projectRoot });
|
|
203
|
+
const progressLogger = createProgressLogger();
|
|
125
204
|
const summary = await transformer.run({
|
|
126
205
|
write: options.write,
|
|
127
206
|
targets: options.target,
|
|
128
207
|
diff: diffRequested,
|
|
129
208
|
migrateTextKeys: options.migrateTextKeys,
|
|
209
|
+
onProgress: progressLogger.emit,
|
|
130
210
|
});
|
|
211
|
+
progressLogger.flush();
|
|
131
212
|
|
|
132
213
|
if (previewMode && options.previewOutput) {
|
|
133
214
|
const savedPath = await writePreviewFile('transform', summary, options.previewOutput);
|
package/src/e2e.test.ts
CHANGED
|
@@ -144,6 +144,50 @@ describe('E2E Fixture Tests', () => {
|
|
|
144
144
|
|
|
145
145
|
expect(parsed).toHaveProperty('diagnostics');
|
|
146
146
|
});
|
|
147
|
+
|
|
148
|
+
it('applies preview selections to add missing keys and prune unused keys', async () => {
|
|
149
|
+
const appFile = path.join(fixtureDir, 'src', 'App.tsx');
|
|
150
|
+
const localeFile = path.join(fixtureDir, 'locales', 'en.json');
|
|
151
|
+
const previewDir = path.join(fixtureDir, '.i18nsmith', 'previews');
|
|
152
|
+
const previewPath = path.join(previewDir, 'integration-preview.json');
|
|
153
|
+
const selectionPath = path.join(previewDir, 'integration-selection.json');
|
|
154
|
+
|
|
155
|
+
const appContent = await fs.readFile(appFile, 'utf8');
|
|
156
|
+
const injected = appContent.replace(
|
|
157
|
+
'</div>',
|
|
158
|
+
" <p>{t('integration.preview-missing')}</p>\n </div>"
|
|
159
|
+
);
|
|
160
|
+
await fs.writeFile(appFile, injected, 'utf8');
|
|
161
|
+
|
|
162
|
+
const locale = JSON.parse(await fs.readFile(localeFile, 'utf8'));
|
|
163
|
+
locale['unused.preview.key'] = 'Preview-only key';
|
|
164
|
+
await fs.writeFile(localeFile, JSON.stringify(locale, null, 2));
|
|
165
|
+
|
|
166
|
+
const previewResult = runCli(['sync', '--preview-output', previewPath], { cwd: fixtureDir });
|
|
167
|
+
expect(previewResult.exitCode).toBe(0);
|
|
168
|
+
|
|
169
|
+
const payload = JSON.parse(await fs.readFile(previewPath, 'utf8')) as {
|
|
170
|
+
summary: { missingKeys: { key: string }[]; unusedKeys: { key: string }[] };
|
|
171
|
+
};
|
|
172
|
+
const selection = {
|
|
173
|
+
missing: payload.summary.missingKeys.map((entry) => entry.key),
|
|
174
|
+
unused: payload.summary.unusedKeys.map((entry) => entry.key),
|
|
175
|
+
};
|
|
176
|
+
expect(selection.missing).toContain('integration.preview-missing');
|
|
177
|
+
expect(selection.unused).toContain('unused.preview.key');
|
|
178
|
+
await fs.mkdir(previewDir, { recursive: true });
|
|
179
|
+
await fs.writeFile(selectionPath, JSON.stringify(selection, null, 2));
|
|
180
|
+
|
|
181
|
+
const applyResult = runCli(
|
|
182
|
+
['sync', '--apply-preview', previewPath, '--selection-file', selectionPath, '--prune', '--yes'],
|
|
183
|
+
{ cwd: fixtureDir }
|
|
184
|
+
);
|
|
185
|
+
expect(applyResult.exitCode).toBe(0);
|
|
186
|
+
|
|
187
|
+
const finalLocale = JSON.parse(await fs.readFile(localeFile, 'utf8'));
|
|
188
|
+
expect(finalLocale).toHaveProperty('integration.preview-missing');
|
|
189
|
+
expect(finalLocale).not.toHaveProperty('unused.preview.key');
|
|
190
|
+
});
|
|
147
191
|
});
|
|
148
192
|
|
|
149
193
|
describe('suspicious-keys fixture', () => {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
|
|
10
|
+
const mockSpawn = vi.fn();
|
|
11
|
+
|
|
12
|
+
vi.mock('child_process', () => ({
|
|
13
|
+
spawn: (...args: Parameters<typeof mockSpawn>) => mockSpawn(...args),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
async function writePreviewFixture(args: string[]): Promise<string> {
|
|
17
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-test-'));
|
|
18
|
+
const previewPath = path.join(tempDir, 'sync-preview.json');
|
|
19
|
+
const payload = {
|
|
20
|
+
type: 'sync-preview',
|
|
21
|
+
version: 1,
|
|
22
|
+
command: 'i18nsmith sync --preview-output tmp.json',
|
|
23
|
+
args,
|
|
24
|
+
timestamp: new Date().toISOString(),
|
|
25
|
+
summary: { missingKeys: [], unusedKeys: [] },
|
|
26
|
+
};
|
|
27
|
+
await fs.writeFile(previewPath, JSON.stringify(payload, null, 2), 'utf8');
|
|
28
|
+
return previewPath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function mockSuccessfulSpawn() {
|
|
32
|
+
mockSpawn.mockImplementation(() => {
|
|
33
|
+
const child = {
|
|
34
|
+
on(event: string, handler: (code?: number) => void) {
|
|
35
|
+
if (event === 'exit') {
|
|
36
|
+
setImmediate(() => handler(0));
|
|
37
|
+
}
|
|
38
|
+
return child;
|
|
39
|
+
},
|
|
40
|
+
} as unknown as ReturnType<typeof mockSpawn>;
|
|
41
|
+
return child;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('applyPreviewFile', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
mockSpawn.mockReset();
|
|
48
|
+
mockSuccessfulSpawn();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
vi.restoreAllMocks();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('replays preview with --write and extra args (selection + prune)', async () => {
|
|
56
|
+
const previewPath = await writePreviewFixture([
|
|
57
|
+
'sync',
|
|
58
|
+
'--target',
|
|
59
|
+
'src/app/**',
|
|
60
|
+
'--preview-output',
|
|
61
|
+
'tmp.json',
|
|
62
|
+
]);
|
|
63
|
+
const selectionFile = path.join(path.dirname(previewPath), 'selection.json');
|
|
64
|
+
|
|
65
|
+
const { applyPreviewFile } = await import('./preview.js');
|
|
66
|
+
|
|
67
|
+
await applyPreviewFile('sync', previewPath, ['--selection-file', selectionFile, '--prune']);
|
|
68
|
+
|
|
69
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
70
|
+
const [, spawnedArgs] = mockSpawn.mock.calls[0];
|
|
71
|
+
const [, ...forwardedArgs] = spawnedArgs as string[];
|
|
72
|
+
expect(forwardedArgs).toEqual([
|
|
73
|
+
'sync',
|
|
74
|
+
'--target',
|
|
75
|
+
'src/app/**',
|
|
76
|
+
'--write',
|
|
77
|
+
'--selection-file',
|
|
78
|
+
selectionFile,
|
|
79
|
+
'--prune',
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('does not duplicate --write when already present in preview args', async () => {
|
|
84
|
+
const previewPath = await writePreviewFixture(['sync', '--write', '--json']);
|
|
85
|
+
const { applyPreviewFile } = await import('./preview.js');
|
|
86
|
+
|
|
87
|
+
await applyPreviewFile('sync', previewPath);
|
|
88
|
+
|
|
89
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
90
|
+
const [, spawnedArgs] = mockSpawn.mock.calls[0];
|
|
91
|
+
const [, ...forwardedArgs] = spawnedArgs as string[];
|
|
92
|
+
expect(forwardedArgs).toEqual(['sync', '--write', '--json']);
|
|
93
|
+
});
|
|
94
|
+
});
|