bctranslate 1.0.4 → 1.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bctranslate",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "CLI to transform source files into i18n-ready code with automatic translation via argostranslate",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- import { existsSync, writeFileSync, mkdirSync } from 'fs';
1
+ import { existsSync, writeFileSync, mkdirSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import chalk from 'chalk';
4
4
  import { getLocaleDir } from './locales.js';
@@ -150,39 +150,102 @@ async function ensureVanillaI18n(cwd, from, to, localeDir) {
150
150
  locales[lang] = await resp.json();
151
151
  }
152
152
 
153
- function t(key, params) {
154
- const msg = (locales[currentLocale] && locales[currentLocale][key]) || key;
155
- if (!params) return msg;
156
- return msg.replace(/\\{(\\w+)\\}/g, function (_, k) {
157
- return params[k] !== undefined ? params[k] : '{' + k + '}';
158
- });
159
- }
153
+ function t(key, params) {
154
+ // Support both flat keys ("home.submit") and nested objects ({ home: { submit: ... } })
155
+ const dict = locales[currentLocale] || {};
156
+ const direct = Object.prototype.hasOwnProperty.call(dict, key) ? dict[key] : null;
157
+ const msg =
158
+ direct !== null && direct !== undefined
159
+ ? direct
160
+ : key.split('.').reduce((obj, i) => (obj ? obj[i] : null), dict) || key;
161
+
162
+ if (!params) return msg;
163
+ return String(msg).replace(/\\{(\\w+)\\}/g, (match, k) =>
164
+ params[k] !== undefined ? params[k] : match
165
+ );
166
+ }
160
167
 
161
168
  async function setLocale(lang) {
162
169
  await loadLocale(lang);
163
170
  currentLocale = lang;
164
171
  // Re-translate all elements with data-i18n attribute
165
- document.querySelectorAll('[data-i18n]').forEach(function (el) {
166
- const key = el.getAttribute('data-i18n');
167
- el.textContent = t(key);
168
- });
172
+ document.querySelectorAll('[data-i18n]').forEach(function (el) {
173
+ const key = el.getAttribute('data-i18n');
174
+ const translated = t(key);
175
+ // Preserve markup translations (e.g. "Hello <strong>world</strong>")
176
+ if (el.children.length > 0 || /<[^>]+>/.test(String(translated))) {
177
+ el.innerHTML = translated;
178
+ } else {
179
+ el.textContent = translated;
180
+ }
181
+ });
169
182
  document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
170
183
  el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
171
184
  });
172
- document.querySelectorAll('[data-i18n-title]').forEach(function (el) {
173
- el.title = t(el.getAttribute('data-i18n-title'));
174
- });
175
- }
176
-
177
- // Auto-init
178
- loadLocale('${from}');
179
- loadLocale('${to}');
185
+ document.querySelectorAll('[data-i18n-title]').forEach(function (el) {
186
+ const translated = t(el.getAttribute('data-i18n-title'));
187
+ if (el.tagName === 'TITLE') {
188
+ document.title = translated;
189
+ } else {
190
+ el.title = translated;
191
+ }
192
+ });
193
+ }
194
+
195
+ // Auto-init
196
+ loadLocale('${from}');
197
+ setLocale('${to}');
180
198
 
181
199
  window.i18n = { t: t, setLocale: setLocale, loadLocale: loadLocale };
182
200
  })();
183
201
  `;
184
202
 
185
- writeFileSync(i18nFile, content, 'utf-8');
186
- console.log(chalk.green(` ✓ Created ${i18nFile}`));
187
- console.log(chalk.yellow(` ⚠ Add <script src="i18n.js"></script> to your HTML`));
188
- }
203
+ writeFileSync(i18nFile, content, 'utf-8');
204
+ console.log(chalk.green(` ✓ Created ${i18nFile}`));
205
+
206
+ const injected = injectVanillaI18nEntrypoint(cwd);
207
+ if (!injected) {
208
+ console.log(chalk.yellow(` ⚠ Add <script src="i18n.js"></script> to your HTML`));
209
+ }
210
+ }
211
+
212
+ function injectVanillaI18nEntrypoint(cwd) {
213
+ // Prefer HTML entrypoint if present
214
+ const htmlPath = join(cwd, 'index.html');
215
+ if (existsSync(htmlPath)) {
216
+ const html = readFileSync(htmlPath, 'utf-8');
217
+ if (!/\bi18n\.js\b/.test(html)) {
218
+ const scriptTag = ` <script src="./i18n.js"></script>\n`;
219
+ let updated = html;
220
+
221
+ const firstScript = updated.match(/<script\b/i);
222
+ if (firstScript) {
223
+ updated = updated.replace(firstScript[0], scriptTag + firstScript[0]);
224
+ } else if (updated.includes('</body>')) {
225
+ updated = updated.replace('</body>', scriptTag + '</body>');
226
+ } else if (updated.includes('</head>')) {
227
+ updated = updated.replace('</head>', scriptTag + '</head>');
228
+ } else {
229
+ updated += '\n' + scriptTag;
230
+ }
231
+
232
+ writeFileSync(htmlPath, updated, 'utf-8');
233
+ console.log(chalk.green(` ✓ Updated ${htmlPath} (added i18n.js script)`));
234
+ }
235
+ return true;
236
+ }
237
+
238
+ // Fallback: ESM entrypoint
239
+ const jsPath = join(cwd, 'index.js');
240
+ if (existsSync(jsPath)) {
241
+ const js = readFileSync(jsPath, 'utf-8');
242
+ const alreadyImports = js.includes("./i18n.js") || js.includes("'./i18n.js'") || js.includes("\"./i18n.js\"");
243
+ if (!alreadyImports && /\b(import|export)\b/.test(js)) {
244
+ writeFileSync(jsPath, `import './i18n.js';\n` + js, 'utf-8');
245
+ console.log(chalk.green(` ✓ Updated ${jsPath} (imported i18n.js)`));
246
+ return true;
247
+ }
248
+ }
249
+
250
+ return false;
251
+ }
@@ -1,5 +1,6 @@
1
1
  import MagicString from 'magic-string';
2
2
  import { parse, NodeTypes } from '@vue/compiler-dom';
3
+ import { parseJs } from './js.js';
3
4
  import { contextKey, isTranslatable } from '../utils.js';
4
5
 
5
6
  const ATTR_WHITELIST = new Set([
@@ -30,7 +31,7 @@ const CONTENT_TAGS = new Set([
30
31
  'td',
31
32
  ]);
32
33
 
33
- const SKIP_TAGS = new Set(['script', 'style', 'noscript']);
34
+ const SKIP_TAGS = new Set(['style', 'noscript']);
34
35
 
35
36
  function stripTags(html) {
36
37
  return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
@@ -71,6 +72,27 @@ export function parseHtml(source, filePath, project) {
71
72
 
72
73
  const openTagText = source.slice(node.loc.start.offset, openTagEnd);
73
74
 
75
+ // Special case: inline <script> blocks inside HTML
76
+ if (tag === 'script' && !node.isSelfClosing) {
77
+ const hasSrc = (node.props || []).some(
78
+ (p) => p.type === NodeTypes.ATTRIBUTE && p.name === 'src'
79
+ );
80
+ if (hasSrc) return;
81
+
82
+ const closeRel = node.loc.source.lastIndexOf('</');
83
+ if (closeRel > -1) {
84
+ const closeTagStart = node.loc.start.offset + closeRel;
85
+ const scriptJs = source.slice(openTagEnd + 1, closeTagStart);
86
+ const jsResult = parseJs(scriptJs, filePath, project);
87
+ if (jsResult.modified) {
88
+ s.overwrite(openTagEnd + 1, closeTagStart, jsResult.source);
89
+ extracted.push(...jsResult.extracted);
90
+ }
91
+ }
92
+
93
+ return;
94
+ }
95
+
74
96
  // 1) Extract translatable attributes
75
97
  for (const prop of node.props || []) {
76
98
  if (prop.type !== NodeTypes.ATTRIBUTE) continue;
package/src/parsers/js.js CHANGED
@@ -1,8 +1,8 @@
1
- import * as babelParser from '@babel/parser';
2
- import _traverse from '@babel/traverse';
3
- import * as t from '@babel/types';
4
- import MagicString from 'magic-string';
5
- import { contextKey, isTranslatable } from '../utils.js';
1
+ import * as babelParser from '@babel/parser';
2
+ import _traverse from '@babel/traverse';
3
+ import * as t from '@babel/types';
4
+ import MagicString from 'magic-string';
5
+ import { contextKey, isTranslatable } from '../utils.js';
6
6
 
7
7
  const traverse = _traverse.default || _traverse;
8
8
 
@@ -10,14 +10,37 @@ const traverse = _traverse.default || _traverse;
10
10
  * Parse a .js/.ts file and extract translatable strings.
11
11
  * These are strings in common UI patterns: alert(), confirm(), DOM manipulation, etc.
12
12
  */
13
- export function parseJs(source, filePath, project) {
14
- const extracted = [];
15
- const isTS = filePath.endsWith('.ts');
16
-
17
- let ast;
18
- try {
19
- ast = babelParser.parse(source, {
20
- sourceType: 'module',
13
+ export function parseJs(source, filePath, project) {
14
+ const extracted = [];
15
+ const isTS = filePath.endsWith('.ts');
16
+
17
+ const tFuncFor = (key) => {
18
+ if (project.type === 'vanilla') return `i18n.t('${key}')`;
19
+ if (project.type === 'vue') return `this.$t('${key}')`;
20
+ return `t('${key}')`;
21
+ };
22
+
23
+ const translatableVarNames = new Set([
24
+ 'title', 'pageTitle', 'heading', 'subheading', 'header', 'subtitle',
25
+ 'label', 'placeholder', 'message', 'text', 'description', 'tooltip', 'hint', 'caption',
26
+ 'error', 'errorMessage', 'success', 'successMessage',
27
+ 'buttonText', 'linkText',
28
+ ]);
29
+
30
+ const translatableAttrNames = new Set([
31
+ 'title',
32
+ 'placeholder',
33
+ 'label',
34
+ 'alt',
35
+ 'aria-label',
36
+ 'aria-placeholder',
37
+ 'aria-description',
38
+ ]);
39
+
40
+ let ast;
41
+ try {
42
+ ast = babelParser.parse(source, {
43
+ sourceType: 'module',
21
44
  plugins: [
22
45
  isTS ? 'typescript' : null,
23
46
  'classProperties',
@@ -33,12 +56,32 @@ export function parseJs(source, filePath, project) {
33
56
 
34
57
  const s = new MagicString(source);
35
58
 
36
- // Track which string literals to translate
37
- traverse(ast, {
38
- // Object property values with translatable keys
39
- ObjectProperty(path) {
40
- const keyNode = path.node.key;
41
- const valueNode = path.node.value;
59
+ // Track which string literals to translate
60
+ traverse(ast, {
61
+ VariableDeclarator(path) {
62
+ const { id, init } = path.node;
63
+ if (!init) return;
64
+ if (id.type !== 'Identifier') return;
65
+ if (!translatableVarNames.has(id.name)) return;
66
+
67
+ let value = null;
68
+ if (init.type === 'StringLiteral') value = init.value;
69
+ else if (init.type === 'TemplateLiteral' && init.expressions.length === 0) {
70
+ value = init.quasis[0]?.value?.cooked ?? '';
71
+ }
72
+
73
+ if (typeof value !== 'string' || !isTranslatable(value)) return;
74
+
75
+ const key = contextKey(value, filePath);
76
+ const tFunc = tFuncFor(key);
77
+ s.overwrite(init.start, init.end, tFunc);
78
+ extracted.push({ key, text: value, context: `js-var-${id.name}` });
79
+ },
80
+
81
+ // Object property values with translatable keys
82
+ ObjectProperty(path) {
83
+ const keyNode = path.node.key;
84
+ const valueNode = path.node.value;
42
85
 
43
86
  if (valueNode.type !== 'StringLiteral') return;
44
87
  if (!isTranslatable(valueNode.value)) return;
@@ -53,19 +96,19 @@ export function parseJs(source, filePath, project) {
53
96
  'name', 'displayName',
54
97
  ]);
55
98
 
56
- if (!translatableKeys.has(keyName)) return;
99
+ if (!translatableKeys.has(keyName)) return;
100
+
101
+ const key = contextKey(valueNode.value, filePath);
102
+ const tFunc = tFuncFor(key);
103
+
104
+ s.overwrite(valueNode.start, valueNode.end, tFunc);
105
+ extracted.push({ key, text: valueNode.value, context: `js-prop-${keyName}` });
106
+ },
57
107
 
58
- const key = contextKey(valueNode.value, filePath);
59
- const tFunc = project.type === 'vue' ? `this.$t('${key}')` : `t('${key}')`;
60
-
61
- s.overwrite(valueNode.start, valueNode.end, tFunc);
62
- extracted.push({ key, text: valueNode.value, context: `js-prop-${keyName}` });
63
- },
64
-
65
- // Function calls: alert('text'), console messages excluded
66
- CallExpression(path) {
67
- const callee = path.node.callee;
68
- let calleeName = '';
108
+ // Function calls: alert('text'), console messages excluded
109
+ CallExpression(path) {
110
+ const callee = path.node.callee;
111
+ let calleeName = '';
69
112
 
70
113
  if (callee.type === 'Identifier') {
71
114
  calleeName = callee.name;
@@ -76,19 +119,35 @@ export function parseJs(source, filePath, project) {
76
119
  // Skip console.*, require(), import()
77
120
  if (calleeName.startsWith('console.') || calleeName === 'require') return;
78
121
 
79
- // Translate alert/confirm/prompt first arg
80
- if (['alert', 'confirm', 'prompt'].includes(calleeName)) {
81
- const arg = path.node.arguments[0];
82
- if (arg && arg.type === 'StringLiteral' && isTranslatable(arg.value)) {
83
- const key = contextKey(arg.value, filePath);
84
- const tFunc = project.type === 'vue' ? `this.$t('${key}')` : `t('${key}')`;
85
- s.overwrite(arg.start, arg.end, tFunc);
86
- extracted.push({ key, text: arg.value, context: 'js-call' });
87
- }
88
- }
89
-
90
- // .textContent = 'text', .innerText = 'text', .title = 'text'
91
- },
122
+ // Translate alert/confirm/prompt first arg
123
+ if (['alert', 'confirm', 'prompt'].includes(calleeName)) {
124
+ const arg = path.node.arguments[0];
125
+ if (arg && arg.type === 'StringLiteral' && isTranslatable(arg.value)) {
126
+ const key = contextKey(arg.value, filePath);
127
+ const tFunc = tFuncFor(key);
128
+ s.overwrite(arg.start, arg.end, tFunc);
129
+ extracted.push({ key, text: arg.value, context: 'js-call' });
130
+ }
131
+ }
132
+
133
+ // element.setAttribute('title', '...')
134
+ if (callee.type === 'MemberExpression' && callee.property?.name === 'setAttribute') {
135
+ const [nameArg, valueArg] = path.node.arguments;
136
+ if (
137
+ nameArg?.type === 'StringLiteral' &&
138
+ valueArg?.type === 'StringLiteral' &&
139
+ translatableAttrNames.has(nameArg.value) &&
140
+ isTranslatable(valueArg.value)
141
+ ) {
142
+ const key = contextKey(valueArg.value, filePath);
143
+ const tFunc = tFuncFor(key);
144
+ s.overwrite(valueArg.start, valueArg.end, tFunc);
145
+ extracted.push({ key, text: valueArg.value, context: `js-attr-${nameArg.value}` });
146
+ }
147
+ }
148
+
149
+ // .textContent = 'text', .innerText = 'text', .title = 'text'
150
+ },
92
151
 
93
152
  // Assignment: element.textContent = 'text'
94
153
  AssignmentExpression(path) {
@@ -105,18 +164,14 @@ export function parseJs(source, filePath, project) {
105
164
  'alt', 'innerHTML',
106
165
  ]);
107
166
 
108
- if (domProps.has(propName)) {
109
- const key = contextKey(right.value, filePath);
110
- const tFunc = project.type === 'vanilla'
111
- ? `i18n.t('${key}')`
112
- : project.type === 'vue'
113
- ? `this.$t('${key}')`
114
- : `t('${key}')`;
115
-
116
- s.overwrite(right.start, right.end, tFunc);
117
- extracted.push({ key, text: right.value, context: `js-dom-${propName}` });
118
- }
119
- }
167
+ if (domProps.has(propName)) {
168
+ const key = contextKey(right.value, filePath);
169
+ const tFunc = tFuncFor(key);
170
+
171
+ s.overwrite(right.start, right.end, tFunc);
172
+ extracted.push({ key, text: right.value, context: `js-dom-${propName}` });
173
+ }
174
+ }
120
175
  },
121
176
  });
122
177
 
@@ -125,4 +180,4 @@ export function parseJs(source, filePath, project) {
125
180
  extracted,
126
181
  modified: extracted.length > 0,
127
182
  };
128
- }
183
+ }