ai-localize-cli 1.0.4 → 2.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # ai-localize-cli
2
+
3
+ ## 2.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix config schema validation (aws optional/nullable, framework type), ignore className CSS values in AST scanner, add includePatterns config support for selective file scanning, add --extract-cdns flag to scan command to dump CDN URLs to a JSON file
8
+ - Updated dependencies
9
+ - ai-localize-scanner@2.0.1
10
+ - ai-localize-config@2.0.1
11
+ - ai-localize-shared@2.0.1
12
+ - ai-localize-aws-cloudfront@2.0.1
13
+ - ai-localize-codemods@2.0.1
14
+ - ai-localize-framework-detectors@2.0.1
15
+ - ai-localize-locale-engine@2.0.1
16
+ - ai-localize-reporting@2.0.1
17
+ - ai-localize-validators@2.0.1
18
+
19
+ ## 2.0.0
20
+
21
+ ### Major Changes
22
+
23
+ - versoion change
24
+
25
+ ### Patch Changes
26
+
27
+ - Updated dependencies
28
+ - ai-localize-aws-cloudfront@2.0.0
29
+ - ai-localize-codemods@2.0.0
30
+ - ai-localize-config@2.0.0
31
+ - ai-localize-framework-detectors@2.0.0
32
+ - ai-localize-locale-engine@2.0.0
33
+ - ai-localize-reporting@2.0.0
34
+ - ai-localize-scanner@2.0.0
35
+ - ai-localize-shared@2.0.0
36
+ - ai-localize-validators@2.0.0
package/dist/cli.js CHANGED
@@ -80,11 +80,12 @@ function initCommand() {
80
80
  var import_commander2 = require("commander");
81
81
  var import_chalk3 = __toESM(require("chalk"));
82
82
  var path = __toESM(require("path"));
83
+ var fs = __toESM(require("fs"));
83
84
  var import_ai_localize_config2 = require("ai-localize-config");
84
85
  var import_ai_localize_scanner = require("ai-localize-scanner");
85
86
  var import_ai_localize_shared = require("ai-localize-shared");
86
87
  function scanCommand() {
87
- return new import_commander2.Command("scan").description("Scan project for hardcoded texts and asset references").option("--incremental", "Scan only changed files based on git diff").option("--staged", "Scan only git staged files").option("--cwd <path>", "Working directory", process.cwd()).option("--output <path>", "Output JSON file path").action(async (opts) => {
88
+ return new import_commander2.Command("scan").description("Scan project for hardcoded texts and asset references").option("--incremental", "Scan only changed files based on git diff").option("--staged", "Scan only git staged files").option("--cwd <path>", "Working directory", process.cwd()).option("--output <path>", "Output JSON file path for full scan results").option("--extract-cdns <path>", "Extract all discovered CDN/legacy URLs to a JSON file (e.g. cdn-urls.json)").action(async (opts) => {
88
89
  logger.header("ai-localize scan");
89
90
  const spinner = createSpinner("Loading configuration...").start();
90
91
  try {
@@ -109,6 +110,38 @@ function scanCommand() {
109
110
  (0, import_ai_localize_shared.writeJson)(outPath, result);
110
111
  logger.success("Results saved to " + import_chalk3.default.cyan(outPath));
111
112
  }
113
+ if (opts.extractCdns) {
114
+ const cdnOutPath = path.resolve(cwd, opts.extractCdns);
115
+ const cdnList = {
116
+ summary: {
117
+ totalLegacyCdnUrls: result.legacyCdnUrls.length,
118
+ totalAssetReferences: result.assets.length,
119
+ scannedAt: result.timestamp
120
+ },
121
+ legacyCdnUrls: result.legacyCdnUrls.map((cdn) => ({
122
+ url: cdn.url,
123
+ assetPath: cdn.assetPath,
124
+ filePath: cdn.filePath,
125
+ line: cdn.line
126
+ })),
127
+ assetReferences: result.assets.map((asset) => ({
128
+ assetPath: asset.assetPath,
129
+ assetType: asset.assetType,
130
+ referenceType: asset.referenceType,
131
+ filePath: asset.filePath,
132
+ line: asset.line
133
+ }))
134
+ };
135
+ const cdnOutDir = path.dirname(cdnOutPath);
136
+ if (!fs.existsSync(cdnOutDir)) {
137
+ fs.mkdirSync(cdnOutDir, { recursive: true });
138
+ }
139
+ fs.writeFileSync(cdnOutPath, JSON.stringify(cdnList, null, 2), "utf-8");
140
+ logger.success("CDN URLs extracted to " + import_chalk3.default.cyan(cdnOutPath));
141
+ logger.info(
142
+ " Legacy CDN URLs: " + import_chalk3.default.red(String(result.legacyCdnUrls.length)) + " Asset References: " + import_chalk3.default.blue(String(result.assets.length))
143
+ );
144
+ }
112
145
  } catch (err) {
113
146
  spinner.fail("Scan failed");
114
147
  logger.error(err.message);
@@ -132,6 +165,8 @@ function extractCommand() {
132
165
  try {
133
166
  const { config } = await (0, import_ai_localize_config3.loadConfig)(cwd);
134
167
  spinner.succeed("Configuration loaded");
168
+ const structure = config.localeStructure ?? "nested";
169
+ logger.info("Locale structure: " + import_chalk4.default.cyan(structure));
135
170
  const ss = createSpinner("Scanning for hardcoded text...").start();
136
171
  const scanner = new import_ai_localize_scanner2.ProjectScanner(config);
137
172
  const scanResult = await scanner.scan();
@@ -141,18 +176,32 @@ function extractCommand() {
141
176
  const extractor = new import_ai_localize_locale_engine.LocaleExtractor({
142
177
  defaultLanguage: config.defaultLanguage,
143
178
  targetLanguages: config.targetLanguages,
144
- namespaceSplitting: true
179
+ namespaceSplitting: structure === "nested"
180
+ // flat mode does not need namespace splitting
145
181
  });
146
182
  const { localeFiles, keyCount, namespaces } = extractor.extract(uniqueTexts);
147
183
  logger.info("Keys generated: " + import_chalk4.default.green(String(keyCount)));
148
- logger.info("Namespaces: " + import_chalk4.default.cyan(namespaces.join(", ")));
184
+ if (structure === "nested") {
185
+ logger.info("Namespaces: " + import_chalk4.default.cyan(namespaces.join(", ")));
186
+ }
149
187
  if (!opts.dryRun) {
150
188
  const localesDir = path2.resolve(cwd, config.localesDir);
151
- const writer = new import_ai_localize_locale_engine.LocaleWriter({ localesDir, merge: opts.merge !== false });
189
+ const writer = new import_ai_localize_locale_engine.LocaleWriter({
190
+ localesDir,
191
+ merge: opts.merge !== false,
192
+ localeStructure: structure
193
+ });
152
194
  const { written, created, merged } = writer.write(localeFiles);
153
- logger.success("Wrote " + written.length + " locale files (" + created.length + " new, " + merged.length + " merged)");
195
+ logger.success(
196
+ "Wrote " + written.length + " locale files (" + created.length + " new, " + merged.length + " merged)"
197
+ );
198
+ written.forEach((f) => logger.info(" " + import_chalk4.default.gray(f)));
154
199
  } else {
155
- logger.info("Dry run - no files written");
200
+ logger.info("Dry run \u2014 no files written");
201
+ localeFiles.forEach((lf) => {
202
+ const label = structure === "flat" ? `${lf.language}.json` : `${lf.language}/${lf.namespace}.json`;
203
+ logger.info(" " + import_chalk4.default.gray(label) + " \u2014 " + Object.keys(lf.entries).length + " keys");
204
+ });
156
205
  }
157
206
  } catch (err) {
158
207
  spinner.fail("Extraction failed");
@@ -209,7 +258,7 @@ function validateCommand() {
209
258
  var import_commander5 = require("commander");
210
259
  var import_chalk6 = __toESM(require("chalk"));
211
260
  var path4 = __toESM(require("path"));
212
- var fs = __toESM(require("fs"));
261
+ var fs2 = __toESM(require("fs"));
213
262
  var import_ai_localize_config5 = require("ai-localize-config");
214
263
  var import_ai_localize_validators2 = require("ai-localize-validators");
215
264
  var import_ai_localize_shared2 = require("ai-localize-shared");
@@ -232,7 +281,7 @@ function cleanupCommand() {
232
281
  unusedKeys.slice(0, 20).forEach((k) => logger.dim("- " + k));
233
282
  if (!opts.dryRun) {
234
283
  const defaultDir = path4.join(localesDir, config.defaultLanguage);
235
- const nsFiles = fs.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
284
+ const nsFiles = fs2.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
236
285
  let removed = 0;
237
286
  for (const nsFile of nsFiles) {
238
287
  const ns = nsFile.replace(".json", "");
@@ -407,7 +456,7 @@ var import_ai_localize_scanner5 = require("ai-localize-scanner");
407
456
  var import_ai_localize_validators3 = require("ai-localize-validators");
408
457
  var import_ai_localize_reporting = require("ai-localize-reporting");
409
458
  function reportCommand() {
410
- return new import_commander9.Command("report").description("Generate JSON and HTML localization reports").option("--cwd <path>", "Working directory", process.cwd()).option("--output-dir <path>", "Output directory for reports", ".ai-localize-reports").option("--no-html", "Skip HTML report generation").action(async (opts) => {
459
+ return new import_commander9.Command("report").description("Generate an HTML localization report with full details").option("--cwd <path>", "Working directory", process.cwd()).option("--output-dir <path>", "Directory where the HTML report is saved", ".ai-localize-reports").option("--filename <name>", "Report filename (default: report-<timestamp>.html)").option("--no-open", "Do not log the file path after generation").action(async (opts) => {
411
460
  logger.header("ai-localize report");
412
461
  const spinner = createSpinner("Loading configuration...").start();
413
462
  try {
@@ -417,7 +466,7 @@ function reportCommand() {
417
466
  const ss = createSpinner("Scanning project...").start();
418
467
  const scanner = new import_ai_localize_scanner5.ProjectScanner(config);
419
468
  const scanResult = await scanner.scan();
420
- ss.succeed("Scanned " + scanResult.scannedFiles + " files");
469
+ ss.succeed("Scanned " + import_chalk10.default.cyan(String(scanResult.scannedFiles)) + " files");
421
470
  const vs = createSpinner("Validating locale files...").start();
422
471
  const validator = new import_ai_localize_validators3.LocaleValidator({
423
472
  localesDir: path8.resolve(cwd, config.localesDir),
@@ -426,15 +475,21 @@ function reportCommand() {
426
475
  targetLanguages: config.targetLanguages
427
476
  });
428
477
  const validationResult = validator.validate();
429
- vs.succeed("Validation complete");
430
- const outDir = path8.resolve(cwd, opts.outputDir);
478
+ vs.succeed(
479
+ validationResult.valid ? import_chalk10.default.green("Locale files valid") : import_chalk10.default.yellow(validationResult.errors.length + " errors, " + validationResult.warnings.length + " warnings")
480
+ );
431
481
  const rs = createSpinner("Building report...").start();
432
482
  const report = (0, import_ai_localize_reporting.buildReport)({ scanResult, validationResult });
433
- if (opts.html !== false) {
434
- (0, import_ai_localize_reporting.generateHtmlReport)(report, outDir);
435
- }
436
- rs.succeed("Report generated at " + import_chalk10.default.cyan(outDir));
483
+ const outDir = path8.resolve(cwd, opts.outputDir);
484
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
485
+ const filename = opts.filename || "report-" + timestamp + ".html";
486
+ const htmlPath = path8.join(outDir, filename);
487
+ (0, import_ai_localize_reporting.generateHtmlReport)(report, htmlPath);
488
+ rs.succeed("HTML report written to " + import_chalk10.default.cyan(htmlPath));
437
489
  (0, import_ai_localize_reporting.printCliSummary)(report);
490
+ logger.info(
491
+ "\n Open the report in your browser:\n " + import_chalk10.default.underline("file://" + htmlPath)
492
+ );
438
493
  } catch (err) {
439
494
  spinner.fail("Report generation failed");
440
495
  logger.error(String(err));
@@ -454,7 +509,7 @@ var import_ai_localize_codemods2 = require("ai-localize-codemods");
454
509
  var import_ai_localize_validators4 = require("ai-localize-validators");
455
510
  var import_ai_localize_reporting2 = require("ai-localize-reporting");
456
511
  function fullMigrateCommand() {
457
- return new import_commander10.Command("full-migrate").description("Run full localization pipeline: scan -> extract -> codemod -> validate -> report").option("--cwd <path>", "Working directory", process.cwd()).option("--dry-run", "Preview changes without modifying files").option("--no-codemods", "Skip codemod phase").option("--no-report", "Skip report generation").action(async (opts) => {
512
+ return new import_commander10.Command("full-migrate").description("Run full localization pipeline: scan -> extract -> codemod -> validate -> report").option("--cwd <path>", "Working directory", process.cwd()).option("--dry-run", "Preview changes without modifying files").option("--no-codemods", "Skip codemod phase").option("--no-report", "Skip report generation").option("--report-dir <path>", "Directory where the HTML report is saved", ".ai-localize-reports").action(async (opts) => {
458
513
  logger.header("ai-localize full-migrate");
459
514
  const cwd = opts.cwd;
460
515
  const dryRun = opts.dryRun;
@@ -462,28 +517,38 @@ function fullMigrateCommand() {
462
517
  const cs = createSpinner("Loading configuration...").start();
463
518
  const { config } = await (0, import_ai_localize_config10.loadConfig)(cwd);
464
519
  cs.succeed("Configuration loaded");
520
+ const structure = config.localeStructure ?? "nested";
465
521
  const ss = createSpinner("Scanning for hardcoded text...").start();
466
522
  const scanner = new import_ai_localize_scanner6.ProjectScanner(config);
467
523
  const scanResult = await scanner.scan();
468
- ss.succeed("Found " + import_chalk11.default.cyan(String(scanResult.detectedTexts.length)) + " texts in " + scanResult.scannedFiles + " files");
524
+ ss.succeed(
525
+ "Found " + import_chalk11.default.cyan(String(scanResult.detectedTexts.length)) + " texts in " + scanResult.scannedFiles + " files"
526
+ );
469
527
  const es = createSpinner("Extracting locale keys...").start();
470
528
  const uniqueTexts = (0, import_ai_localize_locale_engine2.deduplicateTexts)(scanResult.detectedTexts);
471
529
  const extractor = new import_ai_localize_locale_engine2.LocaleExtractor({
472
530
  defaultLanguage: config.defaultLanguage,
473
531
  targetLanguages: config.targetLanguages,
474
- namespaceSplitting: true
532
+ // Flat layout merges all keys into one file per language — no namespace splitting
533
+ namespaceSplitting: structure === "nested"
475
534
  });
476
535
  const { localeFiles, keyCount } = extractor.extract(uniqueTexts);
477
536
  es.succeed("Generated " + import_chalk11.default.green(String(keyCount)) + " locale keys");
478
537
  if (!dryRun) {
479
- const writer = new import_ai_localize_locale_engine2.LocaleWriter({ localesDir: path9.resolve(cwd, config.localesDir), merge: true });
538
+ const writer = new import_ai_localize_locale_engine2.LocaleWriter({
539
+ localesDir: path9.resolve(cwd, config.localesDir),
540
+ merge: true,
541
+ localeStructure: structure
542
+ });
480
543
  writer.write(localeFiles);
481
544
  }
482
545
  if (opts.codemods !== false) {
483
546
  const ms = createSpinner("Applying i18n codemods...").start();
484
- const runner = new import_ai_localize_codemods2.CodemodRunner(config);
547
+ const runner = new import_ai_localize_codemods2.CodemodRunner(config, cwd);
485
548
  const codemodResult = await runner.run(uniqueTexts, { dryRun });
486
- ms.succeed("Codemods: " + import_chalk11.default.green(String(codemodResult.totalReplacements)) + " replacements in " + codemodResult.changedFiles + " files");
549
+ ms.succeed(
550
+ "Codemods: " + import_chalk11.default.green(String(codemodResult.totalReplacements)) + " replacements in " + codemodResult.changedFiles + " files"
551
+ );
487
552
  }
488
553
  const vs = createSpinner("Validating locale files...").start();
489
554
  const validator = new import_ai_localize_validators4.LocaleValidator({
@@ -493,10 +558,23 @@ function fullMigrateCommand() {
493
558
  targetLanguages: config.targetLanguages
494
559
  });
495
560
  const validationResult = validator.validate();
496
- vs.succeed(validationResult.valid ? import_chalk11.default.green("Locale files valid!") : import_chalk11.default.yellow(validationResult.errors.length + " errors, " + validationResult.warnings.length + " warnings"));
561
+ vs.succeed(
562
+ validationResult.valid ? import_chalk11.default.green("Locale files valid!") : import_chalk11.default.yellow(
563
+ validationResult.errors.length + " errors, " + validationResult.warnings.length + " warnings"
564
+ )
565
+ );
497
566
  if (opts.report !== false) {
567
+ const rs = createSpinner("Generating report...").start();
498
568
  const report = (0, import_ai_localize_reporting2.buildReport)({ scanResult, validationResult });
569
+ const reportDir = path9.resolve(cwd, opts.reportDir);
570
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
571
+ const htmlPath = path9.join(reportDir, "report-" + timestamp + ".html");
572
+ (0, import_ai_localize_reporting2.generateHtmlReport)(report, htmlPath);
573
+ rs.succeed("Report saved to " + import_chalk11.default.cyan(htmlPath));
499
574
  (0, import_ai_localize_reporting2.printCliSummary)(report);
575
+ logger.info(
576
+ "\n View report:\n " + import_chalk11.default.underline("file://" + htmlPath)
577
+ );
500
578
  }
501
579
  logger.success("Full migration complete!");
502
580
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-localize-cli",
3
- "version": "1.0.4",
3
+ "version": "2.0.1",
4
4
  "description": "CLI for ai-localize-core: scan, extract, validate, migrate CDN",
5
5
  "bin": {
6
6
  "ai-localize": "./dist/cli.js"
@@ -11,15 +11,15 @@
11
11
  "chalk": "^5.3.0",
12
12
  "ora": "^8.0.1",
13
13
  "inquirer": "^9.2.12",
14
- "ai-localize-shared": "1.0.1",
15
- "ai-localize-framework-detectors": "1.0.1",
16
- "ai-localize-config": "1.0.1",
17
- "ai-localize-aws-cloudfront": "1.0.1",
18
- "ai-localize-locale-engine": "1.0.1",
19
- "ai-localize-codemods": "1.0.1",
20
- "ai-localize-validators": "1.0.1",
21
- "ai-localize-reporting": "1.0.1",
22
- "ai-localize-scanner": "1.0.1"
14
+ "ai-localize-shared": "2.0.1",
15
+ "ai-localize-framework-detectors": "2.0.1",
16
+ "ai-localize-config": "2.0.1",
17
+ "ai-localize-scanner": "2.0.1",
18
+ "ai-localize-codemods": "2.0.1",
19
+ "ai-localize-locale-engine": "2.0.1",
20
+ "ai-localize-aws-cloudfront": "2.0.1",
21
+ "ai-localize-validators": "2.0.1",
22
+ "ai-localize-reporting": "2.0.1"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/inquirer": "^9.0.7",
@@ -17,37 +17,56 @@ export function extractCommand(): Command {
17
17
  logger.header('ai-localize extract');
18
18
  const cwd = opts.cwd as string;
19
19
  const spinner = createSpinner('Loading configuration...').start();
20
- try {
20
+ try {
21
21
  const { config } = await loadConfig(cwd);
22
22
  spinner.succeed('Configuration loaded');
23
23
 
24
- const ss = createSpinner('Scanning for hardcoded text...').start();
24
+ // Log locale structure in use
25
+ const structure = config.localeStructure ?? 'nested';
26
+ logger.info('Locale structure: ' + chalk.cyan(structure));
27
+
28
+ const ss = createSpinner('Scanning for hardcoded text...').start();
25
29
  const scanner = new ProjectScanner(config);
26
30
  const scanResult = await scanner.scan();
27
- ss.succeed('Found ' + chalk.cyan(String(scanResult.detectedTexts.length)) + ' texts in ' + scanResult.scannedFiles + ' files');
31
+ ss.succeed('Found ' + chalk.cyan(String(scanResult.detectedTexts.length)) + ' texts in ' + scanResult.scannedFiles + ' files');
28
32
 
29
- const uniqueTexts = deduplicateTexts(scanResult.detectedTexts);
30
- logger.info('Unique texts: ' + chalk.cyan(String(uniqueTexts.length)));
33
+ const uniqueTexts = deduplicateTexts(scanResult.detectedTexts);
34
+ logger.info('Unique texts: ' + chalk.cyan(String(uniqueTexts.length)));
31
35
 
32
- const extractor = new LocaleExtractor({
33
- defaultLanguage: config.defaultLanguage,
34
- targetLanguages: config.targetLanguages,
35
- namespaceSplitting: true,
36
- });
36
+ const extractor = new LocaleExtractor({
37
+ defaultLanguage: config.defaultLanguage,
38
+ targetLanguages: config.targetLanguages,
39
+ namespaceSplitting: structure === 'nested', // flat mode does not need namespace splitting
40
+ });
37
41
  const { localeFiles, keyCount, namespaces } = extractor.extract(uniqueTexts);
38
42
  logger.info('Keys generated: ' + chalk.green(String(keyCount)));
39
- logger.info('Namespaces: ' + chalk.cyan(namespaces.join(', ')));
43
+ if (structure === 'nested') {
44
+ logger.info('Namespaces: ' + chalk.cyan(namespaces.join(', ')));
45
+ }
40
46
 
41
- if (!opts.dryRun) {
47
+ if (!opts.dryRun) {
42
48
  const localesDir = path.resolve(cwd, config.localesDir);
43
- const writer = new LocaleWriter({ localesDir, merge: opts.merge !== false });
44
- const { written, created, merged } = writer.write(localeFiles);
45
- logger.success('Wrote ' + written.length + ' locale files (' + created.length + ' new, ' + merged.length + ' merged)');
49
+ const writer = new LocaleWriter({
50
+ localesDir,
51
+ merge: opts.merge !== false,
52
+ localeStructure: structure,
53
+ });
54
+ const { written, created, merged } = writer.write(localeFiles);
55
+ logger.success(
56
+ 'Wrote ' + written.length + ' locale files (' + created.length + ' new, ' + merged.length + ' merged)'
57
+ );
58
+ written.forEach((f) => logger.info(' ' + chalk.gray(f)));
46
59
  } else {
47
- logger.info('Dry run - no files written');
60
+ logger.info('Dry run no files written');
61
+ localeFiles.forEach((lf) => {
62
+ const label = structure === 'flat'
63
+ ? `${lf.language}.json`
64
+ : `${lf.language}/${lf.namespace}.json`;
65
+ logger.info(' ' + chalk.gray(label) + ' — ' + Object.keys(lf.entries).length + ' keys');
66
+ });
48
67
  }
49
68
  } catch (err) {
50
- spinner.fail('Extraction failed');
69
+ spinner.fail('Extraction failed');
51
70
  logger.error(String(err));
52
71
  process.exit(1);
53
72
  }
@@ -17,8 +17,9 @@ export function fullMigrateCommand(): Command {
17
17
  .option('--dry-run', 'Preview changes without modifying files')
18
18
  .option('--no-codemods', 'Skip codemod phase')
19
19
  .option('--no-report', 'Skip report generation')
20
- .action(async (opts) => {
21
- logger.header('ai-localize full-migrate');
20
+ .option('--report-dir <path>', 'Directory where the HTML report is saved', '.ai-localize-reports')
21
+ .action(async (opts) => {
22
+ logger.header('ai-localize full-migrate');
22
23
  const cwd = opts.cwd as string;
23
24
  const dryRun = opts.dryRun as boolean;
24
25
  try {
@@ -26,49 +27,84 @@ export function fullMigrateCommand(): Command {
26
27
  const { config } = await loadConfig(cwd);
27
28
  cs.succeed('Configuration loaded');
28
29
 
30
+ const structure = config.localeStructure ?? 'nested';
31
+
29
32
  const ss = createSpinner('Scanning for hardcoded text...').start();
30
33
  const scanner = new ProjectScanner(config);
31
34
  const scanResult = await scanner.scan();
32
- ss.succeed('Found ' + chalk.cyan(String(scanResult.detectedTexts.length)) + ' texts in ' + scanResult.scannedFiles + ' files');
35
+ ss.succeed(
36
+ 'Found ' + chalk.cyan(String(scanResult.detectedTexts.length)) +
37
+ ' texts in ' + scanResult.scannedFiles + ' files'
38
+ );
33
39
 
34
40
  const es = createSpinner('Extracting locale keys...').start();
35
- const uniqueTexts = deduplicateTexts(scanResult.detectedTexts);
41
+ const uniqueTexts = deduplicateTexts(scanResult.detectedTexts);
36
42
  const extractor = new LocaleExtractor({
37
43
  defaultLanguage: config.defaultLanguage,
38
44
  targetLanguages: config.targetLanguages,
39
- namespaceSplitting: true,
40
- });
45
+ // Flat layout merges all keys into one file per language — no namespace splitting
46
+ namespaceSplitting: structure === 'nested',
47
+ });
41
48
  const { localeFiles, keyCount } = extractor.extract(uniqueTexts);
42
49
  es.succeed('Generated ' + chalk.green(String(keyCount)) + ' locale keys');
43
50
 
44
- if (!dryRun) {
45
- const writer = new LocaleWriter({ localesDir: path.resolve(cwd, config.localesDir), merge: true });
51
+ if (!dryRun) {
52
+ const writer = new LocaleWriter({
53
+ localesDir: path.resolve(cwd, config.localesDir),
54
+ merge: true,
55
+ localeStructure: structure,
56
+ });
46
57
  writer.write(localeFiles);
47
58
  }
48
59
 
49
60
  if (opts.codemods !== false) {
50
- const ms = createSpinner('Applying i18n codemods...').start();
51
- const runner = new CodemodRunner(config);
52
- const codemodResult = await runner.run(uniqueTexts, { dryRun });
53
- ms.succeed('Codemods: ' + chalk.green(String(codemodResult.totalReplacements)) + ' replacements in ' + codemodResult.changedFiles + ' files');
54
- }
61
+ const ms = createSpinner('Applying i18n codemods...').start();
62
+ // Pass cwd so CodemodRunner can compute per-file relative import paths
63
+ // when codemods.importPackage is a local project path (e.g. "src/Locales/translate").
64
+ const runner = new CodemodRunner(config, cwd);
65
+ const codemodResult = await runner.run(uniqueTexts, { dryRun });
66
+ ms.succeed(
67
+ 'Codemods: ' + chalk.green(String(codemodResult.totalReplacements)) +
68
+ ' replacements in ' + codemodResult.changedFiles + ' files'
69
+ );
70
+ }
55
71
 
56
- const vs = createSpinner('Validating locale files...').start();
72
+ const vs = createSpinner('Validating locale files...').start();
57
73
  const validator = new LocaleValidator({
58
- localesDir: path.resolve(cwd, config.localesDir),
74
+ localesDir: path.resolve(cwd, config.localesDir),
59
75
  sourceDir: path.resolve(cwd, config.sourceDir),
60
- defaultLanguage: config.defaultLanguage,
61
- targetLanguages: config.targetLanguages,
76
+ defaultLanguage: config.defaultLanguage,
77
+ targetLanguages: config.targetLanguages,
62
78
  });
63
79
  const validationResult = validator.validate();
64
- vs.succeed(validationResult.valid ? chalk.green('Locale files valid!') : chalk.yellow(validationResult.errors.length + ' errors, ' + validationResult.warnings.length + ' warnings'));
80
+ vs.succeed(
81
+ validationResult.valid
82
+ ? chalk.green('Locale files valid!')
83
+ : chalk.yellow(
84
+ validationResult.errors.length + ' errors, ' +
85
+ validationResult.warnings.length + ' warnings'
86
+ )
87
+ );
65
88
 
66
- if (opts.report !== false) {
67
- const report = buildReport({ scanResult, validationResult });
68
- printCliSummary(report);
69
- }
89
+ if (opts.report !== false) {
90
+ const rs = createSpinner('Generating report...').start();
91
+ const report = buildReport({ scanResult, validationResult });
92
+
93
+ // Always write an HTML report file
94
+ const reportDir = path.resolve(cwd, opts.reportDir as string);
95
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
96
+ const htmlPath = path.join(reportDir, 'report-' + timestamp + '.html');
97
+ generateHtmlReport(report, htmlPath);
98
+ rs.succeed('Report saved to ' + chalk.cyan(htmlPath));
99
+
100
+ printCliSummary(report);
101
+ logger.info(
102
+ '\n View report:\n' +
103
+ ' ' + chalk.underline('file://' + htmlPath)
104
+ );
105
+ }
70
106
 
71
- logger.success('Full migration complete!');
107
+ logger.success('Full migration complete!');
72
108
  } catch (err) {
73
109
  logger.error('Migration failed: ' + String(err));
74
110
  process.exit(1);
@@ -10,10 +10,11 @@ import { buildReport, generateHtmlReport, printCliSummary } from 'ai-localize-re
10
10
 
11
11
  export function reportCommand(): Command {
12
12
  return new Command('report')
13
- .description('Generate JSON and HTML localization reports')
14
- .option('--cwd <path>', 'Working directory', process.cwd())
15
- .option('--output-dir <path>', 'Output directory for reports', '.ai-localize-reports')
16
- .option('--no-html', 'Skip HTML report generation')
13
+ .description('Generate an HTML localization report with full details')
14
+ .option('--cwd <path>', 'Working directory', process.cwd())
15
+ .option('--output-dir <path>', 'Directory where the HTML report is saved', '.ai-localize-reports')
16
+ .option('--filename <name>', 'Report filename (default: report-<timestamp>.html)')
17
+ .option('--no-open', 'Do not log the file path after generation')
17
18
  .action(async (opts) => {
18
19
  logger.header('ai-localize report');
19
20
  const spinner = createSpinner('Loading configuration...').start();
@@ -24,10 +25,10 @@ export function reportCommand(): Command {
24
25
 
25
26
  const ss = createSpinner('Scanning project...').start();
26
27
  const scanner = new ProjectScanner(config);
27
- const scanResult = await scanner.scan();
28
- ss.succeed('Scanned ' + scanResult.scannedFiles + ' files');
28
+ const scanResult = await scanner.scan();
29
+ ss.succeed('Scanned ' + chalk.cyan(String(scanResult.scannedFiles)) + ' files');
29
30
 
30
- const vs = createSpinner('Validating locale files...').start();
31
+ const vs = createSpinner('Validating locale files...').start();
31
32
  const validator = new LocaleValidator({
32
33
  localesDir: path.resolve(cwd, config.localesDir),
33
34
  sourceDir: path.resolve(cwd, config.sourceDir),
@@ -35,22 +36,35 @@ export function reportCommand(): Command {
35
36
  targetLanguages: config.targetLanguages,
36
37
  });
37
38
  const validationResult = validator.validate();
38
- vs.succeed('Validation complete');
39
+ vs.succeed(
40
+ validationResult.valid
41
+ ? chalk.green('Locale files valid')
42
+ : chalk.yellow(validationResult.errors.length + ' errors, ' + validationResult.warnings.length + ' warnings')
43
+ );
39
44
 
40
- const outDir = path.resolve(cwd, opts.outputDir as string);
41
45
  const rs = createSpinner('Building report...').start();
42
- const report = buildReport({ scanResult, validationResult });
46
+ const report = buildReport({ scanResult, validationResult });
47
+
48
+ // Resolve output path — always a .html file, never a bare directory
49
+ const outDir = path.resolve(cwd, opts.outputDir as string);
50
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
51
+ const filename = (opts.filename as string | undefined) || ('report-' + timestamp + '.html');
52
+ const htmlPath = path.join(outDir, filename);
43
53
 
44
- if (opts.html !== false) {
45
- generateHtmlReport(report, outDir);
46
- }
47
-
48
- rs.succeed('Report generated at ' + chalk.cyan(outDir));
54
+ generateHtmlReport(report, htmlPath);
55
+ rs.succeed('HTML report written to ' + chalk.cyan(htmlPath));
56
+
57
+ // Print CLI summary to terminal as well
49
58
  printCliSummary(report);
50
- } catch (err) {
59
+
60
+ logger.info(
61
+ '\n Open the report in your browser:\n' +
62
+ ' ' + chalk.underline('file://' + htmlPath)
63
+ );
64
+ } catch (err) {
51
65
  spinner.fail('Report generation failed');
52
- logger.error(String(err));
53
- process.exit(1);
66
+ logger.error(String(err));
67
+ process.exit(1);
54
68
  }
55
69
  });
56
70
  }
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import * as path from 'path';
4
+ import * as fs from 'fs';
4
5
  import { logger } from '../utils/logger.js';
5
6
  import { createSpinner } from '../utils/spinner.js';
6
7
  import { loadConfig } from 'ai-localize-config';
@@ -13,40 +14,101 @@ export function scanCommand(): Command {
13
14
  .option('--incremental', 'Scan only changed files based on git diff')
14
15
  .option('--staged', 'Scan only git staged files')
15
16
  .option('--cwd <path>', 'Working directory', process.cwd())
16
- .option('--output <path>', 'Output JSON file path')
17
+ .option('--output <path>', 'Output JSON file path for full scan results')
18
+ .option('--extract-cdns <path>', 'Extract all discovered CDN/legacy URLs to a JSON file (e.g. cdn-urls.json)')
17
19
  .action(async (opts) => {
18
20
  logger.header('ai-localize scan');
19
- const spinner = createSpinner('Loading configuration...').start();
21
+ const spinner = createSpinner('Loading configuration...').start();
20
22
  try {
21
23
  const cwd = opts.cwd as string;
22
24
  const { config } = await loadConfig(cwd);
23
25
  spinner.succeed('Configuration loaded');
24
26
 
25
- let files: string[] | undefined;
27
+ let files: string[] | undefined;
26
28
  if (opts.staged || opts.incremental) {
27
- const git = new GitScanner(cwd);
29
+ const git = new GitScanner(cwd);
28
30
  files = opts.staged ? git.getStagedFiles() : git.getChangedFiles();
29
- logger.info('Changed files: ' + chalk.cyan(String(files.length)));
30
- }
31
+ logger.info('Changed files: ' + chalk.cyan(String(files.length)));
32
+ }
31
33
 
32
34
  const scanSpinner = createSpinner('Scanning files...').start();
33
35
  const scanner = new ProjectScanner(config);
34
36
  const result = await scanner.scan({ files });
35
- scanSpinner.succeed('Scanned ' + chalk.cyan(String(result.scannedFiles)) + ' files in ' + chalk.cyan(result.duration + 'ms'));
37
+ scanSpinner.succeed('Scanned ' + chalk.cyan(String(result.scannedFiles)) + ' files in ' + chalk.cyan(result.duration + 'ms'));
36
38
 
37
39
  logger.info('Hardcoded texts: ' + chalk.yellow(String(result.detectedTexts.length)));
38
- logger.info('Asset references: ' + chalk.blue(String(result.assets.length)));
39
- logger.info('Legacy CDN URLs: ' + chalk.red(String(result.legacyCdnUrls.length)));
40
+ logger.info('Asset references: ' + chalk.blue(String(result.assets.length)));
41
+ logger.info('Legacy CDN URLs: ' + chalk.red(String(result.legacyCdnUrls.length)));
40
42
 
41
43
  if (opts.output) {
42
- const outPath = path.resolve(cwd, opts.output as string);
43
- writeJson(outPath, result);
44
- logger.success('Results saved to ' + chalk.cyan(outPath));
44
+ const outPath = path.resolve(cwd, opts.output as string);
45
+ writeJson(outPath, result);
46
+ logger.success('Results saved to ' + chalk.cyan(outPath));
47
+ }
48
+
49
+ // Extract CDN URLs to a dedicated file
50
+ if (opts.extractCdns) {
51
+ const cdnOutPath = path.resolve(cwd, opts.extractCdns as string);
52
+
53
+ // Build a structured CDN list similar to locale files
54
+ const cdnList: {
55
+ summary: {
56
+ totalLegacyCdnUrls: number;
57
+ totalAssetReferences: number;
58
+ scannedAt: string;
59
+ };
60
+ legacyCdnUrls: Array<{
61
+ url: string;
62
+ assetPath: string;
63
+ filePath: string;
64
+ line: number;
65
+ }>;
66
+ assetReferences: Array<{
67
+ assetPath: string;
68
+ assetType: string;
69
+ referenceType: string;
70
+ filePath: string;
71
+ line: number;
72
+ }>;
73
+ } = {
74
+ summary: {
75
+ totalLegacyCdnUrls: result.legacyCdnUrls.length,
76
+ totalAssetReferences: result.assets.length,
77
+ scannedAt: result.timestamp,
78
+ },
79
+ legacyCdnUrls: result.legacyCdnUrls.map((cdn) => ({
80
+ url: cdn.url,
81
+ assetPath: cdn.assetPath,
82
+ filePath: cdn.filePath,
83
+ line: cdn.line,
84
+ })),
85
+ assetReferences: result.assets.map((asset) => ({
86
+ assetPath: asset.assetPath,
87
+ assetType: asset.assetType,
88
+ referenceType: asset.referenceType,
89
+ filePath: asset.filePath,
90
+ line: asset.line,
91
+ })),
92
+ };
93
+
94
+ // Ensure output directory exists
95
+ const cdnOutDir = path.dirname(cdnOutPath);
96
+ if (!fs.existsSync(cdnOutDir)) {
97
+ fs.mkdirSync(cdnOutDir, { recursive: true });
98
+ }
99
+ fs.writeFileSync(cdnOutPath, JSON.stringify(cdnList, null, 2), 'utf-8');
100
+ logger.success('CDN URLs extracted to ' + chalk.cyan(cdnOutPath));
101
+ logger.info(
102
+ ' Legacy CDN URLs: ' +
103
+ chalk.red(String(result.legacyCdnUrls.length)) +
104
+ ' Asset References: ' +
105
+ chalk.blue(String(result.assets.length))
106
+ );
45
107
  }
46
108
  } catch (err: any) {
47
- spinner.fail('Scan failed');
48
- logger.error(err.message);
49
- process.exit(1);
109
+ spinner.fail('Scan failed');
110
+ logger.error(err.message);
111
+ process.exit(1);
50
112
  }
51
113
  });
52
114
  }