优化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 __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); 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') 为翻译文本 * Vite 插件构建时直接读取 CSV替换代码中的 $t('xxx') 为翻译文本
* @param {object} options - 插件选项 * @param {object} options - 插件选项
* @param {string} options.locale - 目标语言代码 * @param {string} options.locale - 目标语言代码
* @param {string} options.translationsPath - 翻译文件路径相对于插件目录
* @param {string} options.defaultLocale - 默认语言代码
*/ */
function vitePluginTranslate(options = {}) { function vitePluginTranslate(options = {}) {
let translations = {}; let translations = {};
let locale = options.locale || 'en'; const locale = options.locale || 'en';
const defaultLocale = options.defaultLocale || 'en';
const translationsPath = options.translationsPath || '../jcloud/translations';
return { return {
name: 'vite-plugin-translate', name: 'vite-plugin-translate',
enforce: 'pre', enforce: 'pre',
configResolved(config) { configResolved(config) {
if (locale && locale !== 'en') { if (locale && locale !== defaultLocale) {
const csvPath = path.resolve(__dirname, `../jcloud/translations/${locale}.csv`); const csvPath = path.resolve(__dirname, `${translationsPath}/${locale}.csv`);
if (fs.existsSync(csvPath)) { if (fs.existsSync(csvPath)) {
try {
translations = parseCSV(csvPath); translations = parseCSV(csvPath);
} catch (error) {
console.warn(`[vite-plugin-translate] Failed to load translations from ${csvPath}:`, error.message);
}
} }
} }
}, },
transform(code, id) { transform(code, id) {
const fileMatches = /\.(vue|js|ts|jsx|tsx)(\?.*)?$/.test(id); const fileMatches = CONSTANTS.FILE_EXTENSIONS.test(id);
if (!fileMatches || locale === 'en' || Object.keys(translations).length === 0) { if (!fileMatches || locale === defaultLocale || Object.keys(translations).length === 0) {
return null; return null;
} }
let replaced = code; const originalCode = code;
const isVueFile = id.includes('.vue') && !id.includes('?vue&type=style'); const isVueFile = id.includes('.vue') && !id.includes(CONSTANTS.VUE_STYLE_MODULE);
if (isVueFile) { if (isVueFile) {
const isTemplateModule = id.includes('?vue&type=template'); const isTemplateModule = id.includes(CONSTANTS.VUE_TEMPLATE_MODULE);
if (isTemplateModule) { if (isTemplateModule) {
// Vue 编译后的模板模块 // Vue 编译后的模板模块
replaced = replaceInterpolations(replaced, translations); code = replaceInterpolations(code, translations);
replaced = replaceCompiledFormat(replaced, translations); code = replaceCompiledFormat(code, translations);
replaced = replaceAttributeBindings(replaced, translations); code = replaceAttributeBindings(code, translations);
} else { } else {
// 原始 Vue SFC 文件 // 原始 Vue SFC 文件
replaced = processVueSFC(replaced, translations); code = processVueSFC(code, translations);
} }
} else { } else {
// 非 Vue 文件 // 非 Vue 文件
replaced = replaced.replace( code = code.replace(
/\bt\((['"])([^'"]+)\1\)/g, /\bt\((['"])([^'"]+)\1\)/g,
(match, quote, key) => { (match, quote, key) => {
const translation = translations[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 = ''; let templateContent = '';
while (depth > 0 && pos < code.length) { while (depth > 0 && pos < code.length) {
const nextOpen = code.indexOf('<template', pos); const nextOpen = code.indexOf(CONSTANTS.VUE_TEMPLATE_TAG, pos);
const nextClose = code.indexOf('</template>', pos); const nextClose = code.indexOf(CONSTANTS.VUE_TEMPLATE_CLOSE_TAG, pos);
if (nextClose === -1) break; if (nextClose === -1) break;
if (nextOpen !== -1 && nextOpen < nextClose) { if (nextOpen !== -1 && nextOpen < nextClose) {
depth++; depth++;
pos = nextOpen + 9; pos = nextOpen + CONSTANTS.VUE_TEMPLATE_TAG_LEN;
} else { } else {
depth--; depth--;
if (depth === 0) { if (depth === 0) {
templateContent = code.substring(templateRegex.lastIndex, nextClose); templateContent = code.substring(templateRegex.lastIndex, nextClose);
break; 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 = replaceInterpolations(replacedContent, translations);
replacedContent = replaceAttributeBindings(replacedContent, translations); replacedContent = replaceAttributeBindings(replacedContent, translations);
code = code.substring(0, startIndex) + openTag + replacedContent + '</template>' + code = code.substring(0, startIndex) + openTag + replacedContent + CONSTANTS.VUE_TEMPLATE_CLOSE_TAG +
code.substring(startIndex + openTag.length + templateContent.length + 11); code.substring(startIndex + openTag.length + templateContent.length + CONSTANTS.VUE_TEMPLATE_CLOSE_TAG_LEN);
break; break;
} }
} }
@ -139,60 +200,29 @@ function replaceInterpolations(code, translations) {
); );
// 处理带参数的调用:{{ $t('key', { params }) }} // 处理带参数的调用:{{ $t('key', { params }) }}
// 使用更健壮的方法匹配平衡的括号
const matches = []; const matches = [];
const pattern = /\{\{\s*[\$]t\s*\(\s*(['"])([^'"]+)\1\s*,\s*/g; const pattern = /\{\{\s*[\$]t\s*\(\s*(['"])([^'"]+)\1\s*,\s*/g;
let match; let match;
// 先收集所有匹配位置(从后往前处理,避免索引错乱)
while ((match = pattern.exec(code)) !== null) { while ((match = pattern.exec(code)) !== null) {
const startIndex = match.index; const startIndex = match.index;
const quote = match[1]; const quote = match[1];
const key = match[2]; const key = match[2];
const afterKeyIndex = match.index + match[0].length; const afterKeyIndex = match.index + match[0].length;
// 找到参数部分的结束位置(匹配平衡的括号) // 找到参数部分的结束位置
let depth = 1; // 已经有一个开括号 const paramsEnd = findBalancedBracket(code, afterKeyIndex);
let pos = afterKeyIndex; if (paramsEnd === -1) continue;
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++;
}
// 找到整个 {{ $t(...) }} 的结束位置 // 找到整个 {{ $t(...) }} 的结束位置
let fullEnd = paramsEnd + 1; let fullEnd = paramsEnd + 1;
// 跳过空格 while (fullEnd < code.length && code[fullEnd] === ' ') fullEnd++;
while (fullEnd < code.length && code[fullEnd] === ' ') {
fullEnd++;
}
// 跳过 }}
if (fullEnd < code.length && code[fullEnd] === '}') { if (fullEnd < code.length && code[fullEnd] === '}') {
fullEnd++; fullEnd++;
if (fullEnd < code.length && code[fullEnd] === '}') { if (fullEnd < code.length && code[fullEnd] === '}') 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]; const m = matches[i];
let paramsPart = code.substring(m.afterKeyIndex, m.paramsEnd); let paramsPart = code.substring(m.afterKeyIndex, m.paramsEnd);
// 先处理参数部分中的 $t() 调用(递归处理嵌套的翻译) // 处理参数部分中的嵌套 $t() 调用
paramsPart = paramsPart.replace( paramsPart = processNestedTranslations(
/\{\{\s*[\$]t\s*\(\s*(['"])([^'"]+)\1\s*\)\s*\}\}/g, paramsPart,
(match, quote, key) => { translations,
const translation = translations[key]; /\{\{\s*[\$]t\s*\(\s*(['"])([^'"]+)\1\s*\)\s*\}\}/g
return translation || match;
}
); );
const translation = translations[m.key]; const translation = translations[m.key];
if (translation) { if (translation) {
// 替换键为翻译文本,保留参数部分让运行时处理
const replacement = `{{ $t(${m.quote}${escapeString(translation, m.quote)}${m.quote}, ${paramsPart}) }}`; const replacement = `{{ $t(${m.quote}${escapeString(translation, m.quote)}${m.quote}, ${paramsPart}) }}`;
code = code.substring(0, m.startIndex) + replacement + code.substring(m.fullEnd); 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')支持带参数 * 替换 Vue 编译后的格式_ctx.$t('xxx') $t('xxx')支持带参数
*/ */
function replaceCompiledFormat(code, translations) { function replaceCompiledFormat(code, translations) {
// 处理带参数的调用_ctx.$t('key', params) 或 $t('key', params) 或 t.$t('key', params) // 处理带参数的调用
const matches = []; const matches = [];
const pattern = /([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])([^'"]+)\2\s*,\s*/g; const pattern = /([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])([^'"]+)\2\s*,\s*/g;
let match; let match;
// 收集所有带参数的调用
while ((match = pattern.exec(code)) !== null) { while ((match = pattern.exec(code)) !== null) {
const startIndex = match.index; const startIndex = match.index;
const ctxPrefix = match[1] || ''; const ctxPrefix = match[1] || '';
@ -237,34 +263,10 @@ function replaceCompiledFormat(code, translations) {
const key = match[3]; const key = match[3];
const afterKeyIndex = match.index + match[0].length; const afterKeyIndex = match.index + match[0].length;
// 找到参数部分的结束位置(匹配平衡的括号) const paramsEnd = findBalancedBracket(code, afterKeyIndex);
let depth = 1; if (paramsEnd === -1) continue;
let pos = afterKeyIndex;
let paramsEnd = -1;
while (pos < code.length && depth > 0) { matches.push({ startIndex, ctxPrefix, quote, key, afterKeyIndex, paramsEnd });
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
});
}
} }
// 从后往前替换 // 从后往前替换
@ -272,13 +274,11 @@ function replaceCompiledFormat(code, translations) {
const m = matches[i]; const m = matches[i];
let paramsPart = code.substring(m.afterKeyIndex, m.paramsEnd); let paramsPart = code.substring(m.afterKeyIndex, m.paramsEnd);
// 先处理参数部分中的 $t() 调用(递归处理嵌套的翻译) // 处理参数部分中的嵌套 $t() 调用
paramsPart = paramsPart.replace( paramsPart = processNestedTranslations(
/([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])([^'"]+)\2\)/g, paramsPart,
(match, ctxPrefix, quote, key) => { translations,
const translation = translations[key]; /([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])([^'"]+)\2\)/g
return translation ? `${ctxPrefix || ''}$t(${quote}${escapeString(translation, quote)}${quote})` : match;
}
); );
const translation = translations[m.key]; const translation = translations[m.key];
@ -288,7 +288,7 @@ function replaceCompiledFormat(code, translations) {
} }
} }
// 处理不带参数的调用_ctx.$t('xxx') 或 $t('xxx') 或 t.$t('xxx') // 处理不带参数的调用
code = code.replace( code = code.replace(
/([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])([^'"]+)\2\)/g, /([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])([^'"]+)\2\)/g,
(match, ctxPrefix, quote, key) => { (match, ctxPrefix, quote, key) => {
@ -308,9 +308,7 @@ function replaceAttributeBindings(code, translations) {
/([:@]|v-bind:|v-on:)([a-zA-Z0-9_-]+)=(["'])[\$]?t\((['"])([^'"]+)\4\)\3/g, /([:@]|v-bind:|v-on:)([a-zA-Z0-9_-]+)=(["'])[\$]?t\((['"])([^'"]+)\4\)\3/g,
(match, prefix, attrName, outerQuote, innerQuote, key) => { (match, prefix, attrName, outerQuote, innerQuote, key) => {
const translation = translations[key]; const translation = translations[key];
if (!translation || !translation.trim()) { if (!translation || !translation.trim()) return match;
return match;
}
let escaped = escapeString(translation, outerQuote); let escaped = escapeString(translation, outerQuote);
if (outerQuote === '"') { if (outerQuote === '"') {
@ -336,8 +334,7 @@ function escapeString(text, quote) {
/** /**
* 解析 CSV 文件 * 解析 CSV 文件
* 格式key,translation,context * 格式key,translation,context
* 注意如果 key translation 包含逗号需要用引号括起来 * 支持包含逗号的键通过检测非ASCII字符识别翻译部分
* 但为了兼容性也支持没有引号的情况通过限制分割次数
*/ */
function parseCSV(csvPath) { function parseCSV(csvPath) {
if (!fs.existsSync(csvPath)) { if (!fs.existsSync(csvPath)) {
@ -352,7 +349,7 @@ function parseCSV(csvPath) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue; if (!trimmed || trimmed.startsWith('#')) continue;
// 先尝试用引号解析(标准 CSV 格式 // 标准 CSV 解析(支持引号
const parts = []; const parts = [];
let current = ''; let current = '';
let inQuotes = false; let inQuotes = false;
@ -363,7 +360,7 @@ function parseCSV(csvPath) {
// 处理转义的引号 "" // 处理转义的引号 ""
if (i + 1 < trimmed.length && trimmed[i + 1] === '"') { if (i + 1 < trimmed.length && trimmed[i + 1] === '"') {
current += '"'; current += '"';
i++; // 跳过下一个引号 i++;
} else { } else {
inQuotes = !inQuotes; inQuotes = !inQuotes;
} }
@ -377,19 +374,17 @@ function parseCSV(csvPath) {
parts.push(current); parts.push(current);
// 如果解析出的部分超过2个可能是键中包含逗号但没有引号 // 如果解析出的部分超过2个可能是键中包含逗号但没有引号
// 尝试从右边开始合并,找到 translation通常包含非 ASCII 字符) // 通过检测非ASCII字符来识别翻译部分
if (parts.length > 2) { if (parts.length > 2) {
// 从右边开始,找到第一个看起来像翻译的部分(包含非 ASCII 字符)
let translationIndex = -1; let translationIndex = -1;
for (let i = parts.length - 1; i >= 0; i--) { 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; translationIndex = i;
break; break;
} }
} }
if (translationIndex > 0) { if (translationIndex > 0) {
// 合并 translationIndex 之前的所有部分作为 key
const key = parts.slice(0, translationIndex).join(',').trim(); const key = parts.slice(0, translationIndex).join(',').trim();
const translation = parts[translationIndex].trim(); const translation = parts[translationIndex].trim();
translations[key] = translation; translations[key] = translation;
@ -402,11 +397,7 @@ function parseCSV(csvPath) {
const translation = parts[1].trim(); const translation = parts[1].trim();
const context = parts[2] ? parts[2].trim() : ''; const context = parts[2] ? parts[2].trim() : '';
if (context) { translations[context ? `${key}:${context}` : key] = translation;
translations[`${key}:${context}`] = translation;
} else {
translations[key] = translation;
}
} }
} }