@syedamirali/i18n-toolkit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +15 -13
  2. package/dist/cli.js +109 -74
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -31,7 +31,7 @@ Requires **Node ≥ 20**. For `translate` you also need a running Ollama instanc
31
31
  i18n-toolkit extract --src /path/to/your/app/src
32
32
  ```
33
33
 
34
- Output: `output/{timestamp}_translation_extractor.json` — sorted, deterministic.
34
+ Output: `i18n-toolkit/output/{timestamp}_translation_extractor.json` — sorted, deterministic.
35
35
 
36
36
  **What it matches** (all AST-based, no regex):
37
37
 
@@ -48,8 +48,8 @@ Output: `output/{timestamp}_translation_extractor.json` — sorted, deterministi
48
48
  ```
49
49
  --src <path> root to scan (default ".")
50
50
  --out <path> explicit output file (overrides --output-dir)
51
- --output-dir <path> default ./output
52
- --log-dir <path> default ./logs
51
+ --output-dir <path> default ./i18n-toolkit/output
52
+ --log-dir <path> default ./i18n-toolkit/logs
53
53
  --lang <code> top-level language key (default "en")
54
54
  --fail-on-conflict exit 1 if any key has conflicting defaults
55
55
  --help full options list
@@ -70,7 +70,7 @@ i18n-toolkit diff
70
70
  `--src` accepts either a directory (runs extraction in-process) or a previously-extracted JSON file.
71
71
  `--resource` auto-detects flat `{ key: value }` vs language-wrapped `{ "en": { … } }` and picks the `en` slice.
72
72
 
73
- **Output:** `diff-output/{timestamp}/`
73
+ **Output:** `i18n-toolkit/diff-output/{timestamp}/`
74
74
 
75
75
  | File | What's in it |
76
76
  |---|---|
@@ -96,7 +96,7 @@ Interactive. Three steps:
96
96
 
97
97
  The Ollama call streams with live thinking + response phases and a sticky status footer (elapsed / tokens / KB).
98
98
 
99
- **Output:** `translate-output/{timestamp}/translation.json`
99
+ **Output:** `i18n-toolkit/translate-output/{timestamp}/translation.json`
100
100
 
101
101
  ```json
102
102
  {
@@ -121,15 +121,17 @@ NO_COLOR disable ANSI colors
121
121
  The three commands compose:
122
122
 
123
123
  ```
124
- your-app/src/ ──extract──▶ output/…json
125
-
126
-
127
- resource.json ──diff──▶ diff-output/…/missing_in_resource.json
128
-
129
-
130
- translate ──▶ translate-output/…/translation.json
124
+ your-app/src/ ──extract──▶ i18n-toolkit/output/…json
125
+
126
+
127
+ resource.json ──diff──▶ i18n-toolkit/diff-output/…/missing_in_resource.json
128
+
129
+
130
+ translate ──▶ i18n-toolkit/translate-output/…/translation.json
131
131
  ```
132
132
 
133
+ All four output directories live under a single `i18n-toolkit/` workspace folder in your project root, so you only have **one** entry to ignore (`i18n-toolkit/`) — and the tool actually drops an auto-generated `i18n-toolkit/.gitignore` for you on first run, so you don't even have to do that.
134
+
133
135
  Drop the final `translation.json` back into your app's translation resource and the cycle closes.
134
136
 
135
137
  ---
@@ -169,7 +171,7 @@ docs/plans/ design docs (one per command)
169
171
  dist/ bundled output (generated; not in git)
170
172
  ```
171
173
 
172
- Generated output dirs (gitignored): `output/`, `logs/`, `diff-output/`, `translate-output/`.
174
+ All generated artifacts go under `i18n-toolkit/` (auto-gitignored): `output/`, `logs/`, `diff-output/`, `translate-output/`.
173
175
 
174
176
  ---
175
177
 
package/dist/cli.js CHANGED
@@ -5,18 +5,49 @@ import { parse } from "@babel/parser";
5
5
  import _traverse from "@babel/traverse";
6
6
  import * as t from "@babel/types";
7
7
  import fg from "fast-glob";
8
- import fs from "fs/promises";
8
+ import fs2 from "fs/promises";
9
9
  import { createWriteStream } from "fs";
10
10
  import os from "os";
11
- import path from "path";
11
+ import path2 from "path";
12
12
  import { format as formatInspect } from "util";
13
+
14
+ // src/lib/workspace.ts
15
+ import fs from "fs/promises";
16
+ import path from "path";
17
+ var WORKSPACE_DIR_NAME = "i18n-toolkit";
18
+ var DEFAULT_DIRS = {
19
+ output: `./${WORKSPACE_DIR_NAME}/output`,
20
+ logs: `./${WORKSPACE_DIR_NAME}/logs`,
21
+ diffOutput: `./${WORKSPACE_DIR_NAME}/diff-output`,
22
+ translateOutput: `./${WORKSPACE_DIR_NAME}/translate-output`
23
+ };
24
+ var GITIGNORE_BODY = `# Auto-generated by @syedamirali/i18n-toolkit.
25
+ # Safe to delete or commit. Everything inside is build output.
26
+ *
27
+ !.gitignore
28
+ `;
29
+ async function ensureWorkspaceGitignore(targetPath) {
30
+ const workspaceAbs = path.resolve(WORKSPACE_DIR_NAME);
31
+ const targetAbs = path.resolve(targetPath);
32
+ if (targetAbs !== workspaceAbs && !targetAbs.startsWith(workspaceAbs + path.sep)) return;
33
+ await fs.mkdir(workspaceAbs, { recursive: true });
34
+ const gitignorePath = path.join(workspaceAbs, ".gitignore");
35
+ try {
36
+ await fs.stat(gitignorePath);
37
+ return;
38
+ } catch {
39
+ }
40
+ await fs.writeFile(gitignorePath, GITIGNORE_BODY, "utf8");
41
+ }
42
+
43
+ // src/index.ts
13
44
  var traverse = _traverse.default ?? _traverse;
14
45
  function parseArgs(argv) {
15
46
  const opts = {
16
47
  src: ".",
17
48
  out: null,
18
- outputDir: "./output",
19
- logDir: "./logs",
49
+ outputDir: DEFAULT_DIRS.output,
50
+ logDir: DEFAULT_DIRS.logs,
20
51
  noLog: false,
21
52
  lang: "en",
22
53
  includeDynamic: true,
@@ -101,8 +132,8 @@ Usage:
101
132
  Options:
102
133
  --src <path> Root directory to scan (default ".")
103
134
  --out <path> Output JSON file (overrides --output-dir)
104
- --output-dir <path> Directory for auto-named output (default "./output")
105
- --log-dir <path> Directory for run logs (default "./logs")
135
+ --output-dir <path> Directory for auto-named output (default "./i18n-toolkit/output")
136
+ --log-dir <path> Directory for run logs (default "./i18n-toolkit/logs")
106
137
  --no-log Disable writing the per-run log file
107
138
  --lang <code> Top-level language key (default "en")
108
139
  --include-dynamic <bool> Emit dynamic keys with "__dynamic__" value (default true)
@@ -473,8 +504,8 @@ function extractFromFile(filePath, source, opts) {
473
504
  }
474
505
  traverse(ast, {
475
506
  // Handle `t("k") || "fallback"` BEFORE the inner CallExpression visitor sees it.
476
- LogicalExpression(path4) {
477
- const node = path4.node;
507
+ LogicalExpression(path5) {
508
+ const node = path5.node;
478
509
  if (node.operator !== "||") return;
479
510
  if (!t.isCallExpression(node.left)) return;
480
511
  if (!isTCall(node.left)) return;
@@ -483,14 +514,14 @@ function extractFromFile(filePath, source, opts) {
483
514
  handleTCall(node.left, fallback);
484
515
  handledCalls.add(node.left);
485
516
  },
486
- CallExpression(path4) {
487
- const node = path4.node;
517
+ CallExpression(path5) {
518
+ const node = path5.node;
488
519
  if (handledCalls.has(node)) return;
489
520
  if (!isTCall(node)) return;
490
521
  handleTCall(node, null);
491
522
  },
492
- JSXOpeningElement(path4) {
493
- const openingEl = path4.node;
523
+ JSXOpeningElement(path5) {
524
+ const openingEl = path5.node;
494
525
  let localKeyAttr = null;
495
526
  for (const attr of openingEl.attributes) {
496
527
  if (!t.isJSXAttribute(attr)) continue;
@@ -509,7 +540,7 @@ function extractFromFile(filePath, source, opts) {
509
540
  }
510
541
  const keyInfo = getKeyFromNode(keyNode);
511
542
  if (!keyInfo) return;
512
- const parent = path4.parent;
543
+ const parent = path5.parent;
513
544
  const children = t.isJSXElement(parent) ? parent.children : [];
514
545
  let value;
515
546
  if (openingEl.selfClosing || children.length === 0) {
@@ -526,7 +557,7 @@ function extractFromFile(filePath, source, opts) {
526
557
  return records;
527
558
  }
528
559
  async function runExtraction(opts) {
529
- const srcAbs = path.resolve(opts.src);
560
+ const srcAbs = path2.resolve(opts.src);
530
561
  const includeDynamic = opts.includeDynamic !== false;
531
562
  const includeMissing = opts.includeMissing !== false;
532
563
  const debug = opts.debug === true;
@@ -557,7 +588,7 @@ async function runExtraction(opts) {
557
588
  const file = files[i];
558
589
  let size;
559
590
  try {
560
- const st = await fs.stat(file);
591
+ const st = await fs2.stat(file);
561
592
  size = st.size;
562
593
  } catch (err) {
563
594
  if (debug) console.warn(`${c.warn("[stat-error]")} ${c.path(file)}: ${err.message}`);
@@ -570,7 +601,7 @@ async function runExtraction(opts) {
570
601
  }
571
602
  let source;
572
603
  try {
573
- source = await fs.readFile(file, "utf8");
604
+ source = await fs2.readFile(file, "utf8");
574
605
  } catch (err) {
575
606
  if (debug) console.warn(`${c.warn("[read-error]")} ${c.path(file)}: ${err.message}`);
576
607
  skippedFiles++;
@@ -579,7 +610,7 @@ async function runExtraction(opts) {
579
610
  try {
580
611
  perFileRecords[i] = extractFromFile(file, source, fileOpts);
581
612
  parsedFiles++;
582
- if (debug) console.log(`${c.ok("[parsed]")} ${c.path(path.relative(process.cwd(), file))}`);
613
+ if (debug) console.log(`${c.ok("[parsed]")} ${c.path(path2.relative(process.cwd(), file))}`);
583
614
  } catch {
584
615
  parseErrorFiles++;
585
616
  }
@@ -611,21 +642,23 @@ async function main() {
611
642
  const opts = parseArgs(process.argv.slice(2));
612
643
  const startedAt = /* @__PURE__ */ new Date();
613
644
  const timestamp = formatTimestamp(startedAt);
614
- const srcAbs = path.resolve(opts.src);
645
+ const srcAbs = path2.resolve(opts.src);
615
646
  let outAbs;
616
647
  if (opts.out !== null) {
617
- outAbs = path.resolve(opts.out);
648
+ outAbs = path2.resolve(opts.out);
618
649
  } else {
619
- const outDirAbs = path.resolve(opts.outputDir);
620
- await fs.mkdir(outDirAbs, { recursive: true });
621
- outAbs = path.join(outDirAbs, `${timestamp}_translation_extractor.json`);
650
+ const outDirAbs = path2.resolve(opts.outputDir);
651
+ await ensureWorkspaceGitignore(outDirAbs);
652
+ await fs2.mkdir(outDirAbs, { recursive: true });
653
+ outAbs = path2.join(outDirAbs, `${timestamp}_translation_extractor.json`);
622
654
  }
623
655
  let logTee = null;
624
656
  let logAbs = null;
625
657
  if (!opts.noLog) {
626
- const logDirAbs = path.resolve(opts.logDir);
627
- await fs.mkdir(logDirAbs, { recursive: true });
628
- logAbs = path.join(logDirAbs, `${timestamp}_translation_extractor.log`);
658
+ const logDirAbs = path2.resolve(opts.logDir);
659
+ await ensureWorkspaceGitignore(logDirAbs);
660
+ await fs2.mkdir(logDirAbs, { recursive: true });
661
+ logAbs = path2.join(logDirAbs, `${timestamp}_translation_extractor.log`);
629
662
  const stream = createWriteStream(logAbs, { flags: "a" });
630
663
  logTee = setupLogTee(stream);
631
664
  const tag = c.head("[translation-extractor]");
@@ -651,8 +684,8 @@ async function main() {
651
684
  for (const conf of result.conflicts) {
652
685
  const keptAbs = conf.kept.location.file;
653
686
  const seenAbs = conf.seen.location.file;
654
- const keptRel = path.relative(process.cwd(), keptAbs);
655
- const seenRel = path.relative(process.cwd(), seenAbs);
687
+ const keptRel = path2.relative(process.cwd(), keptAbs);
688
+ const seenRel = path2.relative(process.cwd(), seenAbs);
656
689
  const keptLine = conf.kept.location.line;
657
690
  const seenLine = conf.seen.location.line;
658
691
  const keptVal = JSON.stringify(conf.kept.value);
@@ -677,8 +710,8 @@ async function main() {
677
710
  for (const [k, v] of result.entries) langObj[k] = v;
678
711
  const output2 = { [opts.lang]: langObj };
679
712
  const json = opts.pretty ? JSON.stringify(output2, null, 2) + "\n" : JSON.stringify(output2);
680
- await fs.mkdir(path.dirname(outAbs), { recursive: true });
681
- await fs.writeFile(outAbs, json, "utf8");
713
+ await fs2.mkdir(path2.dirname(outAbs), { recursive: true });
714
+ await fs2.writeFile(outAbs, json, "utf8");
682
715
  const skippedNum = result.skippedFiles > 0 ? c.warn(result.skippedFiles) : c.value(result.skippedFiles);
683
716
  const skipNote = result.skippedFiles > 0 ? c.label(" (too large or unreadable; rerun with --debug to include)") : "";
684
717
  const conflictsNum = result.conflicts.length > 0 ? c.warn(result.conflicts.length) : c.ok(result.conflicts.length);
@@ -699,8 +732,8 @@ async function main() {
699
732
  )
700
733
  );
701
734
  console.log(stat("Conflicts", String(conflictsNum)));
702
- console.log(stat("Output", c.path(path.relative(process.cwd(), outAbs))));
703
- if (logAbs) console.log(stat("Log", c.path(path.relative(process.cwd(), logAbs))));
735
+ console.log(stat("Output", c.path(path2.relative(process.cwd(), outAbs))));
736
+ if (logAbs) console.log(stat("Log", c.path(path2.relative(process.cwd(), logAbs))));
704
737
  if (logTee) await logTee.teardown();
705
738
  if (opts.failOnConflict && result.conflicts.length > 0) {
706
739
  process.exit(1);
@@ -717,12 +750,12 @@ async function runCommand(_argv) {
717
750
 
718
751
  // src/diff.ts
719
752
  import { createInterface } from "readline/promises";
720
- import fs2 from "fs/promises";
753
+ import fs3 from "fs/promises";
721
754
  import { createWriteStream as createWriteStream2 } from "fs";
722
- import path2 from "path";
755
+ import path3 from "path";
723
756
  import { stdin as input, stdout as output } from "process";
724
757
  var TARGET_LANG = "en";
725
- var DIFF_OUTPUT_DIR = "./diff-output";
758
+ var DIFF_OUTPUT_DIR = DEFAULT_DIRS.diffOutput;
726
759
  function parseDiffArgs(argv) {
727
760
  const opts = { src: null, resource: null };
728
761
  for (let i = 0; i < argv.length; i++) {
@@ -753,9 +786,9 @@ function cleanPath(raw) {
753
786
  }
754
787
  async function resolveSourcePath(p) {
755
788
  if (!p) return { error: "Empty input." };
756
- const abs = path2.resolve(p);
789
+ const abs = path3.resolve(p);
757
790
  try {
758
- const st = await fs2.stat(abs);
791
+ const st = await fs3.stat(abs);
759
792
  if (st.isDirectory()) return { kind: "directory", absPath: abs };
760
793
  if (st.isFile()) {
761
794
  if (!abs.toLowerCase().endsWith(".json")) return { error: `Not a .json file: ${abs}` };
@@ -768,9 +801,9 @@ async function resolveSourcePath(p) {
768
801
  }
769
802
  async function resolveResourcePath(p) {
770
803
  if (!p) return { error: "Empty input." };
771
- const abs = path2.resolve(p);
804
+ const abs = path3.resolve(p);
772
805
  try {
773
- const st = await fs2.stat(abs);
806
+ const st = await fs3.stat(abs);
774
807
  if (!st.isFile()) return { error: `Not a file: ${abs}` };
775
808
  if (!abs.toLowerCase().endsWith(".json")) return { error: `Not a .json file: ${abs}` };
776
809
  return { absPath: abs };
@@ -810,7 +843,7 @@ async function promptForResource(rl) {
810
843
  }
811
844
  }
812
845
  async function loadKeyMap(filePath, sourceLabel) {
813
- const text = await fs2.readFile(filePath, "utf8");
846
+ const text = await fs3.readFile(filePath, "utf8");
814
847
  const json = JSON.parse(text);
815
848
  if (json === null || typeof json !== "object" || Array.isArray(json)) {
816
849
  throw new Error(`${sourceLabel}: expected a JSON object at the top level`);
@@ -848,11 +881,12 @@ async function loadKeyMap(filePath, sourceLabel) {
848
881
  async function main2() {
849
882
  const startedAt = /* @__PURE__ */ new Date();
850
883
  const timestamp = formatTimestamp(startedAt);
851
- const runDir = path2.resolve(DIFF_OUTPUT_DIR, timestamp);
852
- await fs2.mkdir(runDir, { recursive: true });
853
- const logAbs = path2.join(runDir, `${timestamp}_translation_diff.log`);
854
- const missingInResourcePath = path2.join(runDir, "missing_in_resource.json");
855
- const missingInExtractorPath = path2.join(runDir, "missing_in_extractor.json");
884
+ const runDir = path3.resolve(DIFF_OUTPUT_DIR, timestamp);
885
+ await ensureWorkspaceGitignore(runDir);
886
+ await fs3.mkdir(runDir, { recursive: true });
887
+ const logAbs = path3.join(runDir, `${timestamp}_translation_diff.log`);
888
+ const missingInResourcePath = path3.join(runDir, "missing_in_resource.json");
889
+ const missingInExtractorPath = path3.join(runDir, "missing_in_extractor.json");
856
890
  const stream = createWriteStream2(logAbs, { flags: "a" });
857
891
  const logTee = setupLogTee(stream);
858
892
  const tag = c.head("[translation-diff]");
@@ -983,8 +1017,8 @@ async function main2() {
983
1017
  return out;
984
1018
  };
985
1019
  const wrap2 = (m) => ({ [TARGET_LANG]: sortedObject(m) });
986
- await fs2.writeFile(missingInResourcePath, JSON.stringify(wrap2(missingInResource), null, 2) + "\n", "utf8");
987
- await fs2.writeFile(missingInExtractorPath, JSON.stringify(wrap2(missingInExtractor), null, 2) + "\n", "utf8");
1020
+ await fs3.writeFile(missingInResourcePath, JSON.stringify(wrap2(missingInResource), null, 2) + "\n", "utf8");
1021
+ await fs3.writeFile(missingInExtractorPath, JSON.stringify(wrap2(missingInExtractor), null, 2) + "\n", "utf8");
988
1022
  const labels = ["Extractor keys", "Resource keys", "Dynamic skipped", "Missing in resource", "Missing in extractor", "Output dir"];
989
1023
  const pad = Math.max(...labels.map((l) => l.length));
990
1024
  const stat = (label, value) => `${c.label(label.padEnd(pad) + " :")} ${value}`;
@@ -1007,10 +1041,10 @@ async function main2() {
1007
1041
  `${Object.keys(missingInExtractor).length > 0 ? c.warn(Object.keys(missingInExtractor).length) : c.ok(0)} ${c.label("(in resource, not in extractor)")}`
1008
1042
  )
1009
1043
  );
1010
- console.log(stat("Output dir", c.path(path2.relative(process.cwd(), runDir))));
1044
+ console.log(stat("Output dir", c.path(path3.relative(process.cwd(), runDir))));
1011
1045
  console.log(` ${c.label("\u251C\u2500")} ${c.path("missing_in_resource.json")}`);
1012
1046
  console.log(` ${c.label("\u251C\u2500")} ${c.path("missing_in_extractor.json")}`);
1013
- console.log(` ${c.label("\u2514\u2500")} ${c.path(path2.basename(logAbs))}`);
1047
+ console.log(` ${c.label("\u2514\u2500")} ${c.path(path3.basename(logAbs))}`);
1014
1048
  await logTee.teardown();
1015
1049
  }
1016
1050
  async function runCommand2(_argv) {
@@ -1023,9 +1057,9 @@ async function runCommand2(_argv) {
1023
1057
  }
1024
1058
 
1025
1059
  // src/translate.ts
1026
- import fs3 from "fs/promises";
1060
+ import fs4 from "fs/promises";
1027
1061
  import { createWriteStream as createWriteStream3 } from "fs";
1028
- import path3 from "path";
1062
+ import path4 from "path";
1029
1063
  import { fileURLToPath } from "url";
1030
1064
  import { checkbox, select, input as inquirerInput, Separator } from "@inquirer/prompts";
1031
1065
 
@@ -1533,12 +1567,12 @@ function formatError(e) {
1533
1567
  }
1534
1568
 
1535
1569
  // src/translate.ts
1536
- var SCRIPT_DIR = path3.dirname(fileURLToPath(import.meta.url));
1537
- var PROJECT_ROOT = path3.resolve(SCRIPT_DIR, "..");
1538
- var PROMPT_PATH = path3.resolve(PROJECT_ROOT, "prompt/translation-prompt.md");
1539
- var LANGUAGES_JSON_PATH = path3.resolve(PROJECT_ROOT, "data/languages.json");
1540
- var DIFF_OUTPUT_DIR2 = path3.resolve(PROJECT_ROOT, "diff-output");
1541
- var TRANSLATE_OUTPUT_DIR = path3.resolve(PROJECT_ROOT, "translate-output");
1570
+ var SCRIPT_DIR = path4.dirname(fileURLToPath(import.meta.url));
1571
+ var PACKAGE_ROOT = path4.resolve(SCRIPT_DIR, "..");
1572
+ var PROMPT_PATH = path4.resolve(PACKAGE_ROOT, "prompt/translation-prompt.md");
1573
+ var LANGUAGES_JSON_PATH = path4.resolve(PACKAGE_ROOT, "data/languages.json");
1574
+ var DIFF_OUTPUT_DIR2 = path4.resolve(process.cwd(), DEFAULT_DIRS.diffOutput);
1575
+ var TRANSLATE_OUTPUT_DIR = path4.resolve(process.cwd(), DEFAULT_DIRS.translateOutput);
1542
1576
  var PLACEHOLDER_CONTENT = "PASTE CONTENT HERE";
1543
1577
  var PLACEHOLDER_LANGS = "LANGUAGES TO PRODUCE";
1544
1578
  var SOURCE_LANG = "en";
@@ -1588,17 +1622,17 @@ function cleanPath2(raw) {
1588
1622
  async function findRecentDiffOutputs(max = 5) {
1589
1623
  let dirEntries = [];
1590
1624
  try {
1591
- dirEntries = await fs3.readdir(DIFF_OUTPUT_DIR2);
1625
+ dirEntries = await fs4.readdir(DIFF_OUTPUT_DIR2);
1592
1626
  } catch {
1593
1627
  return [];
1594
1628
  }
1595
1629
  const candidates = [];
1596
1630
  for (const dirName of dirEntries) {
1597
- const filePath = path3.join(DIFF_OUTPUT_DIR2, dirName, "missing_in_resource.json");
1631
+ const filePath = path4.join(DIFF_OUTPUT_DIR2, dirName, "missing_in_resource.json");
1598
1632
  try {
1599
- const st = await fs3.stat(filePath);
1633
+ const st = await fs4.stat(filePath);
1600
1634
  if (!st.isFile()) continue;
1601
- const text = await fs3.readFile(filePath, "utf8");
1635
+ const text = await fs4.readFile(filePath, "utf8");
1602
1636
  const json = JSON.parse(text);
1603
1637
  const enSlice = json[SOURCE_LANG] ?? json;
1604
1638
  const keyCount = typeof enSlice === "object" && enSlice !== null ? Object.keys(enSlice).length : 0;
@@ -1609,7 +1643,7 @@ async function findRecentDiffOutputs(max = 5) {
1609
1643
  candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
1610
1644
  return candidates.slice(0, max).map((it) => ({
1611
1645
  absPath: it.abs,
1612
- relPath: path3.relative(process.cwd(), it.abs),
1646
+ relPath: path4.relative(process.cwd(), it.abs),
1613
1647
  mtimeMs: it.mtimeMs,
1614
1648
  keyCount: it.keyCount,
1615
1649
  ageLabel: humanAge(Date.now() - it.mtimeMs)
@@ -1626,7 +1660,7 @@ function humanAge(ms) {
1626
1660
  return `${d}d ago`;
1627
1661
  }
1628
1662
  async function loadLanguages() {
1629
- const text = await fs3.readFile(LANGUAGES_JSON_PATH, "utf8");
1663
+ const text = await fs4.readFile(LANGUAGES_JSON_PATH, "utf8");
1630
1664
  const arr = JSON.parse(text);
1631
1665
  return arr.filter((l) => l && l.active && l.code);
1632
1666
  }
@@ -1716,7 +1750,7 @@ async function promptInput(recents) {
1716
1750
  choices
1717
1751
  });
1718
1752
  if (pick.kind === "recent") {
1719
- const text = await fs3.readFile(pick.file.absPath, "utf8");
1753
+ const text = await fs4.readFile(pick.file.absPath, "utf8");
1720
1754
  return {
1721
1755
  sourceLabel: pick.file.absPath,
1722
1756
  keyMap: parseEnglishKeyMap(text, pick.file.absPath)
@@ -1728,15 +1762,15 @@ async function promptInput(recents) {
1728
1762
  message: "Paste a JSON file path:",
1729
1763
  validate: (v) => v.trim().length > 0 ? true : "Path required"
1730
1764
  });
1731
- const p = path3.resolve(cleanPath2(raw));
1765
+ const p = path4.resolve(cleanPath2(raw));
1732
1766
  try {
1733
- const st = await fs3.stat(p);
1767
+ const st = await fs4.stat(p);
1734
1768
  if (!st.isFile() || !p.toLowerCase().endsWith(".json")) {
1735
1769
  console.log(c.err(` Not a .json file: ${p}
1736
1770
  `));
1737
1771
  continue;
1738
1772
  }
1739
- const text = await fs3.readFile(p, "utf8");
1773
+ const text = await fs4.readFile(p, "utf8");
1740
1774
  return { sourceLabel: p, keyMap: parseEnglishKeyMap(text, p) };
1741
1775
  } catch (err) {
1742
1776
  console.log(c.err(` ${err.message}
@@ -1992,10 +2026,11 @@ function printHeader(args) {
1992
2026
  async function main3() {
1993
2027
  const startedAt = /* @__PURE__ */ new Date();
1994
2028
  const timestamp = formatTimestamp(startedAt);
1995
- const runDir = path3.join(TRANSLATE_OUTPUT_DIR, timestamp);
1996
- await fs3.mkdir(runDir, { recursive: true });
1997
- const outputPath = path3.join(runDir, "translation.json");
1998
- const logAbs = path3.join(runDir, `${timestamp}_translate.log`);
2029
+ const runDir = path4.join(TRANSLATE_OUTPUT_DIR, timestamp);
2030
+ await ensureWorkspaceGitignore(runDir);
2031
+ await fs4.mkdir(runDir, { recursive: true });
2032
+ const outputPath = path4.join(runDir, "translation.json");
2033
+ const logAbs = path4.join(runDir, `${timestamp}_translate.log`);
1999
2034
  const stream = createWriteStream3(logAbs, { flags: "a" });
2000
2035
  const logTee = setupLogTee(stream);
2001
2036
  const tag = c.head("[translation-translate]");
@@ -2027,9 +2062,9 @@ async function main3() {
2027
2062
  sourceLabel: picked.sourceLabel,
2028
2063
  keyCount: Object.keys(picked.keyMap).length,
2029
2064
  langs: selectedLangs,
2030
- outputDir: path3.relative(process.cwd(), runDir)
2065
+ outputDir: path4.relative(process.cwd(), runDir)
2031
2066
  });
2032
- const promptTemplate = await fs3.readFile(PROMPT_PATH, "utf8");
2067
+ const promptTemplate = await fs4.readFile(PROMPT_PATH, "utf8");
2033
2068
  const prompt = renderPrompt(promptTemplate, selectedLangs, picked.keyMap);
2034
2069
  const t0 = Date.now();
2035
2070
  const spinner = new Spinner();
@@ -2041,7 +2076,7 @@ async function main3() {
2041
2076
  parsed = JSON.parse(jsonText);
2042
2077
  } catch (e) {
2043
2078
  const debugPath = `${outputPath}.raw.txt`;
2044
- await fs3.writeFile(debugPath, response, "utf8");
2079
+ await fs4.writeFile(debugPath, response, "utf8");
2045
2080
  throw new Error(
2046
2081
  `Failed to parse JSON from model output. Raw response saved to ${debugPath}.
2047
2082
  ${e.message}`
@@ -2051,7 +2086,7 @@ ${e.message}`
2051
2086
  const expectedKeys = Object.keys(picked.keyMap);
2052
2087
  const report = validateTranslation(parsed, expectedLangs, expectedKeys);
2053
2088
  const sorted = sortedLangBundle(parsed, expectedLangs, expectedKeys);
2054
- await fs3.writeFile(outputPath, JSON.stringify(sorted, null, 2) + "\n", "utf8");
2089
+ await fs4.writeFile(outputPath, JSON.stringify(sorted, null, 2) + "\n", "utf8");
2055
2090
  const responseRate = metrics.evalTokens && metrics.evalMs ? (metrics.evalTokens / metrics.evalMs * 1e3).toFixed(1) : "\u2014";
2056
2091
  process.stdout.write(`
2057
2092
  ${rule()}
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@syedamirali/i18n-toolkit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "AST-based CLI to extract translation keys from React/Next.js code, diff them against translation files, and translate missing keys via a local Ollama instance.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Syed Amir Ali",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/SyedAmirAli/i18n-toolkit.git"
10
+ "url": "git+https://github.com/SyedAmirAli/i18n-toolkit.git"
11
11
  },
12
12
  "homepage": "https://github.com/SyedAmirAli/i18n-toolkit#readme",
13
13
  "bugs": {
@@ -31,7 +31,7 @@
31
31
  "node": ">=20"
32
32
  },
33
33
  "bin": {
34
- "i18n-toolkit": "./dist/cli.js"
34
+ "i18n-toolkit": "dist/cli.js"
35
35
  },
36
36
  "files": [
37
37
  "dist",