jcloud/dashboard/vite-plugin-translate.mjs

417 lines
11 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);
/**
* Vite 插件:构建时直接读取 CSV替换代码中的 $t('xxx') 为翻译文本
* @param {object} options - 插件选项
* @param {string} options.locale - 目标语言代码
*/
function vitePluginTranslate(options = {}) {
let translations = {};
let locale = options.locale || 'en';
return {
name: 'vite-plugin-translate',
enforce: 'pre',
configResolved(config) {
if (locale && locale !== 'en') {
const csvPath = path.resolve(__dirname, `../jcloud/translations/${locale}.csv`);
if (fs.existsSync(csvPath)) {
translations = parseCSV(csvPath);
}
}
},
transform(code, id) {
const fileMatches = /\.(vue|js|ts|jsx|tsx)(\?.*)?$/.test(id);
if (!fileMatches || locale === 'en' || Object.keys(translations).length === 0) {
return null;
}
let replaced = code;
const isVueFile = id.includes('.vue') && !id.includes('?vue&type=style');
if (isVueFile) {
const isTemplateModule = id.includes('?vue&type=template');
if (isTemplateModule) {
// Vue 编译后的模板模块
replaced = replaceInterpolations(replaced, translations);
replaced = replaceCompiledFormat(replaced, translations);
replaced = replaceAttributeBindings(replaced, translations);
} else {
// 原始 Vue SFC 文件
replaced = processVueSFC(replaced, translations);
}
} else {
// 非 Vue 文件
replaced = replaced.replace(
/\bt\((['"])([^'"]+)\1\)/g,
(match, quote, key) => {
const translation = translations[key];
return translation ? quote + escapeString(translation, quote) + quote : match;
}
);
}
return replaced !== code ? { code: replaced, 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('<template', pos);
const nextClose = code.indexOf('</template>', pos);
if (nextClose === -1) break;
if (nextOpen !== -1 && nextOpen < nextClose) {
depth++;
pos = nextOpen + 9;
} else {
depth--;
if (depth === 0) {
templateContent = code.substring(templateRegex.lastIndex, nextClose);
break;
}
pos = nextClose + 11;
}
}
if (depth === 0 && templateContent) {
let replacedContent = templateContent;
replacedContent = replaceInterpolations(replacedContent, translations);
replacedContent = replaceAttributeBindings(replacedContent, translations);
code = code.substring(0, startIndex) + openTag + replacedContent + '</template>' +
code.substring(startIndex + openTag.length + templateContent.length + 11);
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;
// 找到参数部分的结束位置(匹配平衡的括号)
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++;
}
// 找到整个 {{ $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++;
}
}
if (paramsEnd !== -1) {
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() 调用(递归处理嵌套的翻译)
paramsPart = paramsPart.replace(
/\{\{\s*[\$]t\s*\(\s*(['"])([^'"]+)\1\s*\)\s*\}\}/g,
(match, quote, key) => {
const translation = translations[key];
return translation || match;
}
);
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) {
// 先处理带参数的调用_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] || '';
const quote = match[2];
const key = match[3];
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++;
}
if (paramsEnd !== -1) {
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 = 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;
}
);
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);
}
}
// 处理不带参数的调用_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) => {
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
* 注意:如果 key 或 translation 包含逗号,需要用引号括起来
* 但为了兼容性,也支持没有引号的情况(通过限制分割次数)
*/
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个可能是键中包含逗号但没有引号
// 尝试从右边开始合并,找到 translation通常包含非 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])) {
translationIndex = i;
break;
}
}
if (translationIndex > 0) {
// 合并 translationIndex 之前的所有部分作为 key
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() : '';
if (context) {
translations[`${key}:${context}`] = translation;
} else {
translations[key] = translation;
}
}
}
return translations;
}
export default vitePluginTranslate;