bctranslate 1.0.5 → 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 +44 -38
- package/package.json +1 -1
- package/src/generators/setup.js +274 -80
- 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,9 +92,9 @@ 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, cwd }) {
|
|
96
|
-
const project = detectProject(cwd);
|
|
97
|
-
console.log(chalk.green(` ✓ Project type: ${chalk.bold(project.type)}`));
|
|
95
|
+
async function runTranslation({ pathArg, from, to, localesDir, dryRun, outdir, verbose, jsonMode, profile, setup, autoImport, cwd }) {
|
|
96
|
+
const project = detectProject(cwd);
|
|
97
|
+
console.log(chalk.green(` ✓ Project type: ${chalk.bold(project.type)}`));
|
|
98
98
|
|
|
99
99
|
const files = await resolveFiles(pathArg, cwd, project);
|
|
100
100
|
if (files.length === 0) {
|
|
@@ -104,23 +104,24 @@ async function runTranslation({ pathArg, from, to, localesDir, dryRun, outdir, v
|
|
|
104
104
|
|
|
105
105
|
console.log(chalk.cyan(` → ${files.length} file(s) [${from} → ${to}]...\n`));
|
|
106
106
|
|
|
107
|
-
if (setup !== false) {
|
|
108
|
-
const resolvedLocalesDir = localesDir ? resolve(cwd, localesDir) : undefined;
|
|
109
|
-
await ensureI18nSetup(cwd, project, from, to, resolvedLocalesDir);
|
|
110
|
-
}
|
|
107
|
+
if (setup !== false) {
|
|
108
|
+
const resolvedLocalesDir = localesDir ? resolve(cwd, localesDir) : undefined;
|
|
109
|
+
await ensureI18nSetup(cwd, project, from, to, resolvedLocalesDir, { autoImport });
|
|
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;
|
|
@@ -277,17 +278,20 @@ program
|
|
|
277
278
|
.argument('[lang]', 'Target language code (e.g. fr)')
|
|
278
279
|
.option('-t, --to <lang>', 'Target language(s), comma-separated (e.g. fr or fr,es)')
|
|
279
280
|
.option('-d, --dry-run', 'Preview changes without writing files', false)
|
|
280
|
-
.option('-o, --outdir <dir>', 'Output directory for translated files')
|
|
281
|
-
.option('--no-setup', 'Skip i18n setup file generation')
|
|
282
|
-
.option('--
|
|
283
|
-
.option('-
|
|
281
|
+
.option('-o, --outdir <dir>', 'Output directory for translated files')
|
|
282
|
+
.option('--no-setup', 'Skip i18n setup file generation')
|
|
283
|
+
.option('--no-import', 'Do not auto-inject i18n imports/script tags')
|
|
284
|
+
.option('--json-mode <mode>', 'JSON translation mode: values or full', 'values')
|
|
285
|
+
.option('--profile', 'Print timing breakdown', false)
|
|
286
|
+
.option('-v, --verbose', 'Show per-file diffs and skipped files', false)
|
|
284
287
|
.action(async (pathArg, fromArg, keyword, langArg, opts) => {
|
|
285
288
|
console.log(chalk.cyan.bold('\n ⚡ bctranslate\n'));
|
|
286
289
|
|
|
287
|
-
const invokedCwd = process.cwd();
|
|
288
|
-
const config = loadConfig(invokedCwd);
|
|
289
|
-
|
|
290
|
-
|
|
290
|
+
const invokedCwd = process.cwd();
|
|
291
|
+
const config = loadConfig(invokedCwd);
|
|
292
|
+
const autoImport = opts.import !== false && (config?.autoImport !== false);
|
|
293
|
+
|
|
294
|
+
let cwd = invokedCwd;
|
|
291
295
|
if (!config && pathArg) {
|
|
292
296
|
const resolvedTarget = resolve(invokedCwd, pathArg);
|
|
293
297
|
if (existsSync(resolvedTarget)) {
|
|
@@ -338,18 +342,20 @@ program
|
|
|
338
342
|
for (const to of targets) {
|
|
339
343
|
if (targets.length > 1) console.log(chalk.cyan.bold(`\n ── Translating to ${to} ──`));
|
|
340
344
|
|
|
341
|
-
const { totalStrings, totalFiles } = await runTranslation({
|
|
342
|
-
pathArg,
|
|
343
|
-
from,
|
|
344
|
-
to,
|
|
345
|
-
localesDir: config?.localesDir,
|
|
346
|
-
dryRun: opts.dryRun,
|
|
347
|
-
outdir: opts.outdir,
|
|
348
|
-
verbose: opts.verbose,
|
|
349
|
-
jsonMode: opts.jsonMode,
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
345
|
+
const { totalStrings, totalFiles } = await runTranslation({
|
|
346
|
+
pathArg,
|
|
347
|
+
from,
|
|
348
|
+
to,
|
|
349
|
+
localesDir: config?.localesDir,
|
|
350
|
+
dryRun: opts.dryRun,
|
|
351
|
+
outdir: opts.outdir,
|
|
352
|
+
verbose: opts.verbose,
|
|
353
|
+
jsonMode: opts.jsonMode,
|
|
354
|
+
profile: opts.profile,
|
|
355
|
+
setup: opts.setup,
|
|
356
|
+
autoImport,
|
|
357
|
+
cwd,
|
|
358
|
+
});
|
|
353
359
|
|
|
354
360
|
grandTotal += totalStrings;
|
|
355
361
|
grandFiles += totalFiles;
|
package/package.json
CHANGED
package/src/generators/setup.js
CHANGED
|
@@ -13,9 +13,10 @@ import { getLocaleDir } from './locales.js';
|
|
|
13
13
|
* @param {string} to - Target language code
|
|
14
14
|
* @param {string} [customLocaleDir] - Optional override for locale directory
|
|
15
15
|
*/
|
|
16
|
-
export async function ensureI18nSetup(cwd, project, from, to, customLocaleDir) {
|
|
17
|
-
const
|
|
18
|
-
|
|
16
|
+
export async function ensureI18nSetup(cwd, project, from, to, customLocaleDir, options = {}) {
|
|
17
|
+
const { autoImport = true } = options;
|
|
18
|
+
const localeDir = customLocaleDir || getLocaleDir(cwd, project);
|
|
19
|
+
mkdirSync(localeDir, { recursive: true });
|
|
19
20
|
|
|
20
21
|
// Ensure locale JSON files exist
|
|
21
22
|
for (const lang of [from, to]) {
|
|
@@ -24,18 +25,18 @@ export async function ensureI18nSetup(cwd, project, from, to, customLocaleDir) {
|
|
|
24
25
|
writeFileSync(localePath, '{}\n', 'utf-8');
|
|
25
26
|
console.log(chalk.green(` ✓ Created ${localePath}`));
|
|
26
27
|
}
|
|
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
|
-
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (project.type === 'vue') {
|
|
31
|
+
await ensureVueI18n(cwd, project, from, to, localeDir, { autoImport });
|
|
32
|
+
} else if (project.type === 'react') {
|
|
33
|
+
await ensureReactI18n(cwd, project, from, to, localeDir, { autoImport });
|
|
34
|
+
} else {
|
|
35
|
+
await ensureVanillaI18n(cwd, from, to, localeDir, { autoImport });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
37
38
|
|
|
38
|
-
async function ensureVueI18n(cwd, project, from, to, localeDir) {
|
|
39
|
+
async function ensureVueI18n(cwd, project, from, to, localeDir, { autoImport }) {
|
|
39
40
|
const i18nFile = join(cwd, 'src', 'i18n.js');
|
|
40
41
|
|
|
41
42
|
if (existsSync(i18nFile)) {
|
|
@@ -48,51 +49,105 @@ async function ensureVueI18n(cwd, project, from, to, localeDir) {
|
|
|
48
49
|
|
|
49
50
|
let content;
|
|
50
51
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
52
|
+
content = `import { createI18n } from 'vue-i18n';
|
|
53
|
+
import ${from} from '${relLocale}/${from}.json';
|
|
54
|
+
import ${to} from '${relLocale}/${to}.json';
|
|
55
|
+
|
|
56
|
+
function nestMessages(input) {
|
|
57
|
+
if (!input || typeof input !== 'object') return {};
|
|
58
|
+
const out = Array.isArray(input) ? [] : {};
|
|
59
|
+
for (const [rawKey, value] of Object.entries(input)) {
|
|
60
|
+
if (!rawKey.includes('.')) {
|
|
61
|
+
out[rawKey] = value;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const parts = rawKey.split('.').filter(Boolean);
|
|
65
|
+
let cur = out;
|
|
66
|
+
for (let i = 0; i < parts.length; i++) {
|
|
67
|
+
const p = parts[i];
|
|
68
|
+
if (i === parts.length - 1) {
|
|
69
|
+
cur[p] = value;
|
|
70
|
+
} else {
|
|
71
|
+
if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
|
|
72
|
+
cur = cur[p];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const i18n = createI18n({
|
|
80
|
+
legacy: false,
|
|
81
|
+
locale: '${from}',
|
|
82
|
+
fallbackLocale: '${from}',
|
|
83
|
+
messages: {
|
|
84
|
+
${from}: nestMessages(${from}),
|
|
85
|
+
${to}: nestMessages(${to}),
|
|
86
|
+
},
|
|
87
|
+
});
|
|
64
88
|
|
|
65
89
|
export default i18n;
|
|
66
90
|
`;
|
|
67
91
|
} 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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
92
|
+
content = `import Vue from 'vue';
|
|
93
|
+
import VueI18n from 'vue-i18n';
|
|
94
|
+
import ${from} from '${relLocale}/${from}.json';
|
|
95
|
+
import ${to} from '${relLocale}/${to}.json';
|
|
96
|
+
|
|
97
|
+
Vue.use(VueI18n);
|
|
98
|
+
|
|
99
|
+
function nestMessages(input) {
|
|
100
|
+
if (!input || typeof input !== 'object') return {};
|
|
101
|
+
const out = Array.isArray(input) ? [] : {};
|
|
102
|
+
for (const [rawKey, value] of Object.entries(input)) {
|
|
103
|
+
if (!rawKey.includes('.')) {
|
|
104
|
+
out[rawKey] = value;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const parts = rawKey.split('.').filter(Boolean);
|
|
108
|
+
let cur = out;
|
|
109
|
+
for (let i = 0; i < parts.length; i++) {
|
|
110
|
+
const p = parts[i];
|
|
111
|
+
if (i === parts.length - 1) {
|
|
112
|
+
cur[p] = value;
|
|
113
|
+
} else {
|
|
114
|
+
if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
|
|
115
|
+
cur = cur[p];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const i18n = new VueI18n({
|
|
123
|
+
locale: '${from}',
|
|
124
|
+
fallbackLocale: '${from}',
|
|
125
|
+
messages: {
|
|
126
|
+
${from}: nestMessages(${from}),
|
|
127
|
+
${to}: nestMessages(${to}),
|
|
128
|
+
},
|
|
129
|
+
});
|
|
83
130
|
|
|
84
131
|
export default i18n;
|
|
85
132
|
`;
|
|
86
133
|
}
|
|
87
134
|
|
|
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
|
-
|
|
93
|
-
|
|
135
|
+
mkdirSync(join(cwd, 'src'), { recursive: true });
|
|
136
|
+
writeFileSync(i18nFile, content, 'utf-8');
|
|
137
|
+
console.log(chalk.green(` ✓ Created ${i18nFile}`));
|
|
138
|
+
console.log(chalk.yellow(` ⚠ Don't forget to install vue-i18n: npm install vue-i18n`));
|
|
139
|
+
|
|
140
|
+
if (autoImport) {
|
|
141
|
+
const injected = injectVueEntrypoint(cwd, isVue3);
|
|
142
|
+
if (!injected) {
|
|
143
|
+
console.log(chalk.yellow(` ⚠ Import and use i18n in your main.js/main.ts`));
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
console.log(chalk.yellow(` ⚠ Import and use i18n in your main.js/main.ts`));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
94
149
|
|
|
95
|
-
async function ensureReactI18n(cwd, project, from, to, localeDir) {
|
|
150
|
+
async function ensureReactI18n(cwd, project, from, to, localeDir, { autoImport }) {
|
|
96
151
|
const i18nFile = join(cwd, 'src', 'i18n.js');
|
|
97
152
|
|
|
98
153
|
if (existsSync(i18nFile)) {
|
|
@@ -102,16 +157,39 @@ async function ensureReactI18n(cwd, project, from, to, localeDir) {
|
|
|
102
157
|
|
|
103
158
|
const relLocale = localeDir.replace(cwd, '.').replace(/\\/g, '/');
|
|
104
159
|
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
160
|
+
const content = `import i18n from 'i18next';
|
|
161
|
+
import { initReactI18next } from 'react-i18next';
|
|
162
|
+
import ${from} from '${relLocale}/${from}.json';
|
|
163
|
+
import ${to} from '${relLocale}/${to}.json';
|
|
164
|
+
|
|
165
|
+
function nestMessages(input) {
|
|
166
|
+
if (!input || typeof input !== 'object') return {};
|
|
167
|
+
const out = Array.isArray(input) ? [] : {};
|
|
168
|
+
for (const [rawKey, value] of Object.entries(input)) {
|
|
169
|
+
if (!rawKey.includes('.')) {
|
|
170
|
+
out[rawKey] = value;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const parts = rawKey.split('.').filter(Boolean);
|
|
174
|
+
let cur = out;
|
|
175
|
+
for (let i = 0; i < parts.length; i++) {
|
|
176
|
+
const p = parts[i];
|
|
177
|
+
if (i === parts.length - 1) {
|
|
178
|
+
cur[p] = value;
|
|
179
|
+
} else {
|
|
180
|
+
if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
|
|
181
|
+
cur = cur[p];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
i18n.use(initReactI18next).init({
|
|
189
|
+
resources: {
|
|
190
|
+
${from}: { translation: nestMessages(${from}) },
|
|
191
|
+
${to}: { translation: nestMessages(${to}) },
|
|
192
|
+
},
|
|
115
193
|
lng: '${from}',
|
|
116
194
|
fallbackLng: '${from}',
|
|
117
195
|
interpolation: {
|
|
@@ -122,14 +200,22 @@ i18n.use(initReactI18next).init({
|
|
|
122
200
|
export default i18n;
|
|
123
201
|
`;
|
|
124
202
|
|
|
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
|
-
|
|
130
|
-
|
|
203
|
+
mkdirSync(join(cwd, 'src'), { recursive: true });
|
|
204
|
+
writeFileSync(i18nFile, content, 'utf-8');
|
|
205
|
+
console.log(chalk.green(` ✓ Created ${i18nFile}`));
|
|
206
|
+
console.log(chalk.yellow(` ⚠ Don't forget to install: npm install i18next react-i18next`));
|
|
207
|
+
|
|
208
|
+
if (autoImport) {
|
|
209
|
+
const injected = injectReactEntrypoint(cwd);
|
|
210
|
+
if (!injected) {
|
|
211
|
+
console.log(chalk.yellow(` ⚠ Import './i18n' in your index.js/App.js`));
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
console.log(chalk.yellow(` ⚠ Import './i18n' in your index.js/App.js`));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
131
217
|
|
|
132
|
-
async function ensureVanillaI18n(cwd, from, to, localeDir) {
|
|
218
|
+
async function ensureVanillaI18n(cwd, from, to, localeDir, { autoImport }) {
|
|
133
219
|
const i18nFile = join(cwd, 'i18n.js');
|
|
134
220
|
|
|
135
221
|
if (existsSync(i18nFile)) {
|
|
@@ -140,9 +226,10 @@ async function ensureVanillaI18n(cwd, from, to, localeDir) {
|
|
|
140
226
|
const content = `/**
|
|
141
227
|
* Simple i18n for vanilla JS — generated by bctranslate
|
|
142
228
|
*/
|
|
143
|
-
(function () {
|
|
144
|
-
const locales = {};
|
|
145
|
-
let currentLocale = '${from}';
|
|
229
|
+
(function () {
|
|
230
|
+
const locales = {};
|
|
231
|
+
let currentLocale = '${from}';
|
|
232
|
+
const fallbackLocale = '${from}';
|
|
146
233
|
|
|
147
234
|
async function loadLocale(lang) {
|
|
148
235
|
if (locales[lang]) return;
|
|
@@ -153,11 +240,16 @@ async function ensureVanillaI18n(cwd, from, to, localeDir) {
|
|
|
153
240
|
function t(key, params) {
|
|
154
241
|
// Support both flat keys ("home.submit") and nested objects ({ home: { submit: ... } })
|
|
155
242
|
const dict = locales[currentLocale] || {};
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
159
248
|
? direct
|
|
160
|
-
: 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;
|
|
161
253
|
|
|
162
254
|
if (!params) return msg;
|
|
163
255
|
return String(msg).replace(/\\{(\\w+)\\}/g, (match, k) =>
|
|
@@ -194,21 +286,123 @@ async function ensureVanillaI18n(cwd, from, to, localeDir) {
|
|
|
194
286
|
|
|
195
287
|
// Auto-init
|
|
196
288
|
loadLocale('${from}');
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
window.i18n =
|
|
200
|
-
})
|
|
201
|
-
|
|
289
|
+
|
|
290
|
+
const api = { t: t, setLocale: setLocale, loadLocale: loadLocale, ready: null };
|
|
291
|
+
window.i18n = api;
|
|
292
|
+
api.ready = setLocale('${to}');
|
|
293
|
+
})();
|
|
294
|
+
`;
|
|
202
295
|
|
|
203
296
|
writeFileSync(i18nFile, content, 'utf-8');
|
|
204
297
|
console.log(chalk.green(` ✓ Created ${i18nFile}`));
|
|
205
298
|
|
|
206
|
-
|
|
207
|
-
|
|
299
|
+
if (autoImport) {
|
|
300
|
+
const injected = injectVanillaI18nEntrypoint(cwd);
|
|
301
|
+
if (!injected) {
|
|
302
|
+
console.log(chalk.yellow(` ⚠ Add <script src="i18n.js"></script> to your HTML`));
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
208
305
|
console.log(chalk.yellow(` ⚠ Add <script src="i18n.js"></script> to your HTML`));
|
|
209
306
|
}
|
|
210
307
|
}
|
|
211
308
|
|
|
309
|
+
function injectVueEntrypoint(cwd, isVue3) {
|
|
310
|
+
const candidates = [
|
|
311
|
+
join(cwd, 'src', 'main.ts'),
|
|
312
|
+
join(cwd, 'src', 'main.js'),
|
|
313
|
+
join(cwd, 'src', 'main.tsx'),
|
|
314
|
+
join(cwd, 'src', 'main.jsx'),
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
const mainFile = candidates.find((p) => existsSync(p));
|
|
318
|
+
if (!mainFile) return false;
|
|
319
|
+
|
|
320
|
+
let code = readFileSync(mainFile, 'utf-8');
|
|
321
|
+
|
|
322
|
+
const hasI18nImport =
|
|
323
|
+
code.includes("from './i18n'") ||
|
|
324
|
+
code.includes('from "./i18n"') ||
|
|
325
|
+
code.includes("from './i18n.js'") ||
|
|
326
|
+
code.includes('from "./i18n.js"') ||
|
|
327
|
+
code.includes("import './i18n'") ||
|
|
328
|
+
code.includes('import "./i18n"');
|
|
329
|
+
|
|
330
|
+
if (!hasI18nImport) {
|
|
331
|
+
const importRe = /^import\s.+?;\s*$/gm;
|
|
332
|
+
let lastImportEnd = 0;
|
|
333
|
+
let m;
|
|
334
|
+
while ((m = importRe.exec(code)) !== null) lastImportEnd = m.index + m[0].length;
|
|
335
|
+
code =
|
|
336
|
+
code.slice(0, lastImportEnd) +
|
|
337
|
+
`\nimport i18n from './i18n';\n` +
|
|
338
|
+
code.slice(lastImportEnd);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (isVue3) {
|
|
342
|
+
if (!code.includes('.use(i18n)') && !code.includes('use(i18n)')) {
|
|
343
|
+
const chained = /createApp\(([^)]*)\)\s*\.mount\(/;
|
|
344
|
+
if (chained.test(code)) {
|
|
345
|
+
code = code.replace(chained, 'createApp($1).use(i18n).mount(');
|
|
346
|
+
} else {
|
|
347
|
+
const hasAppVar =
|
|
348
|
+
/\bconst\s+app\s*=\s*createApp\(/.test(code) ||
|
|
349
|
+
/\blet\s+app\s*=\s*createApp\(/.test(code);
|
|
350
|
+
const mountIdx = code.indexOf('app.mount(');
|
|
351
|
+
if (hasAppVar && mountIdx !== -1 && !code.includes('app.use(i18n')) {
|
|
352
|
+
code = code.slice(0, mountIdx) + `app.use(i18n);\n` + code.slice(mountIdx);
|
|
353
|
+
} else {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
if (!/\bi18n\s*[:,]/.test(code)) {
|
|
360
|
+
const newVue = /new\s+Vue\s*\(\s*\{/;
|
|
361
|
+
if (newVue.test(code)) {
|
|
362
|
+
code = code.replace(newVue, (m) => m + '\n i18n,');
|
|
363
|
+
} else {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
writeFileSync(mainFile, code, 'utf-8');
|
|
370
|
+
console.log(chalk.green(` ✓ Updated ${mainFile} (wired i18n)`));
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function injectReactEntrypoint(cwd) {
|
|
375
|
+
const candidates = [
|
|
376
|
+
join(cwd, 'src', 'index.tsx'),
|
|
377
|
+
join(cwd, 'src', 'index.js'),
|
|
378
|
+
join(cwd, 'src', 'main.tsx'),
|
|
379
|
+
join(cwd, 'src', 'main.jsx'),
|
|
380
|
+
];
|
|
381
|
+
|
|
382
|
+
const entryFile = candidates.find((p) => existsSync(p));
|
|
383
|
+
if (!entryFile) return false;
|
|
384
|
+
|
|
385
|
+
let code = readFileSync(entryFile, 'utf-8');
|
|
386
|
+
if (
|
|
387
|
+
code.includes("from './i18n'") ||
|
|
388
|
+
code.includes('from "./i18n"') ||
|
|
389
|
+
code.includes("import './i18n'") ||
|
|
390
|
+
code.includes('import "./i18n"')
|
|
391
|
+
) {
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const importRe = /^import\s.+?;\s*$/gm;
|
|
396
|
+
let lastImportEnd = 0;
|
|
397
|
+
let m;
|
|
398
|
+
while ((m = importRe.exec(code)) !== null) lastImportEnd = m.index + m[0].length;
|
|
399
|
+
code = code.slice(0, lastImportEnd) + `\nimport './i18n';\n` + code.slice(lastImportEnd);
|
|
400
|
+
|
|
401
|
+
writeFileSync(entryFile, code, 'utf-8');
|
|
402
|
+
console.log(chalk.green(` ✓ Updated ${entryFile} (imported i18n)`));
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
212
406
|
function injectVanillaI18nEntrypoint(cwd) {
|
|
213
407
|
// Prefer HTML entrypoint if present
|
|
214
408
|
const htmlPath = join(cwd, 'index.html');
|
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) {
|