bctranslate 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -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 +99 -105
- package/src/utils.js +58 -1
package/package.json
CHANGED
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,149 +14,149 @@ 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
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Type 5 = Interpolation ({{ expr }})
|
|
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
|
|
138
|
+
if (node.children) walkTemplate(node.children, s, baseOffset, extracted, filePath, tpl);
|
|
134
139
|
}
|
|
135
140
|
|
|
136
|
-
//
|
|
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
|
|
|
157
|
+
function extractScriptStrings(scriptContent, s, baseOffset, extracted, filePath, scr) {
|
|
159
158
|
const patterns = [
|
|
160
|
-
// Function calls: alert('...'), confirm('...'), toast('...'), notify('...')
|
|
161
159
|
/\b(alert|confirm|toast|notify|message\.(?:success|error|warning|info))\s*\(\s*(['"`])((?:(?!\2).)+)\2\s*\)/g,
|
|
162
|
-
// Object properties: title: '...', label: '...', message: '...'
|
|
163
160
|
/\b(title|label|placeholder|message|text|description|tooltip|hint|caption|header|subtitle|errorMessage|successMessage)\s*:\s*(['"`])((?:(?!\2).)+)\2/g,
|
|
164
161
|
];
|
|
165
162
|
|
|
@@ -168,44 +165,41 @@ function extractScriptStrings(scriptContent, s, baseOffset, extracted, isSetup)
|
|
|
168
165
|
while ((match = pattern.exec(scriptContent)) !== null) {
|
|
169
166
|
const text = match[3];
|
|
170
167
|
if (isTranslatable(text) && text.length > 1) {
|
|
171
|
-
const key
|
|
172
|
-
const fullMatchStart = baseOffset + match.index;
|
|
168
|
+
const key = contextKey(text, filePath);
|
|
173
169
|
const quoteChar = match[2];
|
|
174
170
|
const textStart = baseOffset + match.index + match[0].indexOf(quoteChar + text);
|
|
175
|
-
const textEnd
|
|
176
|
-
|
|
177
|
-
const tCall = isSetup ? `t('${key}')` : `this.$t('${key}')`;
|
|
178
|
-
|
|
179
|
-
s.overwrite(textStart, textEnd, tCall);
|
|
171
|
+
const textEnd = textStart + text.length + 2;
|
|
172
|
+
s.overwrite(textStart, textEnd, scr(key));
|
|
180
173
|
extracted.push({ key, text, context: 'script' });
|
|
181
174
|
}
|
|
182
175
|
}
|
|
183
176
|
}
|
|
184
177
|
}
|
|
185
178
|
|
|
179
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
186
181
|
function isAlreadyWrapped(source, start, end) {
|
|
187
|
-
//
|
|
188
|
-
const before = source.slice(Math.max(0, start -
|
|
189
|
-
return
|
|
182
|
+
// Look back 25 chars for an open t( or $t( call — node is inside an interpolation
|
|
183
|
+
const before = source.slice(Math.max(0, start - 25), start);
|
|
184
|
+
return /\$?t\s*\(\s*['"]/.test(before);
|
|
190
185
|
}
|
|
191
186
|
|
|
192
|
-
function extractTemplateRegex(source, s, extracted) {
|
|
193
|
-
|
|
194
|
-
const textPattern = />([^<]+)</g;
|
|
187
|
+
function extractTemplateRegex(source, s, extracted, filePath, tpl) {
|
|
188
|
+
const pattern = />([^<]+)</g;
|
|
195
189
|
let match;
|
|
196
|
-
while ((match =
|
|
190
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
197
191
|
const text = match[1].trim();
|
|
198
192
|
if (isTranslatable(text)) {
|
|
199
|
-
const key = textKey(text);
|
|
200
193
|
const textStart = match.index + 1;
|
|
201
|
-
const textEnd
|
|
194
|
+
const textEnd = textStart + match[1].length;
|
|
202
195
|
if (!isAlreadyWrapped(source, textStart, textEnd)) {
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
196
|
+
const key = contextKey(text, filePath);
|
|
197
|
+
const orig = match[1];
|
|
198
|
+
const lws = orig.match(/^(\s*)/)[1];
|
|
199
|
+
const tws = orig.match(/(\s*)$/)[1];
|
|
200
|
+
s.overwrite(textStart, textEnd, `${lws}{{ ${tpl(key)} }}${tws}`);
|
|
207
201
|
extracted.push({ key, text, context: 'template-text' });
|
|
208
202
|
}
|
|
209
203
|
}
|
|
210
204
|
}
|
|
211
|
-
}
|
|
205
|
+
}
|
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
|
|