237 lines
6.1 KiB
JavaScript
237 lines
6.1 KiB
JavaScript
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;
|