jcloud/dashboard/vite-plugin-translate.mjs
2025-12-29 22:21:35 +08:00

609 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 常量配置
const CONSTANTS = {
VUE_TEMPLATE_TAG: '<template',
VUE_TEMPLATE_CLOSE_TAG: '</template>',
VUE_TEMPLATE_TAG_LEN: 9,
VUE_TEMPLATE_CLOSE_TAG_LEN: 11,
VUE_TEMPLATE_MODULE: '?vue&type=template',
VUE_STYLE_MODULE: '?vue&type=style',
FILE_EXTENSIONS: /\.(vue|js|ts|jsx|tsx)(\?.*)?$/,
NON_ASCII_REGEX: /[^\x00-\x7F]/,
};
/**
* 查找平衡括号的结束位置
* @param {string} code - 代码字符串
* @param {number} startPos - 起始位置(开括号之后)
* @param {string} openChar - 开括号字符
* @param {string} closeChar - 闭括号字符
* @returns {number} 闭括号的位置,未找到返回 -1
*/
function findBalancedBracket(code, startPos, openChar = '(', closeChar = ')') {
let depth = 1;
let pos = startPos;
while (pos < code.length && depth > 0) {
const char = code[pos];
if (char === openChar) depth++;
else if (char === closeChar) {
depth--;
if (depth === 0) return pos;
}
pos++;
}
return -1;
}
/**
* 处理参数对象中的嵌套 $t() 调用
* @param {string} paramsCode - 参数部分的代码
* @param {object} translations - 翻译字典
* @param {RegExp} pattern - 匹配 $t() 调用的正则表达式
* @returns {string} 处理后的参数代码
*/
function processNestedTranslations(paramsCode, translations, pattern) {
return paramsCode.replace(pattern, (match, ...args) => {
// String.replace 回调的参数match, ...captureGroups, index, input
// 需要根据捕获组数量判断格式
// 格式1: /([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])([^'"]+)\2\)/g -> 3个捕获组
// 格式2: /\$t\((['"])([^'"]+)\1\)/g -> 2个捕获组
// 格式3: /\{\{\s*[\$]t\s*\(\s*(['"])([^'"]+)\1\s*\)\s*\}\}/g -> 2个捕获组
// 获取捕获组数量排除最后两个参数index 和 input
const captureGroups = args.slice(0, -2);
let ctxPrefix = '';
let quote;
let key;
if (captureGroups.length === 3) {
// 格式1带前缀的 (ctxPrefix, quote, key)
ctxPrefix = captureGroups[0] || '';
quote = captureGroups[1];
key = unescapeString(captureGroups[2]);
} else if (captureGroups.length === 2) {
// 格式2或3不带前缀的 (quote, key)
quote = captureGroups[0];
key = unescapeString(captureGroups[1]);
} else {
// 无法解析,返回原匹配
return match;
}
const translation = translations[key];
return translation
? `${ctxPrefix}$t(${quote}${escapeString(translation, quote)}${quote})`
: match;
});
}
/**
* Vite 插件:构建时直接读取 CSV替换代码中的 $t('xxx') 为翻译文本
* @param {object} options - 插件选项
* @param {string} options.locale - 目标语言代码
* @param {string} options.translationsPath - 翻译文件路径(相对于插件目录)
* @param {string} options.defaultLocale - 默认语言代码
*/
function vitePluginTranslate(options = {}) {
let translations = {};
const locale = options.locale || 'en';
const defaultLocale = options.defaultLocale || 'en';
const translationsPath = options.translationsPath || '../jcloud/translations';
return {
name: 'vite-plugin-translate',
enforce: 'pre',
configResolved(config) {
if (locale && locale !== defaultLocale) {
const csvPath = path.resolve(__dirname, `${translationsPath}/${locale}.csv`);
if (fs.existsSync(csvPath)) {
try {
translations = parseCSV(csvPath);
} catch (error) {
console.warn(`[vite-plugin-translate] Failed to load translations from ${csvPath}:`, error.message);
}
}
}
},
transform(code, id) {
const fileMatches = CONSTANTS.FILE_EXTENSIONS.test(id);
if (!fileMatches || locale === defaultLocale || Object.keys(translations).length === 0) {
return null;
}
const originalCode = code;
const isVueFile = id.includes('.vue') && !id.includes(CONSTANTS.VUE_STYLE_MODULE);
if (isVueFile) {
const isTemplateModule = id.includes(CONSTANTS.VUE_TEMPLATE_MODULE);
if (isTemplateModule) {
// Vue 编译后的模板模块
code = replaceInterpolations(code, translations);
code = replaceCompiledFormat(code, translations);
code = replaceAttributeBindings(code, translations);
} else {
// 原始 Vue SFC 文件
code = processVueSFC(code, translations);
}
} else {
// 非 Vue 文件
code = code.replace(
/\bt\((['"])((?:\\.|(?!\1).)*)\1\)/g,
(match, quote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
return translation ? quote + escapeString(translation, quote) + quote : match;
}
);
}
return code !== originalCode ? { code, map: null } : null;
}
};
}
/**
* 处理 Vue SFC 文件
*/
function processVueSFC(code, translations) {
// 处理 <template> 部分
const templateRegex = /<template[^>]*>/gi;
let templateMatch;
while ((templateMatch = templateRegex.exec(code)) !== null) {
const startIndex = templateMatch.index;
const openTag = templateMatch[0];
// 查找对应的 </template>,处理嵌套
let depth = 1;
let pos = templateRegex.lastIndex;
let templateContent = '';
while (depth > 0 && pos < code.length) {
const nextOpen = code.indexOf(CONSTANTS.VUE_TEMPLATE_TAG, pos);
const nextClose = code.indexOf(CONSTANTS.VUE_TEMPLATE_CLOSE_TAG, pos);
if (nextClose === -1) break;
if (nextOpen !== -1 && nextOpen < nextClose) {
depth++;
pos = nextOpen + CONSTANTS.VUE_TEMPLATE_TAG_LEN;
} else {
depth--;
if (depth === 0) {
templateContent = code.substring(templateRegex.lastIndex, nextClose);
break;
}
pos = nextClose + CONSTANTS.VUE_TEMPLATE_CLOSE_TAG_LEN;
}
}
if (depth === 0 && templateContent) {
let replacedContent = templateContent;
replacedContent = replaceInterpolations(replacedContent, translations);
replacedContent = replaceAttributeBindings(replacedContent, translations);
// 处理属性绑定中的数组字面量,如 :items="[{ label: $t('key'), ... }]"
replacedContent = replaceAttributeArrayLiterals(replacedContent, translations);
// 处理属性绑定中的对象字面量,如 :options="{ title: $t('key'), ... }"
replacedContent = replaceAttributeObjectLiterals(replacedContent, translations);
code = code.substring(0, startIndex) + openTag + replacedContent + CONSTANTS.VUE_TEMPLATE_CLOSE_TAG +
code.substring(startIndex + openTag.length + templateContent.length + CONSTANTS.VUE_TEMPLATE_CLOSE_TAG_LEN);
break;
}
}
// 处理 <script> 部分
code = code.replace(
/(<script[^>]*>)([\s\S]*?)(<\/script>)/gi,
(match, openTag, scriptContent, closeTag) => {
const replacedContent = scriptContent.replace(
/(?:this\.)?\$t\((['"])((?:\\.|(?!\1).)*)\1\)/g,
(match, quote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
// 如果匹配包含 this.,保留它
const hasThis = match.startsWith('this.');
const prefix = hasThis ? 'this.' : '';
return translation ? `${prefix}$t(${quote}${escapeString(translation, quote)}${quote})` : match;
}
);
return openTag + replacedContent + closeTag;
}
);
return code;
}
/**
* 替换插值表达式 {{ $t('xxx') }} 或 {{ $t('xxx', { ... }) }}
*/
function replaceInterpolations(code, translations) {
// 先处理不带参数的调用:{{ $t('xxx') }}
code = code.replace(
/\{\{\s*[\$]t\s*\(\s*(['"])((?:\\.|(?!\1).)*)\1\s*\)\s*\}\}/g,
(match, quote, key) => {
const unescapedKey = unescapeString(key);
return translations[unescapedKey] || match;
}
);
// 处理插值表达式内部的 $t() 调用(不在单独的 {{ $t(...) }} 中)
// 例如:{{ condition ? $t('key1') : $t('key2') }}
code = code.replace(
/\{\{[\s\S]*?\}\}/g,
(interpolation) => {
// 如果这个插值已经是一个完整的 {{ $t(...) }},跳过
if (/^\{\{\s*[\$]t\s*\(/.test(interpolation.trim())) {
return interpolation;
}
// 否则,处理其中的 $t() 调用
return interpolation.replace(
/\$t\((['"])((?:\\.|(?!\1).)*)\1\)/g,
(match, quote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
return translation ? `$t(${quote}${escapeString(translation, quote)}${quote})` : match;
}
);
}
);
// 处理带参数的调用:{{ $t('key', { params }) }}
const matches = [];
const pattern = /\{\{\s*[\$]t\s*\(\s*(['"])((?:\\.|(?!\1).)*)\1\s*,\s*/g;
let match;
while ((match = pattern.exec(code)) !== null) {
const startIndex = match.index;
const quote = match[1];
const key = unescapeString(match[2]);
const afterKeyIndex = match.index + match[0].length;
// 找到参数部分的结束位置
const paramsEnd = findBalancedBracket(code, afterKeyIndex);
if (paramsEnd === -1) continue;
// 找到整个 {{ $t(...) }} 的结束位置
let fullEnd = paramsEnd + 1;
while (fullEnd < code.length && code[fullEnd] === ' ') fullEnd++;
if (fullEnd < code.length && code[fullEnd] === '}') {
fullEnd++;
if (fullEnd < code.length && code[fullEnd] === '}') fullEnd++;
}
matches.push({ startIndex, quote, key, afterKeyIndex, paramsEnd, fullEnd });
}
// 从后往前替换,避免索引错乱
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
let paramsPart = code.substring(m.afterKeyIndex, m.paramsEnd);
// 处理参数部分中的嵌套 $t() 调用
// 先处理 {{ $t(...) }} 格式(模板插值)
paramsPart = processNestedTranslations(
paramsPart,
translations,
/\{\{\s*[\$]t\s*\(\s*(['"])((?:\\.|(?!\1).)*)\1\s*\)\s*\}\}/g
);
// 再处理 $t(...) 格式(参数对象中的直接调用)
paramsPart = processNestedTranslations(
paramsPart,
translations,
/\$t\((['"])((?:\\.|(?!\1).)*)\1\)/g
);
const translation = translations[m.key];
if (translation) {
const replacement = `{{ $t(${m.quote}${escapeString(translation, m.quote)}${m.quote}, ${paramsPart}) }}`;
code = code.substring(0, m.startIndex) + replacement + code.substring(m.fullEnd);
}
}
return code;
}
/**
* 替换 Vue 编译后的格式_ctx.$t('xxx') 或 $t('xxx'),支持带参数
*/
function replaceCompiledFormat(code, translations) {
// 处理带参数的调用
const matches = [];
const pattern = /([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])((?:\\.|(?!\2).)*)\2\s*,\s*/g;
let match;
while ((match = pattern.exec(code)) !== null) {
const startIndex = match.index;
const ctxPrefix = match[1] || '';
const quote = match[2];
const key = unescapeString(match[3]);
const afterKeyIndex = match.index + match[0].length;
const paramsEnd = findBalancedBracket(code, afterKeyIndex);
if (paramsEnd === -1) continue;
matches.push({ startIndex, ctxPrefix, quote, key, afterKeyIndex, paramsEnd });
}
// 从后往前替换
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
let paramsPart = code.substring(m.afterKeyIndex, m.paramsEnd);
// 处理参数部分中的嵌套 $t() 调用
paramsPart = processNestedTranslations(
paramsPart,
translations,
/([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])((?:\\.|(?!\2).)*)\2\)/g
);
const translation = translations[m.key];
if (translation) {
const replacement = `${m.ctxPrefix}$t(${m.quote}${escapeString(translation, m.quote)}${m.quote}, ${paramsPart})`;
code = code.substring(0, m.startIndex) + replacement + code.substring(m.paramsEnd + 1);
}
}
// 处理不带参数的调用
code = code.replace(
/([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])((?:\\.|(?!\2).)*)\2\)/g,
(match, ctxPrefix, quote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
return translation ? `${ctxPrefix || ''}$t(${quote}${escapeString(translation, quote)}${quote})` : match;
}
);
return code;
}
/**
* 替换属性绑定中的 $t('xxx')
*/
function replaceAttributeBindings(code, translations) {
return code.replace(
/([:@]|v-bind:|v-on:)([a-zA-Z0-9_-]+)=(["'])[\$]?t\((['"])((?:\\.|(?!\4).)*)\4\)\3/g,
(match, prefix, attrName, outerQuote, innerQuote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
if (!translation || !translation.trim()) return match;
let escaped = escapeString(translation, outerQuote);
if (outerQuote === '"') {
escaped = escaped.replace(/'/g, "\\'");
return `${prefix}${attrName}="'${escaped}'"`;
} else {
escaped = escaped.replace(/"/g, '\\"');
return `${prefix}${attrName}='"${escaped}"'`;
}
}
);
}
/**
* 在代码片段中替换所有 $t() 调用为翻译文本
* @param {string} content - 要处理的代码片段
* @param {object} translations - 翻译字典
* @param {RegExp} pattern - 可选的匹配模式,默认匹配所有 $t() 调用
* @returns {string} 替换后的代码
*/
function replaceTranslationCalls(content, translations, pattern = /\$t\((['"])((?:\\.|(?!\1).)*)\1\)/g) {
return content.replace(pattern, (match, quote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
if (!translation || !translation.trim()) return match;
const escaped = escapeString(translation, quote);
return `${quote}${escaped}${quote}`;
});
}
/**
* 替换属性绑定中数组字面量内的 $t('xxx')
* 例如::items="[{ label: $t('key'), ... }]"
*/
function replaceAttributeArrayLiterals(code, translations) {
// 匹配属性绑定中的数组字面量,如 :items="[...]"
// 使用更宽松的匹配,支持单引号和双引号
return code.replace(
/([:@]|v-bind:|v-on:)([a-zA-Z0-9_-]+)=(["'])\[([\s\S]*?)\]\3/g,
(match, prefix, attrName, outerQuote, arrayContent) => {
const replacedContent = replaceTranslationCalls(arrayContent, translations);
return `${prefix}${attrName}=${outerQuote}[${replacedContent}]${outerQuote}`;
}
);
}
/**
* 替换属性绑定中对象字面量内的 $t('xxx')
* 例如::options="{ title: $t('key'), actions: [...] }"
*/
function replaceAttributeObjectLiterals(code, translations) {
// 匹配属性绑定中的对象字面量,使用平衡括号匹配以正确处理嵌套
const pattern = /([:@]|v-bind:|v-on:)([a-zA-Z0-9_-]+)=(["'])\{/g;
const matches = [];
let match;
while ((match = pattern.exec(code)) !== null) {
const startIndex = match.index;
const prefix = match[1];
const attrName = match[2];
const outerQuote = match[3];
const openBraceIndex = startIndex + match[0].length - 1; // 开括号的位置
const afterBraceIndex = openBraceIndex + 1; // 开括号之后的位置
// 找到匹配的闭合大括号
let depth = 1;
let pos = afterBraceIndex;
let braceEnd = -1;
while (pos < code.length && depth > 0) {
const char = code[pos];
// 跳过字符串字面量中的大括号
if (char === '"' || char === "'") {
pos++;
// 跳过转义的引号
while (pos < code.length && (code[pos] !== char || code[pos - 1] === '\\')) {
pos++;
}
pos++;
continue;
}
if (char === '{') depth++;
else if (char === '}') {
depth--;
if (depth === 0) {
braceEnd = pos;
break;
}
}
pos++;
}
if (braceEnd === -1) continue;
// 检查是否以相同的引号结束
if (braceEnd + 1 < code.length && code[braceEnd + 1] === outerQuote) {
const objectContent = code.substring(afterBraceIndex, braceEnd);
matches.push({ startIndex, prefix, attrName, outerQuote, afterBraceIndex, braceEnd, objectContent });
}
}
// 从后往前替换,避免索引错乱
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
// 匹配对象属性值中的 $t(),支持键名(标识符或字符串)
const replacedContent = m.objectContent.replace(
/(['"]?\w+['"]?\s*:\s*)\$t\((['"])((?:\\.|(?!\2).)*)\2\)/g,
(match, keyPrefix, quote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
if (!translation || !translation.trim()) return match;
const escaped = escapeString(translation, quote);
return `${keyPrefix}${quote}${escaped}${quote}`;
}
);
const replacement = `${m.prefix}${m.attrName}=${m.outerQuote}{${replacedContent}}${m.outerQuote}`;
code = code.substring(0, m.startIndex) + replacement + code.substring(m.braceEnd + 2);
}
return code;
}
/**
* 去除字符串中的转义字符(将 \' 转换为 '\" 转换为 " 等)
*/
function unescapeString(text) {
return text
.replace(/\\(.)/g, '$1');
}
/**
* 转义字符串
*/
function escapeString(text, quote) {
return text
.replace(/\\/g, '\\\\')
.replace(quote === '"' ? /"/g : /'/g, quote === '"' ? '\\"' : "\\'");
}
/**
* 解析 CSV 文件
* 格式key,translation,context
* 支持包含逗号的键通过检测非ASCII字符识别翻译部分
*/
function parseCSV(csvPath) {
if (!fs.existsSync(csvPath)) {
return {};
}
const content = fs.readFileSync(csvPath, 'utf-8');
const lines = content.split('\n');
const translations = {};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
// 标准 CSV 解析(支持引号)
// 只有当字段以引号开始时,引号才作为 CSV 格式的引号处理
const parts = [];
let current = '';
let inQuotes = false;
let fieldStartedWithQuote = false;
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
if (char === '"') {
// 如果当前字段还没开始current 为空),且这是字段的第一个字符,标记为引号包裹的字段
if (current === '' && !inQuotes) {
fieldStartedWithQuote = true;
inQuotes = true;
// 不将开始的引号添加到 current
} else if (inQuotes && fieldStartedWithQuote) {
// 在引号包裹的字段中,处理转义的引号 "" 或结束引号
if (i + 1 < trimmed.length && trimmed[i + 1] === '"') {
current += '"';
i++;
} else {
// 结束引号,不添加到 current
inQuotes = false;
fieldStartedWithQuote = false;
}
} else {
// 不在引号包裹的字段中,引号是内容的一部分
current += char;
}
} else if (char === ',' && !inQuotes) {
parts.push(current);
current = '';
fieldStartedWithQuote = false;
} else {
current += char;
}
}
parts.push(current);
// 如果解析出的部分超过2个可能是键中包含逗号但没有引号
// 通过检测非ASCII字符来识别翻译部分
if (parts.length > 2) {
let translationIndex = -1;
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i] && CONSTANTS.NON_ASCII_REGEX.test(parts[i])) {
translationIndex = i;
break;
}
}
if (translationIndex > 0) {
const key = parts.slice(0, translationIndex).join(',').trim();
const translation = parts[translationIndex].trim();
translations[key] = translation;
continue;
}
}
if (parts.length >= 2 && parts[0] && parts[1]) {
const key = parts[0].trim();
const translation = parts[1].trim();
const context = parts[2] ? parts[2].trim() : '';
translations[context ? `${key}:${context}` : key] = translation;
}
}
return translations;
}
export default vitePluginTranslate;