diff --git a/dashboard/vite-plugin-translate.mjs b/dashboard/vite-plugin-translate.mjs index 3171d76..9ea751e 100644 --- a/dashboard/vite-plugin-translate.mjs +++ b/dashboard/vite-plugin-translate.mjs @@ -6,50 +6,111 @@ 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_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)) { - translations = parseCSV(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('', 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 + '' + - 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; } }