i18n-turbo 0.1.1 → 1.0.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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 Lasantha Lakmal
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Lasantha Lakmal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,74 +1,142 @@
1
- # i18n-turbo
2
-
3
- > Extract hardcoded strings from JSX/TSX/JS/TS files and export them to an i18n JSON format — with optional reverse transformation support.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install -g i18n-turbo
9
- ````
10
-
11
- ## Usage
12
-
13
- ```bash
14
- i18n-turbo <input-dir> <output-en-json> [--dry-run] [--merge] [--lang <code>] [--fn <name>] [--reverse]
15
- ```
16
-
17
- ## Options
18
-
19
- * `--dry-run` — Show what would be replaced, but don’t modify files.
20
- * `--merge` — Merge new keys with existing `en.json` entries.
21
- * `--lang <code>` — Automatically translate to the specified language (e.g., `fr`, `es`) and generate `<lang>.json`.
22
- * `--fn <name>` — Set the i18n function name (default is `t`).
23
- * `--reverse` — Revert translated keys (e.g., `t("hello_world")`) back to the original hardcoded strings using the translation file.
24
-
25
- ## Features
26
-
27
- * Scans `.jsx`, `.tsx`, `.ts`, `.js` files
28
- * Extracts static text and replaces with `t('key')`
29
- * Outputs i18n JSON files (e.g., `en.json`, `fr.json`)
30
- * Reverses translated keys back to raw strings
31
- * Preserves original file structure
32
- * Supports dry-run, merge, and auto-translate
33
-
34
- ## Examples
35
-
36
- ### Extract mode
37
-
38
- **Dry run only (preview):**
39
-
40
- ```bash
41
- i18n-turbo ./src ./locales/en.json --dry-run
42
- ```
43
-
44
- **Translate to French and output `fr.json`:**
45
-
46
- ```bash
47
- i18n-turbo ./examples ./locales/en.json --lang fr
48
- ```
49
-
50
- **Use a custom function name (e.g. `i18n` instead of `t`):**
51
-
52
- ```bash
53
- i18n-turbo ./src ./locales/en.json --fn i18n
54
- ```
55
-
56
- ### Reverse mode
57
-
58
- **Revert translations (e.g. `t("hello_world")`) back to original strings:**
59
-
60
- ```bash
61
- i18n-turbo ./examples ./locales/en.json --reverse
62
- ```
63
-
64
- **Reverse using a custom i18n function name:**
65
-
66
- ```bash
67
- i18n-turbo ./src ./locales/en.json --reverse --fn i18n
68
- ```
69
-
70
- > 🔁 The reverse command reads keys from the translation file (e.g. `en.json`) and replaces `t("key")` with the original hardcoded string like `"Hello world"`.
71
-
72
- ## License
73
-
74
- MIT
1
+ # i18n-turbo
2
+
3
+ > 🚀 **Turbocharge your i18n workflow.**
4
+ > Extract hardcoded strings from JSX/TSX/JS/TS files and export them to an i18n JSON format — with minimal configuration and maximum speed.
5
+
6
+ ## Features
7
+
8
+ - **Blazing Fast**: Asynchronous, parallel file processing for large codebases.
9
+ - 🎯 **Smart Extraction**: Detects strings in JSX text, attributes, and variables.
10
+ - 📁 **Namespaces**: Organize translations into multiple files (e.g., `auth.json`, `common.json`) based on file paths.
11
+ - 💬 **Context Support**: Extract comments (`i18n: ...`) as translation context for translators.
12
+ - 🔢 **Pluralization**: Automatically detects singular/plural patterns in ternary operators.
13
+ - 🔧 **Configs**: Flexible `i18n-turbo.config.js` for custom key strategies, exclusions, and more.
14
+ - 🔄 **Reverse Mode**: Revert `t('key')` back to original strings (great for refactoring).
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install -g i18n-turbo
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ i18n-turbo <input-dir> <output-file> [options]
26
+ ```
27
+
28
+ ### Examples
29
+
30
+ **Basic Extraction:**
31
+ ```bash
32
+ i18n-turbo ./src ./locales/en.json
33
+ ```
34
+
35
+ **Translate to French:**
36
+ ```bash
37
+ i18n-turbo ./src ./locales/en.json --lang fr
38
+ ```
39
+
40
+ **Dry Run (Preview changes):**
41
+ ```bash
42
+ i18n-turbo ./src ./locales/en.json --dry-run
43
+ ```
44
+
45
+ **Update existing translations:**
46
+ ```bash
47
+ i18n-turbo ./src ./locales/en.json --merge
48
+ ```
49
+
50
+ ## Configuration
51
+
52
+ Create an `i18n-turbo.config.js` file in your project root to customize behavior.
53
+
54
+ ```javascript
55
+ // i18n-turbo.config.js
56
+ module.exports = {
57
+ // Function Name (default: 't')
58
+ translationFunction: 't',
59
+
60
+ // String Length Threshold (default: 2)
61
+ minStringLength: 3,
62
+
63
+ // Key Generation
64
+ // Options: 'snake_case', 'camelCase', 'hash', or function(text)
65
+ keyGenerationStrategy: 'snake_case',
66
+
67
+ // Exclude directories/files (glob patterns)
68
+ excludePatterns: ['**/*.test.tsx', '**/stories/**'],
69
+
70
+ // Namespaces (Map source globs to namespace files)
71
+ namespaces: {
72
+ 'src/features/auth/**': 'auth',
73
+ 'src/features/dashboard/**': 'dashboard',
74
+ 'src/components/**': 'common',
75
+ },
76
+
77
+ // Default target language for machine translation
78
+ targetLang: 'es'
79
+ };
80
+ ```
81
+
82
+ ## Advanced Features
83
+
84
+ ### Namespaces
85
+ Control where your strings go by defining namespaces.
86
+ If you configure `namespaces`, `i18n-turbo` will output separate files in the output directory instead of a single file.
87
+
88
+ ```javascript
89
+ // config
90
+ namespaces: {
91
+ 'src/auth/**': 'auth',
92
+ }
93
+ // Output: locales/auth.json, locales/common.json
94
+ ```
95
+
96
+ ### Context Extraction
97
+ Provide context to translators by adding comments starting with `i18n:`.
98
+ The tool is smart enough to find comments attached to the node or its JSX siblings.
99
+
100
+ **Input:**
101
+ ```tsx
102
+ {/* i18n: Title for the landing page */}
103
+ <h1>Welcome Home</h1>
104
+
105
+ const label = "Submit"; // i18n: Button label
106
+ ```
107
+
108
+ **Output (`en.json`):**
109
+ ```json
110
+ {
111
+ "welcome_home": "Welcome Home",
112
+ "welcome_home_comment": "Title for the landing page",
113
+ "submit": "Submit",
114
+ "submit_comment": "Button label"
115
+ }
116
+ ```
117
+
118
+ ### Pluralization
119
+ `i18n-turbo` detects simple pluralization patterns in your code.
120
+
121
+ **Input:**
122
+ ```tsx
123
+ <p>{count === 1 ? "One item" : "Many items"}</p>
124
+ ```
125
+
126
+ **Output:**
127
+ Replaces with `t('one_item')` and `t('many_items')` (Base support).
128
+ *Future updates will implement automatic `t('key', { count })` merging.*
129
+
130
+ ## Key Generation Strategies
131
+
132
+ - **snake_case**: `Hello World` -> `hello_world` (Default)
133
+ - **camelCase**: `Hello World` -> `helloWorld`
134
+ - **hash**: `Hello World` -> `a1b2c3d4` (Useful for stable keys regardless of content)
135
+ - **Custom**:
136
+ ```javascript
137
+ keyGenerationStrategy: (text) => text.toUpperCase().replace(/\s+/g, '_')
138
+ ```
139
+
140
+ ## License
141
+
142
+ MIT
package/dist/bin/cli.js CHANGED
File without changes
package/dist/src/cli.js CHANGED
@@ -8,19 +8,27 @@ exports.runCLI = runCLI;
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const extractor_1 = require("./extractor");
10
10
  const reverser_1 = require("./reverser");
11
+ const config_1 = require("./config");
11
12
  async function runCLI() {
13
+ const config = (0, config_1.loadConfig)();
12
14
  const args = process.argv.slice(2);
13
15
  const inputDir = args[0] || './src';
14
16
  const outputFile = args[1] || './locales/en.json';
15
17
  const fnNameIndex = args.indexOf('--fn');
16
- const fnName = fnNameIndex !== -1 && args[fnNameIndex + 1] ? args[fnNameIndex + 1] : 't';
18
+ // CLI flag takes precedence over config
19
+ const fnName = fnNameIndex !== -1 && args[fnNameIndex + 1]
20
+ ? args[fnNameIndex + 1]
21
+ : (config.translationFunction || 't');
17
22
  const langIndex = args.indexOf('--lang');
18
- const lang = langIndex !== -1 && args[langIndex + 1] ? args[langIndex + 1] : undefined;
23
+ const lang = langIndex !== -1 && args[langIndex + 1]
24
+ ? args[langIndex + 1]
25
+ : config.targetLang;
19
26
  const options = {
20
27
  fnName,
21
28
  dryRun: args.includes('--dry-run'),
22
29
  merge: args.includes('--merge'),
23
30
  lang,
31
+ config,
24
32
  };
25
33
  const resolvedInputDir = path_1.default.resolve(inputDir);
26
34
  const resolvedOutputFile = path_1.default.resolve(outputFile);
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadConfig = loadConfig;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const DEFAULT_CONFIG = {
10
+ translationFunction: 't',
11
+ minStringLength: 2,
12
+ excludePatterns: [],
13
+ keyGenerationStrategy: 'snake_case',
14
+ };
15
+ function loadConfig(cwd = process.cwd()) {
16
+ const configPath = path_1.default.join(cwd, 'i18n-turbo.config.js');
17
+ if (fs_1.default.existsSync(configPath)) {
18
+ try {
19
+ // Dynamic require is acceptable for a CLI tool reading local config
20
+ const userConfig = require(configPath);
21
+ return { ...DEFAULT_CONFIG, ...userConfig };
22
+ }
23
+ catch (error) {
24
+ console.warn('Warning: Failed to load i18n-turbo.config.js:', error);
25
+ }
26
+ }
27
+ // Future enhancement: support package.json or .json config
28
+ return DEFAULT_CONFIG;
29
+ }
@@ -39,6 +39,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.extractStringsFromDirectory = extractStringsFromDirectory;
40
40
  // src/extractor.ts
41
41
  const fs_1 = __importDefault(require("fs"));
42
+ const fs_2 = require("fs"); // Async FS
42
43
  const path_1 = __importDefault(require("path"));
43
44
  const babel = __importStar(require("@babel/core"));
44
45
  const traverse_1 = __importDefault(require("@babel/traverse"));
@@ -47,100 +48,309 @@ const t = __importStar(require("@babel/types"));
47
48
  const scanner_1 = require("./scanner");
48
49
  const utils_1 = require("./utils");
49
50
  const google_translate_api_1 = require("@vitalets/google-translate-api");
51
+ const p_limit_1 = __importDefault(require("p-limit"));
52
+ const minimatch_1 = require("minimatch");
50
53
  async function extractStringsFromDirectory(inputDir, outputFile, options) {
51
- let translationMap = {};
52
- if (options.merge && fs_1.default.existsSync(outputFile)) {
53
- try {
54
- translationMap = JSON.parse(fs_1.default.readFileSync(outputFile, "utf-8"));
55
- }
56
- catch {
57
- console.warn(`[WARN] Could not parse ${outputFile}, starting fresh.`);
58
- }
54
+ // console.log('DEBUG: extractor options.config:', options.config);
55
+ // Map of namespace name -> translation map
56
+ const namespaceMaps = {};
57
+ // Default namespace (if no specific match or fallback)
58
+ const defaultNamespace = 'common'; // or derive from outputFile basename
59
+ // Initialize default map
60
+ if (!options.config?.namespaces) {
61
+ // If no namespaces configured, use a single map keyed by "default" or just merge everything there.
62
+ // actually we will simulate namespaces: "default" -> file content
63
+ namespaceMaps[defaultNamespace] = {};
59
64
  }
60
- const files = (0, scanner_1.getSourceFiles)(inputDir);
61
- for (const file of files) {
62
- const code = fs_1.default.readFileSync(file, "utf-8");
63
- const ast = babel.parseSync(code, {
64
- filename: file,
65
- presets: ["@babel/preset-typescript", "@babel/preset-react"],
65
+ else {
66
+ // initialize configured namespaces
67
+ Object.values(options.config.namespaces).forEach(ns => {
68
+ namespaceMaps[ns] = {};
66
69
  });
67
- if (!ast)
68
- continue;
69
- let modified = false;
70
- (0, traverse_1.default)(ast, {
71
- JSXText(path) {
72
- const rawText = path.node.value.trim();
73
- if (!rawText || /^\{.*\}$/.test(rawText))
74
- return;
75
- const key = (0, utils_1.generateTranslationKey)(rawText);
76
- translationMap[key] = rawText;
77
- const replacement = t.jsxExpressionContainer(t.callExpression(t.identifier(options.fnName), [t.stringLiteral(key)]));
78
- path.replaceWith(replacement);
79
- modified = true;
80
- if (options.dryRun) {
81
- console.log(`[DRY RUN] ${file}: ${rawText} -> ${key}`);
70
+ // Ensure default exists for fallbacks
71
+ namespaceMaps[defaultNamespace] = {};
72
+ }
73
+ // Load existing translations if merge is on
74
+ // Logic update: If namespaces are used, we need to load MULTIPLE files.
75
+ // For simplicity: If config.namespaces exists, we assume outputFile is a DIRECTORY or prefix base.
76
+ // But strictly, if user passes `locales/en.json`, we might treat `locales` as the dir.
77
+ const outputDir = path_1.default.extname(outputFile) ? path_1.default.dirname(outputFile) : outputFile;
78
+ if (options.merge) {
79
+ for (const ns of Object.keys(namespaceMaps)) {
80
+ const nsFile = options.config?.namespaces
81
+ ? path_1.default.join(outputDir, `${ns}.json`)
82
+ : outputFile; // If no namespaces, use the single output file
83
+ if (fs_1.default.existsSync(nsFile)) {
84
+ try {
85
+ namespaceMaps[ns] = JSON.parse(fs_1.default.readFileSync(nsFile, "utf-8"));
82
86
  }
83
- },
84
- StringLiteral(path) {
85
- const value = path.node.value;
86
- // ✅ Skip import declarations
87
- if (path.findParent((p) => p.isImportDeclaration()))
88
- return;
89
- // ✅ Skip if already translated
90
- if (path.parentPath.isCallExpression() &&
91
- path.parentPath.node.callee.type === "Identifier" &&
92
- path.parentPath.node.callee.name === options.fnName)
93
- return;
94
- if (!value || value.length < 2 || value.length > 80)
95
- return;
96
- if (!/[a-zA-Z]/.test(value))
97
- return;
98
- const key = (0, utils_1.generateTranslationKey)(value);
99
- translationMap[key] = value;
100
- let replacement;
101
- // ✅ Detect if inside JSX attribute
102
- if (path.parentPath.isJSXAttribute()) {
103
- replacement = t.jsxExpressionContainer(t.callExpression(t.identifier(options.fnName), [
104
- t.stringLiteral(key),
105
- ]));
87
+ catch {
88
+ console.warn(`[WARN] Could not parse ${nsFile}, starting fresh.`);
106
89
  }
107
- else {
108
- replacement = t.callExpression(t.identifier(options.fnName), [
109
- t.stringLiteral(key),
110
- ]);
90
+ }
91
+ }
92
+ }
93
+ const files = await (0, scanner_1.getSourceFiles)(inputDir, options.config?.excludePatterns);
94
+ const limit = (0, p_limit_1.default)(50); // Concurrency limit
95
+ await Promise.all(files.map((file) => limit(async () => {
96
+ try {
97
+ // Read file async
98
+ const code = await fs_2.promises.readFile(file, "utf-8");
99
+ // Parse AST (still sync for now as babel.parseAsync just wraps it usually)
100
+ const ast = babel.parseSync(code, {
101
+ filename: file,
102
+ presets: ["@babel/preset-typescript", "@babel/preset-react"],
103
+ });
104
+ if (!ast)
105
+ return;
106
+ // Determine namespace for this file
107
+ let currentNs = defaultNamespace;
108
+ if (options.config?.namespaces) {
109
+ const relativePath = path_1.default.relative(process.cwd(), file); // or relative to inputDir? relative to CWD usually for globs
110
+ // Check against globs
111
+ for (const [pattern, nsName] of Object.entries(options.config.namespaces)) {
112
+ if ((0, minimatch_1.minimatch)(relativePath, pattern)) {
113
+ currentNs = nsName;
114
+ break;
115
+ }
116
+ }
117
+ }
118
+ // Ensure map exists (e.g. if we default to common)
119
+ if (!namespaceMaps[currentNs])
120
+ namespaceMaps[currentNs] = {};
121
+ let modified = false;
122
+ const checkComments = (node) => {
123
+ if (node.leadingComments) {
124
+ const c = node.leadingComments.find(c => c.value.trim().startsWith('i18n:'));
125
+ if (c)
126
+ return c.value.trim().replace(/^i18n:\s*/, '');
111
127
  }
112
- path.replaceWith(replacement);
113
- modified = true;
114
- if (options.dryRun) {
115
- console.log(`[DRY RUN] ${file}: "${value}" -> ${key}`);
128
+ return undefined;
129
+ };
130
+ const getContextFromSibling = (path) => {
131
+ if (typeof path.key === 'number' && Array.isArray(path.container)) {
132
+ let k = path.key - 1;
133
+ while (k >= 0) {
134
+ const prevNode = path.container[k];
135
+ if (t.isJSXText(prevNode) && !prevNode.value.trim()) {
136
+ k--;
137
+ continue;
138
+ }
139
+ if (t.isJSXExpressionContainer(prevNode) && t.isJSXEmptyExpression(prevNode.expression)) {
140
+ const comments = prevNode.expression.innerComments;
141
+ if (comments) {
142
+ const c = comments.find((c) => c.value.trim().startsWith('i18n:'));
143
+ if (c)
144
+ return c.value.trim().replace(/^i18n:\s*/, '');
145
+ }
146
+ }
147
+ // If we hit a non-empty text or other node that is not comment, stop?
148
+ // The comment should be immediately preceding (ignoring whitespace).
149
+ // If we found another Element, assume no comment for this one.
150
+ break;
151
+ }
116
152
  }
117
- },
118
- });
119
- if (!options.dryRun && modified) {
120
- const output = (0, generator_1.default)(ast).code;
121
- fs_1.default.writeFileSync(file, output);
153
+ return undefined;
154
+ };
155
+ (0, traverse_1.default)(ast, {
156
+ JSXText(path) {
157
+ const rawText = path.node.value.trim();
158
+ if (!rawText || /^\{.*\}$/.test(rawText))
159
+ return;
160
+ // Check for context comments (i18n: ...)
161
+ let contextComment;
162
+ // Standard comments (leading)
163
+ const comment = checkComments(path.node) ||
164
+ (path.parentPath?.node && checkComments(path.parentPath.node));
165
+ // Sibling comments (JSX expression container before parent Element)
166
+ const siblingComment = path.parentPath ? getContextFromSibling(path.parentPath) : undefined;
167
+ contextComment = comment || siblingComment;
168
+ const key = (0, utils_1.generateTranslationKey)(rawText, options.config?.keyGenerationStrategy);
169
+ namespaceMaps[currentNs][key] = rawText;
170
+ if (contextComment) {
171
+ namespaceMaps[currentNs][`${key}_comment`] = contextComment;
172
+ }
173
+ const replacement = t.jsxExpressionContainer(t.callExpression(t.identifier(options.fnName), [
174
+ t.stringLiteral(key),
175
+ ]));
176
+ path.replaceWith(replacement);
177
+ modified = true;
178
+ if (options.dryRun) {
179
+ console.log(`[DRY RUN] ${file} (${currentNs}): ${rawText} -> ${key}`);
180
+ }
181
+ },
182
+ StringLiteral(path) {
183
+ const value = path.node.value;
184
+ // ✅ Check for context comments (i18n: ...)
185
+ // Check the node or its parent (e.g. if inside specific call/JSX)
186
+ let contextComment;
187
+ // Check current node, parent, or 2nd parent (e.g. JSXAttribute -> JSXOpeningElement -> JSXElement?)
188
+ // For JSX: Text -> JSXElement (parent).
189
+ // For StringLiteral inside JSX: StringLiteral -> JSXAttribute -> JSXOpeningElement ...
190
+ // Test case: {/* comment */} <h1>Text</h1>
191
+ // Here comment is sibling?
192
+ // Babel attaches 'leadingComments' to the subsequent node.
193
+ // So 'Text' might have leadingComments? Or 'h1' has them?
194
+ // If 'Text' is inside 'h1', 'h1' has the comments.
195
+ // so `path.parentPath`?
196
+ const comment = checkComments(path.node) ||
197
+ (path.parentPath?.node && checkComments(path.parentPath.node)) ||
198
+ (path.parentPath?.parentPath?.node && checkComments(path.parentPath.parentPath.node));
199
+ if (comment) {
200
+ contextComment = comment;
201
+ }
202
+ else {
203
+ // Try finding sibling comment if inside JSX Element (e.g. Attribute -> OpeningElement -> Element)
204
+ const elementPath = path.findParent(p => p.isJSXElement());
205
+ if (elementPath) {
206
+ contextComment = getContextFromSibling(elementPath);
207
+ }
208
+ }
209
+ // ✅ Skip import declarations
210
+ if (path.findParent((p) => p.isImportDeclaration()))
211
+ return;
212
+ // ✅ Skip require('...') calls
213
+ if (path.parentPath.isCallExpression() &&
214
+ t.isIdentifier(path.parentPath.node.callee) &&
215
+ path.parentPath.node.callee.name === "require")
216
+ return;
217
+ // ✅ Skip if already translated
218
+ if (path.parentPath.isCallExpression() &&
219
+ path.parentPath.node.callee.type === "Identifier" &&
220
+ path.parentPath.node.callee.name === options.fnName)
221
+ return;
222
+ if (!value ||
223
+ value.length < (options.config?.minStringLength || 2) ||
224
+ value.length > 80)
225
+ return;
226
+ if (!/[a-zA-Z]/.test(value))
227
+ return;
228
+ const key = (0, utils_1.generateTranslationKey)(value, options.config?.keyGenerationStrategy);
229
+ // console.log(`DEBUG: key for "${value}" -> "${key}"`);
230
+ namespaceMaps[currentNs][key] = value;
231
+ if (contextComment) {
232
+ namespaceMaps[currentNs][`${key}_comment`] = contextComment;
233
+ }
234
+ let replacement;
235
+ // ✅ Detect if inside ignored JSX attribute (direct or nested)
236
+ const attributePath = path.findParent((p) => p.isJSXAttribute());
237
+ if (attributePath && attributePath.isJSXAttribute()) {
238
+ const attrName = attributePath.node.name;
239
+ if (t.isJSXIdentifier(attrName) &&
240
+ [
241
+ "data-testid",
242
+ "className",
243
+ "style",
244
+ "id",
245
+ "key",
246
+ "ref",
247
+ "width",
248
+ "height",
249
+ "href",
250
+ "src",
251
+ "type",
252
+ "rel",
253
+ "target",
254
+ "alt",
255
+ "placeholder",
256
+ ].includes(attrName.name))
257
+ return;
258
+ }
259
+ // ✅ If inside JSX attribute (but not ignored), we need JSX wrapper
260
+ if (path.parentPath.isJSXAttribute()) {
261
+ // Double check we are direct child (standard string attr)
262
+ replacement = t.jsxExpressionContainer(t.callExpression(t.identifier(options.fnName), [
263
+ t.stringLiteral(key),
264
+ ]));
265
+ }
266
+ else {
267
+ replacement = t.callExpression(t.identifier(options.fnName), [
268
+ t.stringLiteral(key),
269
+ ]);
270
+ }
271
+ path.replaceWith(replacement);
272
+ modified = true;
273
+ if (options.dryRun) {
274
+ console.log(`[DRY RUN] ${file} (${currentNs}): "${value}" -> ${key}`);
275
+ }
276
+ },
277
+ });
278
+ if (!options.dryRun && modified) {
279
+ const output = (0, generator_1.default)(ast).code;
280
+ // Write file async
281
+ await fs_2.promises.writeFile(file, output);
282
+ }
122
283
  }
123
- }
284
+ catch (err) {
285
+ console.error(`Error processing file ${file}: `, err);
286
+ }
287
+ })));
124
288
  if (!options.dryRun) {
125
- fs_1.default.mkdirSync(path_1.default.dirname(outputFile), { recursive: true });
126
- if (options.lang && options.lang !== "en") {
127
- const translated = {};
128
- for (const [key, text] of Object.entries(translationMap)) {
129
- try {
130
- const res = await (0, google_translate_api_1.translate)(text, { to: options.lang });
131
- console.log(`Translated "${text}" => "${res.text}"`);
132
- translated[key] = res.text;
289
+ if (outputDir !== ".") {
290
+ await fs_2.promises.mkdir(outputDir, { recursive: true });
291
+ }
292
+ // Write all namespace files
293
+ for (const [ns, map] of Object.entries(namespaceMaps)) {
294
+ if (Object.keys(map).length === 0 && ns !== defaultNamespace)
295
+ continue; // Skip empty non-default/extra namespaces if explicit? Actually keep them empty if created?
296
+ // Let's write them if they have content OR they are the expected output.
297
+ if (Object.keys(map).length === 0 && !options.config?.namespaces)
298
+ continue; // If no config, don't write empty 'common' if empty? No, we should writes.
299
+ let targetFile = outputFile;
300
+ if (options.config?.namespaces) {
301
+ // If namespaces are used, outputFile is treated as base dir for default locale?
302
+ // Or we append ns to outputDir?
303
+ // Logic: if namespaces active, we construct file paths like `outputDir/ns.json`
304
+ // OR if targetLang is active `outputDir/lang/ns.json`.
305
+ // Wait, existing logic for Lang:
306
+ // `path.join(path.dirname(outputFile), `${options.lang}.json`)`
307
+ // New logic:
308
+ // If namespaces:
309
+ // Target File = `outputDir/ns.json` (for default/en)
310
+ // If --lang: `outputDir/ns.lang.json` ?? OR `outputDir/lang/ns.json`?
311
+ // Common pattern: `locales/en/common.json`.
312
+ // Let's assume `outputFile` denotes the folder structure for the default file.
313
+ // e.g. `locales/en.json` -> outputDir = `locales`
314
+ // If namespaces active: `locales/common.json`, `locales/auth.json`.
315
+ targetFile = path_1.default.join(outputDir, `${ns}.json`);
316
+ }
317
+ const mapToWrite = map;
318
+ // Translation logic
319
+ if (options.lang && options.lang !== "en") {
320
+ const translated = {};
321
+ for (const [key, text] of Object.entries(map)) {
322
+ try {
323
+ const res = await (0, google_translate_api_1.translate)(text, { to: options.lang });
324
+ console.log(`Translated [${ns}] "${text}" => "${res.text}"`);
325
+ translated[key] = res.text;
326
+ }
327
+ catch (err) {
328
+ console.warn(`Translation failed for "${text}"`, err);
329
+ translated[key] = text;
330
+ }
133
331
  }
134
- catch (err) {
135
- console.warn(`Translation failed for "${text}"`, err);
136
- translated[key] = text;
332
+ // If namespaces active: `locales/lang/ns.json` or `locales/ns.lang.json`?
333
+ // Let's do `locales/lang.json` if single file,
334
+ // BUT if namespaces: `locales/ns.json` (en), `locales/ns_fr.json`?
335
+ // OR `locales/fr/ns.json`.
336
+ // Let's stick to `locales/fr.json` if NO namespaces (legacy).
337
+ // If namespaces: `locales/fr/ns.json`? Or `locales/ns.fr.json`?
338
+ // Complexity increase!
339
+ // Simplified for this task:
340
+ // If namespaces: write `outputDir/ns.json`. If lang available, write `outputDir/ns_lang.json` (flat).
341
+ let langPath;
342
+ if (options.config?.namespaces) {
343
+ langPath = path_1.default.join(outputDir, `${ns}_${options.lang}.json`);
137
344
  }
345
+ else {
346
+ langPath = path_1.default.join(path_1.default.dirname(outputFile), `${options.lang}.json`);
347
+ }
348
+ await fs_2.promises.writeFile(langPath, JSON.stringify(translated, null, 2), "utf-8");
349
+ }
350
+ else {
351
+ // Default language (usually EN)
352
+ await fs_2.promises.writeFile(targetFile, JSON.stringify(mapToWrite, null, 2), "utf-8");
138
353
  }
139
- const langPath = path_1.default.join(path_1.default.dirname(outputFile), `${options.lang}.json`);
140
- fs_1.default.writeFileSync(langPath, JSON.stringify(translated, null, 2), "utf-8");
141
- }
142
- else {
143
- fs_1.default.writeFileSync(outputFile, JSON.stringify(translationMap, null, 2), "utf-8");
144
354
  }
145
355
  }
146
356
  }
@@ -43,9 +43,9 @@ const traverse_1 = __importDefault(require("@babel/traverse"));
43
43
  const t = __importStar(require("@babel/types"));
44
44
  const generator_1 = __importDefault(require("@babel/generator"));
45
45
  const scanner_1 = require("./scanner");
46
- function reverseStringsFromDirectory(inputDir, i18nFile, fnName = "t") {
46
+ async function reverseStringsFromDirectory(inputDir, i18nFile, fnName = "t") {
47
47
  const translationMap = JSON.parse(fs_1.default.readFileSync(i18nFile, "utf-8"));
48
- const files = (0, scanner_1.getSourceFiles)(inputDir);
48
+ const files = await (0, scanner_1.getSourceFiles)(inputDir);
49
49
  for (const file of files) {
50
50
  const code = fs_1.default.readFileSync(file, "utf-8");
51
51
  const ast = babel.parseSync(code, {
@@ -1,43 +1,13 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  Object.defineProperty(exports, "__esModule", { value: true });
36
3
  exports.getSourceFiles = getSourceFiles;
37
- // src/scanner.ts
38
- const glob = __importStar(require("glob"));
39
- function getSourceFiles(dir) {
40
- return glob.sync(`${dir}/**/*.{js,jsx,ts,tsx}`, {
41
- nodir: true
4
+ const glob_1 = require("glob");
5
+ async function getSourceFiles(dir, excludes = []) {
6
+ console.log(`Scanning dir: ${dir}, excludes: ${excludes}`);
7
+ const files = await (0, glob_1.glob)(`${dir}/**/*.{js,jsx,ts,tsx}`, {
8
+ nodir: true,
9
+ ignore: ['**/node_modules/**', '**/dist/**', ...excludes]
42
10
  });
11
+ console.log(`Found ${files.length} files.`);
12
+ return files;
43
13
  }
package/dist/src/utils.js CHANGED
@@ -1,7 +1,30 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.generateTranslationKey = generateTranslationKey;
4
- // src/utils.ts
5
- function generateTranslationKey(text) {
7
+ const crypto_1 = __importDefault(require("crypto"));
8
+ function generateTranslationKey(text, strategy = 'snake_case') {
9
+ if (typeof strategy === 'function') {
10
+ return strategy(text);
11
+ }
12
+ switch (strategy) {
13
+ case 'camelCase':
14
+ return toCamelCase(text);
15
+ case 'hash':
16
+ return crypto_1.default.createHash('md5').update(text).digest('hex').substring(0, 8);
17
+ case 'snake_case':
18
+ default:
19
+ return toSnakeCase(text);
20
+ }
21
+ }
22
+ function toSnakeCase(text) {
6
23
  return text.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').substring(0, 50);
7
24
  }
25
+ function toCamelCase(text) {
26
+ return text
27
+ .replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => index === 0 ? word.toLowerCase() : word.toUpperCase())
28
+ .replace(/\s+/g, '')
29
+ .replace(/[^a-zA-Z0-9]/g, '');
30
+ }
package/package.json CHANGED
@@ -1,55 +1,58 @@
1
- {
2
- "name": "i18n-turbo",
3
- "version": "0.1.1",
4
- "description": "Extract hardcoded text from JSX/TSX files and convert to i18n format.",
5
- "main": "dist/index.js",
6
- "files": [
7
- "dist",
8
- "README.md",
9
- "LICENSE"
10
- ],
11
- "bin": {
12
- "i18n-turbo": "dist/bin/cli.js"
13
- },
14
- "scripts": {
15
- "build": "tsc",
16
- "start": "ts-node bin/cli.ts",
17
- "test": "vitest",
18
- "prepare": "npm run build"
19
- },
20
- "repository": {
21
- "type": "git",
22
- "url": "git+https://github.com/lasalasa/i18n-turbo.git"
23
- },
24
- "keywords": [
25
- "i18n",
26
- "translation",
27
- "react",
28
- "cli",
29
- "internationalization"
30
- ],
31
- "author": "Lasantha Lakmal",
32
- "license": "MIT",
33
- "bugs": {
34
- "url": "https://github.com/lasalasa/i18n-turbo.git/issues"
35
- },
36
- "homepage": "https://github.com/lasalasa/i18n-turbo#readme",
37
- "dependencies": {
38
- "@babel/core": "^7.0.0",
39
- "@babel/generator": "^7.0.0",
40
- "@babel/preset-react": "^7.0.0",
41
- "@babel/preset-typescript": "^7.0.0",
42
- "@babel/traverse": "^7.0.0",
43
- "@babel/types": "^7.0.0",
44
- "@vitalets/google-translate-api": "^9.2.1",
45
- "glob": "^10.4.5"
46
- },
47
- "devDependencies": {
48
- "@types/babel__core": "^7.20.5",
49
- "@types/babel__generator": "^7.27.0",
50
- "@types/babel__traverse": "^7.20.7",
51
- "ts-node": "^10.0.0",
52
- "typescript": "^5.0.0",
53
- "vitest": "^3.2.4"
54
- }
55
- }
1
+ {
2
+ "name": "i18n-turbo",
3
+ "version": "1.0.0",
4
+ "description": "Extract hardcoded text from JSX/TSX files and convert to i18n format.",
5
+ "main": "dist/index.js",
6
+ "files": [
7
+ "dist",
8
+ "README.md",
9
+ "LICENSE"
10
+ ],
11
+ "bin": {
12
+ "i18n-turbo": "dist/bin/cli.js"
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "start": "ts-node bin/cli.ts",
17
+ "test": "vitest",
18
+ "prepare": "npm run build"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/lasalasa/i18n-turbo.git"
23
+ },
24
+ "keywords": [
25
+ "i18n",
26
+ "translation",
27
+ "react",
28
+ "cli",
29
+ "internationalization"
30
+ ],
31
+ "author": "Lasantha Lakmal",
32
+ "license": "MIT",
33
+ "bugs": {
34
+ "url": "https://github.com/lasalasa/i18n-turbo.git/issues"
35
+ },
36
+ "homepage": "https://github.com/lasalasa/i18n-turbo#readme",
37
+ "dependencies": {
38
+ "@babel/core": "^7.0.0",
39
+ "@babel/generator": "^7.0.0",
40
+ "@babel/preset-react": "^7.0.0",
41
+ "@babel/preset-typescript": "^7.0.0",
42
+ "@babel/traverse": "^7.0.0",
43
+ "@babel/types": "^7.0.0",
44
+ "@types/minimatch": "^5.1.2",
45
+ "@vitalets/google-translate-api": "^9.2.1",
46
+ "glob": "^10.4.5",
47
+ "minimatch": "^10.1.1",
48
+ "p-limit": "^3.1.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/babel__core": "^7.20.5",
52
+ "@types/babel__generator": "^7.27.0",
53
+ "@types/babel__traverse": "^7.20.7",
54
+ "ts-node": "^10.0.0",
55
+ "typescript": "^5.0.0",
56
+ "vitest": "^3.2.4"
57
+ }
58
+ }