i18nizer 0.1.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.
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # i18nizer 🌍
2
+
3
+ ![banner](./imgs/banner.png)
4
+
5
+ <div align="center">
6
+ <img src="https://img.shields.io/badge/Node.js-5FA04E?logo=nodedotjs&logoColor=fff&style=for-the-badge" alt="Node.js Badge">
7
+ <img src="https://img.shields.io/badge/oclif-000?logo=oclif&logoColor=fff&style=for-the-badge" alt="oclif Badge">
8
+ <img src="https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=fff&style=for-the-badge" alt="TypeScript Badge">
9
+ <img alt="Licence" src="https://img.shields.io/npm/dw/i18nizer.svg?style=for-the-badge">
10
+ <img alt="Licence" src="https://img.shields.io/github/license/yossTheDev/i18nizer?style=for-the-badge">
11
+ </div>
12
+
13
+ i18nizer is a practical CLI to **extract translatable texts from your TSX/JSX components** and automatically generate i18n JSON files.
14
+ It works with **Gemini** and **Hugging Face (DeepSeek)** AI providers to generate translations and prepares your components to use `t()` easily.
15
+
16
+ ---
17
+
18
+ ## 🚀 Installation
19
+
20
+ You can install the CLI globally using **npm**:
21
+
22
+ ```bash
23
+ npm install -g i18nizer
24
+ ```
25
+
26
+ > Requires Node.js 18+ and internet access to call the translation APIs.
27
+
28
+ ---
29
+
30
+ ## 🛠️ API Keys Configuration
31
+
32
+ Before generating translations, you need to set up your API keys:
33
+
34
+ ```bash
35
+ i18nizer keys --setGemini <YOUR_GEMINI_API_KEY>
36
+ i18nizer keys --setHF <YOUR_HUGGING_FACE_API_KEY>
37
+ ```
38
+
39
+ * Keys are stored securely in your **global user folder** (`~/.mycli/api-keys.json` on Linux/Mac or `C:\Users\<User>\.mycli\api-keys.json` on Windows).
40
+ * To see which keys are set (partially masked):
41
+
42
+ ```bash
43
+ i18nizer keys --show
44
+ ```
45
+
46
+ ---
47
+
48
+ ## ⚡ Main Commands
49
+
50
+ ### 1. Extract and Generate Translations
51
+
52
+ ```bash
53
+ i18nizer extract <file-path>
54
+ ```
55
+
56
+ **Available flags:**
57
+
58
+ * `-l, --locales` → List of locales to generate (default: `en,es`)
59
+ * `-p, --provider` → AI provider to generate translations (optional, default: `huggingface`). Options: `gemini`, `huggingface`
60
+
61
+ **Example:**
62
+
63
+ ```bash
64
+ i18nizer extract src/components/Button.tsx --locales en,es,fr --provider gemini
65
+ ```
66
+
67
+ **What this command does:**
68
+
69
+ * Extracts all translatable texts from your JSX/TSX file.
70
+ * Generates translations using the selected AI provider.
71
+ * Automatically inserts `t()` calls in the component.
72
+ * Saves JSON files in `messages/<locale>/<component>.json`.
73
+ * Logs the paths of generated files.
74
+
75
+ ---
76
+
77
+ ### 2. Manage API Keys
78
+
79
+ ```bash
80
+ i18nizer keys
81
+ ```
82
+
83
+ **Available flags:**
84
+
85
+ * `--setGemini` → Set your Gemini key
86
+ * `--setHF` → Set your Hugging Face key
87
+ * `--show` → Show stored keys (masked)
88
+
89
+ ---
90
+
91
+ ## 💡 Current Features
92
+
93
+ * Compatible with **JSX and TSX**
94
+ * Works with AI providers: **Gemini**, **Hugging Face (DeepSeek)**
95
+ * Automatically inserts `t()` calls in components
96
+ * Generates JSON files for each locale
97
+ * Spinner and colored logs with emojis for better UX
98
+
99
+ ---
100
+
101
+ ## 🔮 Future Ideas
102
+
103
+ * Configurable **output directory** for JSON files
104
+ * Support for other frameworks and file types (Vue, Svelte, etc.)
105
+ * Better integration with `next-intl` or `react-i18next`
106
+ * Keychain / Credential Manager support for storing API keys securely
107
+ * Multi-project support and locale presets
108
+
109
+ ---
110
+
111
+ ## 📂 Output Structure
112
+
113
+ ```
114
+ messages/
115
+ ├─ en/
116
+ │ └─ Button.json
117
+ ├─ es/
118
+ │ └─ Button.json
119
+ └─ fr/
120
+ └─ Button.json
121
+ ```
122
+
123
+ Each JSON contains a namespace with AI-generated keys:
124
+
125
+ ```json
126
+ {
127
+ "button": {
128
+ "submitText": "Submit",
129
+ "cancelText": "Cancel"
130
+ }
131
+ }
132
+ ```
133
+
134
+ ---
135
+
136
+ ## ⚠️ Notes
137
+
138
+ * For the first version, JSON files are saved **inside the project** under `messages/<locale>/`.
139
+ * Make sure **not to commit your API keys** to the repository.
140
+ * The CLI is designed to be simple and functional, ideal for starting automatic i18n in React projects.
141
+
142
+ ---
143
+
144
+ > Made with ❤️ by Yoannis Sánchez Soto
package/bin/dev.cmd ADDED
@@ -0,0 +1,3 @@
1
+ @echo off
2
+
3
+ node --loader ts-node/esm --no-warnings=ExperimentalWarning "%~dp0\dev" %*
package/bin/dev.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env -S node --loader ts-node/esm --disable-warning=ExperimentalWarning
2
+
3
+ import {execute} from '@oclif/core'
4
+
5
+ await execute({development: true, dir: import.meta.url})
package/bin/run.cmd ADDED
@@ -0,0 +1,3 @@
1
+ @echo off
2
+
3
+ node "%~dp0\run" %*
package/bin/run.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {execute} from '@oclif/core'
4
+
5
+ await execute({dir: import.meta.url})
@@ -0,0 +1,98 @@
1
+ import { Args, Command, Flags } from "@oclif/core";
2
+ import chalk from "chalk";
3
+ import path from "node:path";
4
+ import ora from "ora";
5
+ import { generateTranslations } from "../core/ai/client.js";
6
+ import { buildPrompt } from "../core/ai/promt.js";
7
+ import { extractTexts } from "../core/ast/extract-text.js";
8
+ import { insertUseTranslations } from "../core/ast/insert-user-translations.js";
9
+ import { parseFile } from "../core/ast/parse-file.js";
10
+ import { replaceTempKeysWithT } from "../core/ast/replace-text-with-text.js";
11
+ import { parseAiJson } from "../core/i18n/parse-ai-json.js";
12
+ import { saveSourceFile } from "../core/i18n/sace-source-file.js";
13
+ import { writeLocaleFiles } from "../core/i18n/write-files.js";
14
+ const VALID_PROVIDERS = ["gemini", "huggingface", "openai"];
15
+ export default class Extract extends Command {
16
+ static args = {
17
+ file: Args.string({
18
+ description: "Path to the TSX/JSX file",
19
+ required: true,
20
+ }),
21
+ };
22
+ static description = "🌍 Extract translatable strings from a TSX/JSX file and generate i18n JSON";
23
+ static flags = {
24
+ locales: Flags.string({
25
+ char: "l",
26
+ default: "en,es",
27
+ description: "Locales to generate",
28
+ }),
29
+ provider: Flags.string({
30
+ char: "p",
31
+ description: "AI provider (gemini | huggingface), optional",
32
+ }),
33
+ };
34
+ async run() {
35
+ const { args, flags } = await this.parse(Extract);
36
+ this.log(chalk.cyan("📄 File:"), args.file);
37
+ this.log(chalk.cyan("🌐 Locales:"), flags.locales);
38
+ let provider = "huggingface";
39
+ if (flags.provider) {
40
+ const p = flags.provider.toLowerCase();
41
+ if (!VALID_PROVIDERS.includes(p)) {
42
+ this.error(`❌ Invalid provider: ${flags.provider}. Valid options: ${VALID_PROVIDERS.join(", ")}`);
43
+ }
44
+ provider = p;
45
+ }
46
+ this.log(chalk.cyan("🤖 Provider:"), provider);
47
+ // Parse file
48
+ const sourceFile = parseFile(args.file);
49
+ const texts = extractTexts(sourceFile);
50
+ if (texts.length === 0) {
51
+ this.log(chalk.yellow("⚠️ No translatable texts found."));
52
+ return;
53
+ }
54
+ this.log(`🔍 Found ${chalk.green(texts.length)} translatable texts`);
55
+ const componentName = path
56
+ .basename(args.file)
57
+ .replace(/\.(tsx|jsx)$/, "");
58
+ const locales = flags.locales.split(",");
59
+ const spinner = ora(`💬 Generating translations with ${provider}...`).start();
60
+ try {
61
+ const prompt = buildPrompt({
62
+ componentName,
63
+ locales,
64
+ texts: texts.map((t) => t.text),
65
+ });
66
+ // Pasamos el provider a la función
67
+ const raw = await generateTranslations(prompt, provider);
68
+ if (!raw)
69
+ throw new Error("AI did not return any data");
70
+ const json = parseAiJson(raw);
71
+ writeLocaleFiles(componentName, json, locales);
72
+ spinner.succeed(`✅ Translations generated with ${provider}`);
73
+ const namespace = componentName.toLowerCase();
74
+ const aiGeneratedKeys = Object.keys(json[namespace] || {});
75
+ const mapped = texts.map((e, i) => ({
76
+ key: aiGeneratedKeys[i],
77
+ node: e.node,
78
+ placeholders: e.placeholders,
79
+ tempKey: e.tempKey,
80
+ }));
81
+ this.log(`🔗 Mapped ${chalk.green(mapped.length)} texts to keys`);
82
+ insertUseTranslations(sourceFile, componentName);
83
+ replaceTempKeysWithT(mapped);
84
+ saveSourceFile(sourceFile);
85
+ this.log(chalk.green("✨ Component rewritten with t() calls"));
86
+ this.log(chalk.green(`🌍 JSON files generated using ${provider}`));
87
+ }
88
+ catch (error) {
89
+ spinner.fail(`❌ Failed to generate translations with ${provider}`);
90
+ if (error instanceof Error) {
91
+ this.error(error.message);
92
+ }
93
+ else {
94
+ this.error("An unknown error occurred");
95
+ }
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,19 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ export default class Hello extends Command {
3
+ static args = {
4
+ person: Args.string({ description: 'Person to say hello to', required: true }),
5
+ };
6
+ static description = 'Say hello';
7
+ static examples = [
8
+ `<%= config.bin %> <%= command.id %> friend --from oclif
9
+ hello friend from oclif! (./src/commands/hello/index.ts)
10
+ `,
11
+ ];
12
+ static flags = {
13
+ from: Flags.string({ char: 'f', description: 'Who is saying hello', required: true }),
14
+ };
15
+ async run() {
16
+ const { args, flags } = await this.parse(Hello);
17
+ this.log(`hello ${args.person} from ${flags.from}! (./src/commands/hello/index.ts)`);
18
+ }
19
+ }
@@ -0,0 +1,14 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class World extends Command {
3
+ static args = {};
4
+ static description = 'Say hello world';
5
+ static examples = [
6
+ `<%= config.bin %> <%= command.id %>
7
+ hello world! (./src/commands/hello/world.ts)
8
+ `,
9
+ ];
10
+ static flags = {};
11
+ async run() {
12
+ this.log('hello world! (./src/commands/hello/world.ts)');
13
+ }
14
+ }
@@ -0,0 +1,74 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import chalk from "chalk";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import ora from "ora";
7
+ const CONFIG_DIR = path.join(os.homedir(), ".18nizer");
8
+ const CONFIG_FILE = path.join(CONFIG_DIR, "api-keys.json");
9
+ export default class Keys extends Command {
10
+ static description = "Manage API keys for your CLI";
11
+ static flags = {
12
+ setGemini: Flags.string({
13
+ char: "g",
14
+ description: "Set Google Gemini API key",
15
+ }),
16
+ setHF: Flags.string({
17
+ char: "h",
18
+ description: "Set Hugging Face API key",
19
+ }),
20
+ setOpenAI: Flags.string({
21
+ char: "o",
22
+ description: "Set OpenAI API key",
23
+ }),
24
+ show: Flags.boolean({
25
+ char: "s",
26
+ description: "Show saved keys (masked)",
27
+ }),
28
+ };
29
+ async run() {
30
+ const { flags } = await this.parse(Keys);
31
+ if (!fs.existsSync(CONFIG_DIR)) {
32
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
33
+ }
34
+ let keys = {};
35
+ if (fs.existsSync(CONFIG_FILE)) {
36
+ try {
37
+ keys = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
38
+ }
39
+ catch {
40
+ this.error("❌ Could not read existing keys file.");
41
+ }
42
+ }
43
+ // Set keys
44
+ if (flags.setGemini) {
45
+ keys.gemini = flags.setGemini;
46
+ this.log(chalk.green("✅ Gemini API key set"));
47
+ }
48
+ if (flags.setHF) {
49
+ keys.huggingface = flags.setHF;
50
+ this.log(chalk.green("✅ Hugging Face API key set"));
51
+ }
52
+ if (flags.setOpenAI) {
53
+ keys.openai = flags.setOpenAI;
54
+ this.log(chalk.green("✅ OpenAI API key set"));
55
+ }
56
+ // Save keys
57
+ if (flags.setGemini || flags.setHF || flags.setOpenAI) {
58
+ const spinner = ora("Saving keys...").start();
59
+ try {
60
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(keys, null, 2), { mode: 0o600 });
61
+ spinner.succeed(`💾 Keys saved successfully at ${CONFIG_FILE}`);
62
+ }
63
+ catch {
64
+ spinner.fail("❌ Failed to save keys");
65
+ }
66
+ }
67
+ // Show keys (masked)
68
+ if (flags.show) {
69
+ this.log("🔑 Saved API keys:");
70
+ this.log("Gemini: ", keys.gemini ? keys.gemini.slice(0, 4) + "****" : chalk.yellow("not set"));
71
+ this.log("Hugging Face: ", keys.huggingface ? keys.huggingface.slice(0, 4) + "****" : chalk.yellow("not set"));
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,77 @@
1
+ import { GoogleGenAI } from "@google/genai";
2
+ import { InferenceClient as HFClient } from "@huggingface/inference";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import OpenAI from "openai";
7
+ const CONFIG_FILE = path.join(os.homedir(), ".18nizer", "api-keys.json");
8
+ function loadApiKeys() {
9
+ if (!fs.existsSync(CONFIG_FILE)) {
10
+ console.warn(`⚠️ API keys file not found: ${CONFIG_FILE}`);
11
+ return {};
12
+ }
13
+ try {
14
+ const keys = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
15
+ return keys;
16
+ }
17
+ catch (error) {
18
+ console.error("❌ Error reading API keys:", error);
19
+ return {};
20
+ }
21
+ }
22
+ export async function generateTranslations(prompt, provider = "huggingface") {
23
+ const keys = loadApiKeys();
24
+ switch (provider) {
25
+ case "gemini": {
26
+ const apiKey = keys.gemini;
27
+ if (!apiKey)
28
+ throw new Error("Gemini API key is not set.");
29
+ console.log("🤖 Using Google Gemini...");
30
+ const gemini = new GoogleGenAI({ apiKey });
31
+ const result = await gemini.models.generateContent({
32
+ contents: prompt,
33
+ model: "gemini-2.5-flash",
34
+ });
35
+ return result.text;
36
+ }
37
+ case "huggingface": {
38
+ const apiKey = keys.huggingface;
39
+ if (!apiKey)
40
+ throw new Error("Hugging Face API key is not set.");
41
+ console.log("🤖 Using Hugging Face (DeepSeek-V3.2)...");
42
+ const hfClient = new HFClient(apiKey);
43
+ try {
44
+ const chatCompletion = await hfClient.chatCompletion({
45
+ messages: [{ content: prompt, role: "user" }],
46
+ model: "deepseek-ai/DeepSeek-V3.2",
47
+ });
48
+ return chatCompletion.choices?.[0]?.message?.content || (typeof chatCompletion.output_text === "string" ? chatCompletion.output_text : undefined);
49
+ }
50
+ catch (error) {
51
+ console.error("❌ Error calling Hugging Face:", error);
52
+ return undefined;
53
+ }
54
+ }
55
+ case "openai": {
56
+ const apiKey = keys.openai;
57
+ if (!apiKey)
58
+ throw new Error("OpenAI API key is not set.");
59
+ console.log("🤖 Using OpenAI...");
60
+ const openai = new OpenAI({ apiKey });
61
+ try {
62
+ const completion = await openai.chat.completions.create({
63
+ messages: [{ content: prompt, role: "user" }],
64
+ model: "gpt-4o-mini",
65
+ });
66
+ return completion.choices?.[0]?.message?.content || "";
67
+ }
68
+ catch (error) {
69
+ console.error("❌ Error calling OpenAI:", error);
70
+ return "";
71
+ }
72
+ }
73
+ default: {
74
+ throw new Error("Provider not supported");
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,30 @@
1
+ export function buildPrompt({ componentName, locales, texts, }) {
2
+ return `
3
+ You are an i18n automation tool.
4
+
5
+ TASK:
6
+ Generate translation keys and translations for a React component.
7
+
8
+ RULES:
9
+ - Keys must be camelCase
10
+ - Namespace must be "${componentName}"
11
+ - Languages: ${locales.join(", ")}
12
+ - Do NOT invent or modify meaning
13
+ - Do NOT add explanations
14
+ - Do NOT add markdown
15
+ - Output ONLY valid JSON
16
+
17
+ FORMAT EXACTLY:
18
+ {
19
+ "${componentName}": {
20
+ "keyName": {
21
+ "${locales[0]}": "...",
22
+ "${locales[1]}": "..."
23
+ }
24
+ }
25
+ }
26
+
27
+ TEXTS:
28
+ ${texts.map((t) => `"${t}"`).join("\n")}
29
+ `.trim();
30
+ }
@@ -0,0 +1,78 @@
1
+ import { Node } from "ts-morph";
2
+ let tempIdCounter = 0;
3
+ const allowedFunctions = new Set(["alert", "confirm", "prompt"]);
4
+ function processTemplateLiteral(node) {
5
+ if (Node.isNoSubstitutionTemplateLiteral(node)) {
6
+ return { placeholders: [], text: node.getLiteralText() };
7
+ }
8
+ if (Node.isTemplateExpression(node)) {
9
+ let text = node.getHead().getLiteralText();
10
+ const placeholders = [];
11
+ for (const span of node.getTemplateSpans()) {
12
+ const exprText = span.getExpression().getText();
13
+ const literalText = span.getLiteral().getLiteralText();
14
+ text += `{${exprText}}${literalText}`;
15
+ placeholders.push(exprText);
16
+ }
17
+ return { placeholders, text };
18
+ }
19
+ return null;
20
+ }
21
+ export function extractTexts(sourceFile) {
22
+ const results = [];
23
+ sourceFile.forEachDescendant((node) => {
24
+ // JSX TEXT simple
25
+ if (Node.isJsxText(node)) {
26
+ const text = node.getText().trim();
27
+ if (text) {
28
+ const tempKey = `i$fdw_${tempIdCounter++}`;
29
+ results.push({ node, placeholders: [], tempKey, text });
30
+ }
31
+ }
32
+ // JSX Expression con TemplateLiteral
33
+ if (Node.isJsxExpression(node)) {
34
+ const expr = node.getExpression();
35
+ if (expr && (Node.isTemplateExpression(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
36
+ const processed = processTemplateLiteral(expr);
37
+ if (processed) {
38
+ const tempKey = `i$fdw_${tempIdCounter++}`;
39
+ results.push({ node: expr, placeholders: processed.placeholders, tempKey, text: processed.text });
40
+ }
41
+ }
42
+ }
43
+ // STRING LITERALS
44
+ if (Node.isStringLiteral(node)) {
45
+ const text = node.getLiteralText();
46
+ const parent = node.getParent();
47
+ // JSX Attributes permitidos
48
+ const allowedProps = ["placeholder", "title", "alt", "aria-label"];
49
+ if (Node.isJsxAttribute(parent) && allowedProps.includes(parent.getNameNode().getText())) {
50
+ const tempKey = `i$fdw_${tempIdCounter++}`;
51
+ results.push({ node, placeholders: [], tempKey, text });
52
+ }
53
+ // Funciones tipo alert, confirm, prompt
54
+ if (Node.isCallExpression(parent)) {
55
+ const fnName = parent.getExpression().getText();
56
+ if (allowedFunctions.has(fnName)) {
57
+ const tempKey = `i$fdw_${tempIdCounter++}`;
58
+ results.push({ node, placeholders: [], tempKey, text });
59
+ }
60
+ }
61
+ }
62
+ // TEMPLATE LITERALS para alert/confirm/prompt
63
+ if (Node.isTemplateExpression(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
64
+ const parent = node.getParent();
65
+ if (Node.isCallExpression(parent)) {
66
+ const fnName = parent.getExpression().getText();
67
+ if (allowedFunctions.has(fnName)) {
68
+ const processed = processTemplateLiteral(node);
69
+ if (processed) {
70
+ const tempKey = `i$fdw_${tempIdCounter++}`;
71
+ results.push({ node, placeholders: processed.placeholders, tempKey, text: processed.text });
72
+ }
73
+ }
74
+ }
75
+ }
76
+ });
77
+ return results;
78
+ }
@@ -0,0 +1,42 @@
1
+ import { SyntaxKind, VariableDeclarationKind } from "ts-morph";
2
+ export function insertUseTranslations(sourceFile, namespace) {
3
+ const defaultExport = sourceFile.getDefaultExportSymbol();
4
+ if (!defaultExport)
5
+ return;
6
+ const declarations = defaultExport.getDeclarations();
7
+ if (declarations.length === 0)
8
+ return;
9
+ const decl = declarations[0];
10
+ // Function Component tradicional
11
+ if (decl.getKind() === SyntaxKind.FunctionDeclaration) {
12
+ const body = decl.asKind(SyntaxKind.FunctionDeclaration)?.getBody();
13
+ if (!body)
14
+ return;
15
+ if (!body.getVariableStatements().some(vs => vs.getDeclarations().some(d => d.getName() === "t"))) {
16
+ body.insertVariableStatement(0, {
17
+ declarationKind: VariableDeclarationKind.Const,
18
+ declarations: [{ initializer: `useTranslations("${namespace}")`, name: "t" }],
19
+ });
20
+ }
21
+ return;
22
+ }
23
+ // Arrow Function Component
24
+ if (decl.getKind() === SyntaxKind.VariableDeclaration) {
25
+ const initializer = decl.getInitializer();
26
+ if (!initializer)
27
+ return;
28
+ // Si es arrow function
29
+ if (initializer.getKind() === SyntaxKind.ArrowFunction) {
30
+ const body = initializer.asKind(SyntaxKind.ArrowFunction)?.getBody();
31
+ if (!body)
32
+ return;
33
+ // Si es un bloque {}
34
+ if (body.getKind() === SyntaxKind.Block && !body.getVariableStatements().some(vs => vs.getDeclarations().some(d => d.getName() === "t"))) {
35
+ body.insertVariableStatement(0, {
36
+ declarationKind: VariableDeclarationKind.Const, declarations: [{ initializer: `useTranslations("${namespace}")`, name: "t" }],
37
+ });
38
+ }
39
+ decl.setInitializer(`() => { const t = useTranslations("${namespace}"); return ${body.getText()}; }`);
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,38 @@
1
+ import { Node } from "ts-morph";
2
+ const IGNORED_PROPS = new Set([
3
+ "class",
4
+ "className",
5
+ "id",
6
+ "key",
7
+ ]);
8
+ export function isTranslatableString(node, text) {
9
+ const parent = node.getParent();
10
+ // use client / use server
11
+ if (text === "use client" || text === "use server") {
12
+ return false;
13
+ }
14
+ // imports
15
+ if (Node.isImportDeclaration(parent))
16
+ return false;
17
+ // JSX attributes (className, id, etc)
18
+ if (Node.isJsxAttribute(parent)) {
19
+ const name = parent.getNameNode().getText();
20
+ if (IGNORED_PROPS.has(name)) {
21
+ return false;
22
+ }
23
+ }
24
+ // Tailwind heurística (simple y efectiva)
25
+ if (/^(flex|grid|gap-|bg-|text-|p-|m-|w-|h-)/.test(text)) {
26
+ return false;
27
+ }
28
+ // paths
29
+ if (text.startsWith("/") || text.startsWith("./")) {
30
+ return false;
31
+ }
32
+ // console.*
33
+ if (Node.isCallExpression(parent) &&
34
+ parent.getExpression().getText().startsWith("console.")) {
35
+ return false;
36
+ }
37
+ return true;
38
+ }
@@ -0,0 +1,7 @@
1
+ import { Project } from "ts-morph";
2
+ export function parseFile(filePath) {
3
+ const project = new Project({
4
+ tsConfigFilePath: "tsconfig.json",
5
+ });
6
+ return project.addSourceFileAtPath(filePath);
7
+ }
@@ -0,0 +1,32 @@
1
+ import { Node } from "ts-morph";
2
+ const allowedProps = new Set(["alt", "aria-label", "placeholder", "title"]);
3
+ const allowedFunctions = new Set(["alert", "confirm", "prompt"]);
4
+ export function replaceTempKeysWithT(mapped) {
5
+ for (const { key, node, placeholders = [] } of mapped) {
6
+ const placeholdersText = placeholders.length > 0
7
+ ? `{ ${placeholders.map(p => `${p}: ${p}`).join(", ")} }`
8
+ : "";
9
+ if (Node.isJsxText(node)) {
10
+ // JSXText → {t("key")}
11
+ node.replaceWithText(`{t("${key}"${placeholdersText ? `, ${placeholdersText}` : ""})}`);
12
+ }
13
+ else if (Node.isStringLiteral(node)) {
14
+ const parent = node.getParent();
15
+ if (Node.isJsxAttribute(parent) && allowedProps.has(parent.getNameNode().getText())) {
16
+ // Props de JSX → {t("key")}
17
+ node.replaceWithText(`{t("${key}"${placeholdersText ? `, ${placeholdersText}` : ""})}`);
18
+ }
19
+ else if (Node.isCallExpression(parent) && allowedFunctions.has(parent.getExpression().getText())) {
20
+ // Literal dentro de alert/confirm/prompt → t("key", { ... })
21
+ node.replaceWithText(`t("${key}"${placeholdersText ? `, ${placeholdersText}` : ""})`);
22
+ }
23
+ }
24
+ else if (Node.isNoSubstitutionTemplateLiteral(node) || Node.isTemplateExpression(node)) {
25
+ const parent = node.getParent();
26
+ if (Node.isCallExpression(parent) && allowedFunctions.has(parent.getExpression().getText())) {
27
+ // Template literal dentro de alert/confirm/prompt
28
+ node.replaceWithText(`t("${key}"${placeholdersText ? `, ${placeholdersText}` : ""})`);
29
+ }
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,12 @@
1
+ export function parseAiJson(raw) {
2
+ const cleaned = raw
3
+ .replaceAll('```json', "")
4
+ .replaceAll('```', "")
5
+ .trim();
6
+ try {
7
+ return JSON.parse(cleaned);
8
+ }
9
+ catch {
10
+ throw new Error("Gemini did not return valid JSON");
11
+ }
12
+ }
@@ -0,0 +1,3 @@
1
+ export function saveSourceFile(sourceFile) {
2
+ sourceFile.saveSync();
3
+ }
@@ -0,0 +1,17 @@
1
+ import chalk from "chalk";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ export function writeLocaleFiles(namespace, data, locales) {
5
+ for (const locale of locales) {
6
+ const content = {};
7
+ content[namespace] = {};
8
+ for (const key of Object.keys(data[namespace])) {
9
+ content[namespace][key] = data[namespace][key][locale];
10
+ }
11
+ const dir = path.join(process.cwd(), "messages", locale);
12
+ fs.mkdirSync(dir, { recursive: true });
13
+ const filePath = path.join(dir, `${namespace}.json`);
14
+ fs.writeFileSync(filePath, JSON.stringify(content, null, 2));
15
+ console.log(chalk.green(`💾 Locale file saved: ${filePath}`));
16
+ }
17
+ }
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { run } from '@oclif/core';
@@ -0,0 +1,165 @@
1
+ {
2
+ "commands": {
3
+ "extract": {
4
+ "aliases": [],
5
+ "args": {
6
+ "file": {
7
+ "description": "Path to the TSX/JSX file",
8
+ "name": "file",
9
+ "required": true
10
+ }
11
+ },
12
+ "description": "🌍 Extract translatable strings from a TSX/JSX file and generate i18n JSON",
13
+ "flags": {
14
+ "locales": {
15
+ "char": "l",
16
+ "description": "Locales to generate",
17
+ "name": "locales",
18
+ "default": "en,es",
19
+ "hasDynamicHelp": false,
20
+ "multiple": false,
21
+ "type": "option"
22
+ },
23
+ "provider": {
24
+ "char": "p",
25
+ "description": "AI provider (gemini | huggingface), optional",
26
+ "name": "provider",
27
+ "hasDynamicHelp": false,
28
+ "multiple": false,
29
+ "type": "option"
30
+ }
31
+ },
32
+ "hasDynamicHelp": false,
33
+ "hiddenAliases": [],
34
+ "id": "extract",
35
+ "pluginAlias": "i18nizer",
36
+ "pluginName": "i18nizer",
37
+ "pluginType": "core",
38
+ "strict": true,
39
+ "enableJsonFlag": false,
40
+ "isESM": true,
41
+ "relativePath": [
42
+ "dist",
43
+ "commands",
44
+ "extract.js"
45
+ ]
46
+ },
47
+ "keys": {
48
+ "aliases": [],
49
+ "args": {},
50
+ "description": "Manage API keys for your CLI",
51
+ "flags": {
52
+ "setGemini": {
53
+ "char": "g",
54
+ "description": "Set Google Gemini API key",
55
+ "name": "setGemini",
56
+ "hasDynamicHelp": false,
57
+ "multiple": false,
58
+ "type": "option"
59
+ },
60
+ "setHF": {
61
+ "char": "h",
62
+ "description": "Set Hugging Face API key",
63
+ "name": "setHF",
64
+ "hasDynamicHelp": false,
65
+ "multiple": false,
66
+ "type": "option"
67
+ },
68
+ "setOpenAI": {
69
+ "char": "o",
70
+ "description": "Set OpenAI API key",
71
+ "name": "setOpenAI",
72
+ "hasDynamicHelp": false,
73
+ "multiple": false,
74
+ "type": "option"
75
+ },
76
+ "show": {
77
+ "char": "s",
78
+ "description": "Show saved keys (masked)",
79
+ "name": "show",
80
+ "allowNo": false,
81
+ "type": "boolean"
82
+ }
83
+ },
84
+ "hasDynamicHelp": false,
85
+ "hiddenAliases": [],
86
+ "id": "keys",
87
+ "pluginAlias": "i18nizer",
88
+ "pluginName": "i18nizer",
89
+ "pluginType": "core",
90
+ "strict": true,
91
+ "enableJsonFlag": false,
92
+ "isESM": true,
93
+ "relativePath": [
94
+ "dist",
95
+ "commands",
96
+ "keys.js"
97
+ ]
98
+ },
99
+ "hello": {
100
+ "aliases": [],
101
+ "args": {
102
+ "person": {
103
+ "description": "Person to say hello to",
104
+ "name": "person",
105
+ "required": true
106
+ }
107
+ },
108
+ "description": "Say hello",
109
+ "examples": [
110
+ "<%= config.bin %> <%= command.id %> friend --from oclif\nhello friend from oclif! (./src/commands/hello/index.ts)\n"
111
+ ],
112
+ "flags": {
113
+ "from": {
114
+ "char": "f",
115
+ "description": "Who is saying hello",
116
+ "name": "from",
117
+ "required": true,
118
+ "hasDynamicHelp": false,
119
+ "multiple": false,
120
+ "type": "option"
121
+ }
122
+ },
123
+ "hasDynamicHelp": false,
124
+ "hiddenAliases": [],
125
+ "id": "hello",
126
+ "pluginAlias": "i18nizer",
127
+ "pluginName": "i18nizer",
128
+ "pluginType": "core",
129
+ "strict": true,
130
+ "enableJsonFlag": false,
131
+ "isESM": true,
132
+ "relativePath": [
133
+ "dist",
134
+ "commands",
135
+ "hello",
136
+ "index.js"
137
+ ]
138
+ },
139
+ "hello:world": {
140
+ "aliases": [],
141
+ "args": {},
142
+ "description": "Say hello world",
143
+ "examples": [
144
+ "<%= config.bin %> <%= command.id %>\nhello world! (./src/commands/hello/world.ts)\n"
145
+ ],
146
+ "flags": {},
147
+ "hasDynamicHelp": false,
148
+ "hiddenAliases": [],
149
+ "id": "hello:world",
150
+ "pluginAlias": "i18nizer",
151
+ "pluginName": "i18nizer",
152
+ "pluginType": "core",
153
+ "strict": true,
154
+ "enableJsonFlag": false,
155
+ "isESM": true,
156
+ "relativePath": [
157
+ "dist",
158
+ "commands",
159
+ "hello",
160
+ "world.js"
161
+ ]
162
+ }
163
+ },
164
+ "version": "0.1.0"
165
+ }
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "i18nizer",
3
+ "description": "CLI to extract texts from JSX/TSX and generate i18n JSON with AI translations",
4
+ "version": "0.1.0",
5
+ "author": "Yoannis Sanchez Soto",
6
+ "bin": "./bin/run.js",
7
+ "bugs": "https://github.com/tools/i18nizer/issues",
8
+ "dependencies": {
9
+ "@google/genai": "^1.34.0",
10
+ "@huggingface/inference": "^4.13.5",
11
+ "@oclif/core": "^4",
12
+ "@oclif/plugin-help": "^6",
13
+ "@oclif/plugin-plugins": "^5",
14
+ "chalk": "^5.6.2",
15
+ "node-fetch": "^3.3.2",
16
+ "openai": "^6.15.0",
17
+ "ora": "^9.0.0",
18
+ "ts-morph": "^27.0.2"
19
+ },
20
+ "devDependencies": {
21
+ "@eslint/compat": "^1",
22
+ "@oclif/prettier-config": "^0.2.1",
23
+ "@oclif/test": "^4",
24
+ "@types/chai": "^4",
25
+ "@types/mocha": "^10",
26
+ "@types/node": "^18",
27
+ "chai": "^4",
28
+ "eslint": "^9",
29
+ "eslint-config-oclif": "^6",
30
+ "eslint-config-prettier": "^10",
31
+ "mocha": "^10",
32
+ "oclif": "^4",
33
+ "shx": "^0.3.3",
34
+ "ts-node": "^10",
35
+ "typescript": "^5"
36
+ },
37
+ "engines": {
38
+ "node": ">=18.0.0"
39
+ },
40
+ "files": [
41
+ "./bin",
42
+ "./dist",
43
+ "./oclif.manifest.json"
44
+ ],
45
+ "homepage": "https://github.com/tools/i18nizer#readme",
46
+ "keywords": [
47
+ "oclif",
48
+ "i18n",
49
+ "cli",
50
+ "translation",
51
+ "ai",
52
+ "generative-ai",
53
+ "google"
54
+ ],
55
+ "license": "MIT",
56
+ "main": "dist/index.js",
57
+ "type": "module",
58
+ "oclif": {
59
+ "bin": "i18nizer",
60
+ "dirname": "i18nizer",
61
+ "commands": "./dist/commands",
62
+ "plugins": [
63
+ "@oclif/plugin-help",
64
+ "@oclif/plugin-plugins"
65
+ ],
66
+ "topicSeparator": " ",
67
+ "topics": {
68
+ "hello": {
69
+ "description": "Say hello to the world and others"
70
+ }
71
+ }
72
+ },
73
+ "repository": "tools/i18nizer",
74
+ "scripts": {
75
+ "build": "shx rm -rf dist && tsc -b",
76
+ "lint": "eslint",
77
+ "postpack": "shx rm -f oclif.manifest.json",
78
+ "posttest": "yarn lint",
79
+ "prepack": "oclif manifest && oclif readme",
80
+ "test": "mocha --forbid-only \"test/**/*.test.ts\"",
81
+ "version": "oclif readme && git add README.md"
82
+ },
83
+ "types": "dist/index.d.ts",
84
+ "packageManager": "yarn@4.12.0"
85
+ }