jcloud/dashboard/vite-plugin-translate.mjs

441 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_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, ...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 = captureGroups[2];
} else if (captureGroups.length === 2) {
// 格式2或3不带前缀的 (quote, key)
quote = captureGroups[0];
key = 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 = {}) {
let translations = {};
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 !== 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 = 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);
if (isTemplateModule) {
// Vue 编译后的模板模块
code = replaceInterpolations(code, translations);
code = replaceCompiledFormat(code, translations);
code = replaceAttributeBindings(code, translations);
} else {
// 原始 Vue SFC 文件
code = processVueSFC(code, translations);
}
} else {
// 非 Vue 文件
code = code.replace(
/\bt\((['"])([^'"]+)\1\)/g,
(match, quote, key) => {
const translation = translations[key];
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;
replacedContent = replaceInterpolations(replacedContent, translations);
replacedContent = replaceAttributeBindings(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) => {
const replacedContent = scriptContent.replace(
/\$t\((['"])([^'"]+)\1\)/g,
(match, quote, key) => {
const translation = translations[key];
return translation ? `$t(${quote}${escapeString(translation, quote)}${quote})` : 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\s*\)\s*\}\}/g,
(match, quote, key) => translations[key] || match
);
// 处理带参数的调用:{{ $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;
// 找到参数部分的结束位置
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\s*\)\s*\}\}/g
);
// 再处理 $t(...) 格式(参数对象中的直接调用)
paramsPart = processNestedTranslations(
paramsPart,
translations,
/\$t\((['"])([^'"]+)\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\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 = 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\)/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\)/g,
(match, ctxPrefix, quote, key) => {
const translation = translations[key];
return translation ? `${ctxPrefix || ''}$t(${quote}${escapeString(translation, quote)}${quote})` : match;
}
);
return code;
}
/**
* 替换属性绑定中的 $t('xxx')
*/
function replaceAttributeBindings(code, translations) {
return code.replace(
/([:@]|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;
let escaped = escapeString(translation, outerQuote);
if (outerQuote === '"') {
escaped = escaped.replace(/'/g, "\\'");
return `${prefix}${attrName}="'${escaped}'"`;
} else {
escaped = escaped.replace(/"/g, '\\"');
return `${prefix}${attrName}='"${escaped}"'`;
}
}
);
}
/**
* 转义字符串
*/
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 解析(支持引号)
const parts = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
if (char === '"') {
// 处理转义的引号 ""
if (i + 1 < trimmed.length && trimmed[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
parts.push(current);
current = '';
} 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;