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: '', 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) { if (id.endsWith('index.html')) { const lang = locale; return { code: code.replace(/]*)>/i, ``), 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) { // 处理 ,处理嵌套 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; } } // 处理