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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bctranslate",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "CLI to transform source files into i18n-ready code with automatic translation via argostranslate",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
- import { join, dirname } from 'path';
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
- * Load an existing locale file, or return empty object.
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
- return JSON.parse(readFileSync(filePath, 'utf-8'));
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: new entries take precedence for new keys,
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
- writeFileSync(filePath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
61
- return filePath;
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
- if (verbose) console.error(` Write error ${parseResult.filePath}: ${err.message}`);
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
  }
@@ -1,6 +1,6 @@
1
1
  import MagicString from 'magic-string';
2
2
  import { parse, NodeTypes } from '@vue/compiler-dom';
3
- import { textKey, isTranslatable } from '../utils.js';
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 = textKey(value);
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 = textKey(text);
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 { textKey, isTranslatable } from '../utils.js';
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 = textKey(valueNode.value);
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 = textKey(arg.value);
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 = textKey(right.value);
109
+ const key = contextKey(right.value, filePath);
110
110
  const tFunc = project.type === 'vanilla'
111
111
  ? `i18n.t('${key}')`
112
112
  : project.type === 'vue'
@@ -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 { textKey, isTranslatable } from '../utils.js';
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 = textKey(text);
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 = textKey(text);
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 = textKey(arg.value);
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
  }
@@ -1,10 +1,7 @@
1
1
  import * as compiler from '@vue/compiler-dom';
2
2
  import MagicString from 'magic-string';
3
- import { textKey, isTranslatable, parseInterpolation } from '../utils.js';
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 = []; // {key, text, context}
46
+ const extracted = [];
34
47
  const s = new MagicString(source);
35
- let modified = false;
36
48
 
37
- // Parse template section
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 = templateStart + templateMatch[0].indexOf(templateContent);
60
+ const templateOffset =
61
+ source.indexOf(templateMatch[0]) + templateMatch[0].indexOf(templateContent);
43
62
 
44
63
  try {
45
- const ast = compiler.parse(templateContent, {
46
- comments: true,
47
- getTextMode: () => 0,
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
- // Parse script section for string literals
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 = scriptStart + scriptMatch[0].indexOf(scriptContent);
65
- const isSetup = /<script\b[^>]*setup[^>]*>/.test(source);
75
+ const scriptOffset =
76
+ source.indexOf(scriptMatch[0]) + scriptMatch[0].indexOf(scriptContent);
77
+ extractScriptStrings(scriptContent, s, scriptOffset, extracted, filePath, scr);
78
+ }
66
79
 
67
- extractScriptStrings(scriptContent, s, scriptOffset, extracted, isSetup);
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: modified || extracted.length > 0 ? s.toString() : source,
93
+ source: extracted.length > 0 ? s.toString() : source,
72
94
  extracted,
73
- modified: modified || extracted.length > 0,
95
+ modified: extracted.length > 0,
74
96
  };
75
97
  }
76
98
 
77
- function walkTemplate(nodes, s, baseOffset, extracted) {
99
+ // ── Template walker ───────────────────────────────────────────────────────────
100
+
101
+ function walkTemplate(nodes, s, baseOffset, extracted, filePath, tpl) {
78
102
  for (const node of nodes) {
79
- // Type 2 = Text
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 = baseOffset + node.loc.end.offset;
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
- // Preserve leading/trailing whitespace
90
- const originalText = node.content;
91
- const leadingWs = originalText.match(/^(\s*)/)[1];
92
- const trailingWs = originalText.match(/(\s*)$/)[1];
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
- // Type 1 = Element
121
+ // Element (type 1) check translatable attributes, then recurse
101
122
  if (node.type === 1) {
102
- // Check translatable attributes
103
- if (node.props) {
104
- for (const prop of node.props) {
105
- // Type 6 = Attribute (static)
106
- if (prop.type === 6 && prop.value) {
107
- const attrName = prop.name.toLowerCase();
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 = baseOffset + prop.loc.end.offset;
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
- // 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
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
- // Type 9 = IfNode — recurse into branches
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
- function extractScriptStrings(scriptContent, s, baseOffset, extracted, isSetup) {
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
- const patterns = [
160
- // Function calls: alert('...'), confirm('...'), toast('...'), notify('...')
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
- // Object properties: title: '...', label: '...', message: '...'
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 patterns) {
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) && text.length > 1) {
171
- const key = textKey(text);
172
- const fullMatchStart = baseOffset + match.index;
173
- const quoteChar = match[2];
174
- const textStart = baseOffset + match.index + match[0].indexOf(quoteChar + text);
175
- const textEnd = textStart + text.length + 2; // +2 for quotes
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
- const tCall = isSetup ? `t('${key}')` : `this.$t('${key}')`;
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
- s.overwrite(textStart, textEnd, tCall);
180
- extracted.push({ key, text, context: 'script' });
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
- // Check if the text is already inside a $t() call or {{ $t(...) }}
188
- const before = source.slice(Math.max(0, start - 20), start);
189
- return /\$t\s*\(\s*['"]/.test(before) || /t\s*\(\s*['"]/.test(before);
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
- // Fallback regex extraction for when the Vue parser fails
194
- const textPattern = />([^<]+)</g;
233
+ function extractTemplateRegex(source, s, extracted, filePath, tpl) {
234
+ const pattern = />([^<]+)</g;
195
235
  let match;
196
- while ((match = textPattern.exec(source)) !== null) {
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 = textStart + match[1].length;
240
+ const textEnd = textStart + match[1].length;
202
241
  if (!isAlreadyWrapped(source, textStart, textEnd)) {
203
- const original = match[1];
204
- const leadingWs = original.match(/^(\s*)/)[1];
205
- const trailingWs = original.match(/(\s*)$/)[1];
206
- s.overwrite(textStart, textEnd, `${leadingWs}{{ $t('${key}') }}${trailingWs}`);
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
- * @deprecated Use textKey() instead. Kept for internal backward compat.
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