bctranslate 1.0.0-beta.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.
- package/README.md +90 -0
- package/bin/bctranslate.js +373 -0
- package/package.json +50 -0
- package/python/translator.py +110 -0
- package/src/bridges/python.js +189 -0
- package/src/config.js +28 -0
- package/src/detect.js +58 -0
- package/src/generators/locales.js +105 -0
- package/src/generators/setup.js +445 -0
- package/src/index.js +233 -0
- package/src/parsers/html.js +161 -0
- package/src/parsers/js.js +156 -0
- package/src/parsers/json.js +93 -0
- package/src/parsers/react.js +148 -0
- package/src/parsers/vue.js +282 -0
- package/src/utils.js +227 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { shieldInterpolations, unshieldInterpolations } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = dirname(__filename);
|
|
8
|
+
const PYTHON_SCRIPT = join(__dirname, '..', '..', 'python', 'translator.py');
|
|
9
|
+
|
|
10
|
+
let cachedPythonCmd = null;
|
|
11
|
+
|
|
12
|
+
async function findPython() {
|
|
13
|
+
if (cachedPythonCmd) return cachedPythonCmd;
|
|
14
|
+
for (const cmd of ['python3', 'python']) {
|
|
15
|
+
try {
|
|
16
|
+
const out = await execSimple(cmd, ['--version']);
|
|
17
|
+
if (out.includes('Python 3')) {
|
|
18
|
+
cachedPythonCmd = cmd;
|
|
19
|
+
return cmd;
|
|
20
|
+
}
|
|
21
|
+
} catch { /* try next */ }
|
|
22
|
+
}
|
|
23
|
+
throw new Error(
|
|
24
|
+
'Python 3 not found. Install Python 3.8+ and add it to your PATH.\n' +
|
|
25
|
+
' Then run: pip install argostranslate'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function execSimple(cmd, args) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
32
|
+
let out = '';
|
|
33
|
+
let err = '';
|
|
34
|
+
proc.stdout.on('data', (d) => (out += d.toString()));
|
|
35
|
+
proc.stderr.on('data', (d) => (err += d.toString()));
|
|
36
|
+
proc.on('close', (code) => {
|
|
37
|
+
if (code === 0) resolve(out.trim());
|
|
38
|
+
else reject(new Error(err || `Process exited with code ${code}`));
|
|
39
|
+
});
|
|
40
|
+
proc.on('error', reject);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Install argostranslate via pip.
|
|
46
|
+
* Called from `init` and from checkPythonBridge when the package is missing.
|
|
47
|
+
*/
|
|
48
|
+
export async function installArgostranslate() {
|
|
49
|
+
const py = await findPython();
|
|
50
|
+
return execSimple(py, ['-m', 'pip', 'install', '--quiet', 'argostranslate']);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check Python + argostranslate availability and download the language pair
|
|
55
|
+
* model if not already installed.
|
|
56
|
+
*/
|
|
57
|
+
export async function checkPythonBridge(from, to) {
|
|
58
|
+
const py = await findPython();
|
|
59
|
+
|
|
60
|
+
const checkScript = `
|
|
61
|
+
import sys, json
|
|
62
|
+
try:
|
|
63
|
+
import argostranslate.package
|
|
64
|
+
import argostranslate.translate
|
|
65
|
+
except ImportError:
|
|
66
|
+
print(json.dumps({"error": "argostranslate not installed. Run: pip install argostranslate"}))
|
|
67
|
+
sys.exit(0)
|
|
68
|
+
|
|
69
|
+
from_code = "${from}"
|
|
70
|
+
to_code = "${to}"
|
|
71
|
+
|
|
72
|
+
installed = argostranslate.translate.get_installed_languages()
|
|
73
|
+
from_lang = next((l for l in installed if l.code == from_code), None)
|
|
74
|
+
to_lang = next((l for l in installed if l.code == to_code), None)
|
|
75
|
+
|
|
76
|
+
if from_lang and to_lang and from_lang.get_translation(to_lang):
|
|
77
|
+
print(json.dumps({"status": "ready"}))
|
|
78
|
+
else:
|
|
79
|
+
try:
|
|
80
|
+
argostranslate.package.update_package_index()
|
|
81
|
+
available = argostranslate.package.get_available_packages()
|
|
82
|
+
pkg = next((p for p in available 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
|
+
available_codes = [l.code for l in installed]
|
|
89
|
+
print(json.dumps({"error": f"Language pair {from_code}->{to_code} not available. Installed: {available_codes}"}))
|
|
90
|
+
except Exception as e:
|
|
91
|
+
print(json.dumps({"error": f"Failed to download language pair: {str(e)}"}))
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
const result = await execSimple(py, ['-c', checkScript]);
|
|
95
|
+
|
|
96
|
+
const lines = result.split('\n').filter((l) => l.trim());
|
|
97
|
+
for (const line of [...lines].reverse()) {
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(line);
|
|
100
|
+
if (parsed.error) throw new Error(parsed.error);
|
|
101
|
+
if (parsed.status === 'ready') return true;
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (e.message && !e.message.includes('Unexpected')) throw e;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Translate a batch of strings using argostranslate via Python.
|
|
111
|
+
*
|
|
112
|
+
* Interpolation variables like {{ name }}, {count}, ${val} are shielded
|
|
113
|
+
* before sending and restored after — Argos never sees them.
|
|
114
|
+
*
|
|
115
|
+
* @param {Array<{key: string, text: string}>} batch
|
|
116
|
+
* @param {string} from Source language code
|
|
117
|
+
* @param {string} to Target language code
|
|
118
|
+
* @returns {Promise<Record<string, string>>} key → translated text
|
|
119
|
+
*/
|
|
120
|
+
export async function translateBatch(batch, from, to) {
|
|
121
|
+
if (batch.length === 0) return {};
|
|
122
|
+
|
|
123
|
+
const py = await findPython();
|
|
124
|
+
|
|
125
|
+
// ── Shield interpolations so Argos never mangles {name} / {{ expr }} ────────
|
|
126
|
+
const shielded = batch.map((item) => {
|
|
127
|
+
const { shielded: text, tokens } = shieldInterpolations(item.text);
|
|
128
|
+
return { key: item.key, text, tokens };
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const proc = spawn(py, [PYTHON_SCRIPT, from, to], {
|
|
133
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
134
|
+
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const input = JSON.stringify(shielded.map(({ key, text }) => ({ key, text })));
|
|
138
|
+
let stdout = '';
|
|
139
|
+
let stderr = '';
|
|
140
|
+
|
|
141
|
+
proc.stdout.on('data', (d) => (stdout += d.toString()));
|
|
142
|
+
proc.stderr.on('data', (d) => (stderr += d.toString()));
|
|
143
|
+
|
|
144
|
+
proc.on('close', (code) => {
|
|
145
|
+
if (code !== 0) {
|
|
146
|
+
return reject(new Error(`Python translator failed (code ${code}): ${stderr}`));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
// Find the JSON output line (skip debug/warning lines)
|
|
151
|
+
const lines = stdout.split('\n');
|
|
152
|
+
let raw = null;
|
|
153
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
154
|
+
const line = lines[i].trim();
|
|
155
|
+
if (line.startsWith('[') || line.startsWith('{')) {
|
|
156
|
+
try { raw = JSON.parse(line); break; } catch { /* try previous */ }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!raw) {
|
|
161
|
+
return reject(new Error(`No valid JSON in Python output: ${stdout.slice(0, 500)}`));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Convert array → map, then unshield interpolations
|
|
165
|
+
const rawMap = {};
|
|
166
|
+
if (Array.isArray(raw)) {
|
|
167
|
+
for (const item of raw) rawMap[item.key] = item.text;
|
|
168
|
+
} else {
|
|
169
|
+
Object.assign(rawMap, raw);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const result = {};
|
|
173
|
+
for (const { key, tokens } of shielded) {
|
|
174
|
+
const translated = rawMap[key] ?? batch.find((b) => b.key === key)?.text ?? '';
|
|
175
|
+
result[key] = unshieldInterpolations(translated, tokens);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
resolve(result);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
reject(new Error(`Failed to parse Python output: ${err.message}\nOutput: ${stdout.slice(0, 500)}`));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
proc.on('error', (err) => reject(new Error(`Failed to spawn Python: ${err.message}`)));
|
|
185
|
+
|
|
186
|
+
proc.stdin.write(input);
|
|
187
|
+
proc.stdin.end();
|
|
188
|
+
});
|
|
189
|
+
}
|
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,105 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } 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, 'src', 'i18n'),
|
|
12
|
+
join(cwd, 'locales'),
|
|
13
|
+
join(cwd, 'src', 'lang'),
|
|
14
|
+
join(cwd, 'public', 'locales'),
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
for (const dir of candidates) {
|
|
18
|
+
if (existsSync(dir)) return dir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Default: create src/locales for vue/react, locales/ for vanilla
|
|
22
|
+
if (project.type === 'vanilla') {
|
|
23
|
+
return join(cwd, 'locales');
|
|
24
|
+
}
|
|
25
|
+
return join(cwd, 'src', 'locales');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Flatten a nested JSON object to dot-notation keys.
|
|
30
|
+
* { home: { notes: 'Notes' } } → { 'home.notes': 'Notes' }
|
|
31
|
+
*/
|
|
32
|
+
function flattenKeys(obj, prefix = '') {
|
|
33
|
+
const result = {};
|
|
34
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
35
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
36
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
37
|
+
Object.assign(result, flattenKeys(value, fullKey));
|
|
38
|
+
} else {
|
|
39
|
+
result[fullKey] = value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Unflatten dot-notation keys to a nested JSON object.
|
|
47
|
+
* { 'home.notes': 'Notes' } → { home: { notes: 'Notes' } }
|
|
48
|
+
*/
|
|
49
|
+
function unflattenKeys(flat) {
|
|
50
|
+
const result = {};
|
|
51
|
+
for (const [dotKey, value] of Object.entries(flat)) {
|
|
52
|
+
const parts = dotKey.split('.');
|
|
53
|
+
let obj = result;
|
|
54
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
55
|
+
if (typeof obj[parts[i]] !== 'object' || obj[parts[i]] === null) {
|
|
56
|
+
obj[parts[i]] = {};
|
|
57
|
+
}
|
|
58
|
+
obj = obj[parts[i]];
|
|
59
|
+
}
|
|
60
|
+
obj[parts[parts.length - 1]] = value;
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Load an existing locale file and return flat dot-notation keys.
|
|
67
|
+
* Handles both nested JSON (vue-i18n standard) and legacy flat format.
|
|
68
|
+
*/
|
|
69
|
+
export function loadLocale(localeDir, langCode) {
|
|
70
|
+
const filePath = join(localeDir, `${langCode}.json`);
|
|
71
|
+
if (existsSync(filePath)) {
|
|
72
|
+
try {
|
|
73
|
+
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
74
|
+
// Flatten nested objects to dot-notation for internal use
|
|
75
|
+
return flattenKeys(raw);
|
|
76
|
+
} catch {
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Save a locale file, merging with existing keys.
|
|
85
|
+
* Writes nested JSON (standard for vue-i18n and react-i18next).
|
|
86
|
+
*/
|
|
87
|
+
export function saveLocale(localeDir, langCode, newEntries) {
|
|
88
|
+
mkdirSync(localeDir, { recursive: true });
|
|
89
|
+
|
|
90
|
+
const filePath = join(localeDir, `${langCode}.json`);
|
|
91
|
+
const existing = loadLocale(localeDir, langCode); // already flat
|
|
92
|
+
|
|
93
|
+
// Merge: don't overwrite existing translations
|
|
94
|
+
const merged = { ...existing };
|
|
95
|
+
for (const [key, value] of Object.entries(newEntries)) {
|
|
96
|
+
if (!(key in merged)) {
|
|
97
|
+
merged[key] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Write as nested JSON for i18n library compatibility
|
|
102
|
+
const nested = unflattenKeys(merged);
|
|
103
|
+
writeFileSync(filePath, JSON.stringify(nested, null, 2) + '\n', 'utf-8');
|
|
104
|
+
return filePath;
|
|
105
|
+
}
|