bctranslate 1.0.0

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.
@@ -0,0 +1,118 @@
1
+ import MagicString from 'magic-string';
2
+ import { parse, NodeTypes } from '@vue/compiler-dom';
3
+ import { hashKey, isTranslatable } from '../utils.js';
4
+
5
+ const ATTR_WHITELIST = new Set([
6
+ 'title',
7
+ 'placeholder',
8
+ 'label',
9
+ 'alt',
10
+ 'aria-label',
11
+ 'aria-placeholder',
12
+ 'aria-description',
13
+ ]);
14
+
15
+ const CONTENT_TAGS = new Set([
16
+ 'title',
17
+ 'h1',
18
+ 'h2',
19
+ 'h3',
20
+ 'h4',
21
+ 'h5',
22
+ 'h6',
23
+ 'p',
24
+ 'a',
25
+ 'button',
26
+ 'label',
27
+ 'option',
28
+ 'li',
29
+ 'th',
30
+ 'td',
31
+ ]);
32
+
33
+ const SKIP_TAGS = new Set(['script', 'style', 'noscript']);
34
+
35
+ function stripTags(html) {
36
+ return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
37
+ }
38
+
39
+ /**
40
+ * Parse an HTML file and extract translatable strings.
41
+ * For vanilla HTML, we use data-i18n attributes instead of $t() calls.
42
+ */
43
+ export function parseHtml(source, filePath, project) {
44
+ const extracted = [];
45
+ const s = new MagicString(source);
46
+
47
+ const ast = parse(source, { comments: false });
48
+
49
+ function visit(node) {
50
+ if (!node) return;
51
+ if (Array.isArray(node)) {
52
+ for (const child of node) visit(child);
53
+ return;
54
+ }
55
+
56
+ if (node.type === NodeTypes.ROOT) {
57
+ visit(node.children);
58
+ return;
59
+ }
60
+
61
+ if (node.type !== NodeTypes.ELEMENT) return;
62
+
63
+ const tag = (node.tag || '').toLowerCase();
64
+ if (SKIP_TAGS.has(tag)) return;
65
+
66
+ const openTagEnd = source.indexOf('>', node.loc.start.offset);
67
+ if (openTagEnd < 0 || openTagEnd > node.loc.end.offset) {
68
+ visit(node.children);
69
+ return;
70
+ }
71
+
72
+ const openTagText = source.slice(node.loc.start.offset, openTagEnd);
73
+
74
+ // 1) Extract translatable attributes
75
+ for (const prop of node.props || []) {
76
+ if (prop.type !== NodeTypes.ATTRIBUTE) continue;
77
+ const name = prop.name;
78
+ if (!ATTR_WHITELIST.has(name)) continue;
79
+ const value = prop.value?.content;
80
+ if (!value || !isTranslatable(value)) continue;
81
+
82
+ const marker = `data-i18n-${name}`;
83
+ if (openTagText.includes(marker)) continue;
84
+
85
+ const key = hashKey(value);
86
+ s.appendRight(prop.loc.end.offset, ` ${marker}="${key}"`);
87
+ extracted.push({ key, text: value, context: `html-attr-${name}` });
88
+ }
89
+
90
+ // 2) Extract element inner HTML for common content tags
91
+ if (CONTENT_TAGS.has(tag) && !node.isSelfClosing) {
92
+ const closeRel = node.loc.source.lastIndexOf('</');
93
+ if (closeRel > -1) {
94
+ const closeTagStart = node.loc.start.offset + closeRel;
95
+ const innerHtml = source.slice(openTagEnd + 1, closeTagStart);
96
+ const text = innerHtml.trim();
97
+ const plain = stripTags(text);
98
+
99
+ const marker = tag === 'title' ? 'data-i18n-title' : 'data-i18n';
100
+ if (!openTagText.includes(marker) && isTranslatable(plain)) {
101
+ const key = hashKey(text);
102
+ s.appendLeft(openTagEnd, ` ${marker}="${key}"`);
103
+ extracted.push({ key, text, context: `html-inner-${tag}` });
104
+ }
105
+ }
106
+ }
107
+
108
+ visit(node.children);
109
+ }
110
+
111
+ visit(ast);
112
+
113
+ return {
114
+ source: extracted.length > 0 ? s.toString() : source,
115
+ extracted,
116
+ modified: extracted.length > 0,
117
+ };
118
+ }
@@ -0,0 +1,128 @@
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 { hashKey, isTranslatable } from '../utils.js';
6
+
7
+ const traverse = _traverse.default || _traverse;
8
+
9
+ /**
10
+ * Parse a .js/.ts file and extract translatable strings.
11
+ * These are strings in common UI patterns: alert(), confirm(), DOM manipulation, etc.
12
+ */
13
+ export function parseJs(source, filePath, project) {
14
+ const extracted = [];
15
+ const isTS = filePath.endsWith('.ts');
16
+
17
+ let ast;
18
+ try {
19
+ ast = babelParser.parse(source, {
20
+ sourceType: 'module',
21
+ plugins: [
22
+ isTS ? 'typescript' : null,
23
+ 'classProperties',
24
+ 'optionalChaining',
25
+ 'nullishCoalescingOperator',
26
+ 'decorators-legacy',
27
+ 'dynamicImport',
28
+ ].filter(Boolean),
29
+ });
30
+ } catch {
31
+ return { source, extracted: [], modified: false };
32
+ }
33
+
34
+ const s = new MagicString(source);
35
+
36
+ // Track which string literals to translate
37
+ traverse(ast, {
38
+ // Object property values with translatable keys
39
+ ObjectProperty(path) {
40
+ const keyNode = path.node.key;
41
+ const valueNode = path.node.value;
42
+
43
+ if (valueNode.type !== 'StringLiteral') return;
44
+ if (!isTranslatable(valueNode.value)) return;
45
+
46
+ // Check if the key name suggests translatable content
47
+ const keyName = keyNode.name || keyNode.value || '';
48
+ const translatableKeys = new Set([
49
+ 'title', 'label', 'placeholder', 'message', 'text',
50
+ 'description', 'tooltip', 'hint', 'caption', 'header',
51
+ 'subtitle', 'errorMessage', 'successMessage', 'content',
52
+ 'heading', 'subheading', 'buttonText', 'linkText',
53
+ 'name', 'displayName',
54
+ ]);
55
+
56
+ if (!translatableKeys.has(keyName)) return;
57
+
58
+ const key = hashKey(valueNode.value);
59
+ const tFunc = project.type === 'vue' ? `this.$t('${key}')` : `t('${key}')`;
60
+
61
+ s.overwrite(valueNode.start, valueNode.end, tFunc);
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 = '';
69
+
70
+ if (callee.type === 'Identifier') {
71
+ calleeName = callee.name;
72
+ } else if (callee.type === 'MemberExpression' && callee.property) {
73
+ calleeName = `${callee.object?.name || ''}.${callee.property.name}`;
74
+ }
75
+
76
+ // Skip console.*, require(), import()
77
+ if (calleeName.startsWith('console.') || calleeName === 'require') return;
78
+
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 = hashKey(arg.value);
84
+ const tFunc = project.type === 'vue' ? `this.$t('${key}')` : `t('${key}')`;
85
+ s.overwrite(arg.start, arg.end, tFunc);
86
+ extracted.push({ key, text: arg.value, context: 'js-call' });
87
+ }
88
+ }
89
+
90
+ // .textContent = 'text', .innerText = 'text', .title = 'text'
91
+ },
92
+
93
+ // Assignment: element.textContent = 'text'
94
+ AssignmentExpression(path) {
95
+ const left = path.node.left;
96
+ const right = path.node.right;
97
+
98
+ if (right.type !== 'StringLiteral') return;
99
+ if (!isTranslatable(right.value)) return;
100
+
101
+ if (left.type === 'MemberExpression' && left.property) {
102
+ const propName = left.property.name || left.property.value;
103
+ const domProps = new Set([
104
+ 'textContent', 'innerText', 'title', 'placeholder',
105
+ 'alt', 'innerHTML',
106
+ ]);
107
+
108
+ if (domProps.has(propName)) {
109
+ const key = hashKey(right.value);
110
+ const tFunc = project.type === 'vanilla'
111
+ ? `i18n.t('${key}')`
112
+ : project.type === 'vue'
113
+ ? `this.$t('${key}')`
114
+ : `t('${key}')`;
115
+
116
+ s.overwrite(right.start, right.end, tFunc);
117
+ extracted.push({ key, text: right.value, context: `js-dom-${propName}` });
118
+ }
119
+ }
120
+ },
121
+ });
122
+
123
+ return {
124
+ source: extracted.length > 0 ? s.toString() : source,
125
+ extracted,
126
+ modified: extracted.length > 0,
127
+ };
128
+ }
@@ -0,0 +1,93 @@
1
+ import { hashKey, isTranslatable } from '../utils.js';
2
+
3
+ /**
4
+ * Parse a JSON file and extract translatable string values.
5
+ * Handles nested objects. Does NOT translate:
6
+ * - Keys (object property names)
7
+ * - Array items that look like identifiers/codes
8
+ * - Non-string values
9
+ * - Strings that look like code, URLs, paths, etc.
10
+ */
11
+ export function parseJson(source, filePath, options = {}) {
12
+ const { jsonMode = 'values' } = options;
13
+ let data;
14
+
15
+ try {
16
+ data = JSON.parse(source);
17
+ } catch {
18
+ return { source, extracted: [], modified: false, jsonData: null };
19
+ }
20
+
21
+ const extracted = [];
22
+
23
+ // Walk the JSON and collect translatable strings
24
+ walkJson(data, [], extracted, jsonMode);
25
+
26
+ return {
27
+ source,
28
+ extracted,
29
+ modified: extracted.length > 0,
30
+ jsonData: data,
31
+ };
32
+ }
33
+
34
+ function walkJson(obj, path, extracted, mode) {
35
+ if (obj === null || obj === undefined) return;
36
+
37
+ if (typeof obj === 'string') {
38
+ if (isTranslatable(obj) && obj.length > 1) {
39
+ const key = path.join('.');
40
+ extracted.push({ key, text: obj, context: 'json-value', jsonPath: path });
41
+ }
42
+ return;
43
+ }
44
+
45
+ if (Array.isArray(obj)) {
46
+ // For arrays, only translate string items that look like real text
47
+ for (let i = 0; i < obj.length; i++) {
48
+ const item = obj[i];
49
+ if (typeof item === 'string' && isTranslatable(item) && item.includes(' ')) {
50
+ // Only translate multi-word strings in arrays (skip identifiers)
51
+ const key = [...path, i].join('.');
52
+ extracted.push({ key, text: item, context: 'json-array', jsonPath: [...path, i] });
53
+ } else if (typeof item === 'object') {
54
+ walkJson(item, [...path, i], extracted, mode);
55
+ }
56
+ }
57
+ return;
58
+ }
59
+
60
+ if (typeof obj === 'object') {
61
+ for (const [k, v] of Object.entries(obj)) {
62
+ walkJson(v, [...path, k], extracted, mode);
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Apply translations to a JSON object by path.
69
+ */
70
+ export function applyJsonTranslations(data, translations) {
71
+ const result = JSON.parse(JSON.stringify(data)); // deep clone
72
+
73
+ for (const [dotPath, translatedText] of Object.entries(translations)) {
74
+ const parts = dotPath.split('.');
75
+ let target = result;
76
+
77
+ for (let i = 0; i < parts.length - 1; i++) {
78
+ const key = isNaN(parts[i]) ? parts[i] : parseInt(parts[i]);
79
+ if (target[key] === undefined) break;
80
+ target = target[key];
81
+ }
82
+
83
+ const lastKey = isNaN(parts[parts.length - 1])
84
+ ? parts[parts.length - 1]
85
+ : parseInt(parts[parts.length - 1]);
86
+
87
+ if (target && target[lastKey] !== undefined) {
88
+ target[lastKey] = translatedText;
89
+ }
90
+ }
91
+
92
+ return result;
93
+ }
@@ -0,0 +1,153 @@
1
+ import * as babelParser from '@babel/parser';
2
+ import _traverse from '@babel/traverse';
3
+ import _generate from '@babel/generator';
4
+ import * as t from '@babel/types';
5
+ import MagicString from 'magic-string';
6
+ import { hashKey, isTranslatable } from '../utils.js';
7
+
8
+ // Handle ESM default export quirks
9
+ const traverse = _traverse.default || _traverse;
10
+ const generate = _generate.default || _generate;
11
+
12
+ /**
13
+ * Non-translatable JSX attribute names.
14
+ */
15
+ const ATTR_BLACKLIST = new Set([
16
+ 'className', 'id', 'style', 'key', 'ref', 'src', 'href',
17
+ 'type', 'name', 'value', 'htmlFor', 'tabIndex', 'role',
18
+ 'data-testid', 'data-cy', 'onClick', 'onChange', 'onSubmit',
19
+ 'onFocus', 'onBlur', 'onKeyDown', 'onKeyUp', 'onMouseEnter',
20
+ 'onMouseLeave', 'target', 'rel', 'method', 'action',
21
+ ]);
22
+
23
+ const ATTR_WHITELIST = new Set([
24
+ 'title', 'placeholder', 'label', 'alt', 'aria-label',
25
+ 'aria-placeholder', 'aria-description',
26
+ ]);
27
+
28
+ /**
29
+ * Parse a JSX/TSX file and extract translatable strings.
30
+ */
31
+ export function parseReact(source, filePath) {
32
+ const extracted = [];
33
+ const isTS = filePath.endsWith('.tsx') || filePath.endsWith('.ts');
34
+
35
+ let ast;
36
+ try {
37
+ ast = babelParser.parse(source, {
38
+ sourceType: 'module',
39
+ plugins: [
40
+ 'jsx',
41
+ isTS ? 'typescript' : null,
42
+ 'classProperties',
43
+ 'optionalChaining',
44
+ 'nullishCoalescingOperator',
45
+ 'decorators-legacy',
46
+ ].filter(Boolean),
47
+ });
48
+ } catch (err) {
49
+ // If Babel fails, return unmodified
50
+ return { source, extracted: [], modified: false };
51
+ }
52
+
53
+ const s = new MagicString(source);
54
+ let needsImport = false;
55
+
56
+ traverse(ast, {
57
+ // JSX text children: <div>Hello World</div>
58
+ JSXText(path) {
59
+ const text = path.node.value.trim();
60
+ if (!isTranslatable(text)) return;
61
+
62
+ const key = hashKey(text);
63
+ const start = path.node.start;
64
+ const end = path.node.end;
65
+
66
+ // Preserve whitespace
67
+ const original = path.node.value;
68
+ const leadingWs = original.match(/^(\s*)/)[1];
69
+ const trailingWs = original.match(/(\s*)$/)[1];
70
+
71
+ s.overwrite(start, end, `${leadingWs}{t('${key}')}${trailingWs}`);
72
+ extracted.push({ key, text, context: 'jsx-text' });
73
+ needsImport = true;
74
+ },
75
+
76
+ // JSX string attributes: <input placeholder="Enter name" />
77
+ JSXAttribute(path) {
78
+ const name = path.node.name?.name;
79
+ if (!name) return;
80
+
81
+ if (ATTR_BLACKLIST.has(name)) return;
82
+
83
+ // Only translate whitelisted attrs, or unknown attrs if they have translatable values
84
+ const value = path.node.value;
85
+ if (!value || value.type !== 'StringLiteral') return;
86
+
87
+ const text = value.value;
88
+ if (!isTranslatable(text)) return;
89
+ if (!ATTR_WHITELIST.has(name) && text.length < 3) return;
90
+
91
+ const key = hashKey(text);
92
+ const attrStart = path.node.start;
93
+ const attrEnd = path.node.end;
94
+
95
+ s.overwrite(attrStart, attrEnd, `${name}={t('${key}')}`);
96
+ extracted.push({ key, text, context: `jsx-attr-${name}` });
97
+ needsImport = true;
98
+ },
99
+
100
+ // String literals in common patterns (not JSX)
101
+ CallExpression(path) {
102
+ const callee = path.node.callee;
103
+ const calleeName = callee.name || (callee.property && callee.property.name);
104
+
105
+ // alert('...'), confirm('...'), toast('...')
106
+ if (['alert', 'confirm'].includes(calleeName)) {
107
+ const arg = path.node.arguments[0];
108
+ if (arg && arg.type === 'StringLiteral' && isTranslatable(arg.value)) {
109
+ const key = hashKey(arg.value);
110
+ s.overwrite(arg.start, arg.end, `t('${key}')`);
111
+ extracted.push({ key, text: arg.value, context: 'call-arg' });
112
+ needsImport = true;
113
+ }
114
+ }
115
+ },
116
+ });
117
+
118
+ // Add useTranslation import if needed
119
+ if (needsImport) {
120
+ const hasUseTranslation = source.includes('useTranslation');
121
+ if (!hasUseTranslation) {
122
+ // Find the first import statement or top of file
123
+ let insertPos = 0;
124
+ const importMatch = source.match(/^import\s.+$/m);
125
+ if (importMatch) {
126
+ // Insert after all imports
127
+ const lastImportMatch = [...source.matchAll(/^import\s.+$/gm)];
128
+ if (lastImportMatch.length > 0) {
129
+ const last = lastImportMatch[lastImportMatch.length - 1];
130
+ insertPos = last.index + last[0].length;
131
+ }
132
+ }
133
+
134
+ s.appendRight(insertPos, `\nimport { useTranslation } from 'react-i18next';\n`);
135
+ }
136
+
137
+ // Add const { t } = useTranslation() if not present
138
+ if (!source.includes('useTranslation()')) {
139
+ // Find the function body
140
+ const funcMatch = source.match(/(?:function\s+\w+|const\s+\w+\s*=\s*(?:\([^)]*\)\s*=>|\w+\s*=>))\s*\{/);
141
+ if (funcMatch) {
142
+ const insertAt = funcMatch.index + funcMatch[0].length;
143
+ s.appendRight(insertAt, `\n const { t } = useTranslation();\n`);
144
+ }
145
+ }
146
+ }
147
+
148
+ return {
149
+ source: extracted.length > 0 ? s.toString() : source,
150
+ extracted,
151
+ modified: extracted.length > 0,
152
+ };
153
+ }