bctranslate 1.0.1 → 1.0.3
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/package.json +1 -1
- package/src/generators/locales.js +63 -20
- package/src/index.js +4 -1
- package/src/parsers/html.js +3 -3
- package/src/parsers/js.js +4 -4
- package/src/parsers/react.js +4 -4
- package/src/parsers/vue.js +151 -111
- package/src/utils.js +58 -1
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
-
import { join
|
|
2
|
+
import { join } from 'path';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Determine the locale directory path based on project type.
|
|
@@ -8,6 +8,7 @@ export function getLocaleDir(cwd, project) {
|
|
|
8
8
|
const candidates = [
|
|
9
9
|
join(cwd, 'src', 'locales'),
|
|
10
10
|
join(cwd, 'src', 'i18n', 'locales'),
|
|
11
|
+
join(cwd, 'src', 'i18n'),
|
|
11
12
|
join(cwd, 'locales'),
|
|
12
13
|
join(cwd, 'src', 'lang'),
|
|
13
14
|
join(cwd, 'public', 'locales'),
|
|
@@ -25,13 +26,53 @@ export function getLocaleDir(cwd, project) {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
*
|
|
29
|
+
* Flatten a nested JSON object to dot-notation keys.
|
|
30
|
+
* { home: { notes: 'Notes' } } → { 'home.notes': 'Notes' }
|
|
31
|
+
*/
|
|
32
|
+
function flattenKeys(obj, prefix = '') {
|
|
33
|
+
const result = {};
|
|
34
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
35
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
36
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
37
|
+
Object.assign(result, flattenKeys(value, fullKey));
|
|
38
|
+
} else {
|
|
39
|
+
result[fullKey] = value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Unflatten dot-notation keys to a nested JSON object.
|
|
47
|
+
* { 'home.notes': 'Notes' } → { home: { notes: 'Notes' } }
|
|
48
|
+
*/
|
|
49
|
+
function unflattenKeys(flat) {
|
|
50
|
+
const result = {};
|
|
51
|
+
for (const [dotKey, value] of Object.entries(flat)) {
|
|
52
|
+
const parts = dotKey.split('.');
|
|
53
|
+
let obj = result;
|
|
54
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
55
|
+
if (typeof obj[parts[i]] !== 'object' || obj[parts[i]] === null) {
|
|
56
|
+
obj[parts[i]] = {};
|
|
57
|
+
}
|
|
58
|
+
obj = obj[parts[i]];
|
|
59
|
+
}
|
|
60
|
+
obj[parts[parts.length - 1]] = value;
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Load an existing locale file and return flat dot-notation keys.
|
|
67
|
+
* Handles both nested JSON (vue-i18n standard) and legacy flat format.
|
|
29
68
|
*/
|
|
30
69
|
export function loadLocale(localeDir, langCode) {
|
|
31
70
|
const filePath = join(localeDir, `${langCode}.json`);
|
|
32
71
|
if (existsSync(filePath)) {
|
|
33
72
|
try {
|
|
34
|
-
|
|
73
|
+
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
74
|
+
// Flatten nested objects to dot-notation for internal use
|
|
75
|
+
return flattenKeys(raw);
|
|
35
76
|
} catch {
|
|
36
77
|
return {};
|
|
37
78
|
}
|
|
@@ -41,22 +82,24 @@ export function loadLocale(localeDir, langCode) {
|
|
|
41
82
|
|
|
42
83
|
/**
|
|
43
84
|
* Save a locale file, merging with existing keys.
|
|
85
|
+
* Writes nested JSON (standard for vue-i18n and react-i18next).
|
|
44
86
|
*/
|
|
45
|
-
export function saveLocale(localeDir, langCode, newEntries) {
|
|
46
|
-
mkdirSync(localeDir, { recursive: true });
|
|
47
|
-
|
|
48
|
-
const filePath = join(localeDir, `${langCode}.json`);
|
|
49
|
-
const existing = loadLocale(localeDir, langCode);
|
|
50
|
-
|
|
51
|
-
// Merge:
|
|
52
|
-
// but don't overwrite existing translations (idempotent)
|
|
87
|
+
export function saveLocale(localeDir, langCode, newEntries) {
|
|
88
|
+
mkdirSync(localeDir, { recursive: true });
|
|
89
|
+
|
|
90
|
+
const filePath = join(localeDir, `${langCode}.json`);
|
|
91
|
+
const existing = loadLocale(localeDir, langCode); // already flat
|
|
92
|
+
|
|
93
|
+
// Merge: don't overwrite existing translations
|
|
53
94
|
const merged = { ...existing };
|
|
54
|
-
for (const [key, value] of Object.entries(newEntries)) {
|
|
55
|
-
if (!(key in merged)) {
|
|
56
|
-
merged[key] = value;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
95
|
+
for (const [key, value] of Object.entries(newEntries)) {
|
|
96
|
+
if (!(key in merged)) {
|
|
97
|
+
merged[key] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Write as nested JSON for i18n library compatibility
|
|
102
|
+
const nested = unflattenKeys(merged);
|
|
103
|
+
writeFileSync(filePath, JSON.stringify(nested, null, 2) + '\n', 'utf-8');
|
|
104
|
+
return filePath;
|
|
105
|
+
}
|
package/src/index.js
CHANGED
|
@@ -165,6 +165,9 @@ export async function translateAllFiles(files, opts) {
|
|
|
165
165
|
const allTranslations = { ...existingTarget, ...newTranslations };
|
|
166
166
|
|
|
167
167
|
// Phase 4 — write files
|
|
168
|
+
if (!opts.dryRun) {
|
|
169
|
+
console.log(` → Locale dir: ${resolvedLocaleDir}`);
|
|
170
|
+
}
|
|
168
171
|
const results = [];
|
|
169
172
|
for (const parseResult of parsed) {
|
|
170
173
|
try {
|
|
@@ -174,7 +177,7 @@ export async function translateAllFiles(files, opts) {
|
|
|
174
177
|
});
|
|
175
178
|
results.push(r);
|
|
176
179
|
} catch (err) {
|
|
177
|
-
|
|
180
|
+
console.error(` ✗ Write error ${relative(cwd, parseResult.filePath)}: ${err.message}`);
|
|
178
181
|
results.push({ count: 0, skipped: 0, relativePath: relative(cwd, parseResult.filePath) });
|
|
179
182
|
}
|
|
180
183
|
}
|
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/react.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as babelParser from '@babel/parser';
|
|
2
2
|
import _traverse from '@babel/traverse';
|
|
3
3
|
import MagicString from 'magic-string';
|
|
4
|
-
import {
|
|
4
|
+
import { contextKey, isTranslatable } from '../utils.js';
|
|
5
5
|
|
|
6
6
|
// Handle ESM default export quirks
|
|
7
7
|
const traverse = _traverse.default || _traverse;
|
|
@@ -78,7 +78,7 @@ export function parseReact(source, filePath) {
|
|
|
78
78
|
const text = path.node.value.trim();
|
|
79
79
|
if (!isTranslatable(text)) return;
|
|
80
80
|
|
|
81
|
-
const key =
|
|
81
|
+
const key = contextKey(text, filePath);
|
|
82
82
|
const { start, end } = path.node;
|
|
83
83
|
const original = path.node.value;
|
|
84
84
|
const leadingWs = original.match(/^(\s*)/)[1];
|
|
@@ -102,7 +102,7 @@ export function parseReact(source, filePath) {
|
|
|
102
102
|
if (!isTranslatable(text)) return;
|
|
103
103
|
if (!ATTR_WHITELIST.has(name) && text.length < 3) return;
|
|
104
104
|
|
|
105
|
-
const key =
|
|
105
|
+
const key = contextKey(text, filePath);
|
|
106
106
|
s.overwrite(path.node.start, path.node.end, `${name}={t('${key}')}`);
|
|
107
107
|
extracted.push({ key, text, context: `jsx-attr-${name}` });
|
|
108
108
|
|
|
@@ -117,7 +117,7 @@ export function parseReact(source, filePath) {
|
|
|
117
117
|
|
|
118
118
|
const arg = path.node.arguments[0];
|
|
119
119
|
if (arg?.type === 'StringLiteral' && isTranslatable(arg.value)) {
|
|
120
|
-
const key =
|
|
120
|
+
const key = contextKey(arg.value, filePath);
|
|
121
121
|
s.overwrite(arg.start, arg.end, `t('${key}')`);
|
|
122
122
|
extracted.push({ key, text: arg.value, context: 'call-arg' });
|
|
123
123
|
}
|
package/src/parsers/vue.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import * as compiler from '@vue/compiler-dom';
|
|
2
2
|
import MagicString from 'magic-string';
|
|
3
|
-
import {
|
|
3
|
+
import { contextKey, isTranslatable } from '../utils.js';
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* Non-translatable attribute names.
|
|
7
|
-
*/
|
|
8
5
|
const ATTR_BLACKLIST = new Set([
|
|
9
6
|
'id', 'class', 'style', 'src', 'href', 'ref', 'key', 'is',
|
|
10
7
|
'v-model', 'v-bind', 'v-on', 'v-if', 'v-else', 'v-else-if',
|
|
@@ -17,195 +14,238 @@ const ATTR_BLACKLIST = new Set([
|
|
|
17
14
|
'xmlns:xlink', 'xlink:href', 'data-testid', 'data-cy',
|
|
18
15
|
]);
|
|
19
16
|
|
|
20
|
-
/**
|
|
21
|
-
* Translatable attribute names.
|
|
22
|
-
*/
|
|
23
17
|
const ATTR_WHITELIST = new Set([
|
|
24
18
|
'title', 'placeholder', 'label', 'alt', 'aria-label',
|
|
25
19
|
'aria-placeholder', 'aria-description', 'aria-roledescription',
|
|
26
20
|
]);
|
|
27
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Detect which t-function call to emit in templates.
|
|
24
|
+
*
|
|
25
|
+
* Rules:
|
|
26
|
+
* - If <script setup> and file already destructures `t` from a composable
|
|
27
|
+
* → use t('key') (matches user's existing pattern)
|
|
28
|
+
* - If <script setup> but no `t` yet
|
|
29
|
+
* → use t('key') AND inject `const { t } = useI18n()` into script
|
|
30
|
+
* - Options API (no setup)
|
|
31
|
+
* → use $t('key') (global plugin property)
|
|
32
|
+
*/
|
|
33
|
+
function detectTStyle(source) {
|
|
34
|
+
const isSetup = /<script\b[^>]*\bsetup\b/i.test(source);
|
|
35
|
+
|
|
36
|
+
// Already has: const { t } = ... or const { t, ... } = ...
|
|
37
|
+
const hasT = /const\s*\{[^}]*\bt\b[^}]*\}\s*=/.test(source);
|
|
38
|
+
|
|
39
|
+
return { isSetup, hasT };
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
/**
|
|
29
43
|
* Parse a .vue file and extract translatable strings.
|
|
30
|
-
* Returns the modified source and the list of extracted strings.
|
|
31
44
|
*/
|
|
32
45
|
export function parseVue(source, filePath) {
|
|
33
|
-
const extracted = [];
|
|
46
|
+
const extracted = [];
|
|
34
47
|
const s = new MagicString(source);
|
|
35
|
-
let modified = false;
|
|
36
48
|
|
|
37
|
-
|
|
49
|
+
const { isSetup, hasT } = detectTStyle(source);
|
|
50
|
+
|
|
51
|
+
// In templates: use t() for Composition API, $t() for Options API
|
|
52
|
+
const tpl = (key) => (isSetup || hasT) ? `t('${key}')` : `$t('${key}')`;
|
|
53
|
+
// In scripts: use t() for setup, this.$t() for options
|
|
54
|
+
const scr = (key) => isSetup ? `t('${key}')` : `this.$t('${key}')`;
|
|
55
|
+
|
|
56
|
+
// ── Template ────────────────────────────────────────────────────────────────
|
|
38
57
|
const templateMatch = source.match(/<template\b[^>]*>([\s\S]*?)<\/template>/);
|
|
39
58
|
if (templateMatch) {
|
|
40
|
-
const templateStart = source.indexOf(templateMatch[0]);
|
|
41
59
|
const templateContent = templateMatch[1];
|
|
42
|
-
const templateOffset =
|
|
60
|
+
const templateOffset =
|
|
61
|
+
source.indexOf(templateMatch[0]) + templateMatch[0].indexOf(templateContent);
|
|
43
62
|
|
|
44
63
|
try {
|
|
45
|
-
const ast = compiler.parse(templateContent, {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
walkTemplate(ast.children, s, templateOffset, extracted);
|
|
51
|
-
modified = extracted.length > 0;
|
|
52
|
-
} catch (err) {
|
|
53
|
-
// If Vue parser fails, fall back to regex-based extraction for template
|
|
54
|
-
extractTemplateRegex(source, s, extracted);
|
|
55
|
-
modified = extracted.length > 0;
|
|
64
|
+
const ast = compiler.parse(templateContent, { comments: true, getTextMode: () => 0 });
|
|
65
|
+
walkTemplate(ast.children, s, templateOffset, extracted, filePath, tpl);
|
|
66
|
+
} catch {
|
|
67
|
+
extractTemplateRegex(source, s, extracted, filePath, tpl);
|
|
56
68
|
}
|
|
57
69
|
}
|
|
58
70
|
|
|
59
|
-
//
|
|
71
|
+
// ── Script ──────────────────────────────────────────────────────────────────
|
|
60
72
|
const scriptMatch = source.match(/<script\b[^>]*>([\s\S]*?)<\/script>/);
|
|
61
73
|
if (scriptMatch) {
|
|
62
|
-
const scriptStart = source.indexOf(scriptMatch[0]);
|
|
63
74
|
const scriptContent = scriptMatch[1];
|
|
64
|
-
const scriptOffset =
|
|
65
|
-
|
|
75
|
+
const scriptOffset =
|
|
76
|
+
source.indexOf(scriptMatch[0]) + scriptMatch[0].indexOf(scriptContent);
|
|
77
|
+
extractScriptStrings(scriptContent, s, scriptOffset, extracted, filePath, scr);
|
|
78
|
+
}
|
|
66
79
|
|
|
67
|
-
|
|
80
|
+
// ── Inject `const { t } = useI18n()` if setup but t not yet declared ────────
|
|
81
|
+
if (extracted.length > 0 && isSetup && !hasT) {
|
|
82
|
+
const scriptSetupMatch = source.match(/(<script\b[^>]*\bsetup\b[^>]*>)([\s\S]*?)<\/script>/i);
|
|
83
|
+
if (scriptSetupMatch) {
|
|
84
|
+
const insertAt =
|
|
85
|
+
source.indexOf(scriptSetupMatch[0]) + scriptSetupMatch[1].length;
|
|
86
|
+
const needsImport = !source.includes('useI18n');
|
|
87
|
+
const importLine = needsImport ? `import { useI18n } from 'vue-i18n';\n` : '';
|
|
88
|
+
s.appendRight(insertAt, `\n${importLine}const { t } = useI18n();\n`);
|
|
89
|
+
}
|
|
68
90
|
}
|
|
69
91
|
|
|
70
92
|
return {
|
|
71
|
-
source:
|
|
93
|
+
source: extracted.length > 0 ? s.toString() : source,
|
|
72
94
|
extracted,
|
|
73
|
-
modified:
|
|
95
|
+
modified: extracted.length > 0,
|
|
74
96
|
};
|
|
75
97
|
}
|
|
76
98
|
|
|
77
|
-
|
|
99
|
+
// ── Template walker ───────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function walkTemplate(nodes, s, baseOffset, extracted, filePath, tpl) {
|
|
78
102
|
for (const node of nodes) {
|
|
79
|
-
//
|
|
103
|
+
// Text node (type 2)
|
|
80
104
|
if (node.type === 2) {
|
|
81
105
|
const text = node.content.trim();
|
|
82
106
|
if (isTranslatable(text)) {
|
|
83
|
-
const key = textKey(text);
|
|
84
107
|
const start = baseOffset + node.loc.start.offset;
|
|
85
|
-
const end
|
|
108
|
+
const end = baseOffset + node.loc.end.offset;
|
|
86
109
|
|
|
87
|
-
// Check if already wrapped in $t()
|
|
88
110
|
if (!isAlreadyWrapped(s.original, start, end)) {
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
s.overwrite(start, end, `${leadingWs}{{ $t('${key}') }}${trailingWs}`);
|
|
111
|
+
const key = contextKey(text, filePath);
|
|
112
|
+
const orig = node.content;
|
|
113
|
+
const lws = orig.match(/^(\s*)/)[1];
|
|
114
|
+
const tws = orig.match(/(\s*)$/)[1];
|
|
115
|
+
s.overwrite(start, end, `${lws}{{ ${tpl(key)} }}${tws}`);
|
|
95
116
|
extracted.push({ key, text, context: 'template-text' });
|
|
96
117
|
}
|
|
97
118
|
}
|
|
98
119
|
}
|
|
99
120
|
|
|
100
|
-
//
|
|
121
|
+
// Element (type 1) — check translatable attributes, then recurse
|
|
101
122
|
if (node.type === 1) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (ATTR_WHITELIST.has(attrName) && isTranslatable(prop.value.content)) {
|
|
110
|
-
const text = prop.value.content;
|
|
111
|
-
const key = textKey(text);
|
|
123
|
+
for (const prop of node.props ?? []) {
|
|
124
|
+
if (prop.type === 6 && prop.value) {
|
|
125
|
+
const attrName = prop.name.toLowerCase();
|
|
126
|
+
if (ATTR_WHITELIST.has(attrName) && isTranslatable(prop.value.content)) {
|
|
127
|
+
const text = prop.value.content;
|
|
128
|
+
if (!ATTR_BLACKLIST.has(attrName)) {
|
|
129
|
+
const key = contextKey(text, filePath);
|
|
112
130
|
const attrStart = baseOffset + prop.loc.start.offset;
|
|
113
|
-
const attrEnd
|
|
114
|
-
|
|
115
|
-
// Convert static attribute to v-bind with $t()
|
|
116
|
-
s.overwrite(attrStart, attrEnd, `:${attrName}="$t('${key}')"`);
|
|
131
|
+
const attrEnd = baseOffset + prop.loc.end.offset;
|
|
132
|
+
s.overwrite(attrStart, attrEnd, `:${attrName}="${tpl(key)}"`);
|
|
117
133
|
extracted.push({ key, text, context: `template-attr-${attrName}` });
|
|
118
134
|
}
|
|
119
135
|
}
|
|
120
136
|
}
|
|
121
137
|
}
|
|
122
|
-
|
|
123
|
-
// Recurse into children
|
|
124
|
-
if (node.children) {
|
|
125
|
-
walkTemplate(node.children, s, baseOffset, extracted);
|
|
126
|
-
}
|
|
138
|
+
if (node.children) walkTemplate(node.children, s, baseOffset, extracted, filePath, tpl);
|
|
127
139
|
}
|
|
128
140
|
|
|
129
|
-
//
|
|
130
|
-
if (node.type === 5 && node.content) {
|
|
131
|
-
// Check for compound expressions that contain string literals
|
|
132
|
-
// e.g., {{ isError ? 'Failed' : 'Success' }}
|
|
133
|
-
// We only extract simple string content, not expressions
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Type 8 = CompoundExpression — skip
|
|
137
|
-
// Type 11 = ForNode — recurse into children
|
|
141
|
+
// ForNode (type 11)
|
|
138
142
|
if (node.type === 11 && node.children) {
|
|
139
|
-
walkTemplate(node.children, s, baseOffset, extracted);
|
|
143
|
+
walkTemplate(node.children, s, baseOffset, extracted, filePath, tpl);
|
|
140
144
|
}
|
|
141
|
-
|
|
145
|
+
|
|
146
|
+
// IfNode (type 9) — walk branches
|
|
142
147
|
if (node.type === 9 && node.branches) {
|
|
143
148
|
for (const branch of node.branches) {
|
|
144
|
-
if (branch.children)
|
|
145
|
-
walkTemplate(branch.children, s, baseOffset, extracted);
|
|
146
|
-
}
|
|
149
|
+
if (branch.children) walkTemplate(branch.children, s, baseOffset, extracted, filePath, tpl);
|
|
147
150
|
}
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
153
|
}
|
|
151
154
|
|
|
152
|
-
|
|
153
|
-
// Match string literals that look like translatable text in common patterns:
|
|
154
|
-
// - alert('text'), confirm('text')
|
|
155
|
-
// - title: 'text', label: 'text', placeholder: 'text', message: 'text'
|
|
156
|
-
// - toast('text'), notify('text')
|
|
157
|
-
// - error/success/warning message strings
|
|
155
|
+
// ── Script string extractor ───────────────────────────────────────────────────
|
|
158
156
|
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
function extractScriptStrings(scriptContent, s, baseOffset, extracted, filePath, scr) {
|
|
158
|
+
// Pattern A: alert/confirm/toast/notify calls → match[2]=quote, match[3]=text
|
|
159
|
+
// Pattern B: UI object property string values → match[2]=quote, match[3]=text
|
|
160
|
+
const patternsAB = [
|
|
161
161
|
/\b(alert|confirm|toast|notify|message\.(?:success|error|warning|info))\s*\(\s*(['"`])((?:(?!\2).)+)\2\s*\)/g,
|
|
162
|
-
|
|
163
|
-
/\b(title|label|placeholder|message|text|description|tooltip|hint|caption|header|subtitle|errorMessage|successMessage)\s*:\s*(['"`])((?:(?!\2).)+)\2/g,
|
|
162
|
+
/\b(title|label|placeholder|message|text|description|tooltip|hint|caption|header|subtitle|errorMessage|successMessage|emptyText|noData|loadingText|buttonText|confirmText|cancelText|successText|failText|warningText|helperText|hintText)\s*:\s*(['"`])((?:(?!\2).)+)\2/g,
|
|
164
163
|
];
|
|
165
164
|
|
|
166
|
-
for (const pattern of
|
|
165
|
+
for (const pattern of patternsAB) {
|
|
167
166
|
let match;
|
|
168
167
|
while ((match = pattern.exec(scriptContent)) !== null) {
|
|
169
168
|
const text = match[3];
|
|
170
|
-
if (isTranslatable(text)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
169
|
+
if (!isTranslatable(text) || text.length <= 1) continue;
|
|
170
|
+
const quoteChar = match[2];
|
|
171
|
+
const relPos = match.index + match[0].indexOf(quoteChar + text);
|
|
172
|
+
if (isAlreadyWrappedScript(scriptContent, relPos)) continue;
|
|
173
|
+
const key = contextKey(text, filePath);
|
|
174
|
+
const textStart = baseOffset + relPos;
|
|
175
|
+
const textEnd = textStart + text.length + 2;
|
|
176
|
+
s.overwrite(textStart, textEnd, scr(key));
|
|
177
|
+
extracted.push({ key, text, context: 'script' });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
176
180
|
|
|
177
|
-
|
|
181
|
+
// Pattern C: ref('string') / ref("string") → match[1]=quote, match[2]=text
|
|
182
|
+
const refPattern = /\bref\s*\(\s*(['"`])((?:(?!\1)[^\\]|\\.)+)\1\s*\)/g;
|
|
183
|
+
{
|
|
184
|
+
let match;
|
|
185
|
+
while ((match = refPattern.exec(scriptContent)) !== null) {
|
|
186
|
+
const text = match[2];
|
|
187
|
+
if (!isTranslatable(text) || text.length <= 1) continue;
|
|
188
|
+
const quoteChar = match[1];
|
|
189
|
+
const innerStr = quoteChar + text + quoteChar;
|
|
190
|
+
const relPos = match.index + match[0].indexOf(innerStr);
|
|
191
|
+
if (isAlreadyWrappedScript(scriptContent, relPos)) continue;
|
|
192
|
+
const key = contextKey(text, filePath);
|
|
193
|
+
const textStart = baseOffset + relPos;
|
|
194
|
+
const textEnd = textStart + innerStr.length;
|
|
195
|
+
s.overwrite(textStart, textEnd, scr(key));
|
|
196
|
+
extracted.push({ key, text, context: 'script-ref' });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
178
199
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
200
|
+
// Pattern D: computed(() => 'string') → match[1]=quote, match[2]=text
|
|
201
|
+
const computedPattern = /\bcomputed\s*\(\s*\(\s*\)\s*=>\s*(['"`])((?:(?!\1)[^\\]|\\.)+)\1\s*\)/g;
|
|
202
|
+
{
|
|
203
|
+
let match;
|
|
204
|
+
while ((match = computedPattern.exec(scriptContent)) !== null) {
|
|
205
|
+
const text = match[2];
|
|
206
|
+
if (!isTranslatable(text) || text.length <= 1) continue;
|
|
207
|
+
const quoteChar = match[1];
|
|
208
|
+
const innerStr = quoteChar + text + quoteChar;
|
|
209
|
+
const relPos = match.index + match[0].indexOf(innerStr);
|
|
210
|
+
if (isAlreadyWrappedScript(scriptContent, relPos)) continue;
|
|
211
|
+
const key = contextKey(text, filePath);
|
|
212
|
+
const textStart = baseOffset + relPos;
|
|
213
|
+
const textEnd = textStart + innerStr.length;
|
|
214
|
+
s.overwrite(textStart, textEnd, scr(key));
|
|
215
|
+
extracted.push({ key, text, context: 'script-computed' });
|
|
182
216
|
}
|
|
183
217
|
}
|
|
184
218
|
}
|
|
185
219
|
|
|
220
|
+
function isAlreadyWrappedScript(scriptContent, pos) {
|
|
221
|
+
const before = scriptContent.slice(Math.max(0, pos - 30), pos);
|
|
222
|
+
return /\$?t\s*\(\s*$/.test(before);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
186
227
|
function isAlreadyWrapped(source, start, end) {
|
|
187
|
-
//
|
|
188
|
-
const before = source.slice(Math.max(0, start -
|
|
189
|
-
return
|
|
228
|
+
// Look back 25 chars for an open t( or $t( call — node is inside an interpolation
|
|
229
|
+
const before = source.slice(Math.max(0, start - 25), start);
|
|
230
|
+
return /\$?t\s*\(\s*['"]/.test(before);
|
|
190
231
|
}
|
|
191
232
|
|
|
192
|
-
function extractTemplateRegex(source, s, extracted) {
|
|
193
|
-
|
|
194
|
-
const textPattern = />([^<]+)</g;
|
|
233
|
+
function extractTemplateRegex(source, s, extracted, filePath, tpl) {
|
|
234
|
+
const pattern = />([^<]+)</g;
|
|
195
235
|
let match;
|
|
196
|
-
while ((match =
|
|
236
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
197
237
|
const text = match[1].trim();
|
|
198
238
|
if (isTranslatable(text)) {
|
|
199
|
-
const key = textKey(text);
|
|
200
239
|
const textStart = match.index + 1;
|
|
201
|
-
const textEnd
|
|
240
|
+
const textEnd = textStart + match[1].length;
|
|
202
241
|
if (!isAlreadyWrapped(source, textStart, textEnd)) {
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
242
|
+
const key = contextKey(text, filePath);
|
|
243
|
+
const orig = match[1];
|
|
244
|
+
const lws = orig.match(/^(\s*)/)[1];
|
|
245
|
+
const tws = orig.match(/(\s*)$/)[1];
|
|
246
|
+
s.overwrite(textStart, textEnd, `${lws}{{ ${tpl(key)} }}${tws}`);
|
|
207
247
|
extracted.push({ key, text, context: 'template-text' });
|
|
208
248
|
}
|
|
209
249
|
}
|
|
210
250
|
}
|
|
211
|
-
}
|
|
251
|
+
}
|
package/src/utils.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
|
+
import { basename, extname } from 'path';
|
|
3
|
+
|
|
4
|
+
// Words too generic to use as the sole semantic component of a key
|
|
5
|
+
const STOP_WORDS = new Set([
|
|
6
|
+
'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
7
|
+
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'shall',
|
|
8
|
+
'should', 'may', 'might', 'must', 'can', 'could',
|
|
9
|
+
'to', 'for', 'and', 'or', 'but', 'of', 'in', 'on', 'at', 'by',
|
|
10
|
+
'as', 'if', 'its', 'it', 'this', 'that', 'these', 'those',
|
|
11
|
+
'my', 'your', 'our', 'their', 'with', 'from', 'up', 'about',
|
|
12
|
+
'no', 'not', 'so',
|
|
13
|
+
]);
|
|
2
14
|
|
|
3
15
|
/**
|
|
4
16
|
* Generate a readable, slug-based i18n key from a string.
|
|
@@ -30,7 +42,52 @@ export function textKey(text) {
|
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
/**
|
|
33
|
-
*
|
|
45
|
+
* Generate a context-aware i18n key using the component name as namespace
|
|
46
|
+
* and key content words as the slug.
|
|
47
|
+
*
|
|
48
|
+
* "Notes" in HomeView.vue → home.notes
|
|
49
|
+
* "Quick Note" in HomeView.vue → home.quickNote
|
|
50
|
+
* "View livestock" in HomeView.vue → home.viewLivestock
|
|
51
|
+
* "Submit" in LoginForm.vue → loginForm.submit
|
|
52
|
+
* "你好" in App.vue → app.key_3d2a1f
|
|
53
|
+
*
|
|
54
|
+
* @param {string} text The source string to key
|
|
55
|
+
* @param {string} filePath Absolute or relative path of the source file
|
|
56
|
+
*/
|
|
57
|
+
export function contextKey(text, filePath) {
|
|
58
|
+
const trimmed = text.trim();
|
|
59
|
+
|
|
60
|
+
// ── Namespace: derive from filename ──────────────────────────────────────
|
|
61
|
+
const fileName = basename(filePath, extname(filePath));
|
|
62
|
+
// Strip common Vue/React suffixes: HomeView → Home, UserCard → User, etc.
|
|
63
|
+
const stripped = fileName.replace(
|
|
64
|
+
/(?:View|Component|Page|Screen|Modal|Dialog|Card|Panel|Widget|Layout|Container)$/,
|
|
65
|
+
''
|
|
66
|
+
) || fileName;
|
|
67
|
+
// camelCase namespace: "UserProfile" → "userProfile", "home" → "home"
|
|
68
|
+
const ns = stripped[0].toLowerCase() + stripped.slice(1);
|
|
69
|
+
|
|
70
|
+
// ── Slug: 1-3 meaningful words in camelCase ───────────────────────────────
|
|
71
|
+
const words = trimmed
|
|
72
|
+
.replace(/[^\w\s]/g, ' ')
|
|
73
|
+
.split(/\s+/)
|
|
74
|
+
.map((w) => w.toLowerCase())
|
|
75
|
+
.filter((w) => w.length >= 2 && /[a-z]/.test(w) && !STOP_WORDS.has(w));
|
|
76
|
+
|
|
77
|
+
if (!words.length) {
|
|
78
|
+
// Non-Latin scripts, emoji, or all stop words — fall back to hash suffix
|
|
79
|
+
const hash = createHash('sha256').update(trimmed).digest('hex').slice(0, 6);
|
|
80
|
+
return `${ns}.key${hash}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const slug =
|
|
84
|
+
words[0] + words.slice(1, 3).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
|
|
85
|
+
|
|
86
|
+
return `${ns}.${slug}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @deprecated Use contextKey() for new code. textKey() kept for non-file contexts.
|
|
34
91
|
*/
|
|
35
92
|
export const hashKey = textKey;
|
|
36
93
|
|