allinssl/frontend/packages/vue/vite/plugin/i18n.cjs

608 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

const { Plugin } = require("vite");
const { glob } = require("glob");
const fs = require("node:fs/promises");
const path = require("node:path");
/**
* 支持的语言类型列表
* 每种语言包含:
* - lang: 语言代码
* - content: 语言名称
*/
const SUPPORTED_LANGUAGES = [
{ lang: "zh", content: "中文" },
{ lang: "zh-CN", content: "中文-繁体" },
{ lang: "en", content: "English" },
{ lang: "fr", content: "Français" },
{ lang: "de", content: "Deutsch" },
{ lang: "es", content: "Español" },
{ lang: "it", content: "Italiano" },
{ lang: "ja", content: "日本語" },
];
/**
* 默认插件配置
*/
const defaultConfig = {
scanDirs: ["src"],
fileTypes: [".vue", ".tsx", ".jsx", ".ts", ".js"],
targetLanguages: ["en", "zh"],
outputDir: "src/locales",
enableInDev: true,
};
/**
* 扫描器类 - 负责扫描文件并提取需要翻译的文本
*/
class Scanner {
constructor(config) {
this.config = config;
}
/**
* 扫描所有匹配的文件
* @returns {Promise<Array>} 扫描结果数组
*/
async scanFiles() {
const results = [];
// 遍历所有需要扫描的目录
for (const dir of this.config.scanDirs) {
// 构建 glob 模式,统一使用正斜杠
const pattern = path.join(dir, "**/*").replace(/\\/g, "/");
// 使用 glob 获取所有匹配的文件
const files = await glob(pattern, {
ignore: ["**/node_modules/**"],
nodir: true,
absolute: true,
});
// 过滤出指定类型的文件
const matchedFiles = files.filter((file) => {
const ext = path.extname(file);
return this.config.fileTypes.includes(ext);
});
// 扫描每个匹配的文件
for (const file of matchedFiles) {
const result = await this.scanFile(file);
if (result.matches.length > 0) {
results.push(result);
}
}
}
return results;
}
/**
* 扫描单个文件
* @param {string} filePath - 文件路径
* @returns {Promise<Object>} 文件的扫描结果
*/
async scanFile(filePath) {
// 读取文件内容
const content = await fs.readFile(filePath, "utf-8");
const lines = content.split("\n");
const matches = [];
// 匹配 t("文本") 或 t('文本') 格式的国际化标记
const pattern = /t\(['"](.+?)['"]\)/g;
// 遍历每一行查找匹配
lines.forEach((line, index) => {
let match;
while ((match = pattern.exec(line)) !== null) {
matches.push({
content: match[1],
line: index + 1,
column: match.index,
});
}
});
return {
filePath,
matches,
};
}
}
/**
* 翻译器类
*/
class Translator {
constructor(config) {
this.config = config;
this.cache = new Map();
this.batchSize = 10; // 批量翻译大小
}
async translate(text, targetLang) {
const langCache = this.cache.get(text);
if (langCache?.has(targetLang)) {
return langCache.get(targetLang);
}
let translatedText;
if (this.config.customTranslator) {
translatedText = await this.config.customTranslator(text, targetLang);
} else {
const results = await this.translateWithGLM([text], targetLang);
translatedText = results[0];
}
if (!this.cache.has(text)) {
this.cache.set(text, new Map());
}
this.cache.get(text).set(targetLang, translatedText);
return translatedText;
}
async translateWithGLM(texts, targetLang) {
// 检查 API 密钥是否配置
if (!this.config.glmConfig?.apiKey) {
throw new Error("GLM API key is required for translation");
}
// 获取 API 端点,如果未配置则使用默认端点
const endpoint =
this.config.glmConfig.apiEndpoint ||
"https://open.bigmodel.cn/api/paas/v4/chat/completions";
try {
// 生成翻译提示词
const prompt = this.generateTranslationPrompt(texts, targetLang);
// 调用智谱 AI 的 API
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.glmConfig.apiKey}`,
},
body: JSON.stringify({
model: "glm-4-flash",
messages: [
{
role: "system",
content:
"你是一个专业的翻译助手,请将用户提供的文本准确翻译成目标语言。保持专业性和准确性,同时确保翻译后的文本通顺易懂。",
},
{
role: "user",
content: prompt,
},
],
temperature: 0.3,
top_p: 0.7,
response_format: { type: "json" },
}),
});
if (!response.ok) {
throw new Error(`Translation failed: ${response.statusText}`);
}
const result = await response.json();
const translatedContent = JSON.parse(result.choices[0].message.content);
return translatedContent.translations;
} catch (error) {
console.error("Translation error:", error);
throw error;
}
}
generateTranslationPrompt(texts, targetLang) {
return `请将以下文本翻译成${targetLang}语言。请以JSON格式返回格式为{"translations": ["翻译1", "翻译2", ...]}
源文本:
${texts.map((text, index) => `${index + 1}. ${text}`).join("\n")}
要求:
1. 保持专业性和准确性
2. 确保翻译后的文本通顺易懂
3. 严格按照JSON格式返回
4. 保持原文的语气和风格
5. 专业术语使用对应语言的标准翻译`;
}
async translateBatch(texts, targetLang) {
const results = [];
const uncachedTexts = [];
texts.forEach((text, index) => {
const langCache = this.cache.get(text);
if (langCache?.has(targetLang)) {
results[index] = {
text,
targetLang,
translatedText: langCache.get(targetLang),
};
} else {
uncachedTexts.push({ text, index });
}
});
if (uncachedTexts.length > 0) {
for (let i = 0; i < uncachedTexts.length; i += this.batchSize) {
const batch = uncachedTexts.slice(i, i + this.batchSize);
const batchTexts = batch.map((item) => item.text);
let translatedTexts;
if (this.config.customTranslator) {
translatedTexts = await Promise.all(
batchTexts.map((text) =>
this.config.customTranslator(text, targetLang),
),
);
} else {
translatedTexts = await this.translateWithGLM(batchTexts, targetLang);
}
batch.forEach((item, batchIndex) => {
const translatedText = translatedTexts[batchIndex];
if (!this.cache.has(item.text)) {
this.cache.set(item.text, new Map());
}
this.cache.get(item.text).set(targetLang, translatedText);
results[item.index] = {
text: item.text,
targetLang,
translatedText,
};
});
}
}
return results;
}
}
/**
* 翻译队列类 - 管理翻译任务的队列
*/
class TranslationQueue {
constructor() {
this.queue = new Map();
this.processed = new Set();
}
add(item) {
const key = this.generateKey(item);
if (!this.queue.has(key)) {
this.queue.set(key, item);
}
}
getUnprocessed() {
return Array.from(this.queue.values()).filter(
(item) => !this.processed.has(this.generateKey(item)),
);
}
markAsProcessed(item) {
this.processed.add(this.generateKey(item));
}
exists(item) {
return this.queue.has(this.generateKey(item));
}
getAll() {
return Array.from(this.queue.values());
}
clear() {
this.queue.clear();
this.processed.clear();
}
generateKey(item) {
return `${item.path}:${item.content}`;
}
generateI18nId(item) {
const dir = path.dirname(item.path);
const components = dir.split(path.sep).filter(Boolean);
const lastComponent = components[components.length - 1] || "root";
const queueArray = Array.from(this.queue.values());
const index = queueArray.findIndex((i) => i.path === item.path);
return `${lastComponent}.${index >= 0 ? index : 0}`;
}
}
/**
* 文件管理器类 - 负责生成和管理国际化文件
*/
class FileManager {
constructor(config) {
this.config = config;
this.contentCache = {};
}
async generateI18nFiles(translations) {
const i18nContent = {};
for (const item of translations) {
i18nContent[item.i18n] = item.lang;
}
await this.loadExistingContent();
Object.assign(this.contentCache, i18nContent);
for (const lang of this.config.targetLanguages) {
const langContent = {};
for (const [key, translations] of Object.entries(this.contentCache)) {
if (translations[lang]) {
langContent[key] = translations[lang];
}
}
await this.writeLanguageFile(lang, langContent);
}
await this.generateTypeDefinition();
}
async loadExistingContent() {
for (const lang of this.config.targetLanguages) {
const filePath = path.join(this.config.outputDir, `${lang}.json`);
try {
const content = await fs.readFile(filePath, "utf-8");
const langContent = JSON.parse(content);
for (const [key, value] of Object.entries(langContent)) {
if (!this.contentCache[key]) {
this.contentCache[key] = {};
}
this.contentCache[key][lang] = value;
}
} catch (error) {
continue;
}
}
}
async writeLanguageFile(lang, content) {
const outputDir = this.config.outputDir;
await fs.mkdir(outputDir, { recursive: true });
const filePath = path.join(outputDir, `${lang}.json`);
await fs.writeFile(filePath, JSON.stringify(content, null, 2));
}
async generateTypeDefinition() {
const keys = Object.keys(this.contentCache);
const typeContent = `// This file is auto-generated. DO NOT EDIT.
export type I18nKey = ${keys.map((key) => `'${key}'`).join(" | ")}
export interface I18nMessages {
[key in I18nKey]: string
}
`;
const typePath = path.join(this.config.outputDir, "i18n-types.ts");
await fs.writeFile(typePath, typeContent);
}
async fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
}
function validateConfig(options) {
const config = { ...defaultConfig, ...options };
if (!Array.isArray(config.scanDirs) || config.scanDirs.length === 0) {
throw new Error("scanDirs must be a non-empty array");
}
if (!Array.isArray(config.fileTypes) || config.fileTypes.length === 0) {
throw new Error("fileTypes must be a non-empty array");
}
if (
!Array.isArray(config.targetLanguages) ||
config.targetLanguages.length === 0
) {
throw new Error("targetLanguages must be a non-empty array");
}
if (!config.outputDir) {
throw new Error("outputDir is required");
}
return config;
}
/**
* 直接处理国际化翻译
* @param {Object} options - 国际化插件配置
* @returns {Promise<void>}
*/
async function processI18n(options = {}) {
const config = validateConfig(options);
const scanner = new Scanner(config);
const translator = new Translator(config);
const queue = new TranslationQueue();
const fileManager = new FileManager(config);
console.log("Starting i18n scanning...");
try {
const scanResults = await scanner.scanFiles();
for (const result of scanResults) {
for (const match of result.matches) {
queue.add({
path: result.filePath,
i18n: queue.generateI18nId({
path: result.filePath,
content: match.content,
i18n: "",
lang: {},
}),
content: match.content,
lang: {},
});
}
}
const unprocessed = queue.getUnprocessed();
const total = unprocessed.length * config.targetLanguages.length;
let processed = 0;
for (const item of unprocessed) {
for (const targetLang of config.targetLanguages) {
const translatedText = await translator.translate(
item.content,
targetLang,
);
item.lang[targetLang] = translatedText;
processed++;
console.log(
`Processing translations: ${processed}/${total} (${Math.round((processed / total) * 100)}%)`,
);
}
queue.markAsProcessed(item);
}
await fileManager.generateI18nFiles(queue.getAll());
console.log("I18n processing completed successfully");
} catch (error) {
console.error("Error in i18n processing:", error);
throw error;
}
}
/**
* Vite 插件主函数
* @param {Object} options - 插件配置选项
* @returns {Object} Vite 插件对象
*/
function viteI18nPlugin(options = {}) {
const config = validateConfig(options);
const scanner = new Scanner(config);
const translator = new Translator(config);
const queue = new TranslationQueue();
const fileManager = new FileManager(config);
return {
name: "vite-plugin-i18n-auto",
async configResolved() {
console.log("Starting i18n scanning...");
try {
const scanResults = await scanner.scanFiles();
for (const result of scanResults) {
for (const match of result.matches) {
queue.add({
path: result.filePath,
i18n: queue.generateI18nId({
path: result.filePath,
content: match.content,
i18n: "",
lang: {},
}),
content: match.content,
lang: {},
});
}
}
const unprocessed = queue.getUnprocessed();
for (const item of unprocessed) {
for (const targetLang of config.targetLanguages) {
const translatedText = await translator.translate(
item.content,
targetLang,
);
item.lang[targetLang] = translatedText;
}
queue.markAsProcessed(item);
}
await fileManager.generateI18nFiles(queue.getAll());
console.log("I18n processing completed successfully");
} catch (error) {
console.error("Error in i18n plugin:", error);
}
},
configureServer(server) {
if (!config.enableInDev) return;
console.log("I18n plugin server configured");
server.watcher.on("change", async (filePath) => {
const ext = filePath.split(".").pop();
if (!ext || !config.fileTypes.includes(`.${ext}`)) return;
try {
const scanResult = await scanner.scanFile(filePath);
let hasChanges = false;
for (const match of scanResult.matches) {
const item = {
path: filePath,
i18n: queue.generateI18nId({
path: filePath,
content: match.content,
i18n: "",
lang: {},
}),
content: match.content,
lang: {},
};
if (!queue.exists(item)) {
hasChanges = true;
queue.add(item);
for (const targetLang of config.targetLanguages) {
const translatedText = await translator.translate(
item.content,
targetLang,
);
item.lang[targetLang] = translatedText;
}
queue.markAsProcessed(item);
}
}
if (hasChanges) {
await fileManager.generateI18nFiles(queue.getAll());
console.log(`Updated i18n files for changes in ${filePath}`);
}
} catch (error) {
console.error(`Error processing file ${filePath}:`, error);
}
});
},
};
}
module.exports = {
SUPPORTED_LANGUAGES,
Scanner,
Translator,
TranslationQueue,
FileManager,
processI18n,
viteI18nPlugin,
};