bctranslate 1.0.0 → 1.0.2

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.
@@ -1,13 +1,10 @@
1
1
  import * as babelParser from '@babel/parser';
2
2
  import _traverse from '@babel/traverse';
3
- import _generate from '@babel/generator';
4
- import * as t from '@babel/types';
5
3
  import MagicString from 'magic-string';
6
- import { hashKey, isTranslatable } from '../utils.js';
4
+ import { contextKey, isTranslatable } from '../utils.js';
7
5
 
8
6
  // Handle ESM default export quirks
9
7
  const traverse = _traverse.default || _traverse;
10
- const generate = _generate.default || _generate;
11
8
 
12
9
  /**
13
10
  * Non-translatable JSX attribute names.
@@ -25,8 +22,31 @@ const ATTR_WHITELIST = new Set([
25
22
  'aria-placeholder', 'aria-description',
26
23
  ]);
27
24
 
25
+ /**
26
+ * Walk up the Babel path tree to find the nearest enclosing function
27
+ * with a BlockStatement body — that is the React component function.
28
+ * Returns the character offset right after the opening `{`.
29
+ */
30
+ function findComponentBodyStart(jsxPath) {
31
+ let p = jsxPath.parentPath;
32
+ while (p) {
33
+ const { node } = p;
34
+ if (
35
+ (node.type === 'FunctionDeclaration' ||
36
+ node.type === 'FunctionExpression' ||
37
+ node.type === 'ArrowFunctionExpression') &&
38
+ node.body?.type === 'BlockStatement'
39
+ ) {
40
+ return node.body.start + 1; // right after opening {
41
+ }
42
+ p = p.parentPath;
43
+ }
44
+ return -1;
45
+ }
46
+
28
47
  /**
29
48
  * Parse a JSX/TSX file and extract translatable strings.
49
+ * Hook injection uses AST-derived character positions — no regex.
30
50
  */
31
51
  export function parseReact(source, filePath) {
32
52
  const extracted = [];
@@ -45,13 +65,12 @@ export function parseReact(source, filePath) {
45
65
  'decorators-legacy',
46
66
  ].filter(Boolean),
47
67
  });
48
- } catch (err) {
49
- // If Babel fails, return unmodified
68
+ } catch {
50
69
  return { source, extracted: [], modified: false };
51
70
  }
52
71
 
53
72
  const s = new MagicString(source);
54
- let needsImport = false;
73
+ let hookInsertPos = -1;
55
74
 
56
75
  traverse(ast, {
57
76
  // JSX text children: <div>Hello World</div>
@@ -59,28 +78,23 @@ export function parseReact(source, filePath) {
59
78
  const text = path.node.value.trim();
60
79
  if (!isTranslatable(text)) return;
61
80
 
62
- const key = hashKey(text);
63
- const start = path.node.start;
64
- const end = path.node.end;
65
-
66
- // Preserve whitespace
81
+ const key = contextKey(text, filePath);
82
+ const { start, end } = path.node;
67
83
  const original = path.node.value;
68
84
  const leadingWs = original.match(/^(\s*)/)[1];
69
85
  const trailingWs = original.match(/(\s*)$/)[1];
70
86
 
71
87
  s.overwrite(start, end, `${leadingWs}{t('${key}')}${trailingWs}`);
72
88
  extracted.push({ key, text, context: 'jsx-text' });
73
- needsImport = true;
89
+
90
+ if (hookInsertPos === -1) hookInsertPos = findComponentBodyStart(path);
74
91
  },
75
92
 
76
93
  // JSX string attributes: <input placeholder="Enter name" />
77
94
  JSXAttribute(path) {
78
95
  const name = path.node.name?.name;
79
- if (!name) return;
80
-
81
- if (ATTR_BLACKLIST.has(name)) return;
96
+ if (!name || ATTR_BLACKLIST.has(name)) return;
82
97
 
83
- // Only translate whitelisted attrs, or unknown attrs if they have translatable values
84
98
  const value = path.node.value;
85
99
  if (!value || value.type !== 'StringLiteral') return;
86
100
 
@@ -88,60 +102,41 @@ export function parseReact(source, filePath) {
88
102
  if (!isTranslatable(text)) return;
89
103
  if (!ATTR_WHITELIST.has(name) && text.length < 3) return;
90
104
 
91
- const key = hashKey(text);
92
- const attrStart = path.node.start;
93
- const attrEnd = path.node.end;
94
-
95
- s.overwrite(attrStart, attrEnd, `${name}={t('${key}')}`);
105
+ const key = contextKey(text, filePath);
106
+ s.overwrite(path.node.start, path.node.end, `${name}={t('${key}')}`);
96
107
  extracted.push({ key, text, context: `jsx-attr-${name}` });
97
- needsImport = true;
108
+
109
+ if (hookInsertPos === -1) hookInsertPos = findComponentBodyStart(path);
98
110
  },
99
111
 
100
- // String literals in common patterns (not JSX)
112
+ // alert('...'), confirm('...')
101
113
  CallExpression(path) {
102
114
  const callee = path.node.callee;
103
- const calleeName = callee.name || (callee.property && callee.property.name);
104
-
105
- // alert('...'), confirm('...'), toast('...')
106
- if (['alert', 'confirm'].includes(calleeName)) {
107
- const arg = path.node.arguments[0];
108
- if (arg && arg.type === 'StringLiteral' && isTranslatable(arg.value)) {
109
- const key = hashKey(arg.value);
110
- s.overwrite(arg.start, arg.end, `t('${key}')`);
111
- extracted.push({ key, text: arg.value, context: 'call-arg' });
112
- needsImport = true;
113
- }
115
+ const calleeName = callee.name || callee.property?.name;
116
+ if (!['alert', 'confirm'].includes(calleeName)) return;
117
+
118
+ const arg = path.node.arguments[0];
119
+ if (arg?.type === 'StringLiteral' && isTranslatable(arg.value)) {
120
+ const key = contextKey(arg.value, filePath);
121
+ s.overwrite(arg.start, arg.end, `t('${key}')`);
122
+ extracted.push({ key, text: arg.value, context: 'call-arg' });
114
123
  }
115
124
  },
116
125
  });
117
126
 
118
- // Add useTranslation import if needed
119
- if (needsImport) {
120
- const hasUseTranslation = source.includes('useTranslation');
121
- if (!hasUseTranslation) {
122
- // Find the first import statement or top of file
123
- let insertPos = 0;
124
- const importMatch = source.match(/^import\s.+$/m);
125
- if (importMatch) {
126
- // Insert after all imports
127
- const lastImportMatch = [...source.matchAll(/^import\s.+$/gm)];
128
- if (lastImportMatch.length > 0) {
129
- const last = lastImportMatch[lastImportMatch.length - 1];
130
- insertPos = last.index + last[0].length;
131
- }
127
+ // ── Inject import and hook using AST-derived positions ───────────────────────
128
+ if (extracted.length > 0) {
129
+ if (!source.includes('useTranslation')) {
130
+ // Find the last ImportDeclaration node — safe even with 'use client' directives
131
+ let lastImportEnd = 0;
132
+ for (const node of ast.program.body) {
133
+ if (node.type === 'ImportDeclaration') lastImportEnd = node.end;
132
134
  }
133
-
134
- s.appendRight(insertPos, `\nimport { useTranslation } from 'react-i18next';\n`);
135
+ s.appendRight(lastImportEnd, `\nimport { useTranslation } from 'react-i18next';`);
135
136
  }
136
137
 
137
- // Add const { t } = useTranslation() if not present
138
- if (!source.includes('useTranslation()')) {
139
- // Find the function body
140
- const funcMatch = source.match(/(?:function\s+\w+|const\s+\w+\s*=\s*(?:\([^)]*\)\s*=>|\w+\s*=>))\s*\{/);
141
- if (funcMatch) {
142
- const insertAt = funcMatch.index + funcMatch[0].length;
143
- s.appendRight(insertAt, `\n const { t } = useTranslation();\n`);
144
- }
138
+ if (!source.includes('useTranslation()') && hookInsertPos > 0) {
139
+ s.appendRight(hookInsertPos, `\n const { t } = useTranslation();\n`);
145
140
  }
146
141
  }
147
142
 
@@ -150,4 +145,4 @@ export function parseReact(source, filePath) {
150
145
  extracted,
151
146
  modified: extracted.length > 0,
152
147
  };
153
- }
148
+ }
@@ -1,10 +1,7 @@
1
1
  import * as compiler from '@vue/compiler-dom';
2
2
  import MagicString from 'magic-string';
3
- import { hashKey, isTranslatable, parseInterpolation } from '../utils.js';
3
+ import { contextKey, isTranslatable } from '../utils.js';
4
4
 
5
- /**
6
- * Non-translatable attribute names.
7
- */
8
5
  const ATTR_BLACKLIST = new Set([
9
6
  'id', 'class', 'style', 'src', 'href', 'ref', 'key', 'is',
10
7
  'v-model', 'v-bind', 'v-on', 'v-if', 'v-else', 'v-else-if',
@@ -17,149 +14,149 @@ const ATTR_BLACKLIST = new Set([
17
14
  'xmlns:xlink', 'xlink:href', 'data-testid', 'data-cy',
18
15
  ]);
19
16
 
20
- /**
21
- * Translatable attribute names.
22
- */
23
17
  const ATTR_WHITELIST = new Set([
24
18
  'title', 'placeholder', 'label', 'alt', 'aria-label',
25
19
  'aria-placeholder', 'aria-description', 'aria-roledescription',
26
20
  ]);
27
21
 
22
+ /**
23
+ * Detect which t-function call to emit in templates.
24
+ *
25
+ * Rules:
26
+ * - If <script setup> and file already destructures `t` from a composable
27
+ * → use t('key') (matches user's existing pattern)
28
+ * - If <script setup> but no `t` yet
29
+ * → use t('key') AND inject `const { t } = useI18n()` into script
30
+ * - Options API (no setup)
31
+ * → use $t('key') (global plugin property)
32
+ */
33
+ function detectTStyle(source) {
34
+ const isSetup = /<script\b[^>]*\bsetup\b/i.test(source);
35
+
36
+ // Already has: const { t } = ... or const { t, ... } = ...
37
+ const hasT = /const\s*\{[^}]*\bt\b[^}]*\}\s*=/.test(source);
38
+
39
+ return { isSetup, hasT };
40
+ }
41
+
28
42
  /**
29
43
  * Parse a .vue file and extract translatable strings.
30
- * Returns the modified source and the list of extracted strings.
31
44
  */
32
45
  export function parseVue(source, filePath) {
33
- const extracted = []; // {key, text, context}
46
+ const extracted = [];
34
47
  const s = new MagicString(source);
35
- let modified = false;
36
48
 
37
- // Parse template section
49
+ const { isSetup, hasT } = detectTStyle(source);
50
+
51
+ // In templates: use t() for Composition API, $t() for Options API
52
+ const tpl = (key) => (isSetup || hasT) ? `t('${key}')` : `$t('${key}')`;
53
+ // In scripts: use t() for setup, this.$t() for options
54
+ const scr = (key) => isSetup ? `t('${key}')` : `this.$t('${key}')`;
55
+
56
+ // ── Template ────────────────────────────────────────────────────────────────
38
57
  const templateMatch = source.match(/<template\b[^>]*>([\s\S]*?)<\/template>/);
39
58
  if (templateMatch) {
40
- const templateStart = source.indexOf(templateMatch[0]);
41
59
  const templateContent = templateMatch[1];
42
- const templateOffset = templateStart + templateMatch[0].indexOf(templateContent);
60
+ const templateOffset =
61
+ source.indexOf(templateMatch[0]) + templateMatch[0].indexOf(templateContent);
43
62
 
44
63
  try {
45
- const ast = compiler.parse(templateContent, {
46
- comments: true,
47
- getTextMode: () => 0,
48
- });
49
-
50
- walkTemplate(ast.children, s, templateOffset, extracted);
51
- modified = extracted.length > 0;
52
- } catch (err) {
53
- // If Vue parser fails, fall back to regex-based extraction for template
54
- extractTemplateRegex(source, s, extracted);
55
- modified = extracted.length > 0;
64
+ const ast = compiler.parse(templateContent, { comments: true, getTextMode: () => 0 });
65
+ walkTemplate(ast.children, s, templateOffset, extracted, filePath, tpl);
66
+ } catch {
67
+ extractTemplateRegex(source, s, extracted, filePath, tpl);
56
68
  }
57
69
  }
58
70
 
59
- // Parse script section for string literals
71
+ // ── Script ──────────────────────────────────────────────────────────────────
60
72
  const scriptMatch = source.match(/<script\b[^>]*>([\s\S]*?)<\/script>/);
61
73
  if (scriptMatch) {
62
- const scriptStart = source.indexOf(scriptMatch[0]);
63
74
  const scriptContent = scriptMatch[1];
64
- const scriptOffset = scriptStart + scriptMatch[0].indexOf(scriptContent);
65
- const isSetup = /<script\b[^>]*setup[^>]*>/.test(source);
75
+ const scriptOffset =
76
+ source.indexOf(scriptMatch[0]) + scriptMatch[0].indexOf(scriptContent);
77
+ extractScriptStrings(scriptContent, s, scriptOffset, extracted, filePath, scr);
78
+ }
66
79
 
67
- extractScriptStrings(scriptContent, s, scriptOffset, extracted, isSetup);
80
+ // ── Inject `const { t } = useI18n()` if setup but t not yet declared ────────
81
+ if (extracted.length > 0 && isSetup && !hasT) {
82
+ const scriptSetupMatch = source.match(/(<script\b[^>]*\bsetup\b[^>]*>)([\s\S]*?)<\/script>/i);
83
+ if (scriptSetupMatch) {
84
+ const insertAt =
85
+ source.indexOf(scriptSetupMatch[0]) + scriptSetupMatch[1].length;
86
+ const needsImport = !source.includes('useI18n');
87
+ const importLine = needsImport ? `import { useI18n } from 'vue-i18n';\n` : '';
88
+ s.appendRight(insertAt, `\n${importLine}const { t } = useI18n();\n`);
89
+ }
68
90
  }
69
91
 
70
92
  return {
71
- source: modified || extracted.length > 0 ? s.toString() : source,
93
+ source: extracted.length > 0 ? s.toString() : source,
72
94
  extracted,
73
- modified: modified || extracted.length > 0,
95
+ modified: extracted.length > 0,
74
96
  };
75
97
  }
76
98
 
77
- function walkTemplate(nodes, s, baseOffset, extracted) {
99
+ // ── Template walker ───────────────────────────────────────────────────────────
100
+
101
+ function walkTemplate(nodes, s, baseOffset, extracted, filePath, tpl) {
78
102
  for (const node of nodes) {
79
- // Type 2 = Text
103
+ // Text node (type 2)
80
104
  if (node.type === 2) {
81
105
  const text = node.content.trim();
82
106
  if (isTranslatable(text)) {
83
- const key = hashKey(text);
84
107
  const start = baseOffset + node.loc.start.offset;
85
- const end = baseOffset + node.loc.end.offset;
108
+ const end = baseOffset + node.loc.end.offset;
86
109
 
87
- // Check if already wrapped in $t()
88
110
  if (!isAlreadyWrapped(s.original, start, end)) {
89
- // Preserve leading/trailing whitespace
90
- const originalText = node.content;
91
- const leadingWs = originalText.match(/^(\s*)/)[1];
92
- const trailingWs = originalText.match(/(\s*)$/)[1];
93
-
94
- s.overwrite(start, end, `${leadingWs}{{ $t('${key}') }}${trailingWs}`);
111
+ const key = contextKey(text, filePath);
112
+ const orig = node.content;
113
+ const lws = orig.match(/^(\s*)/)[1];
114
+ const tws = orig.match(/(\s*)$/)[1];
115
+ s.overwrite(start, end, `${lws}{{ ${tpl(key)} }}${tws}`);
95
116
  extracted.push({ key, text, context: 'template-text' });
96
117
  }
97
118
  }
98
119
  }
99
120
 
100
- // Type 1 = Element
121
+ // Element (type 1) check translatable attributes, then recurse
101
122
  if (node.type === 1) {
102
- // Check translatable attributes
103
- if (node.props) {
104
- for (const prop of node.props) {
105
- // Type 6 = Attribute (static)
106
- if (prop.type === 6 && prop.value) {
107
- const attrName = prop.name.toLowerCase();
108
-
109
- if (ATTR_WHITELIST.has(attrName) && isTranslatable(prop.value.content)) {
110
- const text = prop.value.content;
111
- const key = hashKey(text);
123
+ for (const prop of node.props ?? []) {
124
+ if (prop.type === 6 && prop.value) {
125
+ const attrName = prop.name.toLowerCase();
126
+ if (ATTR_WHITELIST.has(attrName) && isTranslatable(prop.value.content)) {
127
+ const text = prop.value.content;
128
+ if (!ATTR_BLACKLIST.has(attrName)) {
129
+ const key = contextKey(text, filePath);
112
130
  const attrStart = baseOffset + prop.loc.start.offset;
113
- const attrEnd = baseOffset + prop.loc.end.offset;
114
-
115
- // Convert static attribute to v-bind with $t()
116
- s.overwrite(attrStart, attrEnd, `:${attrName}="$t('${key}')"`);
131
+ const attrEnd = baseOffset + prop.loc.end.offset;
132
+ s.overwrite(attrStart, attrEnd, `:${attrName}="${tpl(key)}"`);
117
133
  extracted.push({ key, text, context: `template-attr-${attrName}` });
118
134
  }
119
135
  }
120
136
  }
121
137
  }
122
-
123
- // Recurse into children
124
- if (node.children) {
125
- walkTemplate(node.children, s, baseOffset, extracted);
126
- }
127
- }
128
-
129
- // Type 5 = Interpolation ({{ expr }})
130
- if (node.type === 5 && node.content) {
131
- // Check for compound expressions that contain string literals
132
- // e.g., {{ isError ? 'Failed' : 'Success' }}
133
- // We only extract simple string content, not expressions
138
+ if (node.children) walkTemplate(node.children, s, baseOffset, extracted, filePath, tpl);
134
139
  }
135
140
 
136
- // Type 8 = CompoundExpression — skip
137
- // Type 11 = ForNode — recurse into children
141
+ // ForNode (type 11)
138
142
  if (node.type === 11 && node.children) {
139
- walkTemplate(node.children, s, baseOffset, extracted);
143
+ walkTemplate(node.children, s, baseOffset, extracted, filePath, tpl);
140
144
  }
141
- // Type 9 = IfNode — recurse into branches
145
+
146
+ // IfNode (type 9) — walk branches
142
147
  if (node.type === 9 && node.branches) {
143
148
  for (const branch of node.branches) {
144
- if (branch.children) {
145
- walkTemplate(branch.children, s, baseOffset, extracted);
146
- }
149
+ if (branch.children) walkTemplate(branch.children, s, baseOffset, extracted, filePath, tpl);
147
150
  }
148
151
  }
149
152
  }
150
153
  }
151
154
 
152
- function extractScriptStrings(scriptContent, s, baseOffset, extracted, isSetup) {
153
- // Match string literals that look like translatable text in common patterns:
154
- // - alert('text'), confirm('text')
155
- // - title: 'text', label: 'text', placeholder: 'text', message: 'text'
156
- // - toast('text'), notify('text')
157
- // - error/success/warning message strings
155
+ // ── Script string extractor ───────────────────────────────────────────────────
158
156
 
157
+ function extractScriptStrings(scriptContent, s, baseOffset, extracted, filePath, scr) {
159
158
  const patterns = [
160
- // Function calls: alert('...'), confirm('...'), toast('...'), notify('...')
161
159
  /\b(alert|confirm|toast|notify|message\.(?:success|error|warning|info))\s*\(\s*(['"`])((?:(?!\2).)+)\2\s*\)/g,
162
- // Object properties: title: '...', label: '...', message: '...'
163
160
  /\b(title|label|placeholder|message|text|description|tooltip|hint|caption|header|subtitle|errorMessage|successMessage)\s*:\s*(['"`])((?:(?!\2).)+)\2/g,
164
161
  ];
165
162
 
@@ -168,44 +165,41 @@ function extractScriptStrings(scriptContent, s, baseOffset, extracted, isSetup)
168
165
  while ((match = pattern.exec(scriptContent)) !== null) {
169
166
  const text = match[3];
170
167
  if (isTranslatable(text) && text.length > 1) {
171
- const key = hashKey(text);
172
- const fullMatchStart = baseOffset + match.index;
168
+ const key = contextKey(text, filePath);
173
169
  const quoteChar = match[2];
174
170
  const textStart = baseOffset + match.index + match[0].indexOf(quoteChar + text);
175
- const textEnd = textStart + text.length + 2; // +2 for quotes
176
-
177
- const tCall = isSetup ? `t('${key}')` : `this.$t('${key}')`;
178
-
179
- s.overwrite(textStart, textEnd, tCall);
171
+ const textEnd = textStart + text.length + 2;
172
+ s.overwrite(textStart, textEnd, scr(key));
180
173
  extracted.push({ key, text, context: 'script' });
181
174
  }
182
175
  }
183
176
  }
184
177
  }
185
178
 
179
+ // ── Helpers ───────────────────────────────────────────────────────────────────
180
+
186
181
  function isAlreadyWrapped(source, start, end) {
187
- // Check if the text is already inside a $t() call or {{ $t(...) }}
188
- const before = source.slice(Math.max(0, start - 20), start);
189
- return /\$t\s*\(\s*['"]/.test(before) || /t\s*\(\s*['"]/.test(before);
182
+ // Look back 25 chars for an open t( or $t( call node is inside an interpolation
183
+ const before = source.slice(Math.max(0, start - 25), start);
184
+ return /\$?t\s*\(\s*['"]/.test(before);
190
185
  }
191
186
 
192
- function extractTemplateRegex(source, s, extracted) {
193
- // Fallback regex extraction for when the Vue parser fails
194
- const textPattern = />([^<]+)</g;
187
+ function extractTemplateRegex(source, s, extracted, filePath, tpl) {
188
+ const pattern = />([^<]+)</g;
195
189
  let match;
196
- while ((match = textPattern.exec(source)) !== null) {
190
+ while ((match = pattern.exec(source)) !== null) {
197
191
  const text = match[1].trim();
198
192
  if (isTranslatable(text)) {
199
- const key = hashKey(text);
200
193
  const textStart = match.index + 1;
201
- const textEnd = textStart + match[1].length;
194
+ const textEnd = textStart + match[1].length;
202
195
  if (!isAlreadyWrapped(source, textStart, textEnd)) {
203
- const original = match[1];
204
- const leadingWs = original.match(/^(\s*)/)[1];
205
- const trailingWs = original.match(/(\s*)$/)[1];
206
- s.overwrite(textStart, textEnd, `${leadingWs}{{ $t('${key}') }}${trailingWs}`);
196
+ const key = contextKey(text, filePath);
197
+ const orig = match[1];
198
+ const lws = orig.match(/^(\s*)/)[1];
199
+ const tws = orig.match(/(\s*)$/)[1];
200
+ s.overwrite(textStart, textEnd, `${lws}{{ ${tpl(key)} }}${tws}`);
207
201
  extracted.push({ key, text, context: 'template-text' });
208
202
  }
209
203
  }
210
204
  }
211
- }
205
+ }