ai-localize-cli 2.0.0 → 2.0.3

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 CHANGED
@@ -1,5 +1,64 @@
1
1
  # ai-localize-cli
2
2
 
3
+ ## 2.0.3
4
+
5
+ ### Minor Changes
6
+
7
+ - **`keyStyle` config option** — `extract` and `full-migrate` commands now respect `keyStyle: "screaming_snake"` in `ai-localize.config.json`; all scanned hardcoded strings are extracted with UPPER_SNAKE_CASE keys matching the text value (e.g. `"Save Changes"` -> key `SAVE_CHANGES`, value `"Save Changes"`) instead of the default hierarchical path-based key style
8
+ - **`staticKeys` config option** — `extract` and `full-migrate` commands now inject `staticKeys` entries from config into every generated locale file regardless of scanning results; useful for enum labels, status codes, and other constant strings not present as literals in source files
9
+
10
+ ### Patch Changes
11
+
12
+ - Updated dependencies
13
+ - ai-localize-scanner@2.0.3
14
+ - ai-localize-config@2.0.3
15
+ - ai-localize-shared@2.0.3
16
+ - ai-localize-aws-cloudfront@2.0.3
17
+ - ai-localize-codemods@2.0.3
18
+ - ai-localize-framework-detectors@2.0.3
19
+ - ai-localize-locale-engine@2.0.3
20
+ - ai-localize-reporting@2.0.3
21
+ - ai-localize-validators@2.0.3
22
+
23
+ ## 2.0.2
24
+
25
+ ### Minor Changes
26
+
27
+ - **Flat locale layout in `extract` and `full-migrate` commands** — `--locale-structure flat` flag (or `localeStructure: "flat"` in config) now writes one `<lang>.json` per language with all namespace keys merged
28
+ - **Rich CLI report output** — `report` command now renders a structured terminal dashboard with coverage bars, top-file charts, missing-translation rankings, AST-context distribution, AI Insights, and actionable recommended next steps
29
+ - **HTML analytics dashboard** — `report --format html` now generates a fully self-contained interactive dashboard (light/dark theme, sortable tables, SVG donut chart, CSV/JSON export, print/PDF)
30
+
31
+ ### Patch Changes
32
+
33
+ - **Bug fix — locale key prefix** — resolved an issue where locale keys included ancestor path segments when `sourceRoot` was an absolute path
34
+ - **Bug fix — codemods config wired through** — `codemods` block in `ai-localize.config.json` is now applied by all codemod commands instead of being silently ignored
35
+ - Updated dependencies
36
+ - ai-localize-scanner@2.0.2
37
+ - ai-localize-config@2.0.2
38
+ - ai-localize-shared@2.0.2
39
+ - ai-localize-aws-cloudfront@2.0.2
40
+ - ai-localize-codemods@2.0.2
41
+ - ai-localize-framework-detectors@2.0.2
42
+ - ai-localize-locale-engine@2.0.2
43
+ - ai-localize-reporting@2.0.2
44
+ - ai-localize-validators@2.0.2
45
+
46
+ ## 2.0.1
47
+
48
+ ### Patch Changes
49
+
50
+ - 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
51
+ - Updated dependencies
52
+ - ai-localize-scanner@2.0.1
53
+ - ai-localize-config@2.0.1
54
+ - ai-localize-shared@2.0.1
55
+ - ai-localize-aws-cloudfront@2.0.1
56
+ - ai-localize-codemods@2.0.1
57
+ - ai-localize-framework-detectors@2.0.1
58
+ - ai-localize-locale-engine@2.0.1
59
+ - ai-localize-reporting@2.0.1
60
+ - ai-localize-validators@2.0.1
61
+
3
62
  ## 2.0.0
4
63
 
5
64
  ### Major Changes
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,15 @@ 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));
170
+ const staticKeys = config.staticKeys ?? {};
171
+ const staticKeyCount = Object.keys(staticKeys).length;
172
+ if (staticKeyCount > 0) {
173
+ logger.info(
174
+ "Static keys: " + import_chalk4.default.cyan(String(staticKeyCount)) + " (" + Object.keys(staticKeys).join(", ") + ")"
175
+ );
176
+ }
135
177
  const ss = createSpinner("Scanning for hardcoded text...").start();
136
178
  const scanner = new import_ai_localize_scanner2.ProjectScanner(config);
137
179
  const scanResult = await scanner.scan();
@@ -141,18 +183,35 @@ function extractCommand() {
141
183
  const extractor = new import_ai_localize_locale_engine.LocaleExtractor({
142
184
  defaultLanguage: config.defaultLanguage,
143
185
  targetLanguages: config.targetLanguages,
144
- namespaceSplitting: true
186
+ namespaceSplitting: structure === "nested",
187
+ // flat mode does not need namespace splitting
188
+ staticKeys
145
189
  });
146
190
  const { localeFiles, keyCount, namespaces } = extractor.extract(uniqueTexts);
147
- logger.info("Keys generated: " + import_chalk4.default.green(String(keyCount)));
148
- logger.info("Namespaces: " + import_chalk4.default.cyan(namespaces.join(", ")));
191
+ logger.info(
192
+ "Keys generated: " + import_chalk4.default.green(String(keyCount)) + (staticKeyCount > 0 ? import_chalk4.default.dim(" (+ " + staticKeyCount + " static)") : "")
193
+ );
194
+ if (structure === "nested") {
195
+ logger.info("Namespaces: " + import_chalk4.default.cyan(namespaces.join(", ")));
196
+ }
149
197
  if (!opts.dryRun) {
150
198
  const localesDir = path2.resolve(cwd, config.localesDir);
151
- const writer = new import_ai_localize_locale_engine.LocaleWriter({ localesDir, merge: opts.merge !== false });
199
+ const writer = new import_ai_localize_locale_engine.LocaleWriter({
200
+ localesDir,
201
+ merge: opts.merge !== false,
202
+ localeStructure: structure
203
+ });
152
204
  const { written, created, merged } = writer.write(localeFiles);
153
- logger.success("Wrote " + written.length + " locale files (" + created.length + " new, " + merged.length + " merged)");
205
+ logger.success(
206
+ "Wrote " + written.length + " locale files (" + created.length + " new, " + merged.length + " merged)"
207
+ );
208
+ written.forEach((f) => logger.info(" " + import_chalk4.default.gray(f)));
154
209
  } else {
155
- logger.info("Dry run - no files written");
210
+ logger.info("Dry run \u2014 no files written");
211
+ localeFiles.forEach((lf) => {
212
+ const label = structure === "flat" ? `${lf.language}.json` : `${lf.language}/${lf.namespace}.json`;
213
+ logger.info(" " + import_chalk4.default.gray(label) + " \u2014 " + Object.keys(lf.entries).length + " keys");
214
+ });
156
215
  }
157
216
  } catch (err) {
158
217
  spinner.fail("Extraction failed");
@@ -209,7 +268,7 @@ function validateCommand() {
209
268
  var import_commander5 = require("commander");
210
269
  var import_chalk6 = __toESM(require("chalk"));
211
270
  var path4 = __toESM(require("path"));
212
- var fs = __toESM(require("fs"));
271
+ var fs2 = __toESM(require("fs"));
213
272
  var import_ai_localize_config5 = require("ai-localize-config");
214
273
  var import_ai_localize_validators2 = require("ai-localize-validators");
215
274
  var import_ai_localize_shared2 = require("ai-localize-shared");
@@ -232,7 +291,7 @@ function cleanupCommand() {
232
291
  unusedKeys.slice(0, 20).forEach((k) => logger.dim("- " + k));
233
292
  if (!opts.dryRun) {
234
293
  const defaultDir = path4.join(localesDir, config.defaultLanguage);
235
- const nsFiles = fs.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
294
+ const nsFiles = fs2.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
236
295
  let removed = 0;
237
296
  for (const nsFile of nsFiles) {
238
297
  const ns = nsFile.replace(".json", "");
@@ -407,7 +466,7 @@ var import_ai_localize_scanner5 = require("ai-localize-scanner");
407
466
  var import_ai_localize_validators3 = require("ai-localize-validators");
408
467
  var import_ai_localize_reporting = require("ai-localize-reporting");
409
468
  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) => {
469
+ 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
470
  logger.header("ai-localize report");
412
471
  const spinner = createSpinner("Loading configuration...").start();
413
472
  try {
@@ -417,7 +476,7 @@ function reportCommand() {
417
476
  const ss = createSpinner("Scanning project...").start();
418
477
  const scanner = new import_ai_localize_scanner5.ProjectScanner(config);
419
478
  const scanResult = await scanner.scan();
420
- ss.succeed("Scanned " + scanResult.scannedFiles + " files");
479
+ ss.succeed("Scanned " + import_chalk10.default.cyan(String(scanResult.scannedFiles)) + " files");
421
480
  const vs = createSpinner("Validating locale files...").start();
422
481
  const validator = new import_ai_localize_validators3.LocaleValidator({
423
482
  localesDir: path8.resolve(cwd, config.localesDir),
@@ -426,15 +485,21 @@ function reportCommand() {
426
485
  targetLanguages: config.targetLanguages
427
486
  });
428
487
  const validationResult = validator.validate();
429
- vs.succeed("Validation complete");
430
- const outDir = path8.resolve(cwd, opts.outputDir);
488
+ vs.succeed(
489
+ validationResult.valid ? import_chalk10.default.green("Locale files valid") : import_chalk10.default.yellow(validationResult.errors.length + " errors, " + validationResult.warnings.length + " warnings")
490
+ );
431
491
  const rs = createSpinner("Building report...").start();
432
492
  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));
493
+ const outDir = path8.resolve(cwd, opts.outputDir);
494
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
495
+ const filename = opts.filename || "report-" + timestamp + ".html";
496
+ const htmlPath = path8.join(outDir, filename);
497
+ (0, import_ai_localize_reporting.generateHtmlReport)(report, htmlPath);
498
+ rs.succeed("HTML report written to " + import_chalk10.default.cyan(htmlPath));
437
499
  (0, import_ai_localize_reporting.printCliSummary)(report);
500
+ logger.info(
501
+ "\n Open the report in your browser:\n " + import_chalk10.default.underline("file://" + htmlPath)
502
+ );
438
503
  } catch (err) {
439
504
  spinner.fail("Report generation failed");
440
505
  logger.error(String(err));
@@ -454,7 +519,7 @@ var import_ai_localize_codemods2 = require("ai-localize-codemods");
454
519
  var import_ai_localize_validators4 = require("ai-localize-validators");
455
520
  var import_ai_localize_reporting2 = require("ai-localize-reporting");
456
521
  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) => {
522
+ 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
523
  logger.header("ai-localize full-migrate");
459
524
  const cwd = opts.cwd;
460
525
  const dryRun = opts.dryRun;
@@ -462,28 +527,38 @@ function fullMigrateCommand() {
462
527
  const cs = createSpinner("Loading configuration...").start();
463
528
  const { config } = await (0, import_ai_localize_config10.loadConfig)(cwd);
464
529
  cs.succeed("Configuration loaded");
530
+ const structure = config.localeStructure ?? "nested";
465
531
  const ss = createSpinner("Scanning for hardcoded text...").start();
466
532
  const scanner = new import_ai_localize_scanner6.ProjectScanner(config);
467
533
  const scanResult = await scanner.scan();
468
- ss.succeed("Found " + import_chalk11.default.cyan(String(scanResult.detectedTexts.length)) + " texts in " + scanResult.scannedFiles + " files");
534
+ ss.succeed(
535
+ "Found " + import_chalk11.default.cyan(String(scanResult.detectedTexts.length)) + " texts in " + scanResult.scannedFiles + " files"
536
+ );
469
537
  const es = createSpinner("Extracting locale keys...").start();
470
538
  const uniqueTexts = (0, import_ai_localize_locale_engine2.deduplicateTexts)(scanResult.detectedTexts);
471
539
  const extractor = new import_ai_localize_locale_engine2.LocaleExtractor({
472
540
  defaultLanguage: config.defaultLanguage,
473
541
  targetLanguages: config.targetLanguages,
474
- namespaceSplitting: true
542
+ // Flat layout merges all keys into one file per language — no namespace splitting
543
+ namespaceSplitting: structure === "nested"
475
544
  });
476
545
  const { localeFiles, keyCount } = extractor.extract(uniqueTexts);
477
546
  es.succeed("Generated " + import_chalk11.default.green(String(keyCount)) + " locale keys");
478
547
  if (!dryRun) {
479
- const writer = new import_ai_localize_locale_engine2.LocaleWriter({ localesDir: path9.resolve(cwd, config.localesDir), merge: true });
548
+ const writer = new import_ai_localize_locale_engine2.LocaleWriter({
549
+ localesDir: path9.resolve(cwd, config.localesDir),
550
+ merge: true,
551
+ localeStructure: structure
552
+ });
480
553
  writer.write(localeFiles);
481
554
  }
482
555
  if (opts.codemods !== false) {
483
556
  const ms = createSpinner("Applying i18n codemods...").start();
484
- const runner = new import_ai_localize_codemods2.CodemodRunner(config);
557
+ const runner = new import_ai_localize_codemods2.CodemodRunner(config, cwd);
485
558
  const codemodResult = await runner.run(uniqueTexts, { dryRun });
486
- ms.succeed("Codemods: " + import_chalk11.default.green(String(codemodResult.totalReplacements)) + " replacements in " + codemodResult.changedFiles + " files");
559
+ ms.succeed(
560
+ "Codemods: " + import_chalk11.default.green(String(codemodResult.totalReplacements)) + " replacements in " + codemodResult.changedFiles + " files"
561
+ );
487
562
  }
488
563
  const vs = createSpinner("Validating locale files...").start();
489
564
  const validator = new import_ai_localize_validators4.LocaleValidator({
@@ -493,10 +568,23 @@ function fullMigrateCommand() {
493
568
  targetLanguages: config.targetLanguages
494
569
  });
495
570
  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"));
571
+ vs.succeed(
572
+ validationResult.valid ? import_chalk11.default.green("Locale files valid!") : import_chalk11.default.yellow(
573
+ validationResult.errors.length + " errors, " + validationResult.warnings.length + " warnings"
574
+ )
575
+ );
497
576
  if (opts.report !== false) {
577
+ const rs = createSpinner("Generating report...").start();
498
578
  const report = (0, import_ai_localize_reporting2.buildReport)({ scanResult, validationResult });
579
+ const reportDir = path9.resolve(cwd, opts.reportDir);
580
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
581
+ const htmlPath = path9.join(reportDir, "report-" + timestamp + ".html");
582
+ (0, import_ai_localize_reporting2.generateHtmlReport)(report, htmlPath);
583
+ rs.succeed("Report saved to " + import_chalk11.default.cyan(htmlPath));
499
584
  (0, import_ai_localize_reporting2.printCliSummary)(report);
585
+ logger.info(
586
+ "\n View report:\n " + import_chalk11.default.underline("file://" + htmlPath)
587
+ );
500
588
  }
501
589
  logger.success("Full migration complete!");
502
590
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-localize-cli",
3
- "version": "2.0.0",
3
+ "version": "2.0.3",
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-scanner": "2.0.0",
15
- "ai-localize-config": "2.0.0",
16
- "ai-localize-framework-detectors": "2.0.0",
17
- "ai-localize-shared": "2.0.0",
18
- "ai-localize-locale-engine": "2.0.0",
19
- "ai-localize-aws-cloudfront": "2.0.0",
20
- "ai-localize-validators": "2.0.0",
21
- "ai-localize-codemods": "2.0.0",
22
- "ai-localize-reporting": "2.0.0"
14
+ "ai-localize-framework-detectors": "2.0.3",
15
+ "ai-localize-shared": "2.0.3",
16
+ "ai-localize-config": "2.0.3",
17
+ "ai-localize-scanner": "2.0.3",
18
+ "ai-localize-aws-cloudfront": "2.0.3",
19
+ "ai-localize-validators": "2.0.3",
20
+ "ai-localize-locale-engine": "2.0.3",
21
+ "ai-localize-reporting": "2.0.3",
22
+ "ai-localize-codemods": "2.0.3"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/inquirer": "^9.0.7",
@@ -21,30 +21,69 @@ export function extractCommand(): Command {
21
21
  const { config } = await loadConfig(cwd);
22
22
  spinner.succeed('Configuration loaded');
23
23
 
24
+ // Log locale structure in use
25
+ const structure = config.localeStructure ?? 'nested';
26
+ logger.info('Locale structure: ' + chalk.cyan(structure));
27
+
28
+ // Log static keys if configured
29
+ const staticKeys = config.staticKeys ?? {};
30
+ const staticKeyCount = Object.keys(staticKeys).length;
31
+ if (staticKeyCount > 0) {
32
+ logger.info(
33
+ 'Static keys: ' +
34
+ chalk.cyan(String(staticKeyCount)) +
35
+ ' (' +
36
+ Object.keys(staticKeys).join(', ') +
37
+ ')'
38
+ );
39
+ }
40
+
24
41
  const ss = createSpinner('Scanning for hardcoded text...').start();
25
- const scanner = new ProjectScanner(config);
42
+ const scanner = new ProjectScanner(config);
26
43
  const scanResult = await scanner.scan();
27
- ss.succeed('Found ' + chalk.cyan(String(scanResult.detectedTexts.length)) + ' texts in ' + scanResult.scannedFiles + ' files');
44
+ ss.succeed('Found ' + chalk.cyan(String(scanResult.detectedTexts.length)) + ' texts in ' + scanResult.scannedFiles + ' files');
28
45
 
29
- const uniqueTexts = deduplicateTexts(scanResult.detectedTexts);
30
- logger.info('Unique texts: ' + chalk.cyan(String(uniqueTexts.length)));
46
+ const uniqueTexts = deduplicateTexts(scanResult.detectedTexts);
47
+ logger.info('Unique texts: ' + chalk.cyan(String(uniqueTexts.length)));
31
48
 
32
49
  const extractor = new LocaleExtractor({
33
- defaultLanguage: config.defaultLanguage,
50
+ defaultLanguage: config.defaultLanguage,
34
51
  targetLanguages: config.targetLanguages,
35
- namespaceSplitting: true,
52
+ namespaceSplitting: structure === 'nested', // flat mode does not need namespace splitting
53
+ staticKeys,
36
54
  });
37
55
  const { localeFiles, keyCount, namespaces } = extractor.extract(uniqueTexts);
38
- logger.info('Keys generated: ' + chalk.green(String(keyCount)));
39
- logger.info('Namespaces: ' + chalk.cyan(namespaces.join(', ')));
56
+ logger.info(
57
+ 'Keys generated: ' +
58
+ chalk.green(String(keyCount)) +
59
+ (staticKeyCount > 0
60
+ ? chalk.dim(' (+ ' + staticKeyCount + ' static)')
61
+ : '')
62
+ );
63
+ if (structure === 'nested') {
64
+ logger.info('Namespaces: ' + chalk.cyan(namespaces.join(', ')));
65
+ }
40
66
 
41
67
  if (!opts.dryRun) {
42
68
  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)');
69
+ const writer = new LocaleWriter({
70
+ localesDir,
71
+ merge: opts.merge !== false,
72
+ localeStructure: structure,
73
+ });
74
+ const { written, created, merged } = writer.write(localeFiles);
75
+ logger.success(
76
+ 'Wrote ' + written.length + ' locale files (' + created.length + ' new, ' + merged.length + ' merged)'
77
+ );
78
+ written.forEach((f) => logger.info(' ' + chalk.gray(f)));
46
79
  } else {
47
- logger.info('Dry run - no files written');
80
+ logger.info('Dry run no files written');
81
+ localeFiles.forEach((lf) => {
82
+ const label = structure === 'flat'
83
+ ? `${lf.language}.json`
84
+ : `${lf.language}/${lf.namespace}.json`;
85
+ logger.info(' ' + chalk.gray(label) + ' — ' + Object.keys(lf.entries).length + ' keys');
86
+ });
48
87
  }
49
88
  } catch (err) {
50
89
  spinner.fail('Extraction failed');
@@ -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
  }