bctranslate 1.0.7 → 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.
- package/bin/bctranslate.js +15 -12
- package/package.json +1 -1
- package/src/generators/setup.js +13 -7
- package/src/index.js +49 -35
- package/src/parsers/html.js +22 -1
- package/src/parsers/js.js +0 -27
- package/src/parsers/vue.js +37 -24
package/bin/bctranslate.js
CHANGED
|
@@ -92,7 +92,7 @@ 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, autoImport, cwd }) {
|
|
95
|
+
async function runTranslation({ pathArg, from, to, localesDir, dryRun, outdir, verbose, jsonMode, profile, setup, autoImport, cwd }) {
|
|
96
96
|
const project = detectProject(cwd);
|
|
97
97
|
console.log(chalk.green(` ✓ Project type: ${chalk.bold(project.type)}`));
|
|
98
98
|
|
|
@@ -110,17 +110,18 @@ async function runTranslation({ pathArg, from, to, localesDir, dryRun, outdir, v
|
|
|
110
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
|
-
|
|
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;
|
|
@@ -281,6 +282,7 @@ program
|
|
|
281
282
|
.option('--no-setup', 'Skip i18n setup file generation')
|
|
282
283
|
.option('--no-import', 'Do not auto-inject i18n imports/script tags')
|
|
283
284
|
.option('--json-mode <mode>', 'JSON translation mode: values or full', 'values')
|
|
285
|
+
.option('--profile', 'Print timing breakdown', false)
|
|
284
286
|
.option('-v, --verbose', 'Show per-file diffs and skipped files', false)
|
|
285
287
|
.action(async (pathArg, fromArg, keyword, langArg, opts) => {
|
|
286
288
|
console.log(chalk.cyan.bold('\n ⚡ bctranslate\n'));
|
|
@@ -349,6 +351,7 @@ program
|
|
|
349
351
|
outdir: opts.outdir,
|
|
350
352
|
verbose: opts.verbose,
|
|
351
353
|
jsonMode: opts.jsonMode,
|
|
354
|
+
profile: opts.profile,
|
|
352
355
|
setup: opts.setup,
|
|
353
356
|
autoImport,
|
|
354
357
|
cwd,
|
package/package.json
CHANGED
package/src/generators/setup.js
CHANGED
|
@@ -226,9 +226,10 @@ async function ensureVanillaI18n(cwd, from, to, localeDir, { autoImport }) {
|
|
|
226
226
|
const content = `/**
|
|
227
227
|
* Simple i18n for vanilla JS — generated by bctranslate
|
|
228
228
|
*/
|
|
229
|
-
(function () {
|
|
230
|
-
const locales = {};
|
|
231
|
-
let currentLocale = '${from}';
|
|
229
|
+
(function () {
|
|
230
|
+
const locales = {};
|
|
231
|
+
let currentLocale = '${from}';
|
|
232
|
+
const fallbackLocale = '${from}';
|
|
232
233
|
|
|
233
234
|
async function loadLocale(lang) {
|
|
234
235
|
if (locales[lang]) return;
|
|
@@ -239,11 +240,16 @@ async function ensureVanillaI18n(cwd, from, to, localeDir, { autoImport }) {
|
|
|
239
240
|
function t(key, params) {
|
|
240
241
|
// Support both flat keys ("home.submit") and nested objects ({ home: { submit: ... } })
|
|
241
242
|
const dict = locales[currentLocale] || {};
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
245
248
|
? direct
|
|
246
|
-
: key.split('.').reduce((obj, i) => (obj ? obj[i] : null),
|
|
249
|
+
: key.split('.').reduce((obj, i) => (obj ? obj[i] : null), d);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const msg = lookup(dict) ?? lookup(dictFallback) ?? key;
|
|
247
253
|
|
|
248
254
|
if (!params) return msg;
|
|
249
255
|
return String(msg).replace(/\\{(\\w+)\\}/g, (match, k) =>
|
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
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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).
|
package/src/parsers/html.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/parsers/vue.js
CHANGED
|
@@ -41,9 +41,17 @@ export function parseVue(source, filePath) {
|
|
|
41
41
|
const extracted = [];
|
|
42
42
|
const s = new MagicString(source);
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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) {
|