bctranslate 1.0.5 → 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,9 +92,9 @@ 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, cwd }) {
96
- const project = detectProject(cwd);
97
- console.log(chalk.green(` ✓ Project type: ${chalk.bold(project.type)}`));
95
+ async function runTranslation({ pathArg, from, to, localesDir, dryRun, outdir, verbose, jsonMode, profile, setup, autoImport, cwd }) {
96
+ const project = detectProject(cwd);
97
+ console.log(chalk.green(` ✓ Project type: ${chalk.bold(project.type)}`));
98
98
 
99
99
  const files = await resolveFiles(pathArg, cwd, project);
100
100
  if (files.length === 0) {
@@ -104,23 +104,24 @@ async function runTranslation({ pathArg, from, to, localesDir, dryRun, outdir, v
104
104
 
105
105
  console.log(chalk.cyan(` → ${files.length} file(s) [${from} → ${to}]...\n`));
106
106
 
107
- if (setup !== false) {
108
- const resolvedLocalesDir = localesDir ? resolve(cwd, localesDir) : undefined;
109
- await ensureI18nSetup(cwd, project, from, to, resolvedLocalesDir);
110
- }
107
+ if (setup !== false) {
108
+ const resolvedLocalesDir = localesDir ? resolve(cwd, localesDir) : undefined;
109
+ await ensureI18nSetup(cwd, project, from, to, resolvedLocalesDir, { autoImport });
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;
@@ -277,17 +278,20 @@ program
277
278
  .argument('[lang]', 'Target language code (e.g. fr)')
278
279
  .option('-t, --to <lang>', 'Target language(s), comma-separated (e.g. fr or fr,es)')
279
280
  .option('-d, --dry-run', 'Preview changes without writing files', false)
280
- .option('-o, --outdir <dir>', 'Output directory for translated files')
281
- .option('--no-setup', 'Skip i18n setup file generation')
282
- .option('--json-mode <mode>', 'JSON translation mode: values or full', 'values')
283
- .option('-v, --verbose', 'Show per-file diffs and skipped files', false)
281
+ .option('-o, --outdir <dir>', 'Output directory for translated files')
282
+ .option('--no-setup', 'Skip i18n setup file generation')
283
+ .option('--no-import', 'Do not auto-inject i18n imports/script tags')
284
+ .option('--json-mode <mode>', 'JSON translation mode: values or full', 'values')
285
+ .option('--profile', 'Print timing breakdown', false)
286
+ .option('-v, --verbose', 'Show per-file diffs and skipped files', false)
284
287
  .action(async (pathArg, fromArg, keyword, langArg, opts) => {
285
288
  console.log(chalk.cyan.bold('\n ⚡ bctranslate\n'));
286
289
 
287
- const invokedCwd = process.cwd();
288
- const config = loadConfig(invokedCwd);
289
-
290
- let cwd = invokedCwd;
290
+ const invokedCwd = process.cwd();
291
+ const config = loadConfig(invokedCwd);
292
+ const autoImport = opts.import !== false && (config?.autoImport !== false);
293
+
294
+ let cwd = invokedCwd;
291
295
  if (!config && pathArg) {
292
296
  const resolvedTarget = resolve(invokedCwd, pathArg);
293
297
  if (existsSync(resolvedTarget)) {
@@ -338,18 +342,20 @@ program
338
342
  for (const to of targets) {
339
343
  if (targets.length > 1) console.log(chalk.cyan.bold(`\n ── Translating to ${to} ──`));
340
344
 
341
- const { totalStrings, totalFiles } = await runTranslation({
342
- pathArg,
343
- from,
344
- to,
345
- localesDir: config?.localesDir,
346
- dryRun: opts.dryRun,
347
- outdir: opts.outdir,
348
- verbose: opts.verbose,
349
- jsonMode: opts.jsonMode,
350
- setup: opts.setup,
351
- cwd,
352
- });
345
+ const { totalStrings, totalFiles } = await runTranslation({
346
+ pathArg,
347
+ from,
348
+ to,
349
+ localesDir: config?.localesDir,
350
+ dryRun: opts.dryRun,
351
+ outdir: opts.outdir,
352
+ verbose: opts.verbose,
353
+ jsonMode: opts.jsonMode,
354
+ profile: opts.profile,
355
+ setup: opts.setup,
356
+ autoImport,
357
+ cwd,
358
+ });
353
359
 
354
360
  grandTotal += totalStrings;
355
361
  grandFiles += totalFiles;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bctranslate",
3
- "version": "1.0.5",
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": {
@@ -13,9 +13,10 @@ import { getLocaleDir } from './locales.js';
13
13
  * @param {string} to - Target language code
14
14
  * @param {string} [customLocaleDir] - Optional override for locale directory
15
15
  */
16
- export async function ensureI18nSetup(cwd, project, from, to, customLocaleDir) {
17
- const localeDir = customLocaleDir || getLocaleDir(cwd, project);
18
- mkdirSync(localeDir, { recursive: true });
16
+ export async function ensureI18nSetup(cwd, project, from, to, customLocaleDir, options = {}) {
17
+ const { autoImport = true } = options;
18
+ const localeDir = customLocaleDir || getLocaleDir(cwd, project);
19
+ mkdirSync(localeDir, { recursive: true });
19
20
 
20
21
  // Ensure locale JSON files exist
21
22
  for (const lang of [from, to]) {
@@ -24,18 +25,18 @@ export async function ensureI18nSetup(cwd, project, from, to, customLocaleDir) {
24
25
  writeFileSync(localePath, '{}\n', 'utf-8');
25
26
  console.log(chalk.green(` ✓ Created ${localePath}`));
26
27
  }
27
- }
28
-
29
- if (project.type === 'vue') {
30
- await ensureVueI18n(cwd, project, from, to, localeDir);
31
- } else if (project.type === 'react') {
32
- await ensureReactI18n(cwd, project, from, to, localeDir);
33
- } else {
34
- await ensureVanillaI18n(cwd, from, to, localeDir);
35
- }
36
- }
28
+ }
29
+
30
+ if (project.type === 'vue') {
31
+ await ensureVueI18n(cwd, project, from, to, localeDir, { autoImport });
32
+ } else if (project.type === 'react') {
33
+ await ensureReactI18n(cwd, project, from, to, localeDir, { autoImport });
34
+ } else {
35
+ await ensureVanillaI18n(cwd, from, to, localeDir, { autoImport });
36
+ }
37
+ }
37
38
 
38
- async function ensureVueI18n(cwd, project, from, to, localeDir) {
39
+ async function ensureVueI18n(cwd, project, from, to, localeDir, { autoImport }) {
39
40
  const i18nFile = join(cwd, 'src', 'i18n.js');
40
41
 
41
42
  if (existsSync(i18nFile)) {
@@ -48,51 +49,105 @@ async function ensureVueI18n(cwd, project, from, to, localeDir) {
48
49
 
49
50
  let content;
50
51
  if (isVue3) {
51
- content = `import { createI18n } from 'vue-i18n';
52
- import ${from} from '${relLocale}/${from}.json';
53
- import ${to} from '${relLocale}/${to}.json';
54
-
55
- const i18n = createI18n({
56
- legacy: false,
57
- locale: '${from}',
58
- fallbackLocale: '${from}',
59
- messages: {
60
- ${from},
61
- ${to},
62
- },
63
- });
52
+ content = `import { createI18n } from 'vue-i18n';
53
+ import ${from} from '${relLocale}/${from}.json';
54
+ import ${to} from '${relLocale}/${to}.json';
55
+
56
+ function nestMessages(input) {
57
+ if (!input || typeof input !== 'object') return {};
58
+ const out = Array.isArray(input) ? [] : {};
59
+ for (const [rawKey, value] of Object.entries(input)) {
60
+ if (!rawKey.includes('.')) {
61
+ out[rawKey] = value;
62
+ continue;
63
+ }
64
+ const parts = rawKey.split('.').filter(Boolean);
65
+ let cur = out;
66
+ for (let i = 0; i < parts.length; i++) {
67
+ const p = parts[i];
68
+ if (i === parts.length - 1) {
69
+ cur[p] = value;
70
+ } else {
71
+ if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
72
+ cur = cur[p];
73
+ }
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ const i18n = createI18n({
80
+ legacy: false,
81
+ locale: '${from}',
82
+ fallbackLocale: '${from}',
83
+ messages: {
84
+ ${from}: nestMessages(${from}),
85
+ ${to}: nestMessages(${to}),
86
+ },
87
+ });
64
88
 
65
89
  export default i18n;
66
90
  `;
67
91
  } else {
68
- content = `import Vue from 'vue';
69
- import VueI18n from 'vue-i18n';
70
- import ${from} from '${relLocale}/${from}.json';
71
- import ${to} from '${relLocale}/${to}.json';
72
-
73
- Vue.use(VueI18n);
74
-
75
- const i18n = new VueI18n({
76
- locale: '${from}',
77
- fallbackLocale: '${from}',
78
- messages: {
79
- ${from},
80
- ${to},
81
- },
82
- });
92
+ content = `import Vue from 'vue';
93
+ import VueI18n from 'vue-i18n';
94
+ import ${from} from '${relLocale}/${from}.json';
95
+ import ${to} from '${relLocale}/${to}.json';
96
+
97
+ Vue.use(VueI18n);
98
+
99
+ function nestMessages(input) {
100
+ if (!input || typeof input !== 'object') return {};
101
+ const out = Array.isArray(input) ? [] : {};
102
+ for (const [rawKey, value] of Object.entries(input)) {
103
+ if (!rawKey.includes('.')) {
104
+ out[rawKey] = value;
105
+ continue;
106
+ }
107
+ const parts = rawKey.split('.').filter(Boolean);
108
+ let cur = out;
109
+ for (let i = 0; i < parts.length; i++) {
110
+ const p = parts[i];
111
+ if (i === parts.length - 1) {
112
+ cur[p] = value;
113
+ } else {
114
+ if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
115
+ cur = cur[p];
116
+ }
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+
122
+ const i18n = new VueI18n({
123
+ locale: '${from}',
124
+ fallbackLocale: '${from}',
125
+ messages: {
126
+ ${from}: nestMessages(${from}),
127
+ ${to}: nestMessages(${to}),
128
+ },
129
+ });
83
130
 
84
131
  export default i18n;
85
132
  `;
86
133
  }
87
134
 
88
- mkdirSync(join(cwd, 'src'), { recursive: true });
89
- writeFileSync(i18nFile, content, 'utf-8');
90
- console.log(chalk.green(` ✓ Created ${i18nFile}`));
91
- console.log(chalk.yellow(` ⚠ Don't forget to install vue-i18n: npm install vue-i18n`));
92
- console.log(chalk.yellow(` ⚠ Import and use i18n in your main.js/main.ts`));
93
- }
135
+ mkdirSync(join(cwd, 'src'), { recursive: true });
136
+ writeFileSync(i18nFile, content, 'utf-8');
137
+ console.log(chalk.green(` ✓ Created ${i18nFile}`));
138
+ console.log(chalk.yellow(` ⚠ Don't forget to install vue-i18n: npm install vue-i18n`));
139
+
140
+ if (autoImport) {
141
+ const injected = injectVueEntrypoint(cwd, isVue3);
142
+ if (!injected) {
143
+ console.log(chalk.yellow(` ⚠ Import and use i18n in your main.js/main.ts`));
144
+ }
145
+ } else {
146
+ console.log(chalk.yellow(` ⚠ Import and use i18n in your main.js/main.ts`));
147
+ }
148
+ }
94
149
 
95
- async function ensureReactI18n(cwd, project, from, to, localeDir) {
150
+ async function ensureReactI18n(cwd, project, from, to, localeDir, { autoImport }) {
96
151
  const i18nFile = join(cwd, 'src', 'i18n.js');
97
152
 
98
153
  if (existsSync(i18nFile)) {
@@ -102,16 +157,39 @@ async function ensureReactI18n(cwd, project, from, to, localeDir) {
102
157
 
103
158
  const relLocale = localeDir.replace(cwd, '.').replace(/\\/g, '/');
104
159
 
105
- const content = `import i18n from 'i18next';
106
- import { initReactI18next } from 'react-i18next';
107
- import ${from} from '${relLocale}/${from}.json';
108
- import ${to} from '${relLocale}/${to}.json';
109
-
110
- i18n.use(initReactI18next).init({
111
- resources: {
112
- ${from}: { translation: ${from} },
113
- ${to}: { translation: ${to} },
114
- },
160
+ const content = `import i18n from 'i18next';
161
+ import { initReactI18next } from 'react-i18next';
162
+ import ${from} from '${relLocale}/${from}.json';
163
+ import ${to} from '${relLocale}/${to}.json';
164
+
165
+ function nestMessages(input) {
166
+ if (!input || typeof input !== 'object') return {};
167
+ const out = Array.isArray(input) ? [] : {};
168
+ for (const [rawKey, value] of Object.entries(input)) {
169
+ if (!rawKey.includes('.')) {
170
+ out[rawKey] = value;
171
+ continue;
172
+ }
173
+ const parts = rawKey.split('.').filter(Boolean);
174
+ let cur = out;
175
+ for (let i = 0; i < parts.length; i++) {
176
+ const p = parts[i];
177
+ if (i === parts.length - 1) {
178
+ cur[p] = value;
179
+ } else {
180
+ if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
181
+ cur = cur[p];
182
+ }
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+
188
+ i18n.use(initReactI18next).init({
189
+ resources: {
190
+ ${from}: { translation: nestMessages(${from}) },
191
+ ${to}: { translation: nestMessages(${to}) },
192
+ },
115
193
  lng: '${from}',
116
194
  fallbackLng: '${from}',
117
195
  interpolation: {
@@ -122,14 +200,22 @@ i18n.use(initReactI18next).init({
122
200
  export default i18n;
123
201
  `;
124
202
 
125
- mkdirSync(join(cwd, 'src'), { recursive: true });
126
- writeFileSync(i18nFile, content, 'utf-8');
127
- console.log(chalk.green(` ✓ Created ${i18nFile}`));
128
- console.log(chalk.yellow(` ⚠ Don't forget to install: npm install i18next react-i18next`));
129
- console.log(chalk.yellow(` ⚠ Import './i18n' in your index.js/App.js`));
130
- }
203
+ mkdirSync(join(cwd, 'src'), { recursive: true });
204
+ writeFileSync(i18nFile, content, 'utf-8');
205
+ console.log(chalk.green(` ✓ Created ${i18nFile}`));
206
+ console.log(chalk.yellow(` ⚠ Don't forget to install: npm install i18next react-i18next`));
207
+
208
+ if (autoImport) {
209
+ const injected = injectReactEntrypoint(cwd);
210
+ if (!injected) {
211
+ console.log(chalk.yellow(` ⚠ Import './i18n' in your index.js/App.js`));
212
+ }
213
+ } else {
214
+ console.log(chalk.yellow(` ⚠ Import './i18n' in your index.js/App.js`));
215
+ }
216
+ }
131
217
 
132
- async function ensureVanillaI18n(cwd, from, to, localeDir) {
218
+ async function ensureVanillaI18n(cwd, from, to, localeDir, { autoImport }) {
133
219
  const i18nFile = join(cwd, 'i18n.js');
134
220
 
135
221
  if (existsSync(i18nFile)) {
@@ -140,9 +226,10 @@ async function ensureVanillaI18n(cwd, from, to, localeDir) {
140
226
  const content = `/**
141
227
  * Simple i18n for vanilla JS — generated by bctranslate
142
228
  */
143
- (function () {
144
- const locales = {};
145
- let currentLocale = '${from}';
229
+ (function () {
230
+ const locales = {};
231
+ let currentLocale = '${from}';
232
+ const fallbackLocale = '${from}';
146
233
 
147
234
  async function loadLocale(lang) {
148
235
  if (locales[lang]) return;
@@ -153,11 +240,16 @@ async function ensureVanillaI18n(cwd, from, to, localeDir) {
153
240
  function t(key, params) {
154
241
  // Support both flat keys ("home.submit") and nested objects ({ home: { submit: ... } })
155
242
  const dict = locales[currentLocale] || {};
156
- const direct = Object.prototype.hasOwnProperty.call(dict, key) ? dict[key] : null;
157
- const msg =
158
- 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
159
248
  ? direct
160
- : 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;
161
253
 
162
254
  if (!params) return msg;
163
255
  return String(msg).replace(/\\{(\\w+)\\}/g, (match, k) =>
@@ -194,21 +286,123 @@ async function ensureVanillaI18n(cwd, from, to, localeDir) {
194
286
 
195
287
  // Auto-init
196
288
  loadLocale('${from}');
197
- setLocale('${to}');
198
-
199
- window.i18n = { t: t, setLocale: setLocale, loadLocale: loadLocale };
200
- })();
201
- `;
289
+
290
+ const api = { t: t, setLocale: setLocale, loadLocale: loadLocale, ready: null };
291
+ window.i18n = api;
292
+ api.ready = setLocale('${to}');
293
+ })();
294
+ `;
202
295
 
203
296
  writeFileSync(i18nFile, content, 'utf-8');
204
297
  console.log(chalk.green(` ✓ Created ${i18nFile}`));
205
298
 
206
- const injected = injectVanillaI18nEntrypoint(cwd);
207
- if (!injected) {
299
+ if (autoImport) {
300
+ const injected = injectVanillaI18nEntrypoint(cwd);
301
+ if (!injected) {
302
+ console.log(chalk.yellow(` ⚠ Add <script src="i18n.js"></script> to your HTML`));
303
+ }
304
+ } else {
208
305
  console.log(chalk.yellow(` ⚠ Add <script src="i18n.js"></script> to your HTML`));
209
306
  }
210
307
  }
211
308
 
309
+ function injectVueEntrypoint(cwd, isVue3) {
310
+ const candidates = [
311
+ join(cwd, 'src', 'main.ts'),
312
+ join(cwd, 'src', 'main.js'),
313
+ join(cwd, 'src', 'main.tsx'),
314
+ join(cwd, 'src', 'main.jsx'),
315
+ ];
316
+
317
+ const mainFile = candidates.find((p) => existsSync(p));
318
+ if (!mainFile) return false;
319
+
320
+ let code = readFileSync(mainFile, 'utf-8');
321
+
322
+ const hasI18nImport =
323
+ code.includes("from './i18n'") ||
324
+ code.includes('from "./i18n"') ||
325
+ code.includes("from './i18n.js'") ||
326
+ code.includes('from "./i18n.js"') ||
327
+ code.includes("import './i18n'") ||
328
+ code.includes('import "./i18n"');
329
+
330
+ if (!hasI18nImport) {
331
+ const importRe = /^import\s.+?;\s*$/gm;
332
+ let lastImportEnd = 0;
333
+ let m;
334
+ while ((m = importRe.exec(code)) !== null) lastImportEnd = m.index + m[0].length;
335
+ code =
336
+ code.slice(0, lastImportEnd) +
337
+ `\nimport i18n from './i18n';\n` +
338
+ code.slice(lastImportEnd);
339
+ }
340
+
341
+ if (isVue3) {
342
+ if (!code.includes('.use(i18n)') && !code.includes('use(i18n)')) {
343
+ const chained = /createApp\(([^)]*)\)\s*\.mount\(/;
344
+ if (chained.test(code)) {
345
+ code = code.replace(chained, 'createApp($1).use(i18n).mount(');
346
+ } else {
347
+ const hasAppVar =
348
+ /\bconst\s+app\s*=\s*createApp\(/.test(code) ||
349
+ /\blet\s+app\s*=\s*createApp\(/.test(code);
350
+ const mountIdx = code.indexOf('app.mount(');
351
+ if (hasAppVar && mountIdx !== -1 && !code.includes('app.use(i18n')) {
352
+ code = code.slice(0, mountIdx) + `app.use(i18n);\n` + code.slice(mountIdx);
353
+ } else {
354
+ return false;
355
+ }
356
+ }
357
+ }
358
+ } else {
359
+ if (!/\bi18n\s*[:,]/.test(code)) {
360
+ const newVue = /new\s+Vue\s*\(\s*\{/;
361
+ if (newVue.test(code)) {
362
+ code = code.replace(newVue, (m) => m + '\n i18n,');
363
+ } else {
364
+ return false;
365
+ }
366
+ }
367
+ }
368
+
369
+ writeFileSync(mainFile, code, 'utf-8');
370
+ console.log(chalk.green(` ✓ Updated ${mainFile} (wired i18n)`));
371
+ return true;
372
+ }
373
+
374
+ function injectReactEntrypoint(cwd) {
375
+ const candidates = [
376
+ join(cwd, 'src', 'index.tsx'),
377
+ join(cwd, 'src', 'index.js'),
378
+ join(cwd, 'src', 'main.tsx'),
379
+ join(cwd, 'src', 'main.jsx'),
380
+ ];
381
+
382
+ const entryFile = candidates.find((p) => existsSync(p));
383
+ if (!entryFile) return false;
384
+
385
+ let code = readFileSync(entryFile, 'utf-8');
386
+ if (
387
+ code.includes("from './i18n'") ||
388
+ code.includes('from "./i18n"') ||
389
+ code.includes("import './i18n'") ||
390
+ code.includes('import "./i18n"')
391
+ ) {
392
+ return true;
393
+ }
394
+
395
+ const importRe = /^import\s.+?;\s*$/gm;
396
+ let lastImportEnd = 0;
397
+ let m;
398
+ while ((m = importRe.exec(code)) !== null) lastImportEnd = m.index + m[0].length;
399
+ code = code.slice(0, lastImportEnd) + `\nimport './i18n';\n` + code.slice(lastImportEnd);
400
+
401
+ writeFileSync(entryFile, code, 'utf-8');
402
+ console.log(chalk.green(` ✓ Updated ${entryFile} (imported i18n)`));
403
+ return true;
404
+ }
405
+
212
406
  function injectVanillaI18nEntrypoint(cwd) {
213
407
  // Prefer HTML entrypoint if present
214
408
  const htmlPath = join(cwd, 'index.html');
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) {