优化vite翻译插件

This commit is contained in:
jingrow 2025-12-29 03:51:38 +08:00
parent 50bb70f80b
commit 06ff855816

View File

@ -6,50 +6,111 @@ 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, ctxPrefix, quote, key) => {
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 = {};
let locale = options.locale || 'en';
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 !== 'en') {
const csvPath = path.resolve(__dirname, `../jcloud/translations/${locale}.csv`);
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 = /\.(vue|js|ts|jsx|tsx)(\?.*)?$/.test(id);
if (!fileMatches || locale === 'en' || Object.keys(translations).length === 0) {
const fileMatches = CONSTANTS.FILE_EXTENSIONS.test(id);
if (!fileMatches || locale === defaultLocale || Object.keys(translations).length === 0) {
return null;
}
let replaced = code;
const isVueFile = id.includes('.vue') && !id.includes('?vue&type=style');
const originalCode = code;
const isVueFile = id.includes('.vue') && !id.includes(CONSTANTS.VUE_STYLE_MODULE);
if (isVueFile) {
const isTemplateModule = id.includes('?vue&type=template');
const isTemplateModule = id.includes(CONSTANTS.VUE_TEMPLATE_MODULE);
if (isTemplateModule) {
// Vue 编译后的模板模块
replaced = replaceInterpolations(replaced, translations);
replaced = replaceCompiledFormat(replaced, translations);
replaced = replaceAttributeBindings(replaced, translations);
code = replaceInterpolations(code, translations);
code = replaceCompiledFormat(code, translations);
code = replaceAttributeBindings(code, translations);
} else {
// 原始 Vue SFC 文件
replaced = processVueSFC(replaced, translations);
code = processVueSFC(code, translations);
}
} else {
// 非 Vue 文件
replaced = replaced.replace(
code = code.replace(
/\bt\((['"])([^'"]+)\1\)/g,
(match, quote, key) => {
const translation = translations[key];
@ -58,7 +119,7 @@ function vitePluginTranslate(options = {}) {
);
}
return replaced !== code ? { code: replaced, map: null } : null;
return code !== originalCode ? { code, map: null } : null;
}
};
}
@ -81,21 +142,21 @@ function processVueSFC(code, translations) {
let templateContent = '';
while (depth > 0 && pos < code.length) {
const nextOpen = code.indexOf('<template', pos);
const nextClose = code.indexOf('</template>', pos);
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 + 9;
pos = nextOpen + CONSTANTS.VUE_TEMPLATE_TAG_LEN;
} else {
depth--;
if (depth === 0) {
templateContent = code.substring(templateRegex.lastIndex, nextClose);
break;
}
pos = nextClose + 11;
pos = nextClose + CONSTANTS.VUE_TEMPLATE_CLOSE_TAG_LEN;
}
}
@ -104,8 +165,8 @@ function processVueSFC(code, translations) {
replacedContent = replaceInterpolations(replacedContent, translations);
replacedContent = replaceAttributeBindings(replacedContent, translations);
code = code.substring(0, startIndex) + openTag + replacedContent + '</template>' +
code.substring(startIndex + openTag.length + templateContent.length + 11);
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;
}
}
@ -139,60 +200,29 @@ function replaceInterpolations(code, translations) {
);
// 处理带参数的调用:{{ $t('key', { params }) }}
// 使用更健壮的方法匹配平衡的括号
const matches = [];
const pattern = /\{\{\s*[\$]t\s*\(\s*(['"])([^'"]+)\1\s*,\s*/g;
let match;
// 先收集所有匹配位置(从后往前处理,避免索引错乱)
while ((match = pattern.exec(code)) !== null) {
const startIndex = match.index;
const quote = match[1];
const key = match[2];
const afterKeyIndex = match.index + match[0].length;
// 找到参数部分的结束位置(匹配平衡的括号)
let depth = 1; // 已经有一个开括号
let pos = afterKeyIndex;
let paramsEnd = -1;
while (pos < code.length && depth > 0) {
const char = code[pos];
if (char === '(') depth++;
else if (char === ')') {
depth--;
if (depth === 0) {
paramsEnd = pos;
break;
}
}
pos++;
}
// 找到参数部分的结束位置
const paramsEnd = findBalancedBracket(code, afterKeyIndex);
if (paramsEnd === -1) continue;
// 找到整个 {{ $t(...) }} 的结束位置
let fullEnd = paramsEnd + 1;
// 跳过空格
while (fullEnd < code.length && code[fullEnd] === ' ') {
fullEnd++;
}
// 跳过 }}
while (fullEnd < code.length && code[fullEnd] === ' ') fullEnd++;
if (fullEnd < code.length && code[fullEnd] === '}') {
fullEnd++;
if (fullEnd < code.length && code[fullEnd] === '}') {
fullEnd++;
}
if (fullEnd < code.length && code[fullEnd] === '}') fullEnd++;
}
if (paramsEnd !== -1) {
matches.push({
startIndex,
quote,
key,
afterKeyIndex,
paramsEnd,
fullEnd
});
}
matches.push({ startIndex, quote, key, afterKeyIndex, paramsEnd, fullEnd });
}
// 从后往前替换,避免索引错乱
@ -200,18 +230,15 @@ function replaceInterpolations(code, translations) {
const m = matches[i];
let paramsPart = code.substring(m.afterKeyIndex, m.paramsEnd);
// 先处理参数部分中的 $t() 调用(递归处理嵌套的翻译)
paramsPart = paramsPart.replace(
/\{\{\s*[\$]t\s*\(\s*(['"])([^'"]+)\1\s*\)\s*\}\}/g,
(match, quote, key) => {
const translation = translations[key];
return translation || match;
}
// 处理参数部分中的嵌套 $t() 调用
paramsPart = processNestedTranslations(
paramsPart,
translations,
/\{\{\s*[\$]t\s*\(\s*(['"])([^'"]+)\1\s*\)\s*\}\}/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);
}
@ -224,12 +251,11 @@ function replaceInterpolations(code, translations) {
* 替换 Vue 编译后的格式_ctx.$t('xxx') $t('xxx')支持带参数
*/
function replaceCompiledFormat(code, translations) {
// 处理带参数的调用_ctx.$t('key', params) 或 $t('key', params) 或 t.$t('key', params)
// 处理带参数的调用
const matches = [];
const pattern = /([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])([^'"]+)\2\s*,\s*/g;
let match;
// 收集所有带参数的调用
while ((match = pattern.exec(code)) !== null) {
const startIndex = match.index;
const ctxPrefix = match[1] || '';
@ -237,34 +263,10 @@ function replaceCompiledFormat(code, translations) {
const key = match[3];
const afterKeyIndex = match.index + match[0].length;
// 找到参数部分的结束位置(匹配平衡的括号)
let depth = 1;
let pos = afterKeyIndex;
let paramsEnd = -1;
const paramsEnd = findBalancedBracket(code, afterKeyIndex);
if (paramsEnd === -1) continue;
while (pos < code.length && depth > 0) {
const char = code[pos];
if (char === '(') depth++;
else if (char === ')') {
depth--;
if (depth === 0) {
paramsEnd = pos;
break;
}
}
pos++;
}
if (paramsEnd !== -1) {
matches.push({
startIndex,
ctxPrefix,
quote,
key,
afterKeyIndex,
paramsEnd
});
}
matches.push({ startIndex, ctxPrefix, quote, key, afterKeyIndex, paramsEnd });
}
// 从后往前替换
@ -272,13 +274,11 @@ function replaceCompiledFormat(code, translations) {
const m = matches[i];
let paramsPart = code.substring(m.afterKeyIndex, m.paramsEnd);
// 先处理参数部分中的 $t() 调用(递归处理嵌套的翻译)
paramsPart = paramsPart.replace(
/([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])([^'"]+)\2\)/g,
(match, ctxPrefix, quote, key) => {
const translation = translations[key];
return translation ? `${ctxPrefix || ''}$t(${quote}${escapeString(translation, quote)}${quote})` : match;
}
// 处理参数部分中的嵌套 $t() 调用
paramsPart = processNestedTranslations(
paramsPart,
translations,
/([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])([^'"]+)\2\)/g
);
const translation = translations[m.key];
@ -288,7 +288,7 @@ function replaceCompiledFormat(code, translations) {
}
}
// 处理不带参数的调用_ctx.$t('xxx') 或 $t('xxx') 或 t.$t('xxx')
// 处理不带参数的调用
code = code.replace(
/([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])([^'"]+)\2\)/g,
(match, ctxPrefix, quote, key) => {
@ -308,9 +308,7 @@ function replaceAttributeBindings(code, translations) {
/([:@]|v-bind:|v-on:)([a-zA-Z0-9_-]+)=(["'])[\$]?t\((['"])([^'"]+)\4\)\3/g,
(match, prefix, attrName, outerQuote, innerQuote, key) => {
const translation = translations[key];
if (!translation || !translation.trim()) {
return match;
}
if (!translation || !translation.trim()) return match;
let escaped = escapeString(translation, outerQuote);
if (outerQuote === '"') {
@ -336,8 +334,7 @@ function escapeString(text, quote) {
/**
* 解析 CSV 文件
* 格式key,translation,context
* 注意如果 key translation 包含逗号需要用引号括起来
* 但为了兼容性也支持没有引号的情况通过限制分割次数
* 支持包含逗号的键通过检测非ASCII字符识别翻译部分
*/
function parseCSV(csvPath) {
if (!fs.existsSync(csvPath)) {
@ -352,7 +349,7 @@ function parseCSV(csvPath) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
// 先尝试用引号解析(标准 CSV 格式
// 标准 CSV 解析(支持引号
const parts = [];
let current = '';
let inQuotes = false;
@ -363,7 +360,7 @@ function parseCSV(csvPath) {
// 处理转义的引号 ""
if (i + 1 < trimmed.length && trimmed[i + 1] === '"') {
current += '"';
i++; // 跳过下一个引号
i++;
} else {
inQuotes = !inQuotes;
}
@ -377,19 +374,17 @@ function parseCSV(csvPath) {
parts.push(current);
// 如果解析出的部分超过2个可能是键中包含逗号但没有引号
// 尝试从右边开始合并,找到 translation通常包含非 ASCII 字符)
// 通过检测非ASCII字符来识别翻译部分
if (parts.length > 2) {
// 从右边开始,找到第一个看起来像翻译的部分(包含非 ASCII 字符)
let translationIndex = -1;
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i] && /[^\x00-\x7F]/.test(parts[i])) {
if (parts[i] && CONSTANTS.NON_ASCII_REGEX.test(parts[i])) {
translationIndex = i;
break;
}
}
if (translationIndex > 0) {
// 合并 translationIndex 之前的所有部分作为 key
const key = parts.slice(0, translationIndex).join(',').trim();
const translation = parts[translationIndex].trim();
translations[key] = translation;
@ -402,11 +397,7 @@ function parseCSV(csvPath) {
const translation = parts[1].trim();
const context = parts[2] ? parts[2].trim() : '';
if (context) {
translations[`${key}:${context}`] = translation;
} else {
translations[key] = translation;
}
translations[context ? `${key}:${context}` : key] = translation;
}
}