bctranslate 1.0.0

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.
@@ -0,0 +1,183 @@
1
+ import { spawn } from 'child_process';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, join } from 'path';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+ const PYTHON_SCRIPT = join(__dirname, '..', '..', 'python', 'translator.py');
8
+
9
+ let cachedPythonCmd = null;
10
+
11
+ /**
12
+ * Find the correct python command on this system.
13
+ */
14
+ async function findPython() {
15
+ if (cachedPythonCmd) return cachedPythonCmd;
16
+
17
+ for (const cmd of ['python3', 'python']) {
18
+ try {
19
+ const result = await execSimple(cmd, ['--version']);
20
+ if (result.includes('Python 3')) {
21
+ cachedPythonCmd = cmd;
22
+ return cmd;
23
+ }
24
+ } catch { /* try next */ }
25
+ }
26
+ throw new Error(
27
+ 'Python 3 not found. Install Python 3.8+ and ensure it is on your PATH.\n' +
28
+ ' Also install argostranslate: pip install argostranslate'
29
+ );
30
+ }
31
+
32
+ function execSimple(cmd, args) {
33
+ return new Promise((resolve, reject) => {
34
+ const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
35
+ let out = '';
36
+ let err = '';
37
+ proc.stdout.on('data', (d) => (out += d.toString()));
38
+ proc.stderr.on('data', (d) => (err += d.toString()));
39
+ proc.on('close', (code) => {
40
+ if (code === 0) resolve(out.trim());
41
+ else reject(new Error(err || `Process exited with code ${code}`));
42
+ });
43
+ proc.on('error', reject);
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Check that Python and argostranslate are available,
49
+ * and that the required language pair is installed.
50
+ */
51
+ export async function checkPythonBridge(from, to) {
52
+ const py = await findPython();
53
+
54
+ // Check argostranslate is installed and language pair available
55
+ const checkScript = `
56
+ import sys, json
57
+ try:
58
+ import argostranslate.package
59
+ import argostranslate.translate
60
+ except ImportError:
61
+ print(json.dumps({"error": "argostranslate not installed. Run: pip install argostranslate"}))
62
+ sys.exit(0)
63
+
64
+ from_code = "${from}"
65
+ to_code = "${to}"
66
+
67
+ installed = argostranslate.translate.get_installed_languages()
68
+ from_lang = None
69
+ to_lang = None
70
+ for lang in installed:
71
+ if lang.code == from_code:
72
+ from_lang = lang
73
+ if lang.code == to_code:
74
+ to_lang = lang
75
+
76
+ if not from_lang or not to_lang:
77
+ available_codes = [l.code for l in installed]
78
+ # Try to auto-download
79
+ try:
80
+ argostranslate.package.update_package_index()
81
+ available_packages = argostranslate.package.get_available_packages()
82
+ pkg = next((p for p in available_packages if p.from_code == from_code and p.to_code == to_code), None)
83
+ if pkg:
84
+ print(json.dumps({"status": "downloading", "pair": f"{from_code}->{to_code}"}))
85
+ argostranslate.package.install_from_path(pkg.download())
86
+ print(json.dumps({"status": "ready"}))
87
+ else:
88
+ print(json.dumps({"error": f"Language pair {from_code}->{to_code} not available. Installed: {available_codes}"}))
89
+ except Exception as e:
90
+ print(json.dumps({"error": f"Failed to download language pair: {str(e)}. Installed: {available_codes}"}))
91
+ else:
92
+ print(json.dumps({"status": "ready"}))
93
+ `;
94
+
95
+ const result = await execSimple(py, ['-c', checkScript]);
96
+
97
+ // Parse last JSON line (might have multiple from downloading)
98
+ const lines = result.split('\n').filter(l => l.trim());
99
+ for (const line of lines.reverse()) {
100
+ try {
101
+ const parsed = JSON.parse(line);
102
+ if (parsed.error) throw new Error(parsed.error);
103
+ if (parsed.status === 'ready') return true;
104
+ } catch (e) {
105
+ if (e.message && !e.message.includes('Unexpected')) throw e;
106
+ }
107
+ }
108
+
109
+ return true;
110
+ }
111
+
112
+ /**
113
+ * Translate a batch of strings using argostranslate via Python.
114
+ * Sends all strings at once for efficiency (model loads once).
115
+ *
116
+ * @param {Array<{key: string, text: string}>} batch - Strings to translate
117
+ * @param {string} from - Source language code
118
+ * @param {string} to - Target language code
119
+ * @returns {Promise<Object<string, string>>} Map of key -> translated text
120
+ */
121
+ export async function translateBatch(batch, from, to) {
122
+ if (batch.length === 0) return {};
123
+
124
+ const py = await findPython();
125
+
126
+ return new Promise((resolve, reject) => {
127
+ const proc = spawn(py, [PYTHON_SCRIPT, from, to], {
128
+ stdio: ['pipe', 'pipe', 'pipe'],
129
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
130
+ });
131
+
132
+ const input = JSON.stringify(batch);
133
+ let stdout = '';
134
+ let stderr = '';
135
+
136
+ proc.stdout.on('data', (d) => (stdout += d.toString()));
137
+ proc.stderr.on('data', (d) => (stderr += d.toString()));
138
+
139
+ proc.on('close', (code) => {
140
+ if (code !== 0) {
141
+ return reject(new Error(`Python translator failed (code ${code}): ${stderr}`));
142
+ }
143
+
144
+ try {
145
+ // Find the JSON output line (ignore any debug/warning output)
146
+ const lines = stdout.split('\n');
147
+ let result = null;
148
+ for (let i = lines.length - 1; i >= 0; i--) {
149
+ const line = lines[i].trim();
150
+ if (line.startsWith('{') || line.startsWith('[')) {
151
+ try {
152
+ result = JSON.parse(line);
153
+ break;
154
+ } catch { /* try previous line */ }
155
+ }
156
+ }
157
+
158
+ if (!result) {
159
+ return reject(new Error(`No valid JSON in Python output: ${stdout.slice(0, 500)}`));
160
+ }
161
+
162
+ // Convert array to map
163
+ const map = {};
164
+ if (Array.isArray(result)) {
165
+ for (const item of result) {
166
+ map[item.key] = item.text;
167
+ }
168
+ } else {
169
+ Object.assign(map, result);
170
+ }
171
+
172
+ resolve(map);
173
+ } catch (err) {
174
+ reject(new Error(`Failed to parse Python output: ${err.message}\nOutput: ${stdout.slice(0, 500)}`));
175
+ }
176
+ });
177
+
178
+ proc.on('error', (err) => reject(new Error(`Failed to spawn Python: ${err.message}`)));
179
+
180
+ proc.stdin.write(input);
181
+ proc.stdin.end();
182
+ });
183
+ }
package/src/config.js ADDED
@@ -0,0 +1,28 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join, basename } from 'path';
3
+
4
+ const CONFIG_FILE = '.bctranslaterc.json';
5
+
6
+ export function getConfigPath(cwd = process.cwd()) {
7
+ return join(cwd, CONFIG_FILE);
8
+ }
9
+
10
+ export function loadConfig(cwd = process.cwd()) {
11
+ const configPath = getConfigPath(cwd);
12
+ if (!existsSync(configPath)) return null;
13
+ try {
14
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export function saveConfig(config, cwd = process.cwd()) {
21
+ const configPath = getConfigPath(cwd);
22
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
23
+ return configPath;
24
+ }
25
+
26
+ export function configFileName() {
27
+ return CONFIG_FILE;
28
+ }
package/src/detect.js ADDED
@@ -0,0 +1,58 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * Detect the project type (vue, react, vanilla) based on package.json and file structure.
6
+ */
7
+ export function detectProject(cwd) {
8
+ const pkgPath = join(cwd, 'package.json');
9
+ let pkg = {};
10
+
11
+ if (existsSync(pkgPath)) {
12
+ try {
13
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
14
+ } catch { /* ignore */ }
15
+ }
16
+
17
+ const allDeps = {
18
+ ...(pkg.dependencies || {}),
19
+ ...(pkg.devDependencies || {}),
20
+ };
21
+
22
+ // Vue detection
23
+ if (allDeps['vue'] || allDeps['nuxt'] || allDeps['@vue/cli-service']) {
24
+ const i18nPkg = allDeps['vue-i18n'] ? 'vue-i18n' : null;
25
+ return {
26
+ type: 'vue',
27
+ i18nPackage: i18nPkg,
28
+ usesCompositionApi: detectVueCompositionApi(cwd, allDeps),
29
+ srcDir: existsSync(join(cwd, 'src')) ? 'src' : '.',
30
+ };
31
+ }
32
+
33
+ // React detection
34
+ if (allDeps['react'] || allDeps['next'] || allDeps['gatsby']) {
35
+ const i18nPkg = allDeps['react-i18next'] ? 'react-i18next'
36
+ : allDeps['react-intl'] ? 'react-intl' : null;
37
+ return {
38
+ type: 'react',
39
+ i18nPackage: i18nPkg,
40
+ srcDir: existsSync(join(cwd, 'src')) ? 'src' : '.',
41
+ };
42
+ }
43
+
44
+ // Vanilla
45
+ return {
46
+ type: 'vanilla',
47
+ i18nPackage: null,
48
+ srcDir: '.',
49
+ };
50
+ }
51
+
52
+ function detectVueCompositionApi(cwd, deps) {
53
+ if (deps['@vue/composition-api']) return true;
54
+ // Vue 3 uses composition API by default
55
+ const vueVer = deps['vue'] || '';
56
+ if (vueVer.match(/[~^]?3/)) return true;
57
+ return false;
58
+ }
@@ -0,0 +1,62 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+
4
+ /**
5
+ * Determine the locale directory path based on project type.
6
+ */
7
+ export function getLocaleDir(cwd, project) {
8
+ const candidates = [
9
+ join(cwd, 'src', 'locales'),
10
+ join(cwd, 'src', 'i18n', 'locales'),
11
+ join(cwd, 'locales'),
12
+ join(cwd, 'src', 'lang'),
13
+ join(cwd, 'public', 'locales'),
14
+ ];
15
+
16
+ for (const dir of candidates) {
17
+ if (existsSync(dir)) return dir;
18
+ }
19
+
20
+ // Default: create src/locales for vue/react, locales/ for vanilla
21
+ if (project.type === 'vanilla') {
22
+ return join(cwd, 'locales');
23
+ }
24
+ return join(cwd, 'src', 'locales');
25
+ }
26
+
27
+ /**
28
+ * Load an existing locale file, or return empty object.
29
+ */
30
+ export function loadLocale(localeDir, langCode) {
31
+ const filePath = join(localeDir, `${langCode}.json`);
32
+ if (existsSync(filePath)) {
33
+ try {
34
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+ return {};
40
+ }
41
+
42
+ /**
43
+ * Save a locale file, merging with existing keys.
44
+ */
45
+ export function saveLocale(localeDir, langCode, newEntries) {
46
+ mkdirSync(localeDir, { recursive: true });
47
+
48
+ const filePath = join(localeDir, `${langCode}.json`);
49
+ const existing = loadLocale(localeDir, langCode);
50
+
51
+ // Merge: new entries take precedence for new keys,
52
+ // but don't overwrite existing translations (idempotent)
53
+ const merged = { ...existing };
54
+ for (const [key, value] of Object.entries(newEntries)) {
55
+ if (!(key in merged)) {
56
+ merged[key] = value;
57
+ }
58
+ }
59
+
60
+ writeFileSync(filePath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
61
+ return filePath;
62
+ }
@@ -0,0 +1,188 @@
1
+ import { existsSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import chalk from 'chalk';
4
+ import { getLocaleDir } from './locales.js';
5
+
6
+ /**
7
+ * Ensure the i18n configuration file and locale files exist.
8
+ * Creates them if they don't.
9
+ *
10
+ * @param {string} cwd - Project root
11
+ * @param {object} project - Detected project info
12
+ * @param {string} from - Source language code
13
+ * @param {string} to - Target language code
14
+ * @param {string} [customLocaleDir] - Optional override for locale directory
15
+ */
16
+ export async function ensureI18nSetup(cwd, project, from, to, customLocaleDir) {
17
+ const localeDir = customLocaleDir || getLocaleDir(cwd, project);
18
+ mkdirSync(localeDir, { recursive: true });
19
+
20
+ // Ensure locale JSON files exist
21
+ for (const lang of [from, to]) {
22
+ const localePath = join(localeDir, `${lang}.json`);
23
+ if (!existsSync(localePath)) {
24
+ writeFileSync(localePath, '{}\n', 'utf-8');
25
+ console.log(chalk.green(` ✓ Created ${localePath}`));
26
+ }
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
+ }
37
+
38
+ async function ensureVueI18n(cwd, project, from, to, localeDir) {
39
+ const i18nFile = join(cwd, 'src', 'i18n.js');
40
+
41
+ if (existsSync(i18nFile)) {
42
+ console.log(chalk.gray(' → i18n.js already exists, skipping setup'));
43
+ return;
44
+ }
45
+
46
+ const isVue3 = project.usesCompositionApi;
47
+ const relLocale = localeDir.replace(cwd, '.').replace(/\\/g, '/');
48
+
49
+ let content;
50
+ 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
+ });
64
+
65
+ export default i18n;
66
+ `;
67
+ } 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
+ });
83
+
84
+ export default i18n;
85
+ `;
86
+ }
87
+
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
+ }
94
+
95
+ async function ensureReactI18n(cwd, project, from, to, localeDir) {
96
+ const i18nFile = join(cwd, 'src', 'i18n.js');
97
+
98
+ if (existsSync(i18nFile)) {
99
+ console.log(chalk.gray(' → i18n.js already exists, skipping setup'));
100
+ return;
101
+ }
102
+
103
+ const relLocale = localeDir.replace(cwd, '.').replace(/\\/g, '/');
104
+
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
+ },
115
+ lng: '${from}',
116
+ fallbackLng: '${from}',
117
+ interpolation: {
118
+ escapeValue: false,
119
+ },
120
+ });
121
+
122
+ export default i18n;
123
+ `;
124
+
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
+ }
131
+
132
+ async function ensureVanillaI18n(cwd, from, to, localeDir) {
133
+ const i18nFile = join(cwd, 'i18n.js');
134
+
135
+ if (existsSync(i18nFile)) {
136
+ console.log(chalk.gray(' → i18n.js already exists, skipping setup'));
137
+ return;
138
+ }
139
+
140
+ const content = `/**
141
+ * Simple i18n for vanilla JS — generated by bctranslate
142
+ */
143
+ (function () {
144
+ const locales = {};
145
+ let currentLocale = '${from}';
146
+
147
+ async function loadLocale(lang) {
148
+ if (locales[lang]) return;
149
+ const resp = await fetch('./locales/' + lang + '.json');
150
+ locales[lang] = await resp.json();
151
+ }
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
+ }
160
+
161
+ async function setLocale(lang) {
162
+ await loadLocale(lang);
163
+ currentLocale = lang;
164
+ // 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
+ });
169
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
170
+ el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
171
+ });
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}');
180
+
181
+ window.i18n = { t: t, setLocale: setLocale, loadLocale: loadLocale };
182
+ })();
183
+ `;
184
+
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
+ }
package/src/index.js ADDED
@@ -0,0 +1,155 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { extname, relative, basename, dirname, join } from 'path';
3
+ import { parseVue } from './parsers/vue.js';
4
+ import { parseReact } from './parsers/react.js';
5
+ import { parseHtml } from './parsers/html.js';
6
+ import { parseJs } from './parsers/js.js';
7
+ import { parseJson, applyJsonTranslations } from './parsers/json.js';
8
+ import { translateBatch } from './bridges/python.js';
9
+ import { getLocaleDir, saveLocale, loadLocale } from './generators/locales.js';
10
+
11
+ /**
12
+ * Process a single file: extract strings, translate only untranslated ones,
13
+ * write results. Smart: skips strings already present in the target locale.
14
+ */
15
+ export async function translateFile(filePath, opts) {
16
+ const { from, to, dryRun, outdir, project, cwd, verbose, jsonMode } = opts;
17
+ const ext = extname(filePath).toLowerCase();
18
+ const source = readFileSync(filePath, 'utf-8');
19
+ const relativePath = relative(cwd, filePath);
20
+
21
+ // Route to appropriate parser
22
+ let result;
23
+ switch (ext) {
24
+ case '.vue':
25
+ result = parseVue(source, filePath);
26
+ break;
27
+ case '.jsx':
28
+ case '.tsx':
29
+ result = parseReact(source, filePath);
30
+ break;
31
+ case '.html':
32
+ case '.htm':
33
+ result = parseHtml(source, filePath, project);
34
+ break;
35
+ case '.js':
36
+ case '.ts':
37
+ if (source.includes('React') || source.includes('jsx') || /<\w+[\s>]/.test(source)) {
38
+ result = parseReact(source, filePath);
39
+ } else {
40
+ result = parseJs(source, filePath, project);
41
+ }
42
+ break;
43
+ case '.json':
44
+ result = parseJson(source, filePath, { jsonMode });
45
+ break;
46
+ default:
47
+ return { count: 0, skipped: 0, relativePath };
48
+ }
49
+
50
+ if (!result.extracted || result.extracted.length === 0) {
51
+ return { count: 0, skipped: 0, relativePath };
52
+ }
53
+
54
+ // Deduplicate by key (same text = same hash key)
55
+ const seen = new Set();
56
+ const uniqueBatch = result.extracted
57
+ .map((item) => ({ key: item.key, text: item.text }))
58
+ .filter((item) => {
59
+ if (seen.has(item.key)) return false;
60
+ seen.add(item.key);
61
+ return true;
62
+ });
63
+
64
+ // ── Smart half-translation: load existing locale, skip already-done keys ──
65
+ const localeDir = opts.localesDir || getLocaleDir(cwd, project);
66
+ const existingTarget = loadLocale(localeDir, to);
67
+
68
+ const needsTranslation = uniqueBatch.filter((item) => !(item.key in existingTarget));
69
+ const skippedCount = uniqueBatch.length - needsTranslation.length;
70
+
71
+ // Start with existing translations
72
+ let translations = { ...existingTarget };
73
+
74
+ if (needsTranslation.length > 0) {
75
+ try {
76
+ const newTranslations = await translateBatch(needsTranslation, from, to);
77
+ Object.assign(translations, newTranslations);
78
+ } catch (err) {
79
+ if (verbose) {
80
+ console.error(` Translation error: ${err.message}`);
81
+ }
82
+ // Fallback: use original text
83
+ for (const item of needsTranslation) {
84
+ translations[item.key] = item.text;
85
+ }
86
+ }
87
+ }
88
+
89
+ // Build locale entries for all extracted strings
90
+ const fromEntries = {};
91
+ const toEntries = {};
92
+ for (const item of uniqueBatch) {
93
+ fromEntries[item.key] = item.text;
94
+ toEntries[item.key] = translations[item.key] || item.text;
95
+ }
96
+
97
+ if (!dryRun) {
98
+ // Write locale files (merge with existing — won't overwrite manual edits)
99
+ saveLocale(localeDir, from, fromEntries);
100
+ saveLocale(localeDir, to, toEntries);
101
+
102
+ if (ext === '.json') {
103
+ const translatedData = applyJsonTranslations(result.jsonData, translations);
104
+ const outName = basename(filePath, ext) + `.${to}${ext}`;
105
+ const outPath = outdir
106
+ ? join(outdir, outName)
107
+ : join(dirname(filePath), outName);
108
+
109
+ mkdirSync(dirname(outPath), { recursive: true });
110
+ writeFileSync(outPath, JSON.stringify(translatedData, null, 2) + '\n', 'utf-8');
111
+ } else {
112
+ // Only rewrite source if it actually changed
113
+ if (result.source !== source) {
114
+ const outPath = outdir ? join(outdir, basename(filePath)) : filePath;
115
+ if (outdir) mkdirSync(outdir, { recursive: true });
116
+
117
+ const tmpPath = outPath + '.bctmp';
118
+ writeFileSync(tmpPath, result.source, 'utf-8');
119
+ const { renameSync } = await import('fs');
120
+ renameSync(tmpPath, outPath);
121
+ }
122
+ }
123
+ }
124
+
125
+ // count = newly translated strings; skipped = already had translations
126
+ return {
127
+ count: needsTranslation.length,
128
+ skipped: skippedCount,
129
+ relativePath,
130
+ diff: dryRun ? generateDiff(source, result.source) : null,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Simple line-diff preview for dry-run mode.
136
+ */
137
+ function generateDiff(original, modified) {
138
+ if (original === modified) return '';
139
+
140
+ const origLines = original.split('\n');
141
+ const modLines = modified.split('\n');
142
+ const diffs = [];
143
+
144
+ const maxLines = Math.max(origLines.length, modLines.length);
145
+ for (let i = 0; i < maxLines; i++) {
146
+ const orig = origLines[i] || '';
147
+ const mod = modLines[i] || '';
148
+ if (orig !== mod) {
149
+ diffs.push(` ${i + 1}: - ${orig.trim()}`);
150
+ diffs.push(` ${i + 1}: + ${mod.trim()}`);
151
+ }
152
+ }
153
+
154
+ return diffs.slice(0, 40).join('\n') + (diffs.length > 40 ? '\n ... (truncated)' : '');
155
+ }