voerka-i18n/packages/cli/extract.plugin.js
2023-01-29 21:58:53 +08:00

476 lines
19 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.

/**
*
* Gulp插件用来提取指定文件夹中的翻译文本并输出到指定目录
*
* 本插件需要配合gulp.src(...)使用
*
*/
const through2 = require('through2')
const deepmerge = require("deepmerge")
const path = require('path')
const fs = require('fs-extra')
const createLogger = require("logsets")
const { t } = require("./i18nProxy")
const logger = createLogger()
// 捕获翻译文本正则表达式一: 能匹配完整的t(xx,...)函数调用如果t函数调用不完整则不能匹配到
// 但是当t(xxx,...复杂的表达式时....)则不能正确匹配到,因此放弃该正则表达式
// const DefaultTranslateExtractor = String.raw`\b{funcName}\(\s*("|'){1}(?:((?<namespace>\w+)::))?(?<text>.*?)(((\1\s*\)){1})|((\1){1}\s*(,(\w|\d|(?:\{.*\})|(?:\[.*\])|([\"\'\(].*[\"\'\)]))*)*\s*\)))`
// 捕获翻译文本正则表达式二: 能够支持复杂的表达式但是当提供不完整的t函数定义时也会进行匹配提取
const DefaultTranslateExtractor = String.raw`\bt\(\s*("|'){1}(?:((?<namespace>\w+)::))?(?<text>.*?)(?=(\1\s*\))|(\1\s*\,))`
// 从html文件标签中提取翻译文本
const DefaultHtmlAttrExtractor = String.raw`\<(?<tagName>\w+)(.*?)(?<i18nKey>{attrName}\s*\=\s*)([\"\'']{1})(?<text>.*?)(\4){1}\s*(.*?)(\>|\/\>)`
// 声明各种语言的注释匹配正则表达式
// {"js,jsx"}
const commentRegexs ={
"js,vue,jsx,ts":[
/(^[^\n\r\w\W]*\/\/.*$)|(\/\/.*$)/gm, // 单行注释
/\/\*\s*[\W\w|\r|\n|*]*\s*\*\//gm // 多行注释
],
html:[
/\<\!--[\s\r\n-]*[\w\r\n-\W]*?[\s\r\n-]*--\>/gm, // 注释
]
}
/**
* 匹配文件中的注释部分
*/
function removeComments(content,filetype="js"){
Object.entries(commentRegexs).forEach(([filetype,regexps])=>{
if(filetype.split(",").includes(filetype)){
regexps.forEach(regex=>{
content = content.replaceAll(regex,"")
})
}
})
return content
}
/**
*
* 返回filePath是否在nsPaths名称空间内
*
* inNamespace("a/b/c/xxx.js","a/b") == true
* inNamespace("a/c/c/xxx.js","a/b") == false
*
* @param {*} filePath 文件路径
* @param {*} nsPath 名称空间的路径
* @returns
*/
function inNamespace(filePath,nsPath){
return !path.relative(nsPath,filePath).startsWith("..")
}
// 获取指定文件的名称空间
/**
*
* @param {*} file
* @param {*} options.namespaces 名称空间配置 {<name>:[path,...,path],<name>:path,<name>:(file)=>{}}
*/
function getFileNamespace(file,options){
const {output, namespaces } = options
const fileRefPath = file.relative.toLowerCase() // 当前文件相对源文件夹的路径
for(let [name,paths] of Object.entries(options.namespaces)){
for(let nsPath of paths){
if(typeof(nsPath) === "string" && inNamespace(fileRefPath,nsPath)){
return name
}else if(typeof nsPath === "function" && nsPath(file)===true){
return name
}
}
}
return "default"
}
/**
* 使用正则表达式提取翻译文本
* @param {*} content
* @param {*} file
* @param {*} options
* @returns {namespace:{text:{zh:"",en:"",...,$file:""},text:{zh:"",en:"",...,$file:""}}
*/
function extractTranslateTextUseRegexp(content,namespace,extractor,file,options){
let { languages,defaultLanguage } = options
// 移除代码中的注释,以便正则表达式提取翻译文本时排除注释部分
const fileExtName = file.extname.substr(1).toLowerCase() // 文件扩展名
content = removeComments(content,fileExtName)
let result
let texts = {}
while ((result = extractor.exec(content)) !== null) {
// 这对于避免零宽度匹配的无限循环是必要的
if (result.index === extractor.lastIndex) {
extractor.lastIndex++;
}
const text = result.groups.text
if(text){
const ns = result.groups.namespace || namespace
if(!(ns in texts)){
texts[ns] = {}
}
texts[ns][text] ={}
languages.forEach(language=>{
if(language.name !== defaultLanguage){
texts[ns][text][language.name] = text
}
})
texts[ns][text]["$file"]=[file.relative]
}
}
return texts
}
/**
* 使用函数提取器
* @param {*} content
* @param {*} namespace
* @param {*} extractor 函数提取器(content,file,options)
* @param {*} file
* @param {*} options
* @returns {}
*/
function extractTranslateTextUseFunction(content,namespace,extractor,file,options){
let texts = extractor(content,file,options)
if(typeof(texts)==="object"){
return texts
}else{
return {}
}
}
/**
*
* 返回指定文件类型的提取器
* @param {*} filetype 文件扩展名
* @param {*} extractor 提取器配置={default:[],js:[],html:[],"sass,css":[],json:[],"*":[]}"}
*/
function getFileTypeExtractors(filetype,extractor){
if(!typeof(extractor)=="object") return null
let matchers=[]
for(let [key,value] of Object.entries(extractor)){
if(filetype.toLowerCase()===key.toLowerCase()){
matchers = value
break
}else if(key.split(",").includes(filetype)){
matchers = value
break
}
}
// * 适用于所有文件类型
if("*" in extractor){
matchers = matchers.concat(extractor["*"])
}
// 如果没有指定提取器,则使用默认提取器
if(matchers.length===0){
matchers = extractor["default"]
}
return matchers
}
/**
* 找出要翻译的文本列表 {namespace:[text,text],...}
* {namespace:{text:{zh:"",en:"",$source:""},...}
* @param {*} content
* @param {*} extractor
* @returns
*/
function getTranslateTexts(content,file,options){
let { extractor: extractorOptions,languages,defaultLanguage,activeLanguage,debug } = options
if(!options || Object.keys(extractorOptions).length===0) return
// 获取当前文件的名称空间
const namespace = getFileNamespace(file,options)
const fileExtName = file.extname.substr(1).toLowerCase() // 文件扩展名
let texts = {}
// 提取器
let useExtractors = getFileTypeExtractors(fileExtName,extractorOptions)
// 分别执行所有提取器并合并提取结果
return useExtractors.reduce((preTexts,extractor)=>{
let matchedTexts = {} , extractFunc = ()=>{}
if(extractor instanceof RegExp){
extractFunc = extractTranslateTextUseRegexp
}else if(typeof(extractor) === "function"){
extractFunc = extractTranslateTextUseFunction
}else{
return preTexts
}
try{
matchedTexts = extractFunc(content,namespace,extractor,file,options)
}catch(e){
console.error(`Extract translate text has occur error from ${file.relative}:${extractor.toString()}.`,e)
}
return deepmerge(preTexts,matchedTexts)
},texts)
}
const defaultExtractLanguages = [
{name:'en',title:"英文"},
{name:'zh',title:"中文",default:true},
{name:'de',title:"德语"},
{name:'fr',title:"法语"},
{name:'es',title:"西班牙语"},
{name:'it',title:"意大利语"},
{name:'jp',title:"日語"}
]
function normalizeLanguageOptions(options){
options = Object.assign({
debug : true, // 输出调试信息,控制台输出相关的信息
languages :defaultExtractLanguages, // 默认要支持的语言
defaultLanguage: "zh", // 默认语言:指的是在源代码中的原始文本语言
activeLanguage : "zh", // 当前激活语言:指的是当前启用的语言,比如在源码中使用中文,在默认激活的是英文
extractor : { // 匹配翻译函数并提取内容的正则表达式
"*" : DefaultTranslateExtractor,
"html,vue,jsx" : DefaultHtmlAttrExtractor
},
namespaces : {}, // 命名空间, {[name]: [path,...,path]}
output : {
path : null, // 输出目录,如果没有指定则输出到原目录/languages
// 输出文本时默认采用合并更新方式,当重新扫描时输出时可以用来保留已翻译的内容
// 0 - overwrite 覆盖模式,可能导致翻译了一半的原始内容丢失(不推荐)
// 1 - merge 合并,尽可能保留原来已翻译的内容
// 2 - sync 同步, 在合并基础上,如果文本已经被删除,则同步移除原来的内容
updateMode : 'sync',
},
// 以下变量会被用来传递给提取器正则表达式
translation : {
funcName : "t", // 翻译函数名称
attrName :"data-i18n", // 用在html组件上的翻译属性名称
}
},options)
// 输出配置
if(typeof(options.output)==="string"){
options.output = {path:options.output,updateMode: 'sync'}
}else{
options.output = Object.assign({},{updateMode: 'sync',path:null},options.output)
}
// 语言配置 languages = [{name:"en",title:"英文"},{name:"zh",title:"中文",active:true,default:true}]
if(!Array.isArray(options.languages)){
throw new TypeError("options.languages must be an array")
}else{
if(options.languages.length === 0) throw new TypeError("options.languages'length must be greater than 0")
let defaultLanguage = options.defaultLanguage
let activeLanguage = options.activeLanguage
options.languages = options.languages.map(item=>{
let language = item
if(typeof item === "string"){
return {name:item,title:item}
}else if(typeof item === "object"){
return Object.assign({name:"",title:""},item)
}
if(typeof(item.title)==="string" && item.title.trim().length===0){
item.title = item.name
}
// 默认语言
if(item.default===true && item.name){
defaultLanguage = item.name
}
// 激活语言
if(item.active ===true && item.name){
activeLanguage = item.name
}
return item
})
if(!defaultLanguage) defaultLanguage = options.languages[0].name
options.defaultLanguage = defaultLanguage
options.activeLanguage = activeLanguage
}
// 提取正则表达式匹配
if(typeof(options.extractor)==="string") options.extractor = new RegExp(options.extractor,"gm")
if(options.extractor instanceof RegExp){
options.extractor = {default: [options.extractor] } // 默认文件类型的匹配器
}
// extractor = {default:[regexp,regexp,...],js:[regexp,regexp,...],json:[regexp,regexp,...],"jsx,ts":[regexp,regexp,...],"*":[regexp,regexp,...],...}"}
// 提取器可以是:正则表达式字符串、正则表达式或者是函数
if(typeof(options.extractor)==="object"){
if(Object.keys(options.extractor).length === 0){
throw new TypeError("options.extractor must be an object with at least one key")
}
Object.entries(options.extractor).forEach(([filetype,value])=>{
if(!Array.isArray(value)) value = [value]
value= value.map(item=>{
if(typeof(item)==="string"){ // 如果是字符串,则支持插值变量后,转换为正则表达式
return new RegExp(item.params(options.translation),"gm")
}else if(item instanceof RegExp){
return item
}else if(typeof(item)==="function"){
return item
}
})
options.extractor[filetype] = value
})
if(("*" in options.extractor) && options.extractor["*"].length===0) options.extractor["*"] = []
if(("default" in options.extractor) && options.extractor["default"].length===0) options.extractor["default"] = [DefaultTranslateExtractor]
}else{
options.extractor= {default:[ DefaultTranslateExtractor ]}
}
// 名称空间
if(typeof(options.namespaces)!=="object"){
throw new TypeError("options.namespaces must be an object")
}else{
Object.entries(options.namespaces).forEach(([name,paths])=>{
if(!Array.isArray(paths)) paths = [paths]
if(typeof(name)==="string" && name.trim().length>0){
options.namespaces[name] = paths
}
})
}
return options
}
/**
合并更新语言文件
当使用extract提取到待翻译内容并保存到languages目标文件夹后
翻译人员就可以对该文件夹内容进行翻译
接下来如果源码更新后重新进行扫描extract并重新生成语言文件
此时,需要将重新扫描后的文件合并到已经翻译了一半的内容,以保证翻译的内容不会丢失
*/
function updateLanguageFile(newTexts,toLangFile,options){
const { output:{ updateMode } } = options
// 默认的overwrite
if(!["merge","sync"].includes(updateMode)){
fs.writeFileSync(toLangFile,JSON.stringify(oldTexts,null,4))
return
}
let oldTexts = {}
// 读取原始翻译文件
try{
oldTexts =JSON.parse(fs.readFileSync(toLangFile))
}catch(e){
logger.log("Error while read language file <{}>: {}",toLangFile,e.message)
// 如果读取出错可能是语言文件不是有效的json文件则备份一下
}
// 同步模式下,如果原始文本在新扫描的内容中,则需要删除
if(updateMode==="sync"){
Object.keys(oldTexts).forEach((text)=>{
if(!(text in newTexts)){
delete oldTexts[text]
}
})
}
Object.entries(newTexts).forEach(([text,sourceLangs])=>{
if(text in oldTexts){ // 合并
let targetLangs = oldTexts[text] //{zh:'',en:''}
Object.entries(sourceLangs).forEach(([langName,sourceText])=>{
if(langName.startsWith("$")) return // 以$开头的为保留字段,不是翻译内容
const langExists = langName in targetLangs
const targetText = targetLangs[langName]
// 如果目标语言已经存在并且内容不为空,则不需要更新
if(!langExists){ // 不存在则创建新的翻译条目
targetLangs[langName] = sourceText
}
})
}else{
oldTexts[text] = sourceLangs
}
})
fs.writeFileSync(toLangFile,JSON.stringify(oldTexts,null,4))
}
module.exports = function(options={}){
options = normalizeLanguageOptions(options)
let {debug,outputPath} = options
logger.log(t("支持的语言\t: {}"),options.languages.map(item=>`${item.title}(${item.name})`).join(","))
logger.log(t("默认语言\t: {}"),options.defaultLanguage)
logger.log(t("激活语言\t: {}"),options.activeLanguage)
logger.log(t("名称空间\t: {}"),Object.keys(options.namespaces).join(","))
logger.log("")
// 保存提交提取的文本 = {}
let results = {}
let fileCount=0 // 文件总数
// file == vinyl实例
return through2.obj(function(file, encoding, callback){
// 如果没有指定输出路径,则默认输出到<原文件夹/languages>
if(!outputPath){
outputPath = path.join(file.base,"languages")
}
if(file.isNull()) return callback()
if(file.isStream()) return callback()
// 提取翻译文本
try{
const texts = getTranslateTexts(file.contents.toString(),file,options)
results = deepmerge(results,texts)
fileCount++
if(debug){
const textCount = Object.values(texts).reduce((sum,item)=>sum+Object.keys(item).length,0)
if(textCount>0){
logger.log("提取<{}>, 发现 [{}] 名称空间,{} 条信息。",file.relative,Object.keys(texts).join(),textCount)
}
}
}catch(err){
logger.log("从<{}>提取信息时出错 : {}",file.relative,err.message)
}
callback()
},function(callback){
logger.log("")
logger.log("翻译信息提取完成。")
logger.log(" - 文件总数\t: {}",fileCount)
logger.log(" - 输出路径\t: {}",outputPath)
const translatesPath = path.join(outputPath,"translates")
if(!fs.existsSync(outputPath)) fs.mkdirSync(outputPath)
if(!fs.existsSync(translatesPath)) fs.mkdirSync(translatesPath)
if(!("default" in results)){
results["default"] = {}
}
// 每个名称空间对应一个文件
for(let [namespace,texts] of Object.entries(results)){
const langFile = path.join(outputPath,"translates",`${namespace}.json`)
const isExists = fs.existsSync(langFile)
const langTexts = {}
if(isExists){
updateLanguageFile(texts,langFile,options)
logger.log(" √ 更新语言文件 : {}",path.relative(outputPath,langFile))
}else{
fs.writeFileSync(langFile,JSON.stringify(texts,null,4))
logger.log(" √ 保存语言文件 : {}",path.relative(outputPath,langFile))
}
}
// 生成语言配置文件 settings.json , 仅当不存在时才生成
const settingsFile = path.join(outputPath,"settings.json")
if(!fs.existsSync(settingsFile)){
const settings = {
languages : options.languages,
defaultLanguage: options.defaultLanguage,
activeLanguage : options.activeLanguage,
namespaces : options.namespaces
}
fs.writeFileSync(settingsFile,JSON.stringify(settings,null,4))
logger.log(" - 生成语言配置文件: {}",settingsFile)
}else{
logger.log(" - 应用语言配置文件: {}",settingsFile)
}
logger.log("下一步:")
logger.log(" - 运行<{}>编译语言包","voerkai18n compile")
logger.log(" - 在源码中从[{}]导入编译后的语言包","./languages")
callback()
});
}
module.exports.getTranslateTexts = getTranslateTexts
module.exports.normalizeLanguageOptions = normalizeLanguageOptions