bctranslate 1.0.0 → 1.0.1

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.
@@ -2,13 +2,13 @@
2
2
 
3
3
  import { Command } from 'commander';
4
4
  import chalk from 'chalk';
5
- import { resolve, basename, dirname } from 'path';
5
+ import { resolve, basename, dirname } from 'path';
6
6
  import { existsSync, statSync } from 'fs';
7
7
  import { glob } from 'glob';
8
- import { translateFile } from '../src/index.js';
8
+ import { translateAllFiles, generateDiff } from '../src/index.js';
9
9
  import { detectProject } from '../src/detect.js';
10
10
  import { ensureI18nSetup } from '../src/generators/setup.js';
11
- import { checkPythonBridge } from '../src/bridges/python.js';
11
+ import { checkPythonBridge, installArgostranslate } from '../src/bridges/python.js';
12
12
  import { loadConfig, saveConfig } from '../src/config.js';
13
13
 
14
14
  const program = new Command();
@@ -18,7 +18,7 @@ program
18
18
  .description('Transform source files into i18n-ready code with automatic translation')
19
19
  .version('1.0.0');
20
20
 
21
- // ── Common languages for init prompts ────────────────────────────────────────
21
+ // ── Language lists ────────────────────────────────────────────────────────────
22
22
  const COMMON_LANGUAGES = [
23
23
  { name: 'French (fr)', value: 'fr' },
24
24
  { name: 'Spanish (es)', value: 'es' },
@@ -27,7 +27,7 @@ const COMMON_LANGUAGES = [
27
27
  { name: 'Portuguese (pt)', value: 'pt' },
28
28
  { name: 'Dutch (nl)', value: 'nl' },
29
29
  { name: 'Russian (ru)', value: 'ru' },
30
- { name: 'Chinese (zh)', value: 'zh' },
30
+ { name: 'Chinese (zh)', value: 'zh' },
31
31
  { name: 'Japanese (ja)', value: 'ja' },
32
32
  { name: 'Korean (ko)', value: 'ko' },
33
33
  { name: 'Arabic (ar)', value: 'ar' },
@@ -43,70 +43,60 @@ const COMMON_LANGUAGES = [
43
43
  ];
44
44
 
45
45
  const SOURCE_LANGUAGES = [
46
- { name: 'English (en)', value: 'en' },
47
- { name: 'French (fr)', value: 'fr' },
48
- { name: 'Spanish (es)', value: 'es' },
49
- { name: 'German (de)', value: 'de' },
50
- { name: 'Italian (it)', value: 'it' },
51
- { name: 'Portuguese(pt)', value: 'pt' },
52
- { name: 'Chinese (zh)', value: 'zh' },
46
+ { name: 'English (en)', value: 'en' },
47
+ { name: 'French (fr)', value: 'fr' },
48
+ { name: 'Spanish (es)', value: 'es' },
49
+ { name: 'German (de)', value: 'de' },
50
+ { name: 'Italian (it)', value: 'it' },
51
+ { name: 'Portuguese (pt)', value: 'pt' },
52
+ { name: 'Chinese (zh)', value: 'zh' },
53
53
  { name: 'Other (type below)', value: '__other__' },
54
54
  ];
55
55
 
56
56
  // ── File patterns per project type ───────────────────────────────────────────
57
57
  const FILE_PATTERNS = {
58
- vue: ['**/*.vue', 'src/**/*.js', 'src/**/*.ts'],
59
- react: ['**/*.jsx', '**/*.tsx', 'src/**/*.js', 'src/**/*.ts'],
58
+ vue: ['**/*.vue', 'src/**/*.js', 'src/**/*.ts'],
59
+ react: ['**/*.jsx', '**/*.tsx', 'src/**/*.js', 'src/**/*.ts'],
60
60
  vanilla: ['**/*.html', '**/*.htm', '**/*.js'],
61
61
  };
62
62
 
63
63
  const IGNORE_PATTERNS = [
64
- '**/node_modules/**',
65
- '**/dist/**',
66
- '**/build/**',
67
- '**/.git/**',
68
- '**/coverage/**',
69
- '**/*.min.js',
64
+ '**/node_modules/**', '**/dist/**', '**/build/**',
65
+ '**/.git/**', '**/coverage/**', '**/*.min.js',
70
66
  ];
71
67
 
72
- // ── Resolve files from a path arg ────────────────────────────────────────────
68
+ // ── File resolution ───────────────────────────────────────────────────────────
73
69
  async function resolveFiles(pathArg, cwd, project) {
74
70
  const patterns = FILE_PATTERNS[project.type] || FILE_PATTERNS.vanilla;
75
71
 
76
72
  if (!pathArg) {
77
73
  const files = [];
78
74
  for (const p of patterns) {
79
- const matched = await glob(p, { cwd, absolute: true, ignore: IGNORE_PATTERNS });
80
- files.push(...matched);
75
+ files.push(...await glob(p, { cwd, absolute: true, ignore: IGNORE_PATTERNS }));
81
76
  }
82
77
  return [...new Set(files)];
83
78
  }
84
79
 
85
80
  const resolved = resolve(cwd, pathArg);
86
-
87
81
  if (existsSync(resolved)) {
88
82
  if (statSync(resolved).isDirectory()) {
89
83
  const files = [];
90
84
  for (const p of patterns) {
91
- const matched = await glob(p, { cwd: resolved, absolute: true, ignore: IGNORE_PATTERNS });
92
- files.push(...matched);
85
+ files.push(...await glob(p, { cwd: resolved, absolute: true, ignore: IGNORE_PATTERNS }));
93
86
  }
94
87
  return [...new Set(files)];
95
88
  }
96
89
  return [resolved];
97
90
  }
98
-
99
- // Treat as glob pattern
100
91
  return glob(pathArg, { cwd, absolute: true, ignore: IGNORE_PATTERNS });
101
92
  }
102
93
 
103
- // ── Core translation runner ───────────────────────────────────────────────────
94
+ // ── Translation runner — uses global batching (Python spawned once) ───────────
104
95
  async function runTranslation({ pathArg, from, to, localesDir, dryRun, outdir, verbose, jsonMode, setup, cwd }) {
105
96
  const project = detectProject(cwd);
106
97
  console.log(chalk.green(` ✓ Project type: ${chalk.bold(project.type)}`));
107
98
 
108
99
  const files = await resolveFiles(pathArg, cwd, project);
109
-
110
100
  if (files.length === 0) {
111
101
  console.log(chalk.yellow(' ⚠ No files found to translate.'));
112
102
  return { totalStrings: 0, totalFiles: 0 };
@@ -114,44 +104,39 @@ async function runTranslation({ pathArg, from, to, localesDir, dryRun, outdir, v
114
104
 
115
105
  console.log(chalk.cyan(` → ${files.length} file(s) [${from} → ${to}]...\n`));
116
106
 
117
- // Ensure i18n setup (locale files + framework config)
118
107
  if (setup !== false) {
119
108
  const resolvedLocalesDir = localesDir ? resolve(cwd, localesDir) : undefined;
120
109
  await ensureI18nSetup(cwd, project, from, to, resolvedLocalesDir);
121
110
  }
122
111
 
123
- let totalStrings = 0;
124
- let totalFiles = 0;
125
-
126
- for (const file of files) {
127
- try {
128
- const result = await translateFile(file, {
129
- from,
130
- to,
131
- dryRun,
132
- outdir,
133
- project,
134
- cwd,
135
- verbose,
136
- jsonMode,
137
- localesDir: localesDir ? resolve(cwd, localesDir) : undefined,
138
- });
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
+ });
139
124
 
140
- if (result.count > 0) {
141
- totalFiles++;
142
- totalStrings += result.count;
143
- const label = dryRun ? chalk.yellow('[DRY RUN]') : chalk.green('[DONE] ');
144
- const skip = result.skipped > 0 ? chalk.gray(` (${result.skipped} already translated)`) : '';
145
- console.log(` ${label} ${chalk.white(result.relativePath)} — ${result.count} new string(s)${skip}`);
146
- if (dryRun && verbose && result.diff) {
147
- console.log(chalk.gray(result.diff));
148
- }
149
- } else if (verbose) {
150
- console.log(` ${chalk.gray('[SKIP] ')} ${result.relativePath} — already up to date`);
125
+ let totalStrings = 0;
126
+ let totalFiles = 0;
127
+
128
+ for (const result of results) {
129
+ if (result.count > 0) {
130
+ totalFiles++;
131
+ totalStrings += result.count;
132
+ const label = dryRun ? chalk.yellow('[DRY RUN]') : chalk.green('[DONE] ');
133
+ const skipNote = result.skipped > 0 ? chalk.gray(` (${result.skipped} already done)`) : '';
134
+ console.log(` ${label} ${chalk.white(result.relativePath)} — ${result.count} new${skipNote}`);
135
+ if (result.diff) {
136
+ console.log(chalk.gray(result.diff));
151
137
  }
152
- } catch (err) {
153
- console.error(chalk.red(` ✗ ${file}: ${err.message}`));
154
- if (verbose) console.error(err.stack);
138
+ } else if (verbose) {
139
+ console.log(` ${chalk.gray('[SKIP] ')} ${result.relativePath}`);
155
140
  }
156
141
  }
157
142
 
@@ -161,22 +146,18 @@ async function runTranslation({ pathArg, from, to, localesDir, dryRun, outdir, v
161
146
  // ── init subcommand ───────────────────────────────────────────────────────────
162
147
  program
163
148
  .command('init')
164
- .description('Interactive setup wizard — configure languages, locales folder, and i18n file')
149
+ .description('Interactive setup wizard — languages, locales folder, auto-install')
165
150
  .action(async () => {
166
151
  const { default: inquirer } = await import('inquirer');
167
- const cwd = process.cwd();
152
+ const cwd = process.cwd();
168
153
  const existing = loadConfig(cwd);
169
- const project = detectProject(cwd);
154
+ const project = detectProject(cwd);
170
155
 
171
- const defaultLocalesDir =
172
- project.type === 'vue' || project.type === 'react' ? './src/locales' : './locales';
173
- const defaultI18nFile =
174
- project.type === 'vue' || project.type === 'react' ? './src/i18n.js' : './i18n.js';
156
+ const defaultLocalesDir = project.type === 'vanilla' ? './locales' : './src/locales';
157
+ const defaultI18nFile = project.type === 'vanilla' ? './i18n.js' : './src/i18n.js';
175
158
 
176
159
  console.log(chalk.cyan.bold('\n ⚡ bctranslate init\n'));
177
- if (existing) {
178
- console.log(chalk.gray(` Existing config found — press Enter to keep current values.\n`));
179
- }
160
+ if (existing) console.log(chalk.gray(' Existing config found — press Enter to keep values.\n'));
180
161
 
181
162
  const answers = await inquirer.prompt([
182
163
  {
@@ -195,9 +176,9 @@ program
195
176
  {
196
177
  type: 'input',
197
178
  name: 'fromCustom',
198
- message: 'Source language code:',
179
+ message: 'Source language code (e.g. zh-TW):',
199
180
  when: (ans) => ans.from === '__other__',
200
- validate: (v) => /^[a-z]{2,5}$/.test(v.trim()) || 'Enter a valid BCP 47 code (e.g. en, zh-TW)',
181
+ validate: (v) => v.trim().length >= 2 || 'Enter a valid BCP 47 code',
201
182
  },
202
183
  {
203
184
  type: 'checkbox',
@@ -205,34 +186,30 @@ program
205
186
  message: 'Target language(s) — Space to select, Enter to confirm:',
206
187
  choices: COMMON_LANGUAGES,
207
188
  default: existing?.to,
208
- validate: (v) =>
209
- v.length > 0 || 'Select at least one target language (Space to select)',
189
+ validate: (v) => v.length > 0 || 'Select at least one target language',
210
190
  },
211
191
  {
212
192
  type: 'input',
213
193
  name: 'extraTo',
214
- message: 'Additional target codes (comma-separated, e.g. zh-TW,sr — leave blank to skip):',
194
+ message: 'Extra language codes (comma-separated, e.g. zh-TW,sr — blank to skip):',
215
195
  default: '',
216
196
  },
217
197
  {
218
198
  type: 'input',
219
199
  name: 'i18nFile',
220
- message: 'i18n setup file path (will be created if it does not exist):',
200
+ message: 'i18n setup file path:',
221
201
  default: existing?.i18nFile || defaultI18nFile,
222
202
  },
223
203
  ]);
224
204
 
225
- // Resolve from language
226
205
  const fromLang = answers.from === '__other__' ? answers.fromCustom.trim() : answers.from;
227
-
228
- // Merge checkbox selection + extra codes
229
- const extraTo = answers.extraTo
206
+ const extraTo = answers.extraTo
230
207
  ? answers.extraTo.split(',').map((s) => s.trim()).filter(Boolean)
231
208
  : [];
232
209
  const toLangs = [...new Set([...answers.to, ...extraTo])].filter((l) => l !== fromLang);
233
210
 
234
211
  if (toLangs.length === 0) {
235
- console.log(chalk.red('\n ✗ No target languages selected. Aborting.\n'));
212
+ console.log(chalk.red('\n ✗ No target languages selected.\n'));
236
213
  process.exit(1);
237
214
  }
238
215
 
@@ -240,17 +217,56 @@ program
240
217
  from: fromLang,
241
218
  to: toLangs,
242
219
  localesDir: answers.localesDir.trim(),
243
- i18nFile: answers.i18nFile.trim(),
220
+ i18nFile: answers.i18nFile.trim(),
244
221
  };
245
222
 
246
223
  const configPath = saveConfig(config, cwd);
247
-
248
224
  console.log(chalk.green(`\n ✓ Config saved → ${basename(configPath)}`));
249
225
  console.log(chalk.cyan(`\n Source : ${chalk.bold(config.from)}`));
250
226
  console.log(chalk.cyan(` Targets : ${chalk.bold(config.to.join(', '))}`));
251
227
  console.log(chalk.cyan(` Locales : ${chalk.bold(config.localesDir)}`));
252
228
  console.log(chalk.cyan(` i18n : ${chalk.bold(config.i18nFile)}`));
253
- console.log(chalk.gray('\n Run `bctranslate` to start translating your project.\n'));
229
+
230
+ // ── Auto-install dependencies ─────────────────────────────────────────────
231
+ const { installDeps } = await inquirer.prompt([{
232
+ type: 'confirm',
233
+ name: 'installDeps',
234
+ message: 'Install/update argostranslate now? (required for offline translation)',
235
+ default: !existing,
236
+ }]);
237
+
238
+ if (installDeps) {
239
+ process.stdout.write(chalk.cyan('\n Installing argostranslate... '));
240
+ try {
241
+ await installArgostranslate();
242
+ console.log(chalk.green('done'));
243
+ } catch (err) {
244
+ console.log(chalk.red(`failed\n ${err.message}`));
245
+ console.log(chalk.gray(' Run manually: pip install argostranslate'));
246
+ }
247
+ }
248
+
249
+ // ── Offer to pre-download language models ─────────────────────────────────
250
+ const { downloadModels } = await inquirer.prompt([{
251
+ type: 'confirm',
252
+ name: 'downloadModels',
253
+ message: `Download language models for ${toLangs.join(', ')}? (can take a few minutes, required before first use)`,
254
+ default: true,
255
+ }]);
256
+
257
+ if (downloadModels) {
258
+ for (const to of toLangs) {
259
+ process.stdout.write(chalk.cyan(` Downloading ${fromLang} → ${to}... `));
260
+ try {
261
+ await checkPythonBridge(fromLang, to);
262
+ console.log(chalk.green('ready'));
263
+ } catch (err) {
264
+ console.log(chalk.red(`failed: ${err.message}`));
265
+ }
266
+ }
267
+ }
268
+
269
+ console.log(chalk.gray('\n Run `bctranslate` to start translating.\n'));
254
270
  });
255
271
 
256
272
  // ── Main translation command ──────────────────────────────────────────────────
@@ -259,34 +275,29 @@ program
259
275
  .argument('[from]', 'Source language code (e.g. en)')
260
276
  .argument('[keyword]', '"to" keyword')
261
277
  .argument('[lang]', 'Target language code (e.g. fr)')
262
- .option('-t, --to <lang>', 'Target language (alternative to positional syntax, e.g. fr or fr,es)')
278
+ .option('-t, --to <lang>', 'Target language(s), comma-separated (e.g. fr or fr,es)')
263
279
  .option('-d, --dry-run', 'Preview changes without writing files', false)
264
280
  .option('-o, --outdir <dir>', 'Output directory for translated files')
265
281
  .option('--no-setup', 'Skip i18n setup file generation')
266
282
  .option('--json-mode <mode>', 'JSON translation mode: values or full', 'values')
267
- .option('-v, --verbose', 'Verbose output', false)
283
+ .option('-v, --verbose', 'Show per-file diffs and skipped files', false)
268
284
  .action(async (pathArg, fromArg, keyword, langArg, opts) => {
269
285
  console.log(chalk.cyan.bold('\n ⚡ bctranslate\n'));
270
286
 
271
- const invokedCwd = process.cwd();
272
- const config = loadConfig(invokedCwd);
273
-
274
- // If the user targets a specific file/dir and there's no config in the current
275
- // working directory, treat the target's directory as the project root.
276
- let cwd = invokedCwd;
277
- if (!config && pathArg) {
278
- const resolvedTarget = resolve(invokedCwd, pathArg);
279
- if (existsSync(resolvedTarget)) {
280
- try {
281
- const st = statSync(resolvedTarget);
282
- cwd = st.isDirectory() ? resolvedTarget : dirname(resolvedTarget);
283
- } catch {
284
- cwd = invokedCwd;
285
- }
286
- }
287
- }
288
-
289
- // ── Determine from/to/targets ─────────────────────────────────────────────
287
+ const invokedCwd = process.cwd();
288
+ const config = loadConfig(invokedCwd);
289
+
290
+ let cwd = invokedCwd;
291
+ if (!config && pathArg) {
292
+ const resolvedTarget = resolve(invokedCwd, pathArg);
293
+ if (existsSync(resolvedTarget)) {
294
+ try {
295
+ const st = statSync(resolvedTarget);
296
+ cwd = st.isDirectory() ? resolvedTarget : dirname(resolvedTarget);
297
+ } catch { cwd = invokedCwd; }
298
+ }
299
+ }
300
+
290
301
  const hasExplicitArgs = !!(pathArg || fromArg || keyword || langArg);
291
302
 
292
303
  if (!hasExplicitArgs && !config) {
@@ -297,24 +308,20 @@ program
297
308
  process.exit(0);
298
309
  }
299
310
 
300
- // Resolve from language
301
311
  const from = fromArg || config?.from || 'en';
302
312
 
303
- // Resolve target language(s)
304
313
  let targets;
305
314
  if (keyword === 'to' && langArg) {
306
- // bctranslate ./src en to fr — or bctranslate ./src en to fr,es
307
315
  targets = langArg.split(',').map((s) => s.trim()).filter(Boolean);
308
316
  } else if (opts.to) {
309
- // --to fr or --to fr,es
310
317
  targets = opts.to.split(',').map((s) => s.trim()).filter(Boolean);
311
318
  } else if (config?.to) {
312
319
  targets = Array.isArray(config.to) ? config.to : [config.to];
313
320
  } else {
314
- targets = ['fr']; // ultimate fallback
321
+ targets = ['fr'];
315
322
  }
316
323
 
317
- // ── Check Python bridge for all language pairs ────────────────────────────
324
+ // Check Python + download models for all pairs
318
325
  for (const to of targets) {
319
326
  try {
320
327
  await checkPythonBridge(from, to);
@@ -325,25 +332,22 @@ program
325
332
  }
326
333
  }
327
334
 
328
- // ── Run translation for each target language ──────────────────────────────
329
335
  let grandTotal = 0;
330
336
  let grandFiles = 0;
331
337
 
332
338
  for (const to of targets) {
333
- if (targets.length > 1) {
334
- console.log(chalk.cyan.bold(`\n ── Translating to ${to} ──`));
335
- }
339
+ if (targets.length > 1) console.log(chalk.cyan.bold(`\n ── Translating to ${to} ──`));
336
340
 
337
341
  const { totalStrings, totalFiles } = await runTranslation({
338
342
  pathArg,
339
343
  from,
340
344
  to,
341
345
  localesDir: config?.localesDir,
342
- dryRun: opts.dryRun,
343
- outdir: opts.outdir,
344
- verbose: opts.verbose,
345
- jsonMode: opts.jsonMode,
346
- setup: opts.setup,
346
+ dryRun: opts.dryRun,
347
+ outdir: opts.outdir,
348
+ verbose: opts.verbose,
349
+ jsonMode: opts.jsonMode,
350
+ setup: opts.setup,
347
351
  cwd,
348
352
  });
349
353
 
@@ -352,16 +356,12 @@ program
352
356
  }
353
357
 
354
358
  const langStr = targets.join(', ');
355
- console.log(
356
- chalk.cyan.bold(
357
- `\n Done: ${grandTotal} new string(s) across ${grandFiles} file(s) [→ ${langStr}]\n`
358
- )
359
- );
360
-
361
- console.log(chalk.gray(' Summary:'));
362
- console.log(chalk.gray(` Root : ${cwd}`));
363
- console.log(chalk.gray(` Source : ${from}`));
364
- console.log(chalk.gray(` Target : ${langStr}\n`));
365
- });
359
+ console.log(chalk.cyan.bold(
360
+ `\n Done: ${grandTotal} new string(s) in ${grandFiles} file(s) [→ ${langStr}]\n`
361
+ ));
362
+ console.log(chalk.gray(` Root : ${cwd}`));
363
+ console.log(chalk.gray(` Source : ${from}`));
364
+ console.log(chalk.gray(` Target : ${langStr}\n`));
365
+ });
366
366
 
367
367
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bctranslate",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "CLI to transform source files into i18n-ready code with automatic translation via argostranslate",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,8 +8,15 @@ Usage: python translator.py <from_code> <to_code>
8
8
  """
9
9
 
10
10
  import sys
11
+ import io
11
12
  import json
12
13
 
14
+ # Force UTF-8 on all platforms (critical on Windows where default may be cp1252)
15
+ sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8')
16
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', line_buffering=True)
17
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', line_buffering=True)
18
+
19
+
13
20
  def ensure_model(from_code, to_code):
14
21
  """Ensure the translation model is installed, downloading if needed."""
15
22
  import argostranslate.package