bctranslate 1.0.4 → 1.0.7
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 +30 -27
- package/package.json +1 -1
- package/src/generators/setup.js +344 -93
- package/src/parsers/html.js +23 -1
- package/src/parsers/js.js +112 -57
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, 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,10 +104,10 @@ 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
113
|
const results = await translateAllFiles(files, {
|
|
@@ -277,17 +277,19 @@ program
|
|
|
277
277
|
.argument('[lang]', 'Target language code (e.g. fr)')
|
|
278
278
|
.option('-t, --to <lang>', 'Target language(s), comma-separated (e.g. fr or fr,es)')
|
|
279
279
|
.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('-
|
|
280
|
+
.option('-o, --outdir <dir>', 'Output directory for translated files')
|
|
281
|
+
.option('--no-setup', 'Skip i18n setup file generation')
|
|
282
|
+
.option('--no-import', 'Do not auto-inject i18n imports/script tags')
|
|
283
|
+
.option('--json-mode <mode>', 'JSON translation mode: values or full', 'values')
|
|
284
|
+
.option('-v, --verbose', 'Show per-file diffs and skipped files', false)
|
|
284
285
|
.action(async (pathArg, fromArg, keyword, langArg, opts) => {
|
|
285
286
|
console.log(chalk.cyan.bold('\n ⚡ bctranslate\n'));
|
|
286
287
|
|
|
287
|
-
const invokedCwd = process.cwd();
|
|
288
|
-
const config = loadConfig(invokedCwd);
|
|
289
|
-
|
|
290
|
-
|
|
288
|
+
const invokedCwd = process.cwd();
|
|
289
|
+
const config = loadConfig(invokedCwd);
|
|
290
|
+
const autoImport = opts.import !== false && (config?.autoImport !== false);
|
|
291
|
+
|
|
292
|
+
let cwd = invokedCwd;
|
|
291
293
|
if (!config && pathArg) {
|
|
292
294
|
const resolvedTarget = resolve(invokedCwd, pathArg);
|
|
293
295
|
if (existsSync(resolvedTarget)) {
|
|
@@ -338,18 +340,19 @@ program
|
|
|
338
340
|
for (const to of targets) {
|
|
339
341
|
if (targets.length > 1) console.log(chalk.cyan.bold(`\n ── Translating to ${to} ──`));
|
|
340
342
|
|
|
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
|
-
setup: opts.setup,
|
|
351
|
-
|
|
352
|
-
|
|
343
|
+
const { totalStrings, totalFiles } = await runTranslation({
|
|
344
|
+
pathArg,
|
|
345
|
+
from,
|
|
346
|
+
to,
|
|
347
|
+
localesDir: config?.localesDir,
|
|
348
|
+
dryRun: opts.dryRun,
|
|
349
|
+
outdir: opts.outdir,
|
|
350
|
+
verbose: opts.verbose,
|
|
351
|
+
jsonMode: opts.jsonMode,
|
|
352
|
+
setup: opts.setup,
|
|
353
|
+
autoImport,
|
|
354
|
+
cwd,
|
|
355
|
+
});
|
|
353
356
|
|
|
354
357
|
grandTotal += totalStrings;
|
|
355
358
|
grandFiles += totalFiles;
|
package/package.json
CHANGED
package/src/generators/setup.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
1
|
+
import { existsSync, writeFileSync, mkdirSync, readFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { getLocaleDir } from './locales.js';
|
|
@@ -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
|
+
|
|
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
|
+
}
|
|
28
38
|
|
|
29
|
-
|
|
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
|
+
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)) {
|
|
@@ -150,39 +236,204 @@ async function ensureVanillaI18n(cwd, from, to, localeDir) {
|
|
|
150
236
|
locales[lang] = await resp.json();
|
|
151
237
|
}
|
|
152
238
|
|
|
153
|
-
function t(key, params) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
239
|
+
function t(key, params) {
|
|
240
|
+
// Support both flat keys ("home.submit") and nested objects ({ home: { submit: ... } })
|
|
241
|
+
const dict = locales[currentLocale] || {};
|
|
242
|
+
const direct = Object.prototype.hasOwnProperty.call(dict, key) ? dict[key] : null;
|
|
243
|
+
const msg =
|
|
244
|
+
direct !== null && direct !== undefined
|
|
245
|
+
? direct
|
|
246
|
+
: key.split('.').reduce((obj, i) => (obj ? obj[i] : null), dict) || key;
|
|
247
|
+
|
|
248
|
+
if (!params) return msg;
|
|
249
|
+
return String(msg).replace(/\\{(\\w+)\\}/g, (match, k) =>
|
|
250
|
+
params[k] !== undefined ? params[k] : match
|
|
251
|
+
);
|
|
252
|
+
}
|
|
160
253
|
|
|
161
254
|
async function setLocale(lang) {
|
|
162
255
|
await loadLocale(lang);
|
|
163
256
|
currentLocale = lang;
|
|
164
257
|
// Re-translate all elements with data-i18n attribute
|
|
165
|
-
document.querySelectorAll('[data-i18n]').forEach(function (el) {
|
|
166
|
-
const key = el.getAttribute('data-i18n');
|
|
167
|
-
|
|
168
|
-
|
|
258
|
+
document.querySelectorAll('[data-i18n]').forEach(function (el) {
|
|
259
|
+
const key = el.getAttribute('data-i18n');
|
|
260
|
+
const translated = t(key);
|
|
261
|
+
// Preserve markup translations (e.g. "Hello <strong>world</strong>")
|
|
262
|
+
if (el.children.length > 0 || /<[^>]+>/.test(String(translated))) {
|
|
263
|
+
el.innerHTML = translated;
|
|
264
|
+
} else {
|
|
265
|
+
el.textContent = translated;
|
|
266
|
+
}
|
|
267
|
+
});
|
|
169
268
|
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
|
|
170
269
|
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
|
|
171
270
|
});
|
|
172
|
-
document.querySelectorAll('[data-i18n-title]').forEach(function (el) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
271
|
+
document.querySelectorAll('[data-i18n-title]').forEach(function (el) {
|
|
272
|
+
const translated = t(el.getAttribute('data-i18n-title'));
|
|
273
|
+
if (el.tagName === 'TITLE') {
|
|
274
|
+
document.title = translated;
|
|
275
|
+
} else {
|
|
276
|
+
el.title = translated;
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
176
280
|
|
|
177
|
-
// Auto-init
|
|
178
|
-
loadLocale('${from}');
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
window.i18n =
|
|
182
|
-
})
|
|
183
|
-
|
|
281
|
+
// Auto-init
|
|
282
|
+
loadLocale('${from}');
|
|
283
|
+
|
|
284
|
+
const api = { t: t, setLocale: setLocale, loadLocale: loadLocale, ready: null };
|
|
285
|
+
window.i18n = api;
|
|
286
|
+
api.ready = setLocale('${to}');
|
|
287
|
+
})();
|
|
288
|
+
`;
|
|
184
289
|
|
|
185
|
-
writeFileSync(i18nFile, content, 'utf-8');
|
|
186
|
-
console.log(chalk.green(` ✓ Created ${i18nFile}`));
|
|
187
|
-
|
|
188
|
-
|
|
290
|
+
writeFileSync(i18nFile, content, 'utf-8');
|
|
291
|
+
console.log(chalk.green(` ✓ Created ${i18nFile}`));
|
|
292
|
+
|
|
293
|
+
if (autoImport) {
|
|
294
|
+
const injected = injectVanillaI18nEntrypoint(cwd);
|
|
295
|
+
if (!injected) {
|
|
296
|
+
console.log(chalk.yellow(` ⚠ Add <script src="i18n.js"></script> to your HTML`));
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
console.log(chalk.yellow(` ⚠ Add <script src="i18n.js"></script> to your HTML`));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function injectVueEntrypoint(cwd, isVue3) {
|
|
304
|
+
const candidates = [
|
|
305
|
+
join(cwd, 'src', 'main.ts'),
|
|
306
|
+
join(cwd, 'src', 'main.js'),
|
|
307
|
+
join(cwd, 'src', 'main.tsx'),
|
|
308
|
+
join(cwd, 'src', 'main.jsx'),
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
const mainFile = candidates.find((p) => existsSync(p));
|
|
312
|
+
if (!mainFile) return false;
|
|
313
|
+
|
|
314
|
+
let code = readFileSync(mainFile, 'utf-8');
|
|
315
|
+
|
|
316
|
+
const hasI18nImport =
|
|
317
|
+
code.includes("from './i18n'") ||
|
|
318
|
+
code.includes('from "./i18n"') ||
|
|
319
|
+
code.includes("from './i18n.js'") ||
|
|
320
|
+
code.includes('from "./i18n.js"') ||
|
|
321
|
+
code.includes("import './i18n'") ||
|
|
322
|
+
code.includes('import "./i18n"');
|
|
323
|
+
|
|
324
|
+
if (!hasI18nImport) {
|
|
325
|
+
const importRe = /^import\s.+?;\s*$/gm;
|
|
326
|
+
let lastImportEnd = 0;
|
|
327
|
+
let m;
|
|
328
|
+
while ((m = importRe.exec(code)) !== null) lastImportEnd = m.index + m[0].length;
|
|
329
|
+
code =
|
|
330
|
+
code.slice(0, lastImportEnd) +
|
|
331
|
+
`\nimport i18n from './i18n';\n` +
|
|
332
|
+
code.slice(lastImportEnd);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (isVue3) {
|
|
336
|
+
if (!code.includes('.use(i18n)') && !code.includes('use(i18n)')) {
|
|
337
|
+
const chained = /createApp\(([^)]*)\)\s*\.mount\(/;
|
|
338
|
+
if (chained.test(code)) {
|
|
339
|
+
code = code.replace(chained, 'createApp($1).use(i18n).mount(');
|
|
340
|
+
} else {
|
|
341
|
+
const hasAppVar =
|
|
342
|
+
/\bconst\s+app\s*=\s*createApp\(/.test(code) ||
|
|
343
|
+
/\blet\s+app\s*=\s*createApp\(/.test(code);
|
|
344
|
+
const mountIdx = code.indexOf('app.mount(');
|
|
345
|
+
if (hasAppVar && mountIdx !== -1 && !code.includes('app.use(i18n')) {
|
|
346
|
+
code = code.slice(0, mountIdx) + `app.use(i18n);\n` + code.slice(mountIdx);
|
|
347
|
+
} else {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
if (!/\bi18n\s*[:,]/.test(code)) {
|
|
354
|
+
const newVue = /new\s+Vue\s*\(\s*\{/;
|
|
355
|
+
if (newVue.test(code)) {
|
|
356
|
+
code = code.replace(newVue, (m) => m + '\n i18n,');
|
|
357
|
+
} else {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
writeFileSync(mainFile, code, 'utf-8');
|
|
364
|
+
console.log(chalk.green(` ✓ Updated ${mainFile} (wired i18n)`));
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function injectReactEntrypoint(cwd) {
|
|
369
|
+
const candidates = [
|
|
370
|
+
join(cwd, 'src', 'index.tsx'),
|
|
371
|
+
join(cwd, 'src', 'index.js'),
|
|
372
|
+
join(cwd, 'src', 'main.tsx'),
|
|
373
|
+
join(cwd, 'src', 'main.jsx'),
|
|
374
|
+
];
|
|
375
|
+
|
|
376
|
+
const entryFile = candidates.find((p) => existsSync(p));
|
|
377
|
+
if (!entryFile) return false;
|
|
378
|
+
|
|
379
|
+
let code = readFileSync(entryFile, 'utf-8');
|
|
380
|
+
if (
|
|
381
|
+
code.includes("from './i18n'") ||
|
|
382
|
+
code.includes('from "./i18n"') ||
|
|
383
|
+
code.includes("import './i18n'") ||
|
|
384
|
+
code.includes('import "./i18n"')
|
|
385
|
+
) {
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const importRe = /^import\s.+?;\s*$/gm;
|
|
390
|
+
let lastImportEnd = 0;
|
|
391
|
+
let m;
|
|
392
|
+
while ((m = importRe.exec(code)) !== null) lastImportEnd = m.index + m[0].length;
|
|
393
|
+
code = code.slice(0, lastImportEnd) + `\nimport './i18n';\n` + code.slice(lastImportEnd);
|
|
394
|
+
|
|
395
|
+
writeFileSync(entryFile, code, 'utf-8');
|
|
396
|
+
console.log(chalk.green(` ✓ Updated ${entryFile} (imported i18n)`));
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function injectVanillaI18nEntrypoint(cwd) {
|
|
401
|
+
// Prefer HTML entrypoint if present
|
|
402
|
+
const htmlPath = join(cwd, 'index.html');
|
|
403
|
+
if (existsSync(htmlPath)) {
|
|
404
|
+
const html = readFileSync(htmlPath, 'utf-8');
|
|
405
|
+
if (!/\bi18n\.js\b/.test(html)) {
|
|
406
|
+
const scriptTag = ` <script src="./i18n.js"></script>\n`;
|
|
407
|
+
let updated = html;
|
|
408
|
+
|
|
409
|
+
const firstScript = updated.match(/<script\b/i);
|
|
410
|
+
if (firstScript) {
|
|
411
|
+
updated = updated.replace(firstScript[0], scriptTag + firstScript[0]);
|
|
412
|
+
} else if (updated.includes('</body>')) {
|
|
413
|
+
updated = updated.replace('</body>', scriptTag + '</body>');
|
|
414
|
+
} else if (updated.includes('</head>')) {
|
|
415
|
+
updated = updated.replace('</head>', scriptTag + '</head>');
|
|
416
|
+
} else {
|
|
417
|
+
updated += '\n' + scriptTag;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
writeFileSync(htmlPath, updated, 'utf-8');
|
|
421
|
+
console.log(chalk.green(` ✓ Updated ${htmlPath} (added i18n.js script)`));
|
|
422
|
+
}
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Fallback: ESM entrypoint
|
|
427
|
+
const jsPath = join(cwd, 'index.js');
|
|
428
|
+
if (existsSync(jsPath)) {
|
|
429
|
+
const js = readFileSync(jsPath, 'utf-8');
|
|
430
|
+
const alreadyImports = js.includes("./i18n.js") || js.includes("'./i18n.js'") || js.includes("\"./i18n.js\"");
|
|
431
|
+
if (!alreadyImports && /\b(import|export)\b/.test(js)) {
|
|
432
|
+
writeFileSync(jsPath, `import './i18n.js';\n` + js, 'utf-8');
|
|
433
|
+
console.log(chalk.green(` ✓ Updated ${jsPath} (imported i18n.js)`));
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return false;
|
|
439
|
+
}
|
package/src/parsers/html.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import MagicString from 'magic-string';
|
|
2
2
|
import { parse, NodeTypes } from '@vue/compiler-dom';
|
|
3
|
+
import { parseJs } from './js.js';
|
|
3
4
|
import { contextKey, isTranslatable } from '../utils.js';
|
|
4
5
|
|
|
5
6
|
const ATTR_WHITELIST = new Set([
|
|
@@ -30,7 +31,7 @@ const CONTENT_TAGS = new Set([
|
|
|
30
31
|
'td',
|
|
31
32
|
]);
|
|
32
33
|
|
|
33
|
-
const SKIP_TAGS = new Set(['
|
|
34
|
+
const SKIP_TAGS = new Set(['style', 'noscript']);
|
|
34
35
|
|
|
35
36
|
function stripTags(html) {
|
|
36
37
|
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
@@ -71,6 +72,27 @@ export function parseHtml(source, filePath, project) {
|
|
|
71
72
|
|
|
72
73
|
const openTagText = source.slice(node.loc.start.offset, openTagEnd);
|
|
73
74
|
|
|
75
|
+
// Special case: inline <script> blocks inside HTML
|
|
76
|
+
if (tag === 'script' && !node.isSelfClosing) {
|
|
77
|
+
const hasSrc = (node.props || []).some(
|
|
78
|
+
(p) => p.type === NodeTypes.ATTRIBUTE && p.name === 'src'
|
|
79
|
+
);
|
|
80
|
+
if (hasSrc) return;
|
|
81
|
+
|
|
82
|
+
const closeRel = node.loc.source.lastIndexOf('</');
|
|
83
|
+
if (closeRel > -1) {
|
|
84
|
+
const closeTagStart = node.loc.start.offset + closeRel;
|
|
85
|
+
const scriptJs = source.slice(openTagEnd + 1, closeTagStart);
|
|
86
|
+
const jsResult = parseJs(scriptJs, filePath, project);
|
|
87
|
+
if (jsResult.modified) {
|
|
88
|
+
s.overwrite(openTagEnd + 1, closeTagStart, jsResult.source);
|
|
89
|
+
extracted.push(...jsResult.extracted);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
74
96
|
// 1) Extract translatable attributes
|
|
75
97
|
for (const prop of node.props || []) {
|
|
76
98
|
if (prop.type !== NodeTypes.ATTRIBUTE) continue;
|
package/src/parsers/js.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import * as babelParser from '@babel/parser';
|
|
2
|
-
import _traverse from '@babel/traverse';
|
|
3
|
-
import * as t from '@babel/types';
|
|
4
|
-
import MagicString from 'magic-string';
|
|
5
|
-
import { contextKey, isTranslatable } from '../utils.js';
|
|
1
|
+
import * as babelParser from '@babel/parser';
|
|
2
|
+
import _traverse from '@babel/traverse';
|
|
3
|
+
import * as t from '@babel/types';
|
|
4
|
+
import MagicString from 'magic-string';
|
|
5
|
+
import { contextKey, isTranslatable } from '../utils.js';
|
|
6
6
|
|
|
7
7
|
const traverse = _traverse.default || _traverse;
|
|
8
8
|
|
|
@@ -10,14 +10,37 @@ const traverse = _traverse.default || _traverse;
|
|
|
10
10
|
* Parse a .js/.ts file and extract translatable strings.
|
|
11
11
|
* These are strings in common UI patterns: alert(), confirm(), DOM manipulation, etc.
|
|
12
12
|
*/
|
|
13
|
-
export function parseJs(source, filePath, project) {
|
|
14
|
-
const extracted = [];
|
|
15
|
-
const isTS = filePath.endsWith('.ts');
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
export function parseJs(source, filePath, project) {
|
|
14
|
+
const extracted = [];
|
|
15
|
+
const isTS = filePath.endsWith('.ts');
|
|
16
|
+
|
|
17
|
+
const tFuncFor = (key) => {
|
|
18
|
+
if (project.type === 'vanilla') return `i18n.t('${key}')`;
|
|
19
|
+
if (project.type === 'vue') return `this.$t('${key}')`;
|
|
20
|
+
return `t('${key}')`;
|
|
21
|
+
};
|
|
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
|
+
const translatableAttrNames = new Set([
|
|
31
|
+
'title',
|
|
32
|
+
'placeholder',
|
|
33
|
+
'label',
|
|
34
|
+
'alt',
|
|
35
|
+
'aria-label',
|
|
36
|
+
'aria-placeholder',
|
|
37
|
+
'aria-description',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
let ast;
|
|
41
|
+
try {
|
|
42
|
+
ast = babelParser.parse(source, {
|
|
43
|
+
sourceType: 'module',
|
|
21
44
|
plugins: [
|
|
22
45
|
isTS ? 'typescript' : null,
|
|
23
46
|
'classProperties',
|
|
@@ -33,12 +56,32 @@ export function parseJs(source, filePath, project) {
|
|
|
33
56
|
|
|
34
57
|
const s = new MagicString(source);
|
|
35
58
|
|
|
36
|
-
// Track which string literals to translate
|
|
37
|
-
traverse(ast, {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
// Track which string literals to translate
|
|
60
|
+
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
|
+
// Object property values with translatable keys
|
|
82
|
+
ObjectProperty(path) {
|
|
83
|
+
const keyNode = path.node.key;
|
|
84
|
+
const valueNode = path.node.value;
|
|
42
85
|
|
|
43
86
|
if (valueNode.type !== 'StringLiteral') return;
|
|
44
87
|
if (!isTranslatable(valueNode.value)) return;
|
|
@@ -53,19 +96,19 @@ export function parseJs(source, filePath, project) {
|
|
|
53
96
|
'name', 'displayName',
|
|
54
97
|
]);
|
|
55
98
|
|
|
56
|
-
if (!translatableKeys.has(keyName)) return;
|
|
99
|
+
if (!translatableKeys.has(keyName)) return;
|
|
100
|
+
|
|
101
|
+
const key = contextKey(valueNode.value, filePath);
|
|
102
|
+
const tFunc = tFuncFor(key);
|
|
103
|
+
|
|
104
|
+
s.overwrite(valueNode.start, valueNode.end, tFunc);
|
|
105
|
+
extracted.push({ key, text: valueNode.value, context: `js-prop-${keyName}` });
|
|
106
|
+
},
|
|
57
107
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
extracted.push({ key, text: valueNode.value, context: `js-prop-${keyName}` });
|
|
63
|
-
},
|
|
64
|
-
|
|
65
|
-
// Function calls: alert('text'), console messages excluded
|
|
66
|
-
CallExpression(path) {
|
|
67
|
-
const callee = path.node.callee;
|
|
68
|
-
let calleeName = '';
|
|
108
|
+
// Function calls: alert('text'), console messages excluded
|
|
109
|
+
CallExpression(path) {
|
|
110
|
+
const callee = path.node.callee;
|
|
111
|
+
let calleeName = '';
|
|
69
112
|
|
|
70
113
|
if (callee.type === 'Identifier') {
|
|
71
114
|
calleeName = callee.name;
|
|
@@ -76,19 +119,35 @@ export function parseJs(source, filePath, project) {
|
|
|
76
119
|
// Skip console.*, require(), import()
|
|
77
120
|
if (calleeName.startsWith('console.') || calleeName === 'require') return;
|
|
78
121
|
|
|
79
|
-
// Translate alert/confirm/prompt first arg
|
|
80
|
-
if (['alert', 'confirm', 'prompt'].includes(calleeName)) {
|
|
81
|
-
const arg = path.node.arguments[0];
|
|
82
|
-
if (arg && arg.type === 'StringLiteral' && isTranslatable(arg.value)) {
|
|
83
|
-
const key = contextKey(arg.value, filePath);
|
|
84
|
-
const tFunc =
|
|
85
|
-
s.overwrite(arg.start, arg.end, tFunc);
|
|
86
|
-
extracted.push({ key, text: arg.value, context: 'js-call' });
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// .
|
|
91
|
-
|
|
122
|
+
// Translate alert/confirm/prompt first arg
|
|
123
|
+
if (['alert', 'confirm', 'prompt'].includes(calleeName)) {
|
|
124
|
+
const arg = path.node.arguments[0];
|
|
125
|
+
if (arg && arg.type === 'StringLiteral' && isTranslatable(arg.value)) {
|
|
126
|
+
const key = contextKey(arg.value, filePath);
|
|
127
|
+
const tFunc = tFuncFor(key);
|
|
128
|
+
s.overwrite(arg.start, arg.end, tFunc);
|
|
129
|
+
extracted.push({ key, text: arg.value, context: 'js-call' });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// element.setAttribute('title', '...')
|
|
134
|
+
if (callee.type === 'MemberExpression' && callee.property?.name === 'setAttribute') {
|
|
135
|
+
const [nameArg, valueArg] = path.node.arguments;
|
|
136
|
+
if (
|
|
137
|
+
nameArg?.type === 'StringLiteral' &&
|
|
138
|
+
valueArg?.type === 'StringLiteral' &&
|
|
139
|
+
translatableAttrNames.has(nameArg.value) &&
|
|
140
|
+
isTranslatable(valueArg.value)
|
|
141
|
+
) {
|
|
142
|
+
const key = contextKey(valueArg.value, filePath);
|
|
143
|
+
const tFunc = tFuncFor(key);
|
|
144
|
+
s.overwrite(valueArg.start, valueArg.end, tFunc);
|
|
145
|
+
extracted.push({ key, text: valueArg.value, context: `js-attr-${nameArg.value}` });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// .textContent = 'text', .innerText = 'text', .title = 'text'
|
|
150
|
+
},
|
|
92
151
|
|
|
93
152
|
// Assignment: element.textContent = 'text'
|
|
94
153
|
AssignmentExpression(path) {
|
|
@@ -105,18 +164,14 @@ export function parseJs(source, filePath, project) {
|
|
|
105
164
|
'alt', 'innerHTML',
|
|
106
165
|
]);
|
|
107
166
|
|
|
108
|
-
if (domProps.has(propName)) {
|
|
109
|
-
const key = contextKey(right.value, filePath);
|
|
110
|
-
const tFunc =
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
s.overwrite(right.start, right.end, tFunc);
|
|
117
|
-
extracted.push({ key, text: right.value, context: `js-dom-${propName}` });
|
|
118
|
-
}
|
|
119
|
-
}
|
|
167
|
+
if (domProps.has(propName)) {
|
|
168
|
+
const key = contextKey(right.value, filePath);
|
|
169
|
+
const tFunc = tFuncFor(key);
|
|
170
|
+
|
|
171
|
+
s.overwrite(right.start, right.end, tFunc);
|
|
172
|
+
extracted.push({ key, text: right.value, context: `js-dom-${propName}` });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
120
175
|
},
|
|
121
176
|
});
|
|
122
177
|
|
|
@@ -125,4 +180,4 @@ export function parseJs(source, filePath, project) {
|
|
|
125
180
|
extracted,
|
|
126
181
|
modified: extracted.length > 0,
|
|
127
182
|
};
|
|
128
|
-
}
|
|
183
|
+
}
|