jcloud/dashboard/vite-plugin-translate.mjs

237 lines
6.1 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') }}
*/
function replaceInterpolations(code, translations) {
return code.replace(
/\{\{\s*[\$]t\s*\(\s*(['"])([^'"]+)\1\s*\)\s*\}\}/g,
(match, quote, key) => translations[key] || match
);
}
/**
* 替换 Vue 编译后的格式_ctx.$t('xxx') 或 $t('xxx')
*/
function replaceCompiledFormat(code, translations) {
return code.replace(
/(_ctx\.)?[\$]t\((['"])([^'"]+)\2\)/g,
(match, ctxPrefix, quote, key) => {
const translation = translations[key];
return translation ? quote + escapeString(translation, quote) + quote : match;
}
);
}
/**
* 替换属性绑定中的 $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 文件
*/
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;
const parts = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
parts.push(current);
current = '';
} else {
current += char;
}
}
parts.push(current);
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;