jcloud/dashboard/vite-plugin-translate.mjs

758 lines
25 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_SCRIPT_MODULE: '?vue&type=script',
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
*/
/**
* 查找平衡括号的结束位置,正确处理字符串字面量中的括号
* @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;
let inString = false;
let stringChar = null;
while (pos < code.length && depth > 0) {
const char = code[pos];
// 处理字符串字面量
if (!inString && (char === '"' || char === "'")) {
inString = true;
stringChar = char;
} else if (inString && char === stringChar) {
// 检查是否是转义的引号:计算连续的反斜杠数量
let backslashCount = 0;
let checkPos = pos - 1;
while (checkPos >= 0 && code[checkPos] === '\\') {
backslashCount++;
checkPos--;
}
// 如果反斜杠数量是偶数,则引号未转义,字符串结束
if (backslashCount % 2 === 0) {
inString = false;
stringChar = null;
}
}
// 只在非字符串中计算括号深度
if (!inString) {
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 = unescapeString(captureGroups[2]);
} else if (captureGroups.length === 2) {
// 格式2或3不带前缀的 (quote, key)
quote = captureGroups[0];
key = unescapeString(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 = {}) {
const locale = options.locale || 'en';
const defaultLocale = options.defaultLocale || 'en';
const translationsPath = options.translationsPath || '../jcloud/translations';
// 在插件初始化时立即加载翻译数据,而不是等到 configResolved
let translations = {};
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);
}
}
}
return {
name: 'vite-plugin-translate',
enforce: 'pre',
configResolved(config) {
// 确保翻译数据已加载(双重检查)
if (locale && locale !== defaultLocale && Object.keys(translations).length === 0) {
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);
const isScriptModule = id.includes(CONSTANTS.VUE_SCRIPT_MODULE);
if (isTemplateModule) {
// Vue 编译后的模板模块
code = replaceInterpolations(code, translations);
code = replaceCompiledFormat(code, translations);
code = replaceAttributeBindings(code, translations);
} else if (isScriptModule) {
// Vue 编译后的 script 模块 - 处理 this.$t() 和 $t() 调用
// 注意t() 函数调用已在原始 Vue SFC 文件中处理,编译后应该已经是翻译后的字符串
code = code.replace(
/(?:this\.)?\$t\((['"])((?:\\.|(?!\1).)*)\1\)/g,
(match, quote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
if (translation) {
return quote + escapeString(translation, quote) + quote;
}
return match;
}
);
} else {
// 原始 Vue SFC 文件
code = processVueSFC(code, translations);
}
} else {
// 非 Vue 文件
code = code.replace(
/\bt\((['"])((?:\\.|(?!\1).)*)\1\)/g,
(match, quote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
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;
// 先处理属性绑定(包括带参数的),避免 replaceInterpolations 误处理
replacedContent = replaceAttributeBindings(replacedContent, translations);
// 处理属性绑定中的数组字面量,如 :items="[{ label: $t('key'), ... }]"
replacedContent = replaceAttributeArrayLiterals(replacedContent, translations);
// 处理属性绑定中的对象字面量,如 :options="{ title: $t('key'), ... }"
replacedContent = replaceAttributeObjectLiterals(replacedContent, translations);
// 最后处理插值表达式
replacedContent = replaceInterpolations(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) => {
// 先处理 $t() 和 this.$t() 调用
let replacedContent = scriptContent.replace(
/(?:this\.)?\$t\((['"])((?:\\.|(?!\1).)*)\1\)/g,
(match, quote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
if (translation) {
return quote + escapeString(translation, quote) + quote;
}
return match;
}
);
// 再处理 t() 函数调用(从 i18n.js 导入的翻译函数)
// 检查是否有从 i18n 导入 t 函数,或通过 useI18n composable 使用
const hasTImport = /import\s*\{[^}]*\bt\b[^}]*\}\s*from\s*['"]\.\.?\/.*i18n['"]/i.test(scriptContent) ||
/import\s*\{[^}]*\bt\b[^}]*\}\s*from\s*['"]@\/.*i18n['"]/i.test(scriptContent);
// 检查是否通过 useI18n composable 使用 t 函数
const hasUseI18n = /import\s*\{[^}]*useI18n[^}]*\}\s*from\s*['"]\.\.?\/.*useI18n['"]/i.test(scriptContent) ||
/import\s*\{[^}]*useI18n[^}]*\}\s*from\s*['"]@\/.*useI18n['"]/i.test(scriptContent) ||
/const\s*\{[^}]*\bt\b[^}]*\}\s*=\s*useI18n\(\)/i.test(scriptContent);
if (hasTImport || hasUseI18n) {
// 匹配 t('key') 或 t("key"),但排除 t.something() 或 t['something']()
replacedContent = replacedContent.replace(
/\bt\((['"])((?:\\.|(?!\1).)*)\1\)/g,
(match, quote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
if (translation) {
return quote + escapeString(translation, quote) + quote;
}
return 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).)*)\1\s*\)\s*\}\}/g,
(match, quote, key) => {
const unescapedKey = unescapeString(key);
return translations[unescapedKey] || match;
}
);
// 处理插值表达式内部的 $t() 调用(不在单独的 {{ $t(...) }} 中)
// 例如:{{ condition ? $t('key1') : $t('key2') }}
// 注意:跳过属性绑定中的内容(它们会在 replaceAttributeBindings 中处理)
code = code.replace(
/\{\{[\s\S]*?\}\}/g,
(interpolation, offset, string) => {
// 检查这个插值是否在属性绑定中(前后有 = 和引号)
const beforeMatch = string.substring(Math.max(0, offset - 20), offset);
const afterMatch = string.substring(offset + interpolation.length, Math.min(string.length, offset + interpolation.length + 20));
if (/[=:]["']\s*$/.test(beforeMatch) || /^["']\s*[>\/\s]/.test(afterMatch)) {
// 在属性绑定中,跳过处理
return interpolation;
}
// 如果这个插值已经是一个完整的 {{ $t(...) }},跳过
if (/^\{\{\s*[\$]t\s*\(/.test(interpolation.trim())) {
return interpolation;
}
// 否则,处理其中的 $t() 调用
return interpolation.replace(
/\$t\((['"])((?:\\.|(?!\1).)*)\1\)/g,
(match, quote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
return translation ? `$t(${quote}${escapeString(translation, quote)}${quote})` : match;
}
);
}
);
// 处理带参数的调用:{{ $t('key', { params }) }}
const matches = [];
const pattern = /\{\{\s*[\$]t\s*\(\s*(['"])((?:\\.|(?!\1).)*)\1\s*,\s*/g;
let match;
while ((match = pattern.exec(code)) !== null) {
const startIndex = match.index;
const quote = match[1];
const key = unescapeString(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).)*)\1\s*\)\s*\}\}/g
);
// 再处理 $t(...) 格式(参数对象中的直接调用)
paramsPart = processNestedTranslations(
paramsPart,
translations,
/\$t\((['"])((?:\\.|(?!\1).)*)\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).)*)\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 = unescapeString(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).)*)\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).)*)\2\)/g,
(match, ctxPrefix, quote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
return translation ? `${ctxPrefix || ''}$t(${quote}${escapeString(translation, quote)}${quote})` : match;
}
);
return code;
}
/**
* 替换属性绑定中的 $t('xxx') 或 $t('xxx', { params })
*/
function replaceAttributeBindings(code, translations) {
// 先处理带参数的调用::label="$t('key', { params })"
const matches = [];
const pattern = /([:@]|v-bind:|v-on:)([a-zA-Z0-9_-]+)=(["'])[\$]?t\((['"])((?:\\.|(?!\4).)*)\4\s*,\s*/g;
let match;
while ((match = pattern.exec(code)) !== null) {
const startIndex = match.index;
const prefix = match[1];
const attrName = match[2];
const outerQuote = match[3];
const innerQuote = match[4];
const key = unescapeString(match[5]);
const afterKeyIndex = match.index + match[0].length;
// 找到参数部分的结束位置(闭括号)
const paramsEnd = findBalancedBracket(code, afterKeyIndex);
if (paramsEnd === -1) continue;
// 找到整个属性绑定的结束位置(匹配外层的引号)
let attrEnd = paramsEnd + 1;
while (attrEnd < code.length && (code[attrEnd] === ' ' || code[attrEnd] === '\t' || code[attrEnd] === '\n')) attrEnd++;
if (attrEnd < code.length && code[attrEnd] === outerQuote) {
attrEnd++;
}
matches.push({ startIndex, prefix, attrName, outerQuote, innerQuote, key, afterKeyIndex, paramsEnd, attrEnd });
}
// 从后往前替换,避免索引错乱
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,
/\$t\((['"])((?:\\.|(?!\1).)*)\1\)/g
);
const translation = translations[m.key];
if (translation) {
// 转义翻译文本以适配内层引号
let escaped = escapeString(translation, m.innerQuote);
// 保留 $t() 调用结构,只替换翻译键
const replacement = `${m.prefix}${m.attrName}=${m.outerQuote}$t(${m.innerQuote}${escaped}${m.innerQuote}, ${paramsPart})${m.outerQuote}`;
code = code.substring(0, m.startIndex) + replacement + code.substring(m.attrEnd);
}
}
// 再处理不带参数的调用::label="$t('key')"
// 统一处理逻辑:保留 $t() 调用结构,只替换翻译键(与带参数的处理保持一致)
return code.replace(
/([:@]|v-bind:|v-on:)([a-zA-Z0-9_-]+)=(["'])[\$]?t\((['"])((?:\\.|(?!\4).)*)\4\)\3/g,
(match, prefix, attrName, outerQuote, innerQuote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
if (!translation || !translation.trim()) return match;
// 转义翻译文本以适配内层引号(与带参数的处理保持一致)
const escaped = escapeString(translation, innerQuote);
// 保留 $t() 调用结构,只替换翻译键
return `${prefix}${attrName}=${outerQuote}$t(${innerQuote}${escaped}${innerQuote})${outerQuote}`;
}
);
}
/**
* 在代码片段中替换所有 $t() 调用为翻译文本
* @param {string} content - 要处理的代码片段
* @param {object} translations - 翻译字典
* @param {RegExp} pattern - 可选的匹配模式,默认匹配所有 $t() 调用
* @returns {string} 替换后的代码
*/
function replaceTranslationCalls(content, translations, pattern = /\$t\((['"])((?:\\.|(?!\1).)*)\1\)/g) {
return content.replace(pattern, (match, quote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
if (!translation || !translation.trim()) return match;
const escaped = escapeString(translation, quote);
return `${quote}${escaped}${quote}`;
});
}
/**
* 替换属性绑定中数组字面量内的 $t('xxx')
* 例如::items="[{ label: $t('key'), ... }]"
*/
function replaceAttributeArrayLiterals(code, translations) {
// 匹配属性绑定中的数组字面量,如 :items="[...]"
// 使用更宽松的匹配,支持单引号和双引号
return code.replace(
/([:@]|v-bind:|v-on:)([a-zA-Z0-9_-]+)=(["'])\[([\s\S]*?)\]\3/g,
(match, prefix, attrName, outerQuote, arrayContent) => {
const replacedContent = replaceTranslationCalls(arrayContent, translations);
return `${prefix}${attrName}=${outerQuote}[${replacedContent}]${outerQuote}`;
}
);
}
/**
* 替换属性绑定中对象字面量内的 $t('xxx')
* 例如::options="{ title: $t('key'), actions: [...] }"
*/
function replaceAttributeObjectLiterals(code, translations) {
// 匹配属性绑定中的对象字面量,使用平衡括号匹配以正确处理嵌套
const pattern = /([:@]|v-bind:|v-on:)([a-zA-Z0-9_-]+)=(["'])\{/g;
const matches = [];
let match;
while ((match = pattern.exec(code)) !== null) {
const startIndex = match.index;
const prefix = match[1];
const attrName = match[2];
const outerQuote = match[3];
const openBraceIndex = startIndex + match[0].length - 1; // 开括号的位置
const afterBraceIndex = openBraceIndex + 1; // 开括号之后的位置
// 找到匹配的闭合大括号
let depth = 1;
let pos = afterBraceIndex;
let braceEnd = -1;
while (pos < code.length && depth > 0) {
const char = code[pos];
// 跳过字符串字面量中的大括号
if (char === '"' || char === "'") {
pos++;
// 跳过转义的引号
while (pos < code.length && (code[pos] !== char || code[pos - 1] === '\\')) {
pos++;
}
pos++;
continue;
}
if (char === '{') depth++;
else if (char === '}') {
depth--;
if (depth === 0) {
braceEnd = pos;
break;
}
}
pos++;
}
if (braceEnd === -1) continue;
// 检查是否以相同的引号结束
if (braceEnd + 1 < code.length && code[braceEnd + 1] === outerQuote) {
const objectContent = code.substring(afterBraceIndex, braceEnd);
matches.push({ startIndex, prefix, attrName, outerQuote, afterBraceIndex, braceEnd, objectContent });
}
}
// 从后往前替换,避免索引错乱
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
// 匹配对象属性值中的 $t(),支持键名(标识符或字符串)
const replacedContent = m.objectContent.replace(
/(['"]?\w+['"]?\s*:\s*)\$t\((['"])((?:\\.|(?!\2).)*)\2\)/g,
(match, keyPrefix, quote, key) => {
const unescapedKey = unescapeString(key);
const translation = translations[unescapedKey];
if (!translation || !translation.trim()) return match;
const escaped = escapeString(translation, quote);
return `${keyPrefix}${quote}${escaped}${quote}`;
}
);
const replacement = `${m.prefix}${m.attrName}=${m.outerQuote}{${replacedContent}}${m.outerQuote}`;
code = code.substring(0, m.startIndex) + replacement + code.substring(m.braceEnd + 2);
}
return code;
}
/**
* 去除字符串中的转义字符(将 \' 转换为 '\" 转换为 " 等)
*/
function unescapeString(text) {
return text
.replace(/\\(.)/g, '$1');
}
/**
* 转义字符串
*/
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 解析(支持引号)
// 只有当字段以引号开始时,引号才作为 CSV 格式的引号处理
const parts = [];
let current = '';
let inQuotes = false;
let fieldStartedWithQuote = false;
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
if (char === '"') {
// 如果当前字段还没开始current 为空),且这是字段的第一个字符,标记为引号包裹的字段
if (current === '' && !inQuotes) {
fieldStartedWithQuote = true;
inQuotes = true;
// 不将开始的引号添加到 current
} else if (inQuotes && fieldStartedWithQuote) {
// 在引号包裹的字段中,处理转义的引号 "" 或结束引号
if (i + 1 < trimmed.length && trimmed[i + 1] === '"') {
current += '"';
i++;
} else {
// 结束引号,不添加到 current
inQuotes = false;
fieldStartedWithQuote = false;
}
} else {
// 不在引号包裹的字段中,引号是内容的一部分
current += char;
}
} else if (char === ',' && !inQuotes) {
parts.push(current);
current = '';
fieldStartedWithQuote = false;
} 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;