769 lines
26 KiB
JavaScript
769 lines
26 KiB
JavaScript
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_SCRIPT_MODULE: '?vue&type=script',
|
||
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
|
||
*/
|
||
/**
|
||
* 查找平衡括号的结束位置,正确处理字符串字面量中的括号
|
||
* @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;
|
||
let inString = false;
|
||
let stringChar = null;
|
||
|
||
while (pos < code.length && depth > 0) {
|
||
const char = code[pos];
|
||
|
||
// 处理字符串字面量
|
||
if (!inString && (char === '"' || char === "'")) {
|
||
inString = true;
|
||
stringChar = char;
|
||
} else if (inString && char === stringChar) {
|
||
// 检查是否是转义的引号:计算连续的反斜杠数量
|
||
let backslashCount = 0;
|
||
let checkPos = pos - 1;
|
||
while (checkPos >= 0 && code[checkPos] === '\\') {
|
||
backslashCount++;
|
||
checkPos--;
|
||
}
|
||
// 如果反斜杠数量是偶数,则引号未转义,字符串结束
|
||
if (backslashCount % 2 === 0) {
|
||
inString = false;
|
||
stringChar = null;
|
||
}
|
||
}
|
||
|
||
// 只在非字符串中计算括号深度
|
||
if (!inString) {
|
||
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 = {}) {
|
||
const locale = options.locale || 'en';
|
||
const defaultLocale = options.defaultLocale || 'en';
|
||
const translationsPath = options.translationsPath || '../jcloud/translations';
|
||
|
||
// 在插件初始化时立即加载翻译数据,而不是等到 configResolved
|
||
let translations = {};
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
name: 'vite-plugin-translate',
|
||
enforce: 'pre',
|
||
configResolved(config) {
|
||
// 确保翻译数据已加载(双重检查)
|
||
if (locale && locale !== defaultLocale && Object.keys(translations).length === 0) {
|
||
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) {
|
||
// 处理index.html文件
|
||
if (id.endsWith('index.html')) {
|
||
// 根据locale更新html标签的lang属性
|
||
const lang = locale;
|
||
return {
|
||
code: code.replace(/<html[^>]*lang=["']([^"']*)["']/i, `<html$1lang="${lang}"`)
|
||
.replace(/<html([^>]*)(?![^>]*lang=["'])>/i, `<html$1 lang="${lang}">`),
|
||
map: null
|
||
};
|
||
}
|
||
|
||
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);
|
||
const isScriptModule = id.includes(CONSTANTS.VUE_SCRIPT_MODULE);
|
||
|
||
if (isTemplateModule) {
|
||
// Vue 编译后的模板模块
|
||
code = replaceInterpolations(code, translations);
|
||
code = replaceCompiledFormat(code, translations);
|
||
code = replaceAttributeBindings(code, translations);
|
||
} else if (isScriptModule) {
|
||
// Vue 编译后的 script 模块 - 处理 this.$t() 和 $t() 调用
|
||
// 注意:t() 函数调用已在原始 Vue SFC 文件中处理,编译后应该已经是翻译后的字符串
|
||
code = code.replace(
|
||
/(?:this\.)?\$t\((['"])((?:\\.|(?!\1).)*)\1\)/g,
|
||
(match, quote, key) => {
|
||
const unescapedKey = unescapeString(key);
|
||
const translation = translations[unescapedKey];
|
||
if (translation) {
|
||
return quote + escapeString(translation, quote) + quote;
|
||
}
|
||
return match;
|
||
}
|
||
);
|
||
} 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;
|
||
// 先处理属性绑定(包括带参数的),避免 replaceInterpolations 误处理
|
||
replacedContent = replaceAttributeBindings(replacedContent, translations);
|
||
// 处理属性绑定中的数组字面量,如 :items="[{ label: $t('key'), ... }]"
|
||
replacedContent = replaceAttributeArrayLiterals(replacedContent, translations);
|
||
// 处理属性绑定中的对象字面量,如 :options="{ title: $t('key'), ... }"
|
||
replacedContent = replaceAttributeObjectLiterals(replacedContent, translations);
|
||
// 最后处理插值表达式
|
||
replacedContent = replaceInterpolations(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) => {
|
||
// 先处理 $t() 和 this.$t() 调用
|
||
let replacedContent = scriptContent.replace(
|
||
/(?:this\.)?\$t\((['"])((?:\\.|(?!\1).)*)\1\)/g,
|
||
(match, quote, key) => {
|
||
const unescapedKey = unescapeString(key);
|
||
const translation = translations[unescapedKey];
|
||
if (translation) {
|
||
return quote + escapeString(translation, quote) + quote;
|
||
}
|
||
return match;
|
||
}
|
||
);
|
||
|
||
// 再处理 t() 函数调用(从 i18n.js 导入的翻译函数)
|
||
// 检查是否有从 i18n 导入 t 函数,或通过 useI18n composable 使用
|
||
const hasTImport = /import\s*\{[^}]*\bt\b[^}]*\}\s*from\s*['"]\.\.?\/.*i18n['"]/i.test(scriptContent) ||
|
||
/import\s*\{[^}]*\bt\b[^}]*\}\s*from\s*['"]@\/.*i18n['"]/i.test(scriptContent);
|
||
|
||
// 检查是否通过 useI18n composable 使用 t 函数
|
||
const hasUseI18n = /import\s*\{[^}]*useI18n[^}]*\}\s*from\s*['"]\.\.?\/.*useI18n['"]/i.test(scriptContent) ||
|
||
/import\s*\{[^}]*useI18n[^}]*\}\s*from\s*['"]@\/.*useI18n['"]/i.test(scriptContent) ||
|
||
/const\s*\{[^}]*\bt\b[^}]*\}\s*=\s*useI18n\(\)/i.test(scriptContent);
|
||
|
||
if (hasTImport || hasUseI18n) {
|
||
// 匹配 t('key') 或 t("key"),但排除 t.something() 或 t['something']()
|
||
replacedContent = replacedContent.replace(
|
||
/\bt\((['"])((?:\\.|(?!\1).)*)\1\)/g,
|
||
(match, quote, key) => {
|
||
const unescapedKey = unescapeString(key);
|
||
const translation = translations[unescapedKey];
|
||
if (translation) {
|
||
return quote + escapeString(translation, quote) + quote;
|
||
}
|
||
return 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') }}
|
||
// 注意:跳过属性绑定中的内容(它们会在 replaceAttributeBindings 中处理)
|
||
code = code.replace(
|
||
/\{\{[\s\S]*?\}\}/g,
|
||
(interpolation, offset, string) => {
|
||
// 检查这个插值是否在属性绑定中(前后有 = 和引号)
|
||
const beforeMatch = string.substring(Math.max(0, offset - 20), offset);
|
||
const afterMatch = string.substring(offset + interpolation.length, Math.min(string.length, offset + interpolation.length + 20));
|
||
if (/[=:]["']\s*$/.test(beforeMatch) || /^["']\s*[>\/\s]/.test(afterMatch)) {
|
||
// 在属性绑定中,跳过处理
|
||
return 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') 或 $t('xxx', { params })
|
||
*/
|
||
function replaceAttributeBindings(code, translations) {
|
||
// 先处理带参数的调用::label="$t('key', { params })"
|
||
const matches = [];
|
||
const pattern = /([:@]|v-bind:|v-on:)([a-zA-Z0-9_-]+)=(["'])[\$]?t\((['"])((?:\\.|(?!\4).)*)\4\s*,\s*/g;
|
||
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 innerQuote = match[4];
|
||
const key = unescapeString(match[5]);
|
||
const afterKeyIndex = match.index + match[0].length;
|
||
|
||
// 找到参数部分的结束位置(闭括号)
|
||
const paramsEnd = findBalancedBracket(code, afterKeyIndex);
|
||
if (paramsEnd === -1) continue;
|
||
|
||
// 找到整个属性绑定的结束位置(匹配外层的引号)
|
||
let attrEnd = paramsEnd + 1;
|
||
while (attrEnd < code.length && (code[attrEnd] === ' ' || code[attrEnd] === '\t' || code[attrEnd] === '\n')) attrEnd++;
|
||
if (attrEnd < code.length && code[attrEnd] === outerQuote) {
|
||
attrEnd++;
|
||
}
|
||
|
||
matches.push({ startIndex, prefix, attrName, outerQuote, innerQuote, key, afterKeyIndex, paramsEnd, attrEnd });
|
||
}
|
||
|
||
// 从后往前替换,避免索引错乱
|
||
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,
|
||
/\$t\((['"])((?:\\.|(?!\1).)*)\1\)/g
|
||
);
|
||
|
||
const translation = translations[m.key];
|
||
if (translation) {
|
||
// 转义翻译文本以适配内层引号
|
||
let escaped = escapeString(translation, m.innerQuote);
|
||
// 保留 $t() 调用结构,只替换翻译键
|
||
const replacement = `${m.prefix}${m.attrName}=${m.outerQuote}$t(${m.innerQuote}${escaped}${m.innerQuote}, ${paramsPart})${m.outerQuote}`;
|
||
code = code.substring(0, m.startIndex) + replacement + code.substring(m.attrEnd);
|
||
}
|
||
}
|
||
|
||
// 再处理不带参数的调用::label="$t('key')"
|
||
// 统一处理逻辑:保留 $t() 调用结构,只替换翻译键(与带参数的处理保持一致)
|
||
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;
|
||
|
||
// 转义翻译文本以适配内层引号(与带参数的处理保持一致)
|
||
const escaped = escapeString(translation, innerQuote);
|
||
// 保留 $t() 调用结构,只替换翻译键
|
||
return `${prefix}${attrName}=${outerQuote}$t(${innerQuote}${escaped}${innerQuote})${outerQuote}`;
|
||
}
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 在代码片段中替换所有 $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;
|