bctranslate 1.0.7 → 1.0.9

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.
@@ -92,7 +92,7 @@ async function resolveFiles(pathArg, cwd, project) {
92
92
  }
93
93
 
94
94
  // ── Translation runner — uses global batching (Python spawned once) ───────────
95
- async function runTranslation({ pathArg, from, to, localesDir, dryRun, outdir, verbose, jsonMode, setup, autoImport, cwd }) {
95
+ async function runTranslation({ pathArg, from, to, localesDir, dryRun, outdir, verbose, jsonMode, profile, setup, autoImport, cwd }) {
96
96
  const project = detectProject(cwd);
97
97
  console.log(chalk.green(` ✓ Project type: ${chalk.bold(project.type)}`));
98
98
 
@@ -110,17 +110,18 @@ async function runTranslation({ pathArg, from, to, localesDir, dryRun, outdir, v
110
110
  }
111
111
 
112
112
  // Global batch: all files parsed first, ONE Python call, then all writes
113
- const results = await translateAllFiles(files, {
114
- from,
115
- to,
116
- dryRun,
117
- outdir,
118
- project,
119
- cwd,
120
- verbose,
121
- jsonMode,
122
- localesDir: localesDir ? resolve(cwd, localesDir) : undefined,
123
- });
113
+ const results = await translateAllFiles(files, {
114
+ from,
115
+ to,
116
+ dryRun,
117
+ outdir,
118
+ project,
119
+ cwd,
120
+ verbose,
121
+ jsonMode,
122
+ profile: !!profile,
123
+ localesDir: localesDir ? resolve(cwd, localesDir) : undefined,
124
+ });
124
125
 
125
126
  let totalStrings = 0;
126
127
  let totalFiles = 0;
@@ -281,6 +282,7 @@ program
281
282
  .option('--no-setup', 'Skip i18n setup file generation')
282
283
  .option('--no-import', 'Do not auto-inject i18n imports/script tags')
283
284
  .option('--json-mode <mode>', 'JSON translation mode: values or full', 'values')
285
+ .option('--profile', 'Print timing breakdown', false)
284
286
  .option('-v, --verbose', 'Show per-file diffs and skipped files', false)
285
287
  .action(async (pathArg, fromArg, keyword, langArg, opts) => {
286
288
  console.log(chalk.cyan.bold('\n ⚡ bctranslate\n'));
@@ -349,6 +351,7 @@ program
349
351
  outdir: opts.outdir,
350
352
  verbose: opts.verbose,
351
353
  jsonMode: opts.jsonMode,
354
+ profile: opts.profile,
352
355
  setup: opts.setup,
353
356
  autoImport,
354
357
  cwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bctranslate",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "CLI to transform source files into i18n-ready code with automatic translation via argostranslate",
5
5
  "type": "module",
6
6
  "bin": {
@@ -226,9 +226,10 @@ async function ensureVanillaI18n(cwd, from, to, localeDir, { autoImport }) {
226
226
  const content = `/**
227
227
  * Simple i18n for vanilla JS — generated by bctranslate
228
228
  */
229
- (function () {
230
- const locales = {};
231
- let currentLocale = '${from}';
229
+ (function () {
230
+ const locales = {};
231
+ let currentLocale = '${from}';
232
+ const fallbackLocale = '${from}';
232
233
 
233
234
  async function loadLocale(lang) {
234
235
  if (locales[lang]) return;
@@ -239,11 +240,16 @@ async function ensureVanillaI18n(cwd, from, to, localeDir, { autoImport }) {
239
240
  function t(key, params) {
240
241
  // Support both flat keys ("home.submit") and nested objects ({ home: { submit: ... } })
241
242
  const dict = locales[currentLocale] || {};
242
- const direct = Object.prototype.hasOwnProperty.call(dict, key) ? dict[key] : null;
243
- const msg =
244
- direct !== null && direct !== undefined
243
+ const dictFallback = locales[fallbackLocale] || {};
244
+
245
+ const lookup = (d) => {
246
+ const direct = Object.prototype.hasOwnProperty.call(d, key) ? d[key] : null;
247
+ return direct !== null && direct !== undefined
245
248
  ? direct
246
- : key.split('.').reduce((obj, i) => (obj ? obj[i] : null), dict) || key;
249
+ : key.split('.').reduce((obj, i) => (obj ? obj[i] : null), d);
250
+ };
251
+
252
+ const msg = lookup(dict) ?? lookup(dictFallback) ?? key;
247
253
 
248
254
  if (!params) return msg;
249
255
  return String(msg).replace(/\\{(\\w+)\\}/g, (match, k) =>
package/src/index.js CHANGED
@@ -125,24 +125,27 @@ export async function writeFileResult(parseResult, translations, opts) {
125
125
  * Phase 3: translateBatch() once → Python starts, model loads, all strings translated
126
126
  * Phase 4: Write each file
127
127
  */
128
- export async function translateAllFiles(files, opts) {
129
- const { from, to, cwd, project, verbose, localesDir } = opts;
130
-
131
- const resolvedLocaleDir = opts.outdir ? join(opts.outdir, 'locales') : (localesDir || getLocaleDir(cwd, project));
132
- const existingTarget = loadLocale(resolvedLocaleDir, to);
133
-
134
- // Phase 1 — parse
135
- const parsed = [];
136
- for (const file of files) {
137
- try {
138
- const result = parseFileOnly(file, opts);
139
- if (result.extracted.length > 0) parsed.push(result);
140
- } catch (err) {
141
- if (verbose) console.error(` Parse error ${file}: ${err.message}`);
142
- }
143
- }
144
-
145
- if (parsed.length === 0) return [];
128
+ export async function translateAllFiles(files, opts) {
129
+ const { from, to, cwd, project, verbose, localesDir } = opts;
130
+ const prof0 = opts.profile ? Date.now() : 0;
131
+
132
+ const resolvedLocaleDir = opts.outdir ? join(opts.outdir, 'locales') : (localesDir || getLocaleDir(cwd, project));
133
+ const existingTarget = loadLocale(resolvedLocaleDir, to);
134
+
135
+ // Phase 1 — parse
136
+ const profParse0 = opts.profile ? Date.now() : 0;
137
+ const parsed = [];
138
+ for (const file of files) {
139
+ try {
140
+ const result = parseFileOnly(file, opts);
141
+ if (result.extracted.length > 0) parsed.push(result);
142
+ } catch (err) {
143
+ if (verbose) console.error(` Parse error ${file}: ${err.message}`);
144
+ }
145
+ }
146
+ const profParse1 = opts.profile ? Date.now() : 0;
147
+
148
+ if (parsed.length === 0) return [];
146
149
 
147
150
  // Phase 2 — deduplicate across all files, skip already-translated keys
148
151
  const seenKeys = new Set(Object.keys(existingTarget));
@@ -156,19 +159,22 @@ export async function translateAllFiles(files, opts) {
156
159
  }
157
160
  }
158
161
 
159
- // Phase 3 — translate once (Python spawned exactly once, model loads once)
160
- let newTranslations = {};
161
- if (needed.length > 0) {
162
- newTranslations = await translateBatch(needed, from, to);
163
- }
164
-
165
- const allTranslations = { ...existingTarget, ...newTranslations };
166
-
167
- // Phase 4 write files
168
- if (!opts.dryRun) {
169
- console.log(` Locale dir: ${resolvedLocaleDir}`);
170
- }
171
- const results = [];
162
+ // Phase 3 — translate once (Python spawned exactly once, model loads once)
163
+ const profTrans0 = opts.profile ? Date.now() : 0;
164
+ let newTranslations = {};
165
+ if (needed.length > 0) {
166
+ newTranslations = await translateBatch(needed, from, to);
167
+ }
168
+ const profTrans1 = opts.profile ? Date.now() : 0;
169
+
170
+ const allTranslations = { ...existingTarget, ...newTranslations };
171
+
172
+ // Phase 4 — write files
173
+ const profWrite0 = opts.profile ? Date.now() : 0;
174
+ if (!opts.dryRun) {
175
+ console.log(` → Locale dir: ${resolvedLocaleDir}`);
176
+ }
177
+ const results = [];
172
178
  for (const parseResult of parsed) {
173
179
  try {
174
180
  const r = await writeFileResult(parseResult, allTranslations, {
@@ -180,10 +186,18 @@ export async function translateAllFiles(files, opts) {
180
186
  console.error(` ✗ Write error ${relative(cwd, parseResult.filePath)}: ${err.message}`);
181
187
  results.push({ count: 0, skipped: 0, relativePath: relative(cwd, parseResult.filePath) });
182
188
  }
183
- }
184
-
185
- return results;
186
- }
189
+ }
190
+ const profWrite1 = opts.profile ? Date.now() : 0;
191
+
192
+ if (opts.profile) {
193
+ const total = Date.now() - prof0;
194
+ console.log(
195
+ ` Timing: parse ${profParse1 - profParse0}ms | translate ${profTrans1 - profTrans0}ms | write ${profWrite1 - profWrite0}ms | total ${total}ms`
196
+ );
197
+ }
198
+
199
+ return results;
200
+ }
187
201
 
188
202
  /**
189
203
  * Single-file convenience wrapper (for backward compatibility).
@@ -37,6 +37,26 @@ function stripTags(html) {
37
37
  return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
38
38
  }
39
39
 
40
+ function maybeWrapInlineScriptForI18nReady(script) {
41
+ if (!/\bi18n\.t\s*\(/.test(script)) return script;
42
+ if (/\bi18n\.ready\b/.test(script)) return script;
43
+ if (!/\bcreateApp\b|\bapp\.mount\b|document\./.test(script)) return script;
44
+
45
+ const trimmed = script.trim();
46
+ const indent = (script.match(/^\s*/) || [''])[0];
47
+ const body = trimmed
48
+ .split('\n')
49
+ .map((l) => (l.length ? indent + ' ' + l : l))
50
+ .join('\n');
51
+
52
+ return (
53
+ `${indent}(async () => {\n` +
54
+ `${indent} if (i18n.ready) await i18n.ready;\n` +
55
+ `${body}\n` +
56
+ `${indent}})();`
57
+ );
58
+ }
59
+
40
60
  /**
41
61
  * Parse an HTML file and extract translatable strings.
42
62
  * For vanilla HTML, we use data-i18n attributes instead of $t() calls.
@@ -85,7 +105,8 @@ export function parseHtml(source, filePath, project) {
85
105
  const scriptJs = source.slice(openTagEnd + 1, closeTagStart);
86
106
  const jsResult = parseJs(scriptJs, filePath, project);
87
107
  if (jsResult.modified) {
88
- s.overwrite(openTagEnd + 1, closeTagStart, jsResult.source);
108
+ const wrapped = maybeWrapInlineScriptForI18nReady(jsResult.source);
109
+ s.overwrite(openTagEnd + 1, closeTagStart, wrapped);
89
110
  extracted.push(...jsResult.extracted);
90
111
  }
91
112
  }
package/src/parsers/js.js CHANGED
@@ -20,13 +20,6 @@ export function parseJs(source, filePath, project) {
20
20
  return `t('${key}')`;
21
21
  };
22
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
23
  const translatableAttrNames = new Set([
31
24
  'title',
32
25
  'placeholder',
@@ -58,26 +51,6 @@ export function parseJs(source, filePath, project) {
58
51
 
59
52
  // Track which string literals to translate
60
53
  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
54
  // Object property values with translatable keys
82
55
  ObjectProperty(path) {
83
56
  const keyNode = path.node.key;
@@ -41,9 +41,17 @@ export function parseVue(source, filePath) {
41
41
  const extracted = [];
42
42
  const s = new MagicString(source);
43
43
 
44
- // Always use t() for consistency, as promised in the README.
45
- const tpl = (key) => `t('${key}')`;
46
- const scr = (key) => `t('${key}')`;
44
+ const scriptSetupMatch = source.match(/(<script\b[^>]*\bsetup\b[^>]*>)([\s\S]*?)<\/script>/i);
45
+ const hasScriptSetup = !!scriptSetupMatch;
46
+
47
+ // Prefer what the file already uses in templates: `$t()` vs `t()`.
48
+ const templateBlockMatch = source.match(/<template\b[^>]*>([\s\S]*?)<\/template>/);
49
+ const templateBlock = templateBlockMatch ? templateBlockMatch[1] : '';
50
+ const templatePrefersDollarT = /\$t\s*\(/.test(templateBlock);
51
+ const templatePrefersT = !templatePrefersDollarT && /\bt\s*\(/.test(templateBlock);
52
+
53
+ const tpl = (key) => (templatePrefersT ? `t('${key}')` : `$t('${key}')`);
54
+ const scr = (key) => (hasScriptSetup ? `t('${key}')` : `this.$t('${key}')`);
47
55
 
48
56
  // ── Template ────────────────────────────────────────────────────────────────
49
57
  const templateMatch = source.match(/<template\b[^>]*>([\s\S]*?)<\/template>/);
@@ -60,27 +68,33 @@ export function parseVue(source, filePath) {
60
68
  }
61
69
  }
62
70
 
63
- // ── Script ──────────────────────────────────────────────────────────────────
64
- const scriptMatch = source.match(/<script\b[^>]*>([\s\S]*?)<\/script>/);
65
- if (scriptMatch) {
66
- const scriptContent = scriptMatch[1];
67
- const scriptOffset =
68
- source.indexOf(scriptMatch[0]) + scriptMatch[0].indexOf(scriptContent);
69
- extractScriptStrings(scriptContent, s, scriptOffset, extracted, filePath, scr);
70
- }
71
+ // ── Script (prefer <script setup>, else first non-setup <script>) ────────────
72
+ if (scriptSetupMatch) {
73
+ const scriptContent = scriptSetupMatch[2];
74
+ const scriptOffset =
75
+ source.indexOf(scriptSetupMatch[0]) + scriptSetupMatch[0].indexOf(scriptContent);
76
+ extractScriptStrings(scriptContent, s, scriptOffset, extracted, filePath, scr);
77
+ } else {
78
+ const scriptMatch = source.match(/<script\b(?![^>]*\bsetup\b)[^>]*>([\s\S]*?)<\/script>/i);
79
+ if (scriptMatch) {
80
+ const scriptContent = scriptMatch[1];
81
+ const scriptOffset =
82
+ source.indexOf(scriptMatch[0]) + scriptMatch[0].indexOf(scriptContent);
83
+ extractScriptStrings(scriptContent, s, scriptOffset, extracted, filePath, scr);
84
+ }
85
+ }
71
86
 
72
87
  // ── Inject `const { t } = useI18n()` if not yet declared ────────
73
- if (extracted.length > 0) {
74
- const scriptSetupMatch = source.match(/(<script\b[^>]*\bsetup\b[^>]*>)([\s\S]*?)<\/script>/i);
75
- const hasT = /const\s*\{[^}]*\bt\b[^}]*\}\s*=/.test(source);
76
-
77
- if (scriptSetupMatch && !hasT) {
78
- const insertAt = source.indexOf(scriptSetupMatch[0]) + scriptSetupMatch[1].length;
79
- const needsImport = !source.includes('useI18n');
80
- const importLine = needsImport ? `import { useI18n } from 'vue-i18n';\n` : '';
81
- s.appendRight(insertAt, `\n${importLine}const { t } = useI18n();\n`);
82
- }
83
- }
88
+ if (extracted.length > 0 && hasScriptSetup) {
89
+ const hasT = /const\s*\{[^}]*\bt\b[^}]*\}\s*=/.test(source);
90
+
91
+ if (!hasT) {
92
+ const insertAt = source.indexOf(scriptSetupMatch[0]) + scriptSetupMatch[1].length;
93
+ const needsImport = !source.includes('useI18n');
94
+ const importLine = needsImport ? `import { useI18n } from 'vue-i18n';\n` : '';
95
+ s.appendRight(insertAt, `\n${importLine}const { t } = useI18n();\n`);
96
+ }
97
+ }
84
98
 
85
99
  return {
86
100
  source: extracted.length > 0 ? s.toString() : source,
@@ -244,8 +258,7 @@ function isAlreadyWrappedScript(scriptContent, pos) {
244
258
  function isAlreadyWrapped(source, start, end) {
245
259
  // Look back 25 chars for an open t( call — node is inside an interpolation
246
260
  const before = source.slice(Math.max(0, start - 25), start);
247
- // Note: We only check for `t` now, not `$t`.
248
- return /t\s*\(\s*['"]/.test(before);
261
+ return /\$?t\s*\(\s*['"]/.test(before);
249
262
  }
250
263
 
251
264
  function extractTemplateRegex(source, s, extracted, filePath, tpl) {