bctranslate 1.0.0 → 1.0.2
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/bin/bctranslate.js +132 -132
- package/package.json +1 -1
- package/python/translator.py +7 -0
- package/src/bridges/python.js +61 -55
- package/src/index.js +158 -97
- package/src/parsers/html.js +3 -3
- package/src/parsers/js.js +4 -4
- package/src/parsers/json.js +1 -1
- package/src/parsers/react.js +55 -60
- package/src/parsers/vue.js +99 -105
- package/src/utils.js +159 -23
package/src/bridges/python.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
import { dirname, join } from 'path';
|
|
4
|
+
import { shieldInterpolations, unshieldInterpolations } from '../utils.js';
|
|
4
5
|
|
|
5
6
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
7
|
const __dirname = dirname(__filename);
|
|
@@ -8,24 +9,20 @@ const PYTHON_SCRIPT = join(__dirname, '..', '..', 'python', 'translator.py');
|
|
|
8
9
|
|
|
9
10
|
let cachedPythonCmd = null;
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
-
* Find the correct python command on this system.
|
|
13
|
-
*/
|
|
14
12
|
async function findPython() {
|
|
15
13
|
if (cachedPythonCmd) return cachedPythonCmd;
|
|
16
|
-
|
|
17
14
|
for (const cmd of ['python3', 'python']) {
|
|
18
15
|
try {
|
|
19
|
-
const
|
|
20
|
-
if (
|
|
16
|
+
const out = await execSimple(cmd, ['--version']);
|
|
17
|
+
if (out.includes('Python 3')) {
|
|
21
18
|
cachedPythonCmd = cmd;
|
|
22
19
|
return cmd;
|
|
23
20
|
}
|
|
24
21
|
} catch { /* try next */ }
|
|
25
22
|
}
|
|
26
23
|
throw new Error(
|
|
27
|
-
'Python 3 not found. Install Python 3.8+ and
|
|
28
|
-
'
|
|
24
|
+
'Python 3 not found. Install Python 3.8+ and add it to your PATH.\n' +
|
|
25
|
+
' Then run: pip install argostranslate'
|
|
29
26
|
);
|
|
30
27
|
}
|
|
31
28
|
|
|
@@ -45,13 +42,21 @@ function execSimple(cmd, args) {
|
|
|
45
42
|
}
|
|
46
43
|
|
|
47
44
|
/**
|
|
48
|
-
*
|
|
49
|
-
* and
|
|
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.
|
|
50
56
|
*/
|
|
51
57
|
export async function checkPythonBridge(from, to) {
|
|
52
58
|
const py = await findPython();
|
|
53
59
|
|
|
54
|
-
// Check argostranslate is installed and language pair available
|
|
55
60
|
const checkScript = `
|
|
56
61
|
import sys, json
|
|
57
62
|
try:
|
|
@@ -62,41 +67,34 @@ except ImportError:
|
|
|
62
67
|
sys.exit(0)
|
|
63
68
|
|
|
64
69
|
from_code = "${from}"
|
|
65
|
-
to_code
|
|
70
|
+
to_code = "${to}"
|
|
66
71
|
|
|
67
72
|
installed = argostranslate.translate.get_installed_languages()
|
|
68
|
-
from_lang = None
|
|
69
|
-
to_lang
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
79
|
try:
|
|
80
80
|
argostranslate.package.update_package_index()
|
|
81
|
-
|
|
82
|
-
pkg = next((p for p in
|
|
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
83
|
if pkg:
|
|
84
84
|
print(json.dumps({"status": "downloading", "pair": f"{from_code}->{to_code}"}))
|
|
85
85
|
argostranslate.package.install_from_path(pkg.download())
|
|
86
86
|
print(json.dumps({"status": "ready"}))
|
|
87
87
|
else:
|
|
88
|
+
available_codes = [l.code for l in installed]
|
|
88
89
|
print(json.dumps({"error": f"Language pair {from_code}->{to_code} not available. Installed: {available_codes}"}))
|
|
89
90
|
except Exception as e:
|
|
90
|
-
print(json.dumps({"error": f"Failed to download language pair: {str(e)}
|
|
91
|
-
else:
|
|
92
|
-
print(json.dumps({"status": "ready"}))
|
|
91
|
+
print(json.dumps({"error": f"Failed to download language pair: {str(e)}"}))
|
|
93
92
|
`;
|
|
94
93
|
|
|
95
94
|
const result = await execSimple(py, ['-c', checkScript]);
|
|
96
95
|
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
for (const line of lines.reverse()) {
|
|
96
|
+
const lines = result.split('\n').filter((l) => l.trim());
|
|
97
|
+
for (const line of [...lines].reverse()) {
|
|
100
98
|
try {
|
|
101
99
|
const parsed = JSON.parse(line);
|
|
102
100
|
if (parsed.error) throw new Error(parsed.error);
|
|
@@ -105,31 +103,38 @@ else:
|
|
|
105
103
|
if (e.message && !e.message.includes('Unexpected')) throw e;
|
|
106
104
|
}
|
|
107
105
|
}
|
|
108
|
-
|
|
109
106
|
return true;
|
|
110
107
|
}
|
|
111
108
|
|
|
112
109
|
/**
|
|
113
110
|
* Translate a batch of strings using argostranslate via Python.
|
|
114
|
-
* Sends all strings at once for efficiency (model loads once).
|
|
115
111
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* @
|
|
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
|
|
120
119
|
*/
|
|
121
120
|
export async function translateBatch(batch, from, to) {
|
|
122
121
|
if (batch.length === 0) return {};
|
|
123
122
|
|
|
124
123
|
const py = await findPython();
|
|
125
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
|
+
|
|
126
131
|
return new Promise((resolve, reject) => {
|
|
127
132
|
const proc = spawn(py, [PYTHON_SCRIPT, from, to], {
|
|
128
133
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
129
134
|
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
|
130
135
|
});
|
|
131
136
|
|
|
132
|
-
const input = JSON.stringify(
|
|
137
|
+
const input = JSON.stringify(shielded.map(({ key, text }) => ({ key, text })));
|
|
133
138
|
let stdout = '';
|
|
134
139
|
let stderr = '';
|
|
135
140
|
|
|
@@ -142,34 +147,35 @@ export async function translateBatch(batch, from, to) {
|
|
|
142
147
|
}
|
|
143
148
|
|
|
144
149
|
try {
|
|
145
|
-
// Find the JSON output line (
|
|
150
|
+
// Find the JSON output line (skip debug/warning lines)
|
|
146
151
|
const lines = stdout.split('\n');
|
|
147
|
-
let
|
|
152
|
+
let raw = null;
|
|
148
153
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
149
154
|
const line = lines[i].trim();
|
|
150
|
-
if (line.startsWith('
|
|
151
|
-
try {
|
|
152
|
-
result = JSON.parse(line);
|
|
153
|
-
break;
|
|
154
|
-
} catch { /* try previous line */ }
|
|
155
|
+
if (line.startsWith('[') || line.startsWith('{')) {
|
|
156
|
+
try { raw = JSON.parse(line); break; } catch { /* try previous */ }
|
|
155
157
|
}
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
if (!
|
|
160
|
+
if (!raw) {
|
|
159
161
|
return reject(new Error(`No valid JSON in Python output: ${stdout.slice(0, 500)}`));
|
|
160
162
|
}
|
|
161
163
|
|
|
162
|
-
// Convert array
|
|
163
|
-
const
|
|
164
|
-
if (Array.isArray(
|
|
165
|
-
for (const item of
|
|
166
|
-
map[item.key] = item.text;
|
|
167
|
-
}
|
|
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
168
|
} else {
|
|
169
|
-
Object.assign(
|
|
169
|
+
Object.assign(rawMap, raw);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
|
|
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);
|
|
173
179
|
} catch (err) {
|
|
174
180
|
reject(new Error(`Failed to parse Python output: ${err.message}\nOutput: ${stdout.slice(0, 500)}`));
|
|
175
181
|
}
|
|
@@ -180,4 +186,4 @@ export async function translateBatch(batch, from, to) {
|
|
|
180
186
|
proc.stdin.write(input);
|
|
181
187
|
proc.stdin.end();
|
|
182
188
|
});
|
|
183
|
-
}
|
|
189
|
+
}
|
package/src/index.js
CHANGED
|
@@ -8,148 +8,209 @@ import { parseJson, applyJsonTranslations } from './parsers/json.js';
|
|
|
8
8
|
import { translateBatch } from './bridges/python.js';
|
|
9
9
|
import { getLocaleDir, saveLocale, loadLocale } from './generators/locales.js';
|
|
10
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);
|
|
11
|
+
// ── Route source file to the correct parser ──────────────────────────────────
|
|
20
12
|
|
|
21
|
-
|
|
22
|
-
let result;
|
|
13
|
+
function routeParser(ext, source, filePath, project, jsonMode) {
|
|
23
14
|
switch (ext) {
|
|
24
|
-
case '.vue':
|
|
25
|
-
result = parseVue(source, filePath);
|
|
26
|
-
break;
|
|
15
|
+
case '.vue': return parseVue(source, filePath);
|
|
27
16
|
case '.jsx':
|
|
28
|
-
case '.tsx':
|
|
29
|
-
result = parseReact(source, filePath);
|
|
30
|
-
break;
|
|
17
|
+
case '.tsx': return parseReact(source, filePath);
|
|
31
18
|
case '.html':
|
|
32
|
-
case '.htm':
|
|
33
|
-
result = parseHtml(source, filePath, project);
|
|
34
|
-
break;
|
|
19
|
+
case '.htm': return parseHtml(source, filePath, project);
|
|
35
20
|
case '.js':
|
|
36
21
|
case '.ts':
|
|
37
22
|
if (source.includes('React') || source.includes('jsx') || /<\w+[\s>]/.test(source)) {
|
|
38
|
-
|
|
39
|
-
} else {
|
|
40
|
-
result = parseJs(source, filePath, project);
|
|
23
|
+
return parseReact(source, filePath);
|
|
41
24
|
}
|
|
42
|
-
|
|
43
|
-
case '.json':
|
|
44
|
-
|
|
45
|
-
break;
|
|
46
|
-
default:
|
|
47
|
-
return { count: 0, skipped: 0, relativePath };
|
|
25
|
+
return parseJs(source, filePath, project);
|
|
26
|
+
case '.json': return parseJson(source, filePath, { jsonMode });
|
|
27
|
+
default: return null;
|
|
48
28
|
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse a file — no translation, no disk writes.
|
|
33
|
+
* Returns extracted strings + modified source for later application.
|
|
34
|
+
*/
|
|
35
|
+
export function parseFileOnly(filePath, opts) {
|
|
36
|
+
const { project, jsonMode = 'values' } = opts;
|
|
37
|
+
const ext = extname(filePath).toLowerCase();
|
|
38
|
+
const source = readFileSync(filePath, 'utf-8');
|
|
49
39
|
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
const result = routeParser(ext, source, filePath, project, jsonMode);
|
|
41
|
+
if (!result || !result.extracted?.length) {
|
|
42
|
+
return { source, modified: source, extracted: [], ext, filePath };
|
|
52
43
|
}
|
|
44
|
+
return {
|
|
45
|
+
source,
|
|
46
|
+
modified: result.source,
|
|
47
|
+
extracted: result.extracted,
|
|
48
|
+
ext,
|
|
49
|
+
filePath,
|
|
50
|
+
jsonData: result.jsonData,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return true;
|
|
62
|
-
});
|
|
54
|
+
/**
|
|
55
|
+
* Apply a completed translations map to one parsed file and write to disk.
|
|
56
|
+
* The original file is NOT touched until an atomic rename succeeds.
|
|
57
|
+
*/
|
|
58
|
+
export async function writeFileResult(parseResult, translations, opts) {
|
|
59
|
+
const { source, modified, extracted, ext, filePath, jsonData } = parseResult;
|
|
60
|
+
const { from, to, dryRun, outdir, project, cwd, verbose, localesDir } = opts;
|
|
63
61
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const existingTarget = loadLocale(localeDir, to);
|
|
62
|
+
const relativePath = relative(cwd, filePath);
|
|
63
|
+
if (!extracted?.length) return { count: 0, skipped: 0, relativePath };
|
|
67
64
|
|
|
68
|
-
|
|
69
|
-
const
|
|
65
|
+
// Deduplicate extracted items by key
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
const unique = extracted.filter(({ key }) => {
|
|
68
|
+
if (seen.has(key)) return false;
|
|
69
|
+
seen.add(key);
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
const resolvedLocaleDir = localesDir || getLocaleDir(cwd, project);
|
|
74
|
+
const existingTarget = loadLocale(resolvedLocaleDir, to);
|
|
73
75
|
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
}
|
|
76
|
+
const newCount = unique.filter(({ key }) => !(key in existingTarget)).length;
|
|
77
|
+
const skipped = unique.length - newCount;
|
|
88
78
|
|
|
89
|
-
// Build locale
|
|
79
|
+
// Build locale entry maps
|
|
90
80
|
const fromEntries = {};
|
|
91
|
-
const toEntries
|
|
92
|
-
for (const
|
|
93
|
-
fromEntries[
|
|
94
|
-
toEntries[
|
|
81
|
+
const toEntries = {};
|
|
82
|
+
for (const { key, text } of unique) {
|
|
83
|
+
fromEntries[key] = text;
|
|
84
|
+
toEntries[key] = translations[key] ?? existingTarget[key] ?? text;
|
|
95
85
|
}
|
|
96
86
|
|
|
97
87
|
if (!dryRun) {
|
|
98
|
-
|
|
99
|
-
saveLocale(
|
|
100
|
-
saveLocale(localeDir, to, toEntries);
|
|
88
|
+
saveLocale(resolvedLocaleDir, from, fromEntries);
|
|
89
|
+
saveLocale(resolvedLocaleDir, to, toEntries);
|
|
101
90
|
|
|
102
91
|
if (ext === '.json') {
|
|
103
|
-
const translatedData = applyJsonTranslations(
|
|
92
|
+
const translatedData = applyJsonTranslations(jsonData, toEntries);
|
|
104
93
|
const outName = basename(filePath, ext) + `.${to}${ext}`;
|
|
105
94
|
const outPath = outdir
|
|
106
95
|
? join(outdir, outName)
|
|
107
96
|
: join(dirname(filePath), outName);
|
|
108
|
-
|
|
109
97
|
mkdirSync(dirname(outPath), { recursive: true });
|
|
110
98
|
writeFileSync(outPath, JSON.stringify(translatedData, null, 2) + '\n', 'utf-8');
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
99
|
+
|
|
100
|
+
} else if (modified !== source) {
|
|
101
|
+
// Only rewrite source when content actually changed
|
|
102
|
+
// Atomic: write .bctmp first, then rename — original untouched until swap
|
|
103
|
+
const outPath = outdir ? join(outdir, basename(filePath)) : filePath;
|
|
104
|
+
const tmpPath = outPath + '.bctmp';
|
|
105
|
+
if (outdir) mkdirSync(outdir, { recursive: true });
|
|
106
|
+
writeFileSync(tmpPath, modified, 'utf-8');
|
|
107
|
+
const { renameSync } = await import('fs');
|
|
108
|
+
renameSync(tmpPath, outPath);
|
|
122
109
|
}
|
|
123
110
|
}
|
|
124
111
|
|
|
125
|
-
// count = newly translated strings; skipped = already had translations
|
|
126
112
|
return {
|
|
127
|
-
count:
|
|
128
|
-
skipped
|
|
113
|
+
count: newCount,
|
|
114
|
+
skipped,
|
|
129
115
|
relativePath,
|
|
130
|
-
diff: dryRun ? generateDiff(source,
|
|
116
|
+
diff: dryRun || verbose ? generateDiff(source, modified, relativePath) : null,
|
|
131
117
|
};
|
|
132
118
|
}
|
|
133
119
|
|
|
134
120
|
/**
|
|
135
|
-
*
|
|
121
|
+
* Translate ALL files in one Python invocation — model loads exactly once.
|
|
122
|
+
*
|
|
123
|
+
* Phase 1: Parse every file (pure CPU — no network/Python)
|
|
124
|
+
* Phase 2: Collect all unique keys not already in the target locale
|
|
125
|
+
* Phase 3: translateBatch() once → Python starts, model loads, all strings translated
|
|
126
|
+
* Phase 4: Write each file
|
|
127
|
+
*/
|
|
128
|
+
export async function translateAllFiles(files, opts) {
|
|
129
|
+
const { from, to, cwd, project, verbose, localesDir } = opts;
|
|
130
|
+
|
|
131
|
+
const resolvedLocaleDir = 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 [];
|
|
146
|
+
|
|
147
|
+
// Phase 2 — deduplicate across all files, skip already-translated keys
|
|
148
|
+
const seenKeys = new Set(Object.keys(existingTarget));
|
|
149
|
+
const needed = [];
|
|
150
|
+
for (const { extracted } of parsed) {
|
|
151
|
+
for (const { key, text } of extracted) {
|
|
152
|
+
if (!seenKeys.has(key)) {
|
|
153
|
+
seenKeys.add(key);
|
|
154
|
+
needed.push({ key, text });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
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
|
+
const results = [];
|
|
169
|
+
for (const parseResult of parsed) {
|
|
170
|
+
try {
|
|
171
|
+
const r = await writeFileResult(parseResult, allTranslations, {
|
|
172
|
+
...opts,
|
|
173
|
+
localesDir: resolvedLocaleDir,
|
|
174
|
+
});
|
|
175
|
+
results.push(r);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (verbose) console.error(` Write error ${parseResult.filePath}: ${err.message}`);
|
|
178
|
+
results.push({ count: 0, skipped: 0, relativePath: relative(cwd, parseResult.filePath) });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return results;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Single-file convenience wrapper (for backward compatibility).
|
|
136
187
|
*/
|
|
137
|
-
function
|
|
138
|
-
|
|
188
|
+
export async function translateFile(filePath, opts) {
|
|
189
|
+
const results = await translateAllFiles([filePath], opts);
|
|
190
|
+
return results[0] ?? { count: 0, skipped: 0, relativePath: relative(opts.cwd, filePath) };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Diff display ─────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
export function generateDiff(original, modified, label = '') {
|
|
196
|
+
if (!modified || original === modified) return '';
|
|
139
197
|
|
|
140
198
|
const origLines = original.split('\n');
|
|
141
|
-
const modLines
|
|
142
|
-
const
|
|
199
|
+
const modLines = modified.split('\n');
|
|
200
|
+
const out = label ? [`--- ${label} ---`] : [];
|
|
143
201
|
|
|
144
202
|
const maxLines = Math.max(origLines.length, modLines.length);
|
|
145
203
|
for (let i = 0; i < maxLines; i++) {
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
204
|
+
const a = origLines[i] ?? '';
|
|
205
|
+
const b = modLines[i] ?? '';
|
|
206
|
+
if (a !== b) {
|
|
207
|
+
out.push(` ${String(i + 1).padStart(4)} - ${a.trimEnd()}`);
|
|
208
|
+
out.push(` ${String(i + 1).padStart(4)} + ${b.trimEnd()}`);
|
|
151
209
|
}
|
|
152
210
|
}
|
|
153
211
|
|
|
154
|
-
|
|
212
|
+
if (out.length > 80) {
|
|
213
|
+
return out.slice(0, 80).join('\n') + `\n ... (${out.length - 80} more lines truncated)`;
|
|
214
|
+
}
|
|
215
|
+
return out.join('\n');
|
|
155
216
|
}
|
package/src/parsers/html.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import MagicString from 'magic-string';
|
|
2
2
|
import { parse, NodeTypes } from '@vue/compiler-dom';
|
|
3
|
-
import {
|
|
3
|
+
import { contextKey, isTranslatable } from '../utils.js';
|
|
4
4
|
|
|
5
5
|
const ATTR_WHITELIST = new Set([
|
|
6
6
|
'title',
|
|
@@ -82,7 +82,7 @@ export function parseHtml(source, filePath, project) {
|
|
|
82
82
|
const marker = `data-i18n-${name}`;
|
|
83
83
|
if (openTagText.includes(marker)) continue;
|
|
84
84
|
|
|
85
|
-
const key =
|
|
85
|
+
const key = contextKey(value, filePath);
|
|
86
86
|
s.appendRight(prop.loc.end.offset, ` ${marker}="${key}"`);
|
|
87
87
|
extracted.push({ key, text: value, context: `html-attr-${name}` });
|
|
88
88
|
}
|
|
@@ -98,7 +98,7 @@ export function parseHtml(source, filePath, project) {
|
|
|
98
98
|
|
|
99
99
|
const marker = tag === 'title' ? 'data-i18n-title' : 'data-i18n';
|
|
100
100
|
if (!openTagText.includes(marker) && isTranslatable(plain)) {
|
|
101
|
-
const key =
|
|
101
|
+
const key = contextKey(text, filePath);
|
|
102
102
|
s.appendLeft(openTagEnd, ` ${marker}="${key}"`);
|
|
103
103
|
extracted.push({ key, text, context: `html-inner-${tag}` });
|
|
104
104
|
}
|
package/src/parsers/js.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as babelParser from '@babel/parser';
|
|
|
2
2
|
import _traverse from '@babel/traverse';
|
|
3
3
|
import * as t from '@babel/types';
|
|
4
4
|
import MagicString from 'magic-string';
|
|
5
|
-
import {
|
|
5
|
+
import { contextKey, isTranslatable } from '../utils.js';
|
|
6
6
|
|
|
7
7
|
const traverse = _traverse.default || _traverse;
|
|
8
8
|
|
|
@@ -55,7 +55,7 @@ export function parseJs(source, filePath, project) {
|
|
|
55
55
|
|
|
56
56
|
if (!translatableKeys.has(keyName)) return;
|
|
57
57
|
|
|
58
|
-
const key =
|
|
58
|
+
const key = contextKey(valueNode.value, filePath);
|
|
59
59
|
const tFunc = project.type === 'vue' ? `this.$t('${key}')` : `t('${key}')`;
|
|
60
60
|
|
|
61
61
|
s.overwrite(valueNode.start, valueNode.end, tFunc);
|
|
@@ -80,7 +80,7 @@ export function parseJs(source, filePath, project) {
|
|
|
80
80
|
if (['alert', 'confirm', 'prompt'].includes(calleeName)) {
|
|
81
81
|
const arg = path.node.arguments[0];
|
|
82
82
|
if (arg && arg.type === 'StringLiteral' && isTranslatable(arg.value)) {
|
|
83
|
-
const key =
|
|
83
|
+
const key = contextKey(arg.value, filePath);
|
|
84
84
|
const tFunc = project.type === 'vue' ? `this.$t('${key}')` : `t('${key}')`;
|
|
85
85
|
s.overwrite(arg.start, arg.end, tFunc);
|
|
86
86
|
extracted.push({ key, text: arg.value, context: 'js-call' });
|
|
@@ -106,7 +106,7 @@ export function parseJs(source, filePath, project) {
|
|
|
106
106
|
]);
|
|
107
107
|
|
|
108
108
|
if (domProps.has(propName)) {
|
|
109
|
-
const key =
|
|
109
|
+
const key = contextKey(right.value, filePath);
|
|
110
110
|
const tFunc = project.type === 'vanilla'
|
|
111
111
|
? `i18n.t('${key}')`
|
|
112
112
|
: project.type === 'vue'
|
package/src/parsers/json.js
CHANGED