bctranslate 1.0.0-beta.1

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,161 @@
1
+ import MagicString from 'magic-string';
2
+ import { parse, NodeTypes } from '@vue/compiler-dom';
3
+ import { parseJs } from './js.js';
4
+ import { contextKey, isTranslatable } from '../utils.js';
5
+
6
+ const ATTR_WHITELIST = new Set([
7
+ 'title',
8
+ 'placeholder',
9
+ 'label',
10
+ 'alt',
11
+ 'aria-label',
12
+ 'aria-placeholder',
13
+ 'aria-description',
14
+ ]);
15
+
16
+ const CONTENT_TAGS = new Set([
17
+ 'title',
18
+ 'h1',
19
+ 'h2',
20
+ 'h3',
21
+ 'h4',
22
+ 'h5',
23
+ 'h6',
24
+ 'p',
25
+ 'a',
26
+ 'button',
27
+ 'label',
28
+ 'option',
29
+ 'li',
30
+ 'th',
31
+ 'td',
32
+ ]);
33
+
34
+ const SKIP_TAGS = new Set(['style', 'noscript']);
35
+
36
+ function stripTags(html) {
37
+ return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
38
+ }
39
+
40
+ function maybeWrapInlineScriptForI18nReady(script) {
41
+ if (!/\bi18n\.t\s*\(/.test(script)) return script;
42
+ if (/\bi18n\.ready\b/.test(script)) return script;
43
+ if (!/\bcreateApp\b|\bapp\.mount\b|document\./.test(script)) return script;
44
+
45
+ const trimmed = script.trim();
46
+ const indent = (script.match(/^\s*/) || [''])[0];
47
+ const body = trimmed
48
+ .split('\n')
49
+ .map((l) => (l.length ? indent + ' ' + l : l))
50
+ .join('\n');
51
+
52
+ return (
53
+ `${indent}(async () => {\n` +
54
+ `${indent} if (i18n.ready) await i18n.ready;\n` +
55
+ `${body}\n` +
56
+ `${indent}})();`
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Parse an HTML file and extract translatable strings.
62
+ * For vanilla HTML, we use data-i18n attributes instead of $t() calls.
63
+ */
64
+ export function parseHtml(source, filePath, project) {
65
+ const extracted = [];
66
+ const s = new MagicString(source);
67
+
68
+ const ast = parse(source, { comments: false });
69
+
70
+ function visit(node) {
71
+ if (!node) return;
72
+ if (Array.isArray(node)) {
73
+ for (const child of node) visit(child);
74
+ return;
75
+ }
76
+
77
+ if (node.type === NodeTypes.ROOT) {
78
+ visit(node.children);
79
+ return;
80
+ }
81
+
82
+ if (node.type !== NodeTypes.ELEMENT) return;
83
+
84
+ const tag = (node.tag || '').toLowerCase();
85
+ if (SKIP_TAGS.has(tag)) return;
86
+
87
+ const openTagEnd = source.indexOf('>', node.loc.start.offset);
88
+ if (openTagEnd < 0 || openTagEnd > node.loc.end.offset) {
89
+ visit(node.children);
90
+ return;
91
+ }
92
+
93
+ const openTagText = source.slice(node.loc.start.offset, openTagEnd);
94
+
95
+ // Special case: inline <script> blocks inside HTML
96
+ if (tag === 'script' && !node.isSelfClosing) {
97
+ const hasSrc = (node.props || []).some(
98
+ (p) => p.type === NodeTypes.ATTRIBUTE && p.name === 'src'
99
+ );
100
+ if (hasSrc) return;
101
+
102
+ const closeRel = node.loc.source.lastIndexOf('</');
103
+ if (closeRel > -1) {
104
+ const closeTagStart = node.loc.start.offset + closeRel;
105
+ const scriptJs = source.slice(openTagEnd + 1, closeTagStart);
106
+ const jsResult = parseJs(scriptJs, filePath, project);
107
+ if (jsResult.modified) {
108
+ const wrapped = maybeWrapInlineScriptForI18nReady(jsResult.source);
109
+ s.overwrite(openTagEnd + 1, closeTagStart, wrapped);
110
+ extracted.push(...jsResult.extracted);
111
+ }
112
+ }
113
+
114
+ return;
115
+ }
116
+
117
+ // 1) Extract translatable attributes
118
+ for (const prop of node.props || []) {
119
+ if (prop.type !== NodeTypes.ATTRIBUTE) continue;
120
+ const name = prop.name;
121
+ if (!ATTR_WHITELIST.has(name)) continue;
122
+ const value = prop.value?.content;
123
+ if (!value || !isTranslatable(value)) continue;
124
+
125
+ const marker = `data-i18n-${name}`;
126
+ if (openTagText.includes(marker)) continue;
127
+
128
+ const key = contextKey(value, filePath);
129
+ s.appendRight(prop.loc.end.offset, ` ${marker}="${key}"`);
130
+ extracted.push({ key, text: value, context: `html-attr-${name}` });
131
+ }
132
+
133
+ // 2) Extract element inner HTML for common content tags
134
+ if (CONTENT_TAGS.has(tag) && !node.isSelfClosing) {
135
+ const closeRel = node.loc.source.lastIndexOf('</');
136
+ if (closeRel > -1) {
137
+ const closeTagStart = node.loc.start.offset + closeRel;
138
+ const innerHtml = source.slice(openTagEnd + 1, closeTagStart);
139
+ const text = innerHtml.trim();
140
+ const plain = stripTags(text);
141
+
142
+ const marker = tag === 'title' ? 'data-i18n-title' : 'data-i18n';
143
+ if (!openTagText.includes(marker) && isTranslatable(plain)) {
144
+ const key = contextKey(text, filePath);
145
+ s.appendLeft(openTagEnd, ` ${marker}="${key}"`);
146
+ extracted.push({ key, text, context: `html-inner-${tag}` });
147
+ }
148
+ }
149
+ }
150
+
151
+ visit(node.children);
152
+ }
153
+
154
+ visit(ast);
155
+
156
+ return {
157
+ source: extracted.length > 0 ? s.toString() : source,
158
+ extracted,
159
+ modified: extracted.length > 0,
160
+ };
161
+ }
@@ -0,0 +1,156 @@
1
+ import * as babelParser from '@babel/parser';
2
+ import _traverse from '@babel/traverse';
3
+ import * as t from '@babel/types';
4
+ import MagicString from 'magic-string';
5
+ import { contextKey, isTranslatable } from '../utils.js';
6
+
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
+ const tFuncFor = (key) => {
18
+ if (project.type === 'vanilla') return `i18n.t('${key}')`;
19
+ if (project.type === 'vue') return `this.$t('${key}')`;
20
+ return `t('${key}')`;
21
+ };
22
+
23
+ const translatableAttrNames = new Set([
24
+ 'title',
25
+ 'placeholder',
26
+ 'label',
27
+ 'alt',
28
+ 'aria-label',
29
+ 'aria-placeholder',
30
+ 'aria-description',
31
+ ]);
32
+
33
+ let ast;
34
+ try {
35
+ ast = babelParser.parse(source, {
36
+ sourceType: 'module',
37
+ plugins: [
38
+ isTS ? 'typescript' : null,
39
+ 'classProperties',
40
+ 'optionalChaining',
41
+ 'nullishCoalescingOperator',
42
+ 'decorators-legacy',
43
+ 'dynamicImport',
44
+ ].filter(Boolean),
45
+ });
46
+ } catch {
47
+ return { source, extracted: [], modified: false };
48
+ }
49
+
50
+ const s = new MagicString(source);
51
+
52
+ // Track which string literals to translate
53
+ traverse(ast, {
54
+ // Object property values with translatable keys
55
+ ObjectProperty(path) {
56
+ const keyNode = path.node.key;
57
+ const valueNode = path.node.value;
58
+
59
+ if (valueNode.type !== 'StringLiteral') return;
60
+ if (!isTranslatable(valueNode.value)) return;
61
+
62
+ // Check if the key name suggests translatable content
63
+ const keyName = keyNode.name || keyNode.value || '';
64
+ const translatableKeys = new Set([
65
+ 'title', 'label', 'placeholder', 'message', 'text',
66
+ 'description', 'tooltip', 'hint', 'caption', 'header',
67
+ 'subtitle', 'errorMessage', 'successMessage', 'content',
68
+ 'heading', 'subheading', 'buttonText', 'linkText',
69
+ 'name', 'displayName',
70
+ ]);
71
+
72
+ if (!translatableKeys.has(keyName)) return;
73
+
74
+ const key = contextKey(valueNode.value, filePath);
75
+ const tFunc = tFuncFor(key);
76
+
77
+ s.overwrite(valueNode.start, valueNode.end, tFunc);
78
+ extracted.push({ key, text: valueNode.value, context: `js-prop-${keyName}` });
79
+ },
80
+
81
+ // Function calls: alert('text'), console messages excluded
82
+ CallExpression(path) {
83
+ const callee = path.node.callee;
84
+ let calleeName = '';
85
+
86
+ if (callee.type === 'Identifier') {
87
+ calleeName = callee.name;
88
+ } else if (callee.type === 'MemberExpression' && callee.property) {
89
+ calleeName = `${callee.object?.name || ''}.${callee.property.name}`;
90
+ }
91
+
92
+ // Skip console.*, require(), import()
93
+ if (calleeName.startsWith('console.') || calleeName === 'require') return;
94
+
95
+ // Translate alert/confirm/prompt first arg
96
+ if (['alert', 'confirm', 'prompt'].includes(calleeName)) {
97
+ const arg = path.node.arguments[0];
98
+ if (arg && arg.type === 'StringLiteral' && isTranslatable(arg.value)) {
99
+ const key = contextKey(arg.value, filePath);
100
+ const tFunc = tFuncFor(key);
101
+ s.overwrite(arg.start, arg.end, tFunc);
102
+ extracted.push({ key, text: arg.value, context: 'js-call' });
103
+ }
104
+ }
105
+
106
+ // element.setAttribute('title', '...')
107
+ if (callee.type === 'MemberExpression' && callee.property?.name === 'setAttribute') {
108
+ const [nameArg, valueArg] = path.node.arguments;
109
+ if (
110
+ nameArg?.type === 'StringLiteral' &&
111
+ valueArg?.type === 'StringLiteral' &&
112
+ translatableAttrNames.has(nameArg.value) &&
113
+ isTranslatable(valueArg.value)
114
+ ) {
115
+ const key = contextKey(valueArg.value, filePath);
116
+ const tFunc = tFuncFor(key);
117
+ s.overwrite(valueArg.start, valueArg.end, tFunc);
118
+ extracted.push({ key, text: valueArg.value, context: `js-attr-${nameArg.value}` });
119
+ }
120
+ }
121
+
122
+ // .textContent = 'text', .innerText = 'text', .title = 'text'
123
+ },
124
+
125
+ // Assignment: element.textContent = 'text'
126
+ AssignmentExpression(path) {
127
+ const left = path.node.left;
128
+ const right = path.node.right;
129
+
130
+ if (right.type !== 'StringLiteral') return;
131
+ if (!isTranslatable(right.value)) return;
132
+
133
+ if (left.type === 'MemberExpression' && left.property) {
134
+ const propName = left.property.name || left.property.value;
135
+ const domProps = new Set([
136
+ 'textContent', 'innerText', 'title', 'placeholder',
137
+ 'alt', 'innerHTML',
138
+ ]);
139
+
140
+ if (domProps.has(propName)) {
141
+ const key = contextKey(right.value, filePath);
142
+ const tFunc = tFuncFor(key);
143
+
144
+ s.overwrite(right.start, right.end, tFunc);
145
+ extracted.push({ key, text: right.value, context: `js-dom-${propName}` });
146
+ }
147
+ }
148
+ },
149
+ });
150
+
151
+ return {
152
+ source: extracted.length > 0 ? s.toString() : source,
153
+ extracted,
154
+ modified: extracted.length > 0,
155
+ };
156
+ }
@@ -0,0 +1,93 @@
1
+ import { 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,148 @@
1
+ import * as babelParser from '@babel/parser';
2
+ import _traverse from '@babel/traverse';
3
+ import MagicString from 'magic-string';
4
+ import { contextKey, isTranslatable } from '../utils.js';
5
+
6
+ // Handle ESM default export quirks
7
+ const traverse = _traverse.default || _traverse;
8
+
9
+ /**
10
+ * Non-translatable JSX attribute names.
11
+ */
12
+ const ATTR_BLACKLIST = new Set([
13
+ 'className', 'id', 'style', 'key', 'ref', 'src', 'href',
14
+ 'type', 'name', 'value', 'htmlFor', 'tabIndex', 'role',
15
+ 'data-testid', 'data-cy', 'onClick', 'onChange', 'onSubmit',
16
+ 'onFocus', 'onBlur', 'onKeyDown', 'onKeyUp', 'onMouseEnter',
17
+ 'onMouseLeave', 'target', 'rel', 'method', 'action',
18
+ ]);
19
+
20
+ const ATTR_WHITELIST = new Set([
21
+ 'title', 'placeholder', 'label', 'alt', 'aria-label',
22
+ 'aria-placeholder', 'aria-description',
23
+ ]);
24
+
25
+ /**
26
+ * Walk up the Babel path tree to find the nearest enclosing function
27
+ * with a BlockStatement body — that is the React component function.
28
+ * Returns the character offset right after the opening `{`.
29
+ */
30
+ function findComponentBodyStart(jsxPath) {
31
+ let p = jsxPath.parentPath;
32
+ while (p) {
33
+ const { node } = p;
34
+ if (
35
+ (node.type === 'FunctionDeclaration' ||
36
+ node.type === 'FunctionExpression' ||
37
+ node.type === 'ArrowFunctionExpression') &&
38
+ node.body?.type === 'BlockStatement'
39
+ ) {
40
+ return node.body.start + 1; // right after opening {
41
+ }
42
+ p = p.parentPath;
43
+ }
44
+ return -1;
45
+ }
46
+
47
+ /**
48
+ * Parse a JSX/TSX file and extract translatable strings.
49
+ * Hook injection uses AST-derived character positions — no regex.
50
+ */
51
+ export function parseReact(source, filePath) {
52
+ const extracted = [];
53
+ const isTS = filePath.endsWith('.tsx') || filePath.endsWith('.ts');
54
+
55
+ let ast;
56
+ try {
57
+ ast = babelParser.parse(source, {
58
+ sourceType: 'module',
59
+ plugins: [
60
+ 'jsx',
61
+ isTS ? 'typescript' : null,
62
+ 'classProperties',
63
+ 'optionalChaining',
64
+ 'nullishCoalescingOperator',
65
+ 'decorators-legacy',
66
+ ].filter(Boolean),
67
+ });
68
+ } catch {
69
+ return { source, extracted: [], modified: false };
70
+ }
71
+
72
+ const s = new MagicString(source);
73
+ let hookInsertPos = -1;
74
+
75
+ traverse(ast, {
76
+ // JSX text children: <div>Hello World</div>
77
+ JSXText(path) {
78
+ const text = path.node.value.trim();
79
+ if (!isTranslatable(text)) return;
80
+
81
+ const key = contextKey(text, filePath);
82
+ const { start, end } = path.node;
83
+ const original = path.node.value;
84
+ const leadingWs = original.match(/^(\s*)/)[1];
85
+ const trailingWs = original.match(/(\s*)$/)[1];
86
+
87
+ s.overwrite(start, end, `${leadingWs}{t('${key}')}${trailingWs}`);
88
+ extracted.push({ key, text, context: 'jsx-text' });
89
+
90
+ if (hookInsertPos === -1) hookInsertPos = findComponentBodyStart(path);
91
+ },
92
+
93
+ // JSX string attributes: <input placeholder="Enter name" />
94
+ JSXAttribute(path) {
95
+ const name = path.node.name?.name;
96
+ if (!name || ATTR_BLACKLIST.has(name)) return;
97
+
98
+ const value = path.node.value;
99
+ if (!value || value.type !== 'StringLiteral') return;
100
+
101
+ const text = value.value;
102
+ if (!isTranslatable(text)) return;
103
+ if (!ATTR_WHITELIST.has(name) && text.length < 3) return;
104
+
105
+ const key = contextKey(text, filePath);
106
+ s.overwrite(path.node.start, path.node.end, `${name}={t('${key}')}`);
107
+ extracted.push({ key, text, context: `jsx-attr-${name}` });
108
+
109
+ if (hookInsertPos === -1) hookInsertPos = findComponentBodyStart(path);
110
+ },
111
+
112
+ // alert('...'), confirm('...')
113
+ CallExpression(path) {
114
+ const callee = path.node.callee;
115
+ const calleeName = callee.name || callee.property?.name;
116
+ if (!['alert', 'confirm'].includes(calleeName)) return;
117
+
118
+ const arg = path.node.arguments[0];
119
+ if (arg?.type === 'StringLiteral' && isTranslatable(arg.value)) {
120
+ const key = contextKey(arg.value, filePath);
121
+ s.overwrite(arg.start, arg.end, `t('${key}')`);
122
+ extracted.push({ key, text: arg.value, context: 'call-arg' });
123
+ }
124
+ },
125
+ });
126
+
127
+ // ── Inject import and hook using AST-derived positions ───────────────────────
128
+ if (extracted.length > 0) {
129
+ if (!source.includes('useTranslation')) {
130
+ // Find the last ImportDeclaration node — safe even with 'use client' directives
131
+ let lastImportEnd = 0;
132
+ for (const node of ast.program.body) {
133
+ if (node.type === 'ImportDeclaration') lastImportEnd = node.end;
134
+ }
135
+ s.appendRight(lastImportEnd, `\nimport { useTranslation } from 'react-i18next';`);
136
+ }
137
+
138
+ if (!source.includes('useTranslation()') && hookInsertPos > 0) {
139
+ s.appendRight(hookInsertPos, `\n const { t } = useTranslation();\n`);
140
+ }
141
+ }
142
+
143
+ return {
144
+ source: extracted.length > 0 ? s.toString() : source,
145
+ extracted,
146
+ modified: extracted.length > 0,
147
+ };
148
+ }