mirror of https://github.com/allinssl/allinssl
417 lines
13 KiB
JavaScript
417 lines
13 KiB
JavaScript
import { CacheManager } from './cache/index.js'
|
||
import { FileOperation } from './fileOperation/index.js'
|
||
import { AIBatchAdapter } from './translation/adapter/aiBatchAdapter.js'
|
||
import { TranslationState } from './stateManagement/index.js'
|
||
import { Utils } from './utils/index.js'
|
||
import { UnusedTranslationDetector } from './cleanUp/unusedTranslationDetector.js'
|
||
import configFile from './config/config.js'
|
||
import path from 'path'
|
||
|
||
/**
|
||
* Vite i18n 自动翻译插件
|
||
* @param {Object} options - 插件配置
|
||
*/
|
||
export function vitePluginI18nAiTranslate(options = {}) {
|
||
const config = {
|
||
...configFile,
|
||
...options,
|
||
templateRegex: new RegExp(configFile.templateRegex, 'g'), // Convert string to RegExp
|
||
}
|
||
|
||
const cacheManager = new CacheManager(config.cachePath) // 缓存管理
|
||
const fileOperation = new FileOperation() // 文件操作
|
||
const translator = new AIBatchAdapter() // AI 批量翻译
|
||
const translationState = new TranslationState() // 翻译状态管理
|
||
const unusedDetector = new UnusedTranslationDetector(fileOperation, cacheManager) // 未使用翻译检测器
|
||
|
||
let watcher = null
|
||
let outputDirCreated = false // 跟踪输出目录是否已创建
|
||
let isProcessing = false // 跟踪是否正在进行批量处理
|
||
|
||
/**
|
||
* 处理文件并提取中文文本
|
||
* @param {string[]} files - 要处理的文件路径列表
|
||
*/
|
||
const processFiles = async (files) => {
|
||
// 如果已经在处理中,则跳过
|
||
if (isProcessing) {
|
||
console.log(`[i18n插件] 已有处理正在进行中,跳过本次请求`)
|
||
return
|
||
}
|
||
|
||
try {
|
||
// 设置处理标志
|
||
isProcessing = true
|
||
|
||
console.log(`[i18n插件] 开始处理 ${files.length} 个文件...`)
|
||
|
||
// 第一步:扫描所有文件并提取中文文本
|
||
for (const file of files) {
|
||
try {
|
||
const content = await fileOperation.readFile(file) // 读取文件内容
|
||
const chineseTexts = extractChineseTexts(content) // 提取中文文本
|
||
// console.log(`[i18n插件] 提取 ${chineseTexts} 个中文文本`)
|
||
translationState.recordFileProcessed(file, chineseTexts) // 记录处理的文件
|
||
} catch (error) {
|
||
console.error(`[i18n插件] 处理文件 ${file} 失败:`, error)
|
||
}
|
||
}
|
||
// 第二步:对比缓存,确定需要翻译的内容
|
||
await translateAndProcess()
|
||
} finally {
|
||
// 无论处理成功还是失败,都重置处理标志
|
||
isProcessing = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 翻译文本并处理结果
|
||
*/
|
||
const translateAndProcess = async () => {
|
||
// 如果没有需要翻译的文本,直接返回
|
||
const textsArray = Array.from(translationState.textsToTranslate)
|
||
|
||
// 获取缓存的翻译
|
||
const { cached, uncached } = await cacheManager.getCachedTranslations(textsArray, config.languages)
|
||
|
||
// 记录缓存命中情况
|
||
translationState.recordCacheHit(Object.keys(cached).length)
|
||
translationState.recordCacheMiss(uncached.length)
|
||
console.log(`[i18n插件] 缓存命中: ${Object.keys(cached).length} 个, 需要翻译: ${uncached.length} 个`)
|
||
|
||
// 所有翻译结果(包括缓存和新翻译)
|
||
let allTranslations = { ...cached }
|
||
|
||
// 如果有未缓存的内容,进行翻译
|
||
if (uncached.length > 0) {
|
||
const translations = await translateTexts(uncached)
|
||
// 更新缓存
|
||
await cacheManager.updateCache(uncached, translations, config.languages)
|
||
|
||
// 合并新翻译结果
|
||
translations.forEach((translation) => {
|
||
allTranslations[translation.text] = translation
|
||
})
|
||
|
||
// 记录新翻译的数量
|
||
translationState.recordTranslated(translations.length)
|
||
}
|
||
|
||
// 如果没有新的翻译内容或缓存,获取完整的缓存内容
|
||
if (!Object.keys(allTranslations).length) {
|
||
console.log(`[i18n插件] 没有新的翻译内容,使用完整缓存`)
|
||
const cacheEntries = Array.from(cacheManager.cache.entries())
|
||
cacheEntries.forEach(([text, data]) => {
|
||
allTranslations[text] = {
|
||
text,
|
||
key: data.key,
|
||
translations: data.translations,
|
||
}
|
||
})
|
||
}
|
||
|
||
// 合并历史缓存和当前批次翻译内容
|
||
const cacheEntries = Array.from(cacheManager.cache.entries())
|
||
cacheEntries.forEach(([text, data]) => {
|
||
if (!allTranslations[text]) {
|
||
allTranslations[text] = {
|
||
text,
|
||
key: data.key,
|
||
translations: data.translations,
|
||
}
|
||
}
|
||
})
|
||
|
||
// 第三步:为每个中文文本生成唯一的键名,并建立映射关系
|
||
for (const [text, translation] of Object.entries(allTranslations)) {
|
||
translationState.setTextToKeyMapping(text, translation.key)
|
||
}
|
||
|
||
// 第四步:一次性生成翻译文件(不再每次都检测目录)
|
||
await generateTranslationFiles(allTranslations)
|
||
|
||
// 第五步:替换源文件中的中文文本为翻译键名
|
||
await replaceSourceTexts()
|
||
|
||
// 完成并输出统计信息
|
||
translationState.complete()
|
||
outputStatistics()
|
||
}
|
||
|
||
/**
|
||
* 提取中文文本
|
||
* @param {string} content - 文件内容
|
||
* @returns {Set<string>} - 中文文本集合
|
||
*/
|
||
const extractChineseTexts = (content) => {
|
||
const texts = new Set()
|
||
// 重置正则表达式的lastIndex,确保从头开始匹配
|
||
config.templateRegex.lastIndex = 0
|
||
let match
|
||
while ((match = config.templateRegex.exec(content)) !== null) {
|
||
texts.add(match[1])
|
||
console.log(`[i18n插件] 提取中文文本: ${match[1]}`)
|
||
}
|
||
return texts
|
||
}
|
||
|
||
/**
|
||
* 翻译文本
|
||
* @param {string[]} texts - 待翻译的文本列表
|
||
* @returns {Promise<Object[]>} - 翻译结果列表
|
||
*/
|
||
const translateTexts = async (texts) => {
|
||
const results = []
|
||
const chunks = chunkArray(texts, config.concurrency)
|
||
|
||
console.log(`[i18n插件] 开始翻译 ${texts.length} 个文本,分为 ${chunks.length} 批处理`)
|
||
|
||
for (let i = 0; i < chunks.length; i++) {
|
||
const chunk = chunks[i]
|
||
console.log(`[i18n插件] 正在处理第 ${i + 1}/${chunks.length} 批 (${chunk.length} 个文本)`)
|
||
|
||
const promises = chunk.map((text, index) => {
|
||
return translator.translate(text, config.languages, config.maxRetries, index)
|
||
})
|
||
|
||
const chunkResults = await Promise.all(promises)
|
||
results.push(...chunkResults)
|
||
|
||
// 等待请求间隔
|
||
if (config.requestInterval > 0 && i < chunks.length - 1) {
|
||
await new Promise((resolve) => setTimeout(resolve, config.requestInterval))
|
||
}
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
/**
|
||
* 生成翻译文件
|
||
* @param {Object} translations - 翻译结果
|
||
*/
|
||
const generateTranslationFiles = async (translations) => {
|
||
// 确保输出目录存在(仅检查一次)
|
||
if (!outputDirCreated) {
|
||
await fileOperation.createDirectory(path.join(config.outputPath, 'model'))
|
||
outputDirCreated = true
|
||
}
|
||
|
||
console.log(`[i18n插件] 正在生成 ${config.languages.length} 个语言的翻译文件`)
|
||
|
||
// 构建每种语言的翻译结构
|
||
const languageTranslations = {}
|
||
|
||
// 初始化每种语言的翻译对象
|
||
for (const language of config.languages) {
|
||
languageTranslations[language] = {}
|
||
}
|
||
|
||
console.log(translations, Object.entries(translations).length)
|
||
// 构建翻译键值对
|
||
for (const [text, data] of Object.entries(translations)) {
|
||
// 生成翻译键名
|
||
const key = translationState.textToKeyMap.get(text) || Utils.renderTranslateName(text)
|
||
|
||
console.log(`[i18n插件] 生成翻译键名: ${key} -> ${text}`)
|
||
// 为每种语言添加翻译
|
||
for (const language of config.languages) {
|
||
languageTranslations[language][key] = data.translations[language]
|
||
}
|
||
}
|
||
// console.log(languageTranslations)
|
||
// 一次性写入每种语言的翻译文件
|
||
const writePromises = config.languages.map((language) =>
|
||
fileOperation.generateTranslationFile(
|
||
path.join(config.outputPath, 'model'),
|
||
languageTranslations[language],
|
||
language,
|
||
),
|
||
)
|
||
await Promise.all(writePromises)
|
||
console.log(`[i18n插件] 翻译文件生成完成`)
|
||
// 创建入口文件
|
||
await createI18nEntryFile()
|
||
}
|
||
|
||
/**
|
||
* 替换源文件中的中文文本为翻译键名
|
||
*/
|
||
const replaceSourceTexts = async () => {
|
||
// 获取所有需要更新的文件
|
||
const filesToUpdate = translationState.getFilesToUpdate()
|
||
|
||
console.log(`[i18n插件] 正在替换 ${filesToUpdate.size} 个文件中的中文文本`)
|
||
|
||
// 处理每个需要更新的文件
|
||
for (const [filePath, replacements] of filesToUpdate.entries()) {
|
||
try {
|
||
// 读取文件内容
|
||
let content = await fileOperation.readFile(filePath)
|
||
|
||
// 获取文件相对于项目的命名空间
|
||
// const namespace = Utils.getNamespace(filePath, config.projectPath);
|
||
|
||
// 替换每个中文文本为$t('键名')
|
||
for (const [text, baseKey] of replacements.entries()) {
|
||
// 在替换时为每个文件中的键添加命名空间前缀
|
||
// const key = namespace ? `${namespace}.${baseKey}` : baseKey;
|
||
// 创建正则表达式,匹配$t('中文文本')或$t("中文文本")
|
||
const regex = new RegExp(`\\$t\\(['"]${escapeRegExp(text)}['"]`, 'g')
|
||
content = content.replace(regex, `$t('${baseKey}'`)
|
||
}
|
||
|
||
// 写入更新后的文件内容
|
||
await fileOperation.modifyFile(filePath, content)
|
||
} catch (error) {
|
||
console.error(`[i18n插件] 替换文件 ${filePath} 内容失败:`, error)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建i18n入口文件
|
||
*/
|
||
const createI18nEntryFile = async () => {
|
||
try {
|
||
// 创建i18n入口文件内容
|
||
const entryFileContent = `// 自动生成的i18n入口文件
|
||
// 自动生成的i18n入口文件
|
||
import { useLocale } from '@baota/i18n'
|
||
import zhCN from './model/zhCN${config.createFileExt}'
|
||
import enUS from './model/enUS${config.createFileExt}'
|
||
|
||
// 使用 i18n 插件
|
||
export const { i18n, $t, locale, localeOptions } = useLocale(
|
||
{
|
||
messages: { zhCN, enUS },
|
||
locale: 'zhCN',
|
||
fileExt: 'json'
|
||
},
|
||
import.meta.glob([\`./model/*${config.createFileExt}\`], {
|
||
eager: false,
|
||
}),
|
||
)
|
||
|
||
`
|
||
|
||
// 写入i18n入口文件
|
||
const entryFilePath = path.join(config.outputPath, `index${config.createEntryFileExt}`)
|
||
await fileOperation.createFile(entryFilePath, entryFileContent)
|
||
console.log(`[i18n插件] 已创建i18n入口文件: ${entryFilePath}`)
|
||
} catch (error) {
|
||
console.error(`[i18n插件] 创建i18n入口文件失败:`, error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 输出翻译统计信息
|
||
*/
|
||
const outputStatistics = () => {
|
||
const summary = translationState.getSummary()
|
||
console.log('\n======= i18n翻译插件执行统计 =======')
|
||
console.log(`总耗时: ${summary.duration}`)
|
||
console.log(`处理文件数: ${summary.filesProcessed}`)
|
||
console.log(`包含中文文本的文件数: ${summary.filesWithChineseText}`)
|
||
console.log(`唯一中文文本数: ${summary.uniqueChineseTexts}`)
|
||
console.log(`命中缓存: ${summary.cacheHits} 条`)
|
||
console.log(`新翻译: ${summary.translatedTexts} 条`)
|
||
console.log(`缓存命中率: ${summary.cacheHitRate}`)
|
||
console.log('===================================\n')
|
||
}
|
||
|
||
/**
|
||
* 将数组分块
|
||
* @param {Array} array - 待分块的数组
|
||
* @param {number} size - 块大小
|
||
* @returns {Array[]} - 分块后的数组
|
||
*/
|
||
const chunkArray = (array, size) => {
|
||
const chunks = []
|
||
for (let i = 0; i < array.length; i += size) {
|
||
chunks.push(array.slice(i, i + size))
|
||
}
|
||
return chunks
|
||
}
|
||
|
||
/**
|
||
* 转义正则表达式特殊字符
|
||
* @param {string} string - 需要转义的字符串
|
||
* @returns {string} - 转义后的字符串
|
||
*/
|
||
const escapeRegExp = (string) => {
|
||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
}
|
||
|
||
/**
|
||
* 清理未使用的翻译
|
||
* @param {string[]} files - 要扫描的文件列表
|
||
* @returns {Promise<{removedCount: number}>} - 清理结果
|
||
*/
|
||
const cleanupUnusedTranslations = async (files) => {
|
||
if (isProcessing) {
|
||
console.log(`[i18n插件] 已有处理正在进行中,跳过未使用翻译清理`)
|
||
return { removedCount: 0 }
|
||
}
|
||
|
||
try {
|
||
isProcessing = true
|
||
// 执行未使用翻译检查和清理
|
||
const result = await unusedDetector.cleanUnusedTranslations(config, files)
|
||
return result
|
||
} finally {
|
||
isProcessing = false
|
||
}
|
||
}
|
||
|
||
return {
|
||
name: 'vite-plugin-i18n-ai-translate',
|
||
|
||
// 解析配置时的钩子
|
||
async configResolved() {
|
||
// 初始化缓存
|
||
await cacheManager.initCache()
|
||
|
||
// 确保输出目录存在(仅初始化一次)
|
||
await fileOperation.createDirectory(path.join(config.outputPath, 'model'))
|
||
outputDirCreated = true
|
||
},
|
||
|
||
// 配置服务器时的钩子
|
||
async configureServer(server) {
|
||
// 生成规则
|
||
const globFiles = config.fileExtensions.map((ext) => `**/*${ext}`)
|
||
|
||
// 获取所有文件
|
||
const files = await fileOperation.scanFiles(globFiles, config.projectPath)
|
||
|
||
// 批量处理所有文件
|
||
await processFiles(files)
|
||
|
||
// 设置文件监听
|
||
// watcher = server.watcher
|
||
// watcher.on('change', async (file) => {
|
||
// // 只有在未处理状态且文件扩展名匹配时才处理变更
|
||
// // 排除指定目录
|
||
// if (config.exclude.some((item) => file.includes(item))) return
|
||
// if (!isProcessing && config.fileExtensions.some((ext) => file.endsWith(ext))) {
|
||
// // console.log(`[i18n插件] 检测到文件变更: ${file}`);
|
||
// await processFiles([file])
|
||
// }
|
||
// })
|
||
},
|
||
|
||
// 关闭打包时的钩子
|
||
async closeBundle() {
|
||
if (watcher) {
|
||
watcher.close()
|
||
}
|
||
},
|
||
|
||
// 导出额外功能
|
||
cleanupUnusedTranslations,
|
||
}
|
||
}
|
||
|
||
export default vitePluginI18nAiTranslate
|