diff --git a/images/arch.png b/images/arch.png new file mode 100644 index 0000000..c474dfc Binary files /dev/null and b/images/arch.png differ diff --git a/packages/demo/apps/app/package.json b/packages/demo/apps/app/package.json index 9e84023..477348e 100644 --- a/packages/demo/apps/app/package.json +++ b/packages/demo/apps/app/package.json @@ -1,4 +1,7 @@ { "type": "commonjs", - "license": "MIT" + "license": "MIT", + "devDependencies": { + "@voerkai18n/tools": "workspace:^1.0.0" + } } \ No newline at end of file diff --git a/packages/demo/data.js b/packages/demo/data.js index 191aaeb..3be7a01 100644 --- a/packages/demo/data.js +++ b/packages/demo/data.js @@ -10,13 +10,10 @@ module.exports = { "title" : "标题", "content": "内容" }, - "产品清单\t{}": [ - "手机", - "电脑" - ], + "产品清单\t{}": 2, "产品价格" : { - "手机": 1299, - "电脑": 3999 + "手机" : 1299, + "电脑\t:": 3999 }, - "产品\\清单" : 1 + "产品\\清\\单": 1 } \ No newline at end of file diff --git a/packages/demo/utils.demo.js b/packages/demo/utils.demo.js index 662cceb..50e3006 100644 --- a/packages/demo/utils.demo.js +++ b/packages/demo/utils.demo.js @@ -1,10 +1,16 @@ -const objectToString = require("../tools/stringify") +const { objectStringify } = require("../tools/stringify") const path = require("path"); const fs = require("fs"); const k1 = "产品清单\t{}" -const k2 = "产品\\清单" -const data = { +const k2 = "产品\\清\\单" + +const newTexts = { + [k1]:1, + [k2]:2 +} + +let data = { name:"张三丰", age:12, active:true, @@ -21,14 +27,16 @@ const data = { "手机", "电脑" ], + "产品清单\t{}":2, "产品价格":{ "手机":1299, - "电脑":3999 + "电脑\t:":3999 }, - [k2]:1 + "产品\\清\\单":1 } + -const result = objectToString(data) +const result = objectStringify(data) console.log(result) @@ -36,6 +44,15 @@ fs.writeFileSync(path.join(__dirname,"./data.js"),`module.exports = ${result}`) const loaded = require("./data.js") +Object.entries(loaded).forEach(([key,value])=>{ + if(key===k1){ + console.log("k1=",value) + }else if(key===k2){ + console.log("k2=",value) + console.log("k2 in data",k2 in data) + } +}) + console.log(loaded[k1]) console.log(loaded[k2]) diff --git a/packages/runtime/eventemitter.js b/packages/runtime/eventemitter.js new file mode 100644 index 0000000..6e69a6d --- /dev/null +++ b/packages/runtime/eventemitter.js @@ -0,0 +1,31 @@ +/** +* +* 简单的事件触发器 +* +*/ +module.exports = class EventEmitter{ + constructor(){ + this._callbacks = [] + } + on(callback){ + if(this._callbacks.includes(callback)) return + this._callbacks.push(callback) + } + off(callback){ + for(let i=0;icb(...args))) + }else{ + await Promise.all(this._callbacks.map(cb=>cb(...args))) + } + } +} \ No newline at end of file diff --git a/packages/runtime/formatters.js b/packages/runtime/formatters.js index ef249c0..94f685b 100644 --- a/packages/runtime/formatters.js +++ b/packages/runtime/formatters.js @@ -26,18 +26,19 @@ * @param {...any} args * @returns */ -function dict(value,...args){ - try{ - for(let i=0;i0 && (args.length % 2!==0)) return args[args.length-1] - }catch{} + } + if(args.length >0 && (args.length % 2!==0)) return args[args.length-1] return value } +function formatCurrency(value,symbol,retainDots){ + +} module.exports = { "*":{ @@ -45,6 +46,7 @@ module.exports = { Date:(value)=>value.toLocaleString() }, time:(value)=> value.toLocaleTimeString(), + shorttime:(value)=> value.toLocaleTimeString(), date: (value)=> value.toLocaleDateString(), dict, //字典格式化器 }, @@ -52,11 +54,15 @@ module.exports = { $types:{ Date:(value)=> `${value.getFullYear()}年${value.getMonth()+1}月${value.getDate()}日 ${value.getHours()}点${value.getMinutes()}分${value.getSeconds()}秒` }, + shortime:(value)=> value.toLocaleTimeString(), time:(value)=>`${value.getHours()}点${value.getMinutes()}分${value.getSeconds()}秒`, date: (value)=> `${value.getFullYear()}年${value.getMonth()+1}月${value.getDate()}日`, + shortdate: (value)=> `${value.getFullYear()}-${value.getMonth()+1}-${value.getDate()}`, currency:(value)=>`${value}元`, }, en:{ - currency:(value)=>`$${value}`, + currency:(value)=>{ + return `$${value}` + } } } \ No newline at end of file diff --git a/packages/runtime/index.js b/packages/runtime/index.js index d56479f..286df8e 100644 --- a/packages/runtime/index.js +++ b/packages/runtime/index.js @@ -1,5 +1,8 @@ const deepMerge = require("deepmerge") -const formatters = require("./formatters") +const EventEmitter = require("./eventemitter") +const i18nScope = require("./scope.js") +let formatters = require("./formatters") + // 用来提取字符里面的插值变量参数 , 支持管道符 { var | formatter | formatter } // 不支持参数: let varWithPipeRegexp = /\{\s*(?\w+)?(?(\s*\|\s*\w*\s*)*)\s*\}/g @@ -30,6 +33,8 @@ let varReplaceRegexp =String.raw`\{\s*{varname}\s*\}` function hasInterpolation(str){ return str.includes("{") && str.includes("}") } +const DataTypes = ["String","Number","Boolean","Object","Array","Function","Error","Symbol","RegExp","Date","Null","Undefined","Set","Map","WeakSet","WeakMap"] + /** * 获取指定变量类型名称 * getDataTypeName(1) == Number @@ -251,6 +256,7 @@ function getDataTypeDefaultFormatter(scope,activeLanguage,dataType){ // 先在当前作用域中查找,再在全局查找 const targets = [scope.formatters,scope.global.formatters] for(const target of targets){ + if(!target) continue // 优先在当前语言的$types中查找 if((activeLanguage in target) && isPlainObject(target[activeLanguage].$types)){ let formatters = target[activeLanguage].$types @@ -457,7 +463,29 @@ function getPluraMessage(messages,value){ return Array.isArray(messages) ? messages[0] : messages } } - +function escape(str){ + return str.replaceAll(/\\(?![trnbvf'"]{1})/g,"\\\\") + .replaceAll("\t","\\t") + .replaceAll("\n","\\n") + .replaceAll("\b","\\b") + .replaceAll("\r","\\r") + .replaceAll("\f","\\f") + .replaceAll("\'","\\'") + .replaceAll('\"','\\"') + .replaceAll('\v','\\v') +} +function unescape(str){ + return str + .replaceAll("\\t","\t") + .replaceAll("\\n","\n") + .replaceAll("\\b","\b") + .replaceAll("\\r","\r") + .replaceAll("\\f","\f") + .replaceAll("\\'","\'") + .replaceAll('\\"','\"') + .replaceAll('\\v','\v') + .replaceAll(/\\\\(?![trnbvf'"]{1})/g,"\\") +} /** * 翻译函数 * @@ -517,8 +545,9 @@ function translate(message) { // 2.2 从当前语言包中取得翻译文本模板字符串 // 如果没有启用babel插件将源文本转换为msgId,需要先将文本内容转换为msgId // JSON.stringify在进行转换时会将\t\n\r转换为\\t\\n\\r,这样在进行匹配时就出错 - let msgId = isMessageId(content) ? content : scope.idMap[content] + let msgId = isMessageId(content) ? content : scope.idMap[escape(content)] content = scope.messages[msgId] || content + content = unescape(content) } // 3. 处理复数 @@ -561,10 +590,11 @@ function translate(message) { * VoerkaI18n.off("change",(language)=>{}) * * */ - class I18nManager{ + class I18nManager extends EventEmitter{ static instance = null; // 单例引用 callbacks = [] // 当切换语言时的回调事件 constructor(settings={}){ + super() if(I18nManager.instance!=null){ return I18nManager.instance; } @@ -581,96 +611,39 @@ function translate(message) { get defaultLanguage(){ return this.this._settings.defaultLanguage} // 支持的语言列表 get languages(){ return this._settings.languages} - // 订阅语言切换事件 - on(callback){ - this.callbacks.push(callback) - } - off(callback){ - for(let i=0;icb(newLanguage))) - }else{ - await Promise.all(this.callbacks.map(cb=>cb(newLanguage))) - } - }catch(e){ - console.warn("Error while executing language change events:",e.message) - } - } + // 全局格式化器 + get formatters(){ return formatters } /** * 切换语言 */ async change(value){ if(this.languages.findIndex(lang=>lang.name === value)!==-1){ - await this._triggerChangeEvents(value) + // 通知所有作用域刷新到对应的语言包 + await this._refreshScopes(value) this._settings.activeLanguage = value + /// 触发语言切换事件 + await this.emit(value) }else{ throw new Error("Not supported language:"+value) } - } - /** - * 获取指定作用域的下的语言包加载器 - * - * 同时会进行语言兼容性处理 - * - * 如scope里面定义了一个cn的语言包,当切换到zh-cn时,会自动加载cn语言包 - * - * - * @param {*} scope - * @param {*} lang - */ - _getScopeLoader(scope,lang){ - } /** * 当切换语言时调用此方法来加载更新语言包 * @param {*} newLanguage */ - async _updateScopes(newLanguage){ + async _refreshScopes(newLanguage){ // 并发执行所有作用域语言包的加载 try{ - let scopeLoders = this._scopes.map(scope=>{ - return async ()=>{ - // 默认语言,所有均默认语言均采用静态加载方式,只需要简单的替换即可 - if(newLanguage === scope.defaultLanguage){ - scope.messages = scope.default - return - } - // 异步加载语言文件 - const loader = scope.loaders[newLanguage] - if(typeof(loader) === "function"){ - try{ - scope.messages = (await loader() ).default - }catch(e){ - console.warn(`Error loading language ${newLanguage} : ${e.message}`) - scope.messages = defaultMessages // 出错时回退到默认语言 - } - }else{ - scope.messages = defaultMessages - } - } - }) + const scopeRefreshers = this._scopes.map(scope=>{ + return scope.refresh(newLanguage) + }) if(Promise.allSettled){ - await Promise.allSettled(scopeLoders.map((f)=>f())) + await Promise.allSettled(scopeRefreshers) }else{ - await Promise.all(scopeLoders.map((f)=>f())) + await Promise.all(scopeRefreshers) } }catch(e){ - console.warn("Error while refreshing scope:",e.message) + console.warn("Error while refreshing i18n scopes:",e.message) } } /** @@ -678,33 +651,36 @@ function translate(message) { * 注册一个新的作用域 * * 每一个库均对应一个作用域,每个作用域可以有多个语言包,且对应一个翻译函数 - * scope={ - * defaultLanguage:"cn", - default: defaultMessages, // 转换文本信息 - messages : defaultMessages, // 当前语言的消息 - idMap:messageIds, - formatters:{ - ...formatters, - ...i18nSettings.formatters || {} - }, - loaders:{}, // 异步加载语言文件的函数列表 - settings:{} // 引用全局VoerkaI18n实例的配置 - * } - * * 除了默认语言外,其他语言采用动态加载的方式 * * @param {*} scope */ - register(scope){ - scope.global = this._settings + async register(scope){ + if(!(scope instanceof i18nScope)){ + throw new TypeError("Scope must be an instance of I18nScope") + } this._scopes.push(scope) + await scope.refresh(this.activeLanguage) } /** * 注册全局格式化器 + * 格式化器是一个简单的同步函数value=>{...},用来对输入进行格式化后返回结果 + * + * registerFormatters(name,value=>{...}) // 适用于所有语言 + * registerFormatters(name,value=>{...},{langauge:"cn"}) // 适用于cn语言 + * registerFormatters(name,value=>{...},{langauge:"en"}) // 适用于en语言 + * @param {*} formatters */ - registerFormatters(formatters){ - + registerFormatter(name,formatter,{language="*"}={}){ + if(!typeof(formatter)==="function" || typeof(name)!=="string"){ + throw new TypeError("Formatter must be a function") + } + if(DataTypes.includes(name)){ + this.formatters[language].$types[name] = formatter + }else{ + this.formatters[language][name] = formatter + } } } @@ -714,6 +690,7 @@ module.exports ={ I18nManager, translate, languages, + i18nScope, defaultLanguageSettings, getDataTypeName, isNumber, diff --git a/packages/runtime/scope.js b/packages/runtime/scope.js new file mode 100644 index 0000000..1868dad --- /dev/null +++ b/packages/runtime/scope.js @@ -0,0 +1,115 @@ + +module.exports = class i18nScope { + constructor(options={},callback){ + // 每个作用域都有一个唯一的id + this._id = options.id || (new Date().getTime().toString()+parseInt(Math.random()*1000)) + this._languages = options.languages // 当前作用域的语言列表 + this._defaultLanguage = options.defaultLanguage || "cn" // 默认语言名称 + this._activeLanguage = options.activeLanguage // 当前语言名称 + this._default = options.default // 默认语言包 + this._messages = options.messages // 当前语言包 + this._idMap = options.idMap // 消息id映射列表 + this._formatters = options.formatters // 当前作用域的格式化函数列表 + this._loaders = options.loaders // 异步加载语言文件的函数列表 + this._global = null // 引用全局VoerkaI18n配置,注册后自动引用 + // 主要用来缓存格式化器的引用,当使用格式化器时可以直接引用,避免检索 + this.$cache={ + activeLanguage : null, + typedFormatters: {}, + formatters : {}, + } + // 如果不存在全局VoerkaI18n实例,说明当前Scope是唯一或第一个加载的作用域, + // 则使用当前作用域来初始化全局VoerkaI18n实例 + if(!globalThis.VoerkaI18n){ + const { I18nManager } = require("./") + globalThis.VoerkaI18n = new I18nManager({ + defaultLanguage: this.defaultLanguage, + activeLanguage : this.activeLanguage, + languages: options.languages, + }) + } + this.global = globalThis.VoerkaI18n + // 正在加载语言包标识 + this._loading=false + // 在全局注册作用域 + this.register(callback) + } + // 作用域 + get id(){return this._id} + // 默认语言名称 + get defaultLanguage(){return this._defaultLanguage} + // 默认语言名称 + get activeLanguage(){return this._activeLanguage} + // 默认语言包 + get default(){return this._default} + // 当前语言包 + get messages(){return this._messages} + // 消息id映射列表 + get idMap(){return this._idMap} + // 当前作用域的格式化函数列表 + get formatters(){return this._formatters} + // 异步加载语言文件的函数列表 + get loaders(){return this._loaders} + // 引用全局VoerkaI18n配置,注册后自动引用 + get global(){return this._global} + set global(value){this._global = value} + /** + * 在全局注册作用域 + * @param {*} callback 当注册 + */ + register(callback){ + if(!typeof(callback)==="function") callback = ()=>{} + this.global.register(this).then(callback).catch(callback) + } + registerFormatter(name,formatter,{language="*"}={}){ + if(!typeof(formatter)==="function" || typeof(name)!=="string"){ + throw new TypeError("Formatter must be a function") + } + if(DataTypes.includes(name)){ + this.formatters[language].$types[name] = formatter + }else{ + this.formatters[language][name] = formatter + } + } + /** + * 回退到默认语言 + */ + _fallback(){ + this._messages = this._default + this._activeLanguage = this.defaultLanguage + } + /** + * 刷新当前语言包 + * @param {*} newLanguage + */ + async refresh(newLanguage){ + this._loading = Promise.resolve() + if(!newLanguage) newLanguage = this.activeLanguage + // 默认语言,默认语言采用静态加载方式,只需要简单的替换即可 + if(newLanguage === this.defaultLanguage){ + this._messages = this._default + return + } + // 非默认语言需要异步加载语言包文件,加载器是一个异步函数 + // 如果没有加载器,则无法加载语言包,因此回退到默认语言 + const loader = this.loaders[newLanguage] + if(typeof(loader) === "function"){ + try{ + this._messages = (await loader()).default + this._activeLanguage = newLanguage + }catch(e){ + console.warn(`Error while loading language <${newLanguage}> on i18nScope(${this.id}): ${e.message}`) + this._fallback() + } + }else{ + this._fallback() + } + } + // 以下方法引用全局VoerkaI18n实例的方法 + get on(){return this.global.on.bind(this.global)} + get off(){return this.global.off.bind(this.global)} + get offAll(){return this.global.offAll.bind(this.global)} + get change(){ + return this.global.change.bind(this.global) + } +} \ No newline at end of file diff --git a/packages/tools/.env b/packages/tools/.env index 6ba1528..1cbee58 100644 --- a/packages/tools/.env +++ b/packages/tools/.env @@ -1,2 +1,2 @@ # 命令行工具使用的语言 -language=en \ No newline at end of file +LANGUAGE=en \ No newline at end of file diff --git a/packages/tools/compile.command.js b/packages/tools/compile.command.js index caebaa5..5b366bf 100644 --- a/packages/tools/compile.command.js +++ b/packages/tools/compile.command.js @@ -27,11 +27,11 @@ const readJson = require("readjson") const glob = require("glob") const createLogger = require("logsets") const path = require("path") -const { importModule,findModuleType } = require("./utils") +const { t,importModule,findModuleType,getCurrentPackageJson} = require("./utils") const fs = require("fs") const logger = createLogger() const artTemplate = require("art-template") -const { t } = require("./languages") + function normalizeCompileOptions(opts={}) { let options = Object.assign({ moduleType:"auto" // 指定编译后的语言文件的模块类型,取值common,cjs,esm,es @@ -49,7 +49,7 @@ module.exports =async function compile(langFolder,opts={}){ if(moduleType==="auto"){ moduleType = findModuleType(langFolder) } - + const projectPackageJson = getCurrentPackageJson(langFolder) // 加载多语言配置文件 const settingsFile = path.join(langFolder,"settings.js") try{ @@ -58,13 +58,13 @@ module.exports =async function compile(langFolder,opts={}){ const langSettings = module.default; let { languages,defaultLanguage,activeLanguage,namespaces } = langSettings - logger.log(t("支持的语言\t: {}",languages.map(item=>`${item.title}(${item.name})`).join(","))) - logger.log("默认语言\t: {}",defaultLanguage) - logger.log("激活语言\t: {}",activeLanguage) - logger.log("名称空间\t: {}",Object.keys(namespaces).join(",")) - logger.log("模块类型\t: {}",moduleType) + logger.log(t("支持的语言\t: {}"),languages.map(item=>`${item.title}(${item.name})`).join(",")) + logger.log(t("默认语言\t: {}"),defaultLanguage) + logger.log(t("激活语言\t: {}"),activeLanguage) + logger.log(t("名称空间\t: {}"),Object.keys(namespaces).join(",")) + logger.log(t("模块类型\t: {}"),moduleType) logger.log("") - logger.log("编译结果输出至:{}",langFolder) + logger.log(t("编译结果输出至:{}"),langFolder) // 1. 合并生成最终的语言文件 let messages = {} ,msgId =1 @@ -79,10 +79,10 @@ module.exports =async function compile(langFolder,opts={}){ } }) }catch(e){ - logger.log("读取语言文件{}失败:{}",file,e.message) + logger.log(t("读取语言文件{}失败:{}"),file,e.message) } }) - logger.log(" - 共合成{}条语言包文本",Object.keys(messages).length) + logger.log(t(" - 共合成{}条语言包文本"),Object.keys(messages).length) // 2. 为每一个文本内容生成一个唯一的id let messageIds = {} @@ -103,7 +103,7 @@ module.exports =async function compile(langFolder,opts={}){ }else{ fs.writeFileSync(langFile,`module.exports = ${JSON.stringify(langMessages,null,4)}`) } - logger.log(" - 语言包文件: {}",path.basename(langFile)) + logger.log(t(" - 语言包文件: {}"),path.basename(langFile)) }) // 4. 生成id映射文件 @@ -113,34 +113,46 @@ module.exports =async function compile(langFolder,opts={}){ }else{ fs.writeFileSync(idMapFile,`module.exports = ${JSON.stringify(messageIds,null,4)}`) } - logger.log(" - idMap文件: {}",path.basename(idMapFile)) - - // 5. 生成编译后的访问入口文件 - const entryFile = path.join(langFolder,"index.js") - const entryContent = artTemplate(path.join(__dirname,"templates","entry.js"), {languages,defaultLanguage,activeLanguage,namespaces,moduleType,JSON } ) - fs.writeFileSync(entryFile,entryContent) - logger.log(" - 访问入口文件: {}",path.basename(entryFile)) - + logger.log(t(" - idMap文件: {}"),path.basename(idMapFile)) - // 6 . 生成编译后的格式化函数文件 + const templateContext = { + scopeId:projectPackageJson.name, + languages, + defaultLanguage, + activeLanguage, + namespaces, + moduleType, + JSON + } + + // 5 . 生成编译后的格式化函数文件 const formattersFile = path.join(langFolder,"formatters.js") if(!fs.existsSync(formattersFile)){ - const formattersContent = artTemplate(path.join(__dirname,"templates","formatters.js"), {languages,defaultLanguage,activeLanguage,namespaces,moduleType } ) + const formattersContent = artTemplate(path.join(__dirname,"templates","formatters.js"), templateContext ) fs.writeFileSync(formattersFile,formattersContent) - logger.log(" - 格式化器:{}",path.basename(formattersFile)) + logger.log(t(" - 格式化器:{}"),path.basename(formattersFile)) }else{ // 格式化器如果存在,则需要更改对应的模块类型 let formattersContent = fs.readFileSync(formattersFile,"utf8").toString() if(moduleType == "esm"){ formattersContent = formattersContent.replaceAll(/\s*module.exports\s*\=/g,"export default ") formattersContent = formattersContent.replaceAll(/\s*module.exports\./g,"export ") }else{ - formattersContent = formattersContent.replaceAll(/\s*export\s*default\s*/g,"module.exports = ") - formattersContent = formattersContent.replaceAll(/\s*export\s*/g,"module.exports.") + formattersContent = formattersContent.replaceAll(/^\s*export\s*default\s*/g,"module.exports = ") + formattersContent = formattersContent.replaceAll(/^\s*export\s*/g,"module.exports.") } fs.writeFileSync(formattersFile,formattersContent) - logger.log(" - 更新格式化器:{}",path.basename(formattersFile)) + logger.log(t(" - 更新格式化器:{}"),path.basename(formattersFile)) } + // 6. 生成编译后的访问入口文件 + const entryFile = path.join(langFolder,"index.js") + const entryContent = artTemplate(path.join(__dirname,"templates","entry.js"), templateContext ) + fs.writeFileSync(entryFile,entryContent) + logger.log(t(" - 访问入口文件: {}"),path.basename(entryFile)) + + + + // 7. 重新生成settings ,需要确保settings.js匹配模块类型 if(moduleType==="esm"){ fs.writeFileSync(settingsFile,`export default ${JSON.stringify(langSettings,null,4)}`) @@ -163,6 +175,6 @@ module.exports =async function compile(langFolder,opts={}){ } fs.writeFileSync(packageJsonFile,JSON.stringify(packageJson,null,4)) }catch(e){ - logger.log("加载多语言配置文件<{}>失败: {} ",settingsFile,e.message) + logger.log(t("加载多语言配置文件<{}>失败: {} "),settingsFile,e.message) } } \ No newline at end of file diff --git a/packages/tools/extract.command.js b/packages/tools/extract.command.js index c8e636c..ad7cbda 100644 --- a/packages/tools/extract.command.js +++ b/packages/tools/extract.command.js @@ -1,4 +1,4 @@ -const { findModuleType } = require("./utils") +const { findModuleType,t } = require("./utils") const path = require("path") const fs = require("fs") const gulp = require("gulp") @@ -9,7 +9,7 @@ const logger = createLogger() module.exports = function(targetPath,options={}){ let { filetypes,exclude} = options - if(!filetypes) filetypes = ["*.js","*.json","*.jsx","*.ts","*.tsx","*.vue","*.html"] + if(!filetypes) filetypes = ["*.js","*.jsx","*.ts","*.tsx","*.vue","*.html"] if(!Array.isArray(filetypes)) filetypes = filetypes.split(",") const folders = filetypes.map(ftype=>{ if(ftype.startsWith(".")) ftype = "*"+ftype @@ -19,6 +19,12 @@ module.exports = function(targetPath,options={}){ folders.push(`!${path.join(targetPath,"languages","**")}`) folders.push(`!${path.join(targetPath,"node_modules","**")}`) folders.push(`!${path.join(targetPath,"**","node_modules","**")}`) + folders.push("!**/babel.config.js") + folders.push("!**/gulpfile.js") + folders.push("!**/*.test.js") + folders.push("!__test__/**/*.js") + + if(!Array.isArray(exclude) && exclude){ exclude = exclude.split(",") } @@ -28,11 +34,11 @@ module.exports = function(targetPath,options={}){ }) } if(!fs.existsSync(targetPath)){ - logger.log("目标文件夹<{}>不存在",targetPath) + logger.log(t("目标文件夹<{}>不存在"),targetPath) return } if(options.debug){ - logger.log("扫描提取范围:") + logger.log(t("扫描提取范围:")) logger.format(folders) } diff --git a/packages/tools/extract.plugin.js b/packages/tools/extract.plugin.js index 4634438..f9b88cc 100644 --- a/packages/tools/extract.plugin.js +++ b/packages/tools/extract.plugin.js @@ -12,10 +12,8 @@ const fs = require('fs') const readJson = require("readjson") const createLogger = require("logsets") const { replaceInterpolateVars,getDataTypeName } = require("@voerkai18n/runtime") -const { findModuleType,createPackageJsonFile } = require("./utils") -const stringify = require("./stringify") +const { findModuleType,createPackageJsonFile,t } = require("./utils") const logger = createLogger() -const { t } = require("./languages") // 捕获翻译文本正则表达式一: 能匹配完整的t(xx,...)函数调用,如果t函数调用不完整,则不能匹配到 @@ -311,35 +309,35 @@ function normalizeLanguageOptions(options){ */ -function updateLanguageFile(fromTexts,toLangFile,options){ +function updateLanguageFile(newTexts,toLangFile,options){ const { output:{ updateMode } } = options // 默认的overwrite if(!["merge","sync"].includes(updateMode)){ - fs.writeFileSync(toLangFile,stringify(targetTexts)) + fs.writeFileSync(toLangFile,JSON.stringify(oldTexts,null,4)) return } - let targetTexts = {} + let oldTexts = {} // 读取原始翻译文件 try{ - targetTexts = readJson.sync(toLangFile) + oldTexts =JSON.parse(fs.readFileSync(toLangFile))// readJson.sync(toLangFile) }catch(e){ logger.log("Error while read language file <{}>: {}",toLangFile,e.message) // 如果读取出错,可能是语言文件不是有效的json文件,则备份一下 } // 同步模式下,如果原始文本在新扫描的内容中,则需要删除 if(updateMode==="sync"){ - Object.keys(targetTexts).forEach((text)=>{ - if(!(text in fromTexts)){ - delete targetTexts[text] + Object.keys(oldTexts).forEach((text)=>{ + if(!(text in newTexts)){ + delete oldTexts[text] } }) } - Object.entries(fromTexts).forEach(([text,sourceLangs])=>{ - if(text in targetTexts){ // 合并 - let targetLangs = targetTexts[text] //{cn:'',en:''} + Object.entries(newTexts).forEach(([text,sourceLangs])=>{ + if(text in oldTexts){ // 合并 + let targetLangs = oldTexts[text] //{cn:'',en:''} Object.entries(sourceLangs).forEach(([langName,sourceText])=>{ - if(langName.startsWith("$")) return // + if(langName.startsWith("$")) return // 以$开头的为保留字段,不是翻译内容 const langExists = langName in targetLangs const targetText = targetLangs[langName] // 如果目标语言已经存在并且内容不为空,则不需要更新 @@ -348,10 +346,10 @@ function updateLanguageFile(fromTexts,toLangFile,options){ } }) }else{ - targetTexts[text] = sourceLangs + oldTexts[text] = sourceLangs } }) - fs.writeFileSync(toLangFile,stringify(targetTexts)) + fs.writeFileSync(toLangFile,JSON.stringify(oldTexts,null,4)) } @@ -359,10 +357,10 @@ module.exports = function(options={}){ options = normalizeLanguageOptions(options) let {debug,outputPath, updateMode,languages} = options - logger.log(t("支持的语言\t: {}",options.languages.map(item=>`${item.title}(${item.name})`).join(","))) - logger.log("默认语言\t: {}",options.defaultLanguage) - logger.log("激活语言\t: {}",options.activeLanguage) - logger.log("名称空间\t: {}",Object.keys(options.namespaces).join(",")) + 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("") logger // 保存提交提取的文本 = {} @@ -384,7 +382,9 @@ module.exports = function(options={}){ fileCount++ if(debug){ const textCount = Object.values(texts).reduce((sum,item)=>sum+Object.keys(item).length,0) - logger.log("提取<{}>, 发现 [{}] 名称空间,{} 条信息。",file.relative,Object.keys(texts).join(),textCount) + if(textCount>0){ + logger.log("提取<{}>, 发现 [{}] 名称空间,{} 条信息。",file.relative,Object.keys(texts).join(),textCount) + } } }catch(err){ logger.log("从<{}>提取信息时出错 : {}",file.relative,err.message) @@ -411,7 +411,7 @@ module.exports = function(options={}){ updateLanguageFile(texts,langFile,options) logger.log(" √ 更新语言文件 : {}",path.relative(outputPath,langFile)) }else{ - fs.writeFileSync(langFile,stringify(texts)) + fs.writeFileSync(langFile,JSON.stringify(texts,null,4)) logger.log(" √ 保存语言文件 : {}",path.relative(outputPath,langFile)) } } @@ -430,7 +430,6 @@ module.exports = function(options={}){ logger.log(" - 已更新语言配置文件: {}",settingsFile) } - // 生成package.json createPackageJsonFile(outputPath) diff --git a/packages/tools/index.js b/packages/tools/index.js index 9efecb4..407a960 100644 --- a/packages/tools/index.js +++ b/packages/tools/index.js @@ -2,29 +2,31 @@ const { Command } = require('commander'); const createLogger = require("logsets") const path = require("path") const fs = require("fs"); -const { importModule } = require('./utils'); +const { importModule,t } = require('./utils'); const deepmerge = require('deepmerge'); const logger = createLogger() -require('dotenv').config() +const { scope } = require('./languages'); -const { t, changeLanguage } = require("./languages") const program = new Command(); - + program .command('init') .argument('[location]', t('工程项目所在目录')) - .description('初始化项目国际化配置') - .option('-d, --debug', '输出调试信息') - .option('-r, --reset', '重新生成当前项目的语言配置') - .option('-m, --moduleType [type]', '生成的js模块类型,取值auto,esm,cjs',"auto") - .option('-lngs, --languages ', '支持的语言列表', ['cn','en']) + .description(t('初始化项目国际化配置')) + .option('-d, --debug', t('输出调试信息')) + .option('-r, --reset', t('重新生成当前项目的语言配置')) + .option('-m, --moduleType [type]', t('生成的js模块类型,取值auto,esm,cjs'),"auto") + .option('-lngs, --languages ', t('支持的语言列表'), ['cn','en']) + .hook("preAction",async function(location){ + + }) .action((location,options) => { if(!location) { location = process.cwd() }else{ location = path.join(process.cwd(),location) } - logger.log("工程目录:{}",location) + logger.log(t("工程目录:{}"),location) // if(options.debug){ logger.format(options,{compact:true}) @@ -36,16 +38,16 @@ program program .command('extract') - .description('扫描并提取所有待翻译的字符串到文件夹中') - .option('-d, --debug', '输出调试信息') - .option('-lngs, --languages', '支持的语言', 'cn,en') - .option('-d, --defaultLanguage', '默认语言', 'cn') - .option('-a, --activeLanguage', '激活语言', 'cn') - .option('-ns, --namespaces', '翻译名称空间') - .option('-e, --exclude ', '排除要扫描的文件夹,多个用逗号分隔') - .option('-u, --updateMode', '本次提取内容与已存在内容的数据合并策略,默认取值sync=同步,overwrite=覆盖,merge=合并', 'sync') - .option('-f, --filetypes', '要扫描的文件类型', 'js,vue,html,jsx,ts') - .argument('[location]', '工程所在目录',"./") + .description(t('扫描并提取所有待翻译的字符串到文件夹中')) + .option('-d, --debug', t('输出调试信息')) + .option('-lngs, --languages', t('支持的语言'), 'cn,en') + .option('-default, --defaultLanguage', t('默认语言'), 'cn') + .option('-active, --activeLanguage', t('激活语言'), 'cn') + .option('-ns, --namespaces', t('翻译名称空间')) + .option('-e, --exclude ', t('排除要扫描的文件夹,多个用逗号分隔')) + .option('-u, --updateMode', t('本次提取内容与已存在内容的数据合并策略,默认取值sync=同步,overwrite=覆盖,merge=合并'), 'sync') + .option('-f, --filetypes', t('要扫描的文件类型'), 'js,vue,html,jsx,ts') + .argument('[location]', t('工程项目所在目录'),"./") .action(async (location,options) => { if(!location) { location = process.cwd() @@ -56,10 +58,10 @@ program options.languages = options.languages.split(",").map(l=>({name:l,title:l})) } //options = Object.assign({},options) - logger.log("工程目录:{}",location) + logger.log(t("工程目录:{}"),location) const langSettingsFile = path.join(location,"languages","settings.js") if(fs.existsSync(langSettingsFile)){ - logger.log("语言配置文件<{}>已存在,将优先使用此配置文件中参数来提取文本","./languages/settings.js") + logger.log(t("语言配置文件<{}>已存在,将优先使用此配置文件中参数来提取文本","./languages/settings.js")) let lngOptions = (await importModule("file:///"+langSettingsFile)).default options.languages = lngOptions.languages options.defaultLanguage = lngOptions.defaultLanguage @@ -67,9 +69,9 @@ program options.namespaces = lngOptions.namespaces } // - if(options.debug){ - logger.format(options,{compact:true}) - } + // if(options.debug){ + // logger.format(options,{compact:true}) + // } const extractor = require('./extract.command'); extractor(location,options) }); @@ -77,13 +79,15 @@ program program .command('compile') - .description('编译语言包文件文件夹中') - .option('-d, --debug', '输出调试信息') - .option('-m, --moduleType [types]', '输出模块类型,取值auto,esm,cjs', 'auto') + .description(t('编译指定项目的语言包')) + .option('-d, --debug', t('输出调试信息')) + .option('-m, --moduleType [types]', t('输出模块类型,取值auto,esm,cjs'), 'auto') .argument('[location]', t('工程项目所在目录'),"./") - .hook("preAction",async (location,options) => { - console.log("process.env.language",process.env.language) - await changeLanguage("en") + .hook("preAction",async function(location){ + const lang= process.env.LANGUAGE + if(lang){ + await scope.change(lang) + } }) .action(async (location,options) => { if(!location) { @@ -93,12 +97,12 @@ program } const langFolder = path.join(location,"languages") if(!fs.existsSync(langFolder)){ - logger.error("语言包文件夹<{}>不存在",langFolder) + logger.error(t("语言包文件夹<{}>不存在",langFolder)) return } - if(options.debug){ - logger.format(options,{compact:true}) - } + // if(options.debug){ + // logger.format(options,{compact:true}) + // } compile = require("./compile.command") compile(langFolder,options) }); diff --git a/packages/tools/init.command.js b/packages/tools/init.command.js index 804a8ca..1a1f8fb 100644 --- a/packages/tools/init.command.js +++ b/packages/tools/init.command.js @@ -4,38 +4,40 @@ */ -const { findModuleType,createPackageJsonFile } = require("./utils") +const { findModuleType,createPackageJsonFile,t } = require("./utils") const path = require("path") const fs = require("fs") const createLogger = require("logsets") const logger = createLogger() -module.exports = function(targetPath,{debug = true,languages=["cn","en"],defaultLanguage="cn",activeLanguage="cn",moduleType = "auto",reset=false}={}){ +module.exports = function(srcPath,{debug = true,languages=["cn","en"],defaultLanguage="cn",activeLanguage="cn",moduleType = "auto",reset=false}={}){ // 语言文件夹名称 const langPath = "languages" // 查找当前项目的语言包类型路径 - const lngPath = path.join(targetPath,langPath) + const lngPath = path.join(srcPath,langPath) + if(!fs.existsSync(lngPath)){ + fs.mkdirSync(lngPath) + if(debug) logger.log(t("创建语言包文件夹: {}"),lngPath) + } + moduleType = createPackageJsonFile(lngPath,moduleType) if(moduleType==null) { if(debug){ - logger.log("找不到{}文件,{}只能在js项目工程中使用","package.json","voerkai18n") + logger.log(t("找不到{}文件,{}只能在js项目工程中使用"),"package.json","voerkai18n") }else{ - throw new Error("找不到package.json文件,voerkai18n只能在js项目工程中使用") + throw new Error(t("找不到package.json文件,voerkai18n只能在js项目工程中使用")) } } - if(!fs.existsSync(lngPath)){ - fs.mkdirSync(lngPath) - if(debug) logger.log("创建语言包文件夹: {}",lngPath) - } + // 创建settings.js文件 const settingsFile = path.join(lngPath,"settings.js") if(fs.existsSync(settingsFile) && !reset){ - if(debug) logger.log("语言配置文件{}文件已存在,跳过创建。\n使用{}可以重新覆盖创建",settingsFile,"-r") + if(debug) logger.log(t("语言配置文件{}文件已存在,跳过创建。\n使用{}可以重新覆盖创建"),settingsFile,"-r") return } const settings = { @@ -52,11 +54,11 @@ module.exports = function(targetPath,{debug = true,languages=["cn","en"],default } if(debug) { - logger.log("生成语言配置文件:{}","./languages/settings.js") - logger.log("拟支持的语言:{}",settings.languages.map(l=>l.name).join(",")) - logger.log("初始化成功,下一步:") - logger.log(" - 编辑{}确定拟支持的语言种类等参数","languages/settings.js") - logger.log(" - 运行<{}>扫描提取要翻译的文本","voerkai18n extract") - logger.log(" - 运行<{}>编译语言包","voerkai18n compile") + logger.log(t("生成语言配置文件:{}"),"./languages/settings.js") + logger.log(t("拟支持的语言:{}"),settings.languages.map(l=>l.name).join(",")) + logger.log(t("初始化成功,下一步:")) + logger.log(t(" - 编辑{}确定拟支持的语言种类等参数"),"languages/settings.js") + logger.log(t(" - 运行<{}>扫描提取要翻译的文本"),"voerkai18n extract") + logger.log(t(" - 运行<{}>编译语言包"),"voerkai18n compile") } } \ No newline at end of file diff --git a/packages/tools/languages/cn.js b/packages/tools/languages/cn.js index 388e839..47c9502 100644 --- a/packages/tools/languages/cn.js +++ b/packages/tools/languages/cn.js @@ -1,4 +1,46 @@ module.exports = { - "1": "支持的语言\t: {}", - "2": "工程项目所在目录" + "1": "支持的语言\\t: {}", + "2": "默认语言\\t: {}", + "3": "激活语言\\t: {}", + "4": "名称空间\\t: {}", + "5": "模块类型\\t: {}", + "6": "编译结果输出至:{}", + "7": "读取语言文件{}失败:{}", + "8": " - 共合成{}条语言包文本", + "9": " - 语言包文件: {}", + "10": " - idMap文件: {}", + "11": " - 格式化器:{}", + "12": " - 更新格式化器:{}", + "13": " - 访问入口文件: {}", + "14": "加载多语言配置文件<{}>失败: {} ", + "15": "目标文件夹<{}>不存在", + "16": "扫描提取范围:", + "17": "工程项目所在目录", + "18": "初始化项目国际化配置", + "19": "输出调试信息", + "20": "重新生成当前项目的语言配置", + "21": "生成的js模块类型,取值auto,esm,cjs", + "22": "支持的语言列表", + "23": "工程目录:{}", + "24": "扫描并提取所有待翻译的字符串到文件夹中", + "25": "支持的语言", + "26": "默认语言", + "27": "激活语言", + "28": "翻译名称空间", + "29": "排除要扫描的文件夹,多个用逗号分隔", + "30": "本次提取内容与已存在内容的数据合并策略,默认取值sync=同步,overwrite=覆盖,merge=合并", + "31": "要扫描的文件类型", + "32": "语言配置文件<{}>已存在,将优先使用此配置文件中参数来提取文本", + "33": "编译指定项目的语言包", + "34": "输出模块类型,取值auto,esm,cjs", + "35": "语言包文件夹<{}>不存在", + "36": "找不到{}文件,{}只能在js项目工程中使用", + "37": "找不到package.json文件,voerkai18n只能在js项目工程中使用", + "38": "语言配置文件{}文件已存在,跳过创建。\\n使用{}可以重新覆盖创建", + "39": "生成语言配置文件:{}", + "40": "拟支持的语言:{}", + "41": "初始化成功,下一步:", + "42": " - 编辑{}确定拟支持的语言种类等参数", + "43": " - 运行<{}>扫描提取要翻译的文本", + "44": " - 运行<{}>编译语言包" } \ No newline at end of file diff --git a/packages/tools/languages/en.js b/packages/tools/languages/en.js index 49c31c9..19774a0 100644 --- a/packages/tools/languages/en.js +++ b/packages/tools/languages/en.js @@ -1,4 +1,46 @@ module.exports = { - "1": "Supported Languages\t: {}", - "2": "Project Directory" + "1": "Supported languages\\t: {}", + "2": "Default language\\t: {}", + "3": "Active language\\t\\t: {}", + "4": "Namespaces\\t\\t: {}", + "5": "Module type\\t\\t: {}", + "6": "Compile to:{}", + "7": "Error while read language of file<{}>:{}", + "8": " - Total {} messages", + "9": " - language messages: {}", + "10": " - idMap file: {}", + "11": " - Formatters:{}", + "12": " - Update formatters:{}", + "13": " - Entry of language: {}", + "14": "Failed to load multilingual configuration file <{}>: {}", + "15": "The destination folder < {} > does not exist", + "16": "Scan for:", + "17": "Project directory", + "18": "Initialize project i18n configuration", + "19": "Output debug information", + "20": "Regenerate the language configuration of the current project", + "21": "Generated JS module type, with values of auto, esm, cjs", + "22": "Supported languages", + "23": "Folder of project:{}", + "24": "Scan and extract all strings to be translated into the folder", + "25": "Supported languages", + "26": "Default language", + "27": "Active language", + "28": "Namespaces", + "29": "Exclude folders to scan, multiple separated by commas", + "30": " strategy of messages merge,with value of sync(default),overwrite,merge", + "31": "Type of file to scan", + "32": "The language configuration file <{}> already exists. It will be used preferentially to extract messages", + "33": "Compiles the language messages for project", + "34": "Output module type, values: auto, esm, cjs", + "35": "The language messages folder <{}> does not exist", + "36": "Cannot find {} file, {} can only be used in JS project", + "37": "Cannot find file, voerkai18n can only be used in JS project", + "38": "Language configuration {} file already exists, skipping creation\\n use {} to overwrite the creation", + "39": "Generate language configuration: {}", + "40": "Languages to be supported:{}", + "41": "Initialization succeeded, next step:", + "42": " - Edit language parameters in {}", + "43": " - Run <{}> scan to extract the messages to be translated", + "44": " - Run <{}> compile language messages" } \ No newline at end of file diff --git a/packages/tools/languages/formatters.js b/packages/tools/languages/formatters.js index 8985775..d9be91c 100644 --- a/packages/tools/languages/formatters.js +++ b/packages/tools/languages/formatters.js @@ -47,59 +47,83 @@ * */ -const formatters = { - "*":{ }, // 在所有语言下生效的格式化器 - $types:{ } // 在所有语言下只作用于特定数据类型的格式化器 - +module.exports = { + // 在所有语言下生效的格式化器 + "*":{ + //[格式化名称]:(value)=>{...}, + //[格式化名称]:(value,arg)=>{...}, + }, + // 在所有语言下只作用于特定数据类型的格式化器 + $types:{ + + }, cn:{ $types:{ - "*":{ + // 所有类型的默认格式化器 + // "*":{ - }, - Date:{ + // }, + // Date:{ - }, - Number:{ + // }, + // Number:{ - }, - String:{ + // }, + // String:{ - }, - Array:{ + // }, + // Array:{ - }, - Object:{ + // }, + // Object:{ - } + // } } - } - + }, en:{ $types:{ - "*":{ + // 所有类型的默认格式化器 + // "*":{ - }, - Date:{ + // }, + // Date:{ - }, - Number:{ + // }, + // Number:{ - }, - String:{ + // }, + // String:{ - }, - Array:{ + // }, + // Array:{ - }, - Object:{ + // }, + // Object:{ - } + // } + } + }, + jp:{ + $types:{ + // 所有类型的默认格式化器 + // "*":{ + + // }, + // Date:{ + + // }, + // Number:{ + + // }, + // String:{ + + // }, + // Array:{ + + // }, + // Object:{ + + // } } } - -} - - -module.module.module.module.module.module.module.module.module.module.module.module.module.module.module.module.module.module.module.module.module.module.module.module.module.module.module.exports.s.s.s.s.s.s.s.s.s.s.s.s.s.s.s.s.s.s.s.s.s.s.s.s.s.s = formatters - - +} \ No newline at end of file diff --git a/packages/tools/languages/idMap.js b/packages/tools/languages/idMap.js index 21ba2f7..ef28728 100644 --- a/packages/tools/languages/idMap.js +++ b/packages/tools/languages/idMap.js @@ -1,4 +1,46 @@ module.exports = { - "支持的语言\t: {}": 1, - "工程项目所在目录": 2 + "支持的语言\\t: {}": 1, + "默认语言\\t: {}": 2, + "激活语言\\t: {}": 3, + "名称空间\\t: {}": 4, + "模块类型\\t: {}": 5, + "编译结果输出至:{}": 6, + "读取语言文件{}失败:{}": 7, + " - 共合成{}条语言包文本": 8, + " - 语言包文件: {}": 9, + " - idMap文件: {}": 10, + " - 格式化器:{}": 11, + " - 更新格式化器:{}": 12, + " - 访问入口文件: {}": 13, + "加载多语言配置文件<{}>失败: {} ": 14, + "目标文件夹<{}>不存在": 15, + "扫描提取范围:": 16, + "工程项目所在目录": 17, + "初始化项目国际化配置": 18, + "输出调试信息": 19, + "重新生成当前项目的语言配置": 20, + "生成的js模块类型,取值auto,esm,cjs": 21, + "支持的语言列表": 22, + "工程目录:{}": 23, + "扫描并提取所有待翻译的字符串到文件夹中": 24, + "支持的语言": 25, + "默认语言": 26, + "激活语言": 27, + "翻译名称空间": 28, + "排除要扫描的文件夹,多个用逗号分隔": 29, + "本次提取内容与已存在内容的数据合并策略,默认取值sync=同步,overwrite=覆盖,merge=合并": 30, + "要扫描的文件类型": 31, + "语言配置文件<{}>已存在,将优先使用此配置文件中参数来提取文本": 32, + "编译指定项目的语言包": 33, + "输出模块类型,取值auto,esm,cjs": 34, + "语言包文件夹<{}>不存在": 35, + "找不到{}文件,{}只能在js项目工程中使用": 36, + "找不到package.json文件,voerkai18n只能在js项目工程中使用": 37, + "语言配置文件{}文件已存在,跳过创建。\\n使用{}可以重新覆盖创建": 38, + "生成语言配置文件:{}": 39, + "拟支持的语言:{}": 40, + "初始化成功,下一步:": 41, + " - 编辑{}确定拟支持的语言种类等参数": 42, + " - 运行<{}>扫描提取要翻译的文本": 43, + " - 运行<{}>编译语言包": 44 } \ No newline at end of file diff --git a/packages/tools/languages/index.js b/packages/tools/languages/index.js index e877f34..da5d66c 100644 --- a/packages/tools/languages/index.js +++ b/packages/tools/languages/index.js @@ -1,57 +1,28 @@ const messageIds = require("./idMap") -const { translate,I18nManager } = require("@voerkai18n/runtime") -const defaultMessages = require("./cn.js") +const { translate,I18nManager,i18nScope } = require("@voerkai18n/runtime") const scopeSettings = require("./settings.js") +const formatters = require("./formatters.js") +const defaultMessages = require("./cn.js") +const activeMessages = defaultMessages - -// 自动创建全局VoerkaI18n实例 -if(!globalThis.VoerkaI18n){ - globalThis.VoerkaI18n = new I18nManager(scopeSettings) -} - -let scope = { - defaultLanguage: "cn", // 默认语言名称 + +// 语言作用域 +const scope = new i18nScope({ + ...scopeSettings, // languages,defaultLanguage,activeLanguage,namespaces,formatters + id: "@voerkai18n/tools", // 当前作用域的id,自动取当前工程的package.json的name default: defaultMessages, // 默认语言包 - messages : defaultMessages, // 当前语言包 + messages : activeMessages, // 当前语言包 idMap:messageIds, // 消息id映射列表 - formatters:{}, // 当前作用域的格式化函数列表 - loaders:{}, // 异步加载语言文件的函数列表 - global:{}, // 引用全局VoerkaI18n配置,注册后自动引用 - // 主要用来缓存格式化器的引用,当使用格式化器时可以直接引用,避免检索 - $cache:{ - activeLanguage:null, - typedFormatters:{}, - formatters:{}, + formatters, // 当前作用域的格式化函数列表 + loaders:{ + "en" : ()=>import("./en.js") } -} - -let supportedlanguages = {} - - -scope.loaders["en"] = ()=>import("./en.js") - - +}) +// 翻译函数 const t = translate.bind(scope) -const languages = [ - { - "name": "cn", - "title": "中文" - }, - { - "name": "en", - "title": "英文" - } -] -// 注册当前作用域到全局VoerkaI18n实例 -VoerkaI18n.register(scope) - -module.exports.languages = languages -module.exports.scope = scope module.exports.t = t -module.exports.changeLanguage = VoerkaI18n.change.bind(VoerkaI18n) -module.exports.addLanguageListener = VoerkaI18n.on.bind(VoerkaI18n) -module.exports.removeLanguageListener = VoerkaI18n.off.bind(VoerkaI18n) +module.exports.scope = scope module.exports.i18nManager = VoerkaI18n diff --git a/packages/tools/languages/translates/default.json b/packages/tools/languages/translates/default.json index 5e74769..d4a0685 100644 --- a/packages/tools/languages/translates/default.json +++ b/packages/tools/languages/translates/default.json @@ -1,21 +1,270 @@ { - "工程项目所在目录" : { - "en" : "Project Directory", - "$file": [ - "index.js" - ] - }, "支持的语言\\t: {}": { - "en" : "支持的语言\\t: {}", + "en": "Supported languages\\t: {}", "$file": [ "compile.command.js", "extract.plugin.js" ] }, - "保存{}\\n" : { - "en" : "保存{}\\n", + "默认语言\\t: {}": { + "en": "Default language\\t: {}", "$file": [ - "stringify.js" + "compile.command.js", + "extract.plugin.js" + ] + }, + "激活语言\\t: {}": { + "en": "Active language\\t\\t: {}", + "$file": [ + "compile.command.js", + "extract.plugin.js" + ] + }, + "名称空间\\t: {}": { + "en": "Namespaces\\t\\t: {}", + "$file": [ + "compile.command.js", + "extract.plugin.js" + ] + }, + "模块类型\\t: {}": { + "en": "Module type\\t\\t: {}", + "$file": [ + "compile.command.js" + ] + }, + "编译结果输出至:{}": { + "en": "Compile to:{}", + "$file": [ + "compile.command.js" + ] + }, + "读取语言文件{}失败:{}": { + "en": "Error while read language of file<{}>:{}", + "$file": [ + "compile.command.js" + ] + }, + " - 共合成{}条语言包文本": { + "en": " - Total {} messages", + "$file": [ + "compile.command.js" + ] + }, + " - 语言包文件: {}": { + "en": " - language messages: {}", + "$file": [ + "compile.command.js" + ] + }, + " - idMap文件: {}": { + "en": " - idMap file: {}", + "$file": [ + "compile.command.js" + ] + }, + " - 格式化器:{}": { + "en": " - Formatters:{}", + "$file": [ + "compile.command.js" + ] + }, + " - 更新格式化器:{}": { + "en": " - Update formatters:{}", + "$file": [ + "compile.command.js" + ] + }, + " - 访问入口文件: {}": { + "en": " - Entry of language: {}", + "$file": [ + "compile.command.js" + ] + }, + "加载多语言配置文件<{}>失败: {} ": { + "en": "Failed to load multilingual configuration file <{}>: {}", + "$file": [ + "compile.command.js" + ] + }, + "目标文件夹<{}>不存在": { + "en": "The destination folder < {} > does not exist", + "$file": [ + "extract.command.js" + ] + }, + "扫描提取范围:": { + "en": "Scan for:", + "$file": [ + "extract.command.js" + ] + }, + "工程项目所在目录": { + "en": "Project directory", + "$file": [ + "index.js" + ] + }, + "初始化项目国际化配置": { + "en": "Initialize project i18n configuration", + "$file": [ + "index.js" + ] + }, + "输出调试信息": { + "en": "Output debug information", + "$file": [ + "index.js" + ] + }, + "重新生成当前项目的语言配置": { + "en": "Regenerate the language configuration of the current project", + "$file": [ + "index.js" + ] + }, + "生成的js模块类型,取值auto,esm,cjs": { + "en": "Generated JS module type, with values of auto, esm, cjs", + "$file": [ + "index.js" + ] + }, + "支持的语言列表": { + "en": "Supported languages", + "$file": [ + "index.js" + ] + }, + "工程目录:{}": { + "en": "Folder of project:{}", + "$file": [ + "index.js" + ] + }, + "扫描并提取所有待翻译的字符串到文件夹中": { + "en": "Scan and extract all strings to be translated into the folder", + "$file": [ + "index.js" + ] + }, + "支持的语言": { + "en": "Supported languages", + "$file": [ + "index.js" + ] + }, + "默认语言": { + "en": "Default language", + "$file": [ + "index.js" + ] + }, + "激活语言": { + "en": "Active language", + "$file": [ + "index.js" + ] + }, + "翻译名称空间": { + "en": "Namespaces", + "$file": [ + "index.js" + ] + }, + "排除要扫描的文件夹,多个用逗号分隔": { + "en": "Exclude folders to scan, multiple separated by commas", + "$file": [ + "index.js" + ] + }, + "本次提取内容与已存在内容的数据合并策略,默认取值sync=同步,overwrite=覆盖,merge=合并": { + "en": " strategy of messages merge,with value of sync(default),overwrite,merge", + "$file": [ + "index.js" + ] + }, + "要扫描的文件类型": { + "en": "Type of file to scan", + "$file": [ + "index.js" + ] + }, + "语言配置文件<{}>已存在,将优先使用此配置文件中参数来提取文本": { + "en": "The language configuration file <{}> already exists. It will be used preferentially to extract messages", + "$file": [ + "index.js" + ] + }, + "编译指定项目的语言包": { + "en": "Compiles the language messages for project", + "$file": [ + "index.js" + ] + }, + "输出模块类型,取值auto,esm,cjs": { + "en": "Output module type, values: auto, esm, cjs", + "$file": [ + "index.js" + ] + }, + "语言包文件夹<{}>不存在": { + "en": "The language messages folder <{}> does not exist", + "$file": [ + "index.js" + ] + }, + "找不到{}文件,{}只能在js项目工程中使用": { + "en": "Cannot find {} file, {} can only be used in JS project", + "$file": [ + "init.command.js" + ] + }, + "找不到package.json文件,voerkai18n只能在js项目工程中使用": { + "en": "Cannot find file, voerkai18n can only be used in JS project", + "$file": [ + "init.command.js" + ] + }, + "语言配置文件{}文件已存在,跳过创建。\\n使用{}可以重新覆盖创建": { + "en": "Language configuration {} file already exists, skipping creation\\n use {} to overwrite the creation", + "$file": [ + "init.command.js" + ] + }, + "生成语言配置文件:{}": { + "en": "Generate language configuration: {}", + "$file": [ + "init.command.js" + ] + }, + "拟支持的语言:{}": { + "en": "Languages to be supported:{}", + "$file": [ + "init.command.js" + ] + }, + "初始化成功,下一步:": { + "en": "Initialization succeeded, next step:", + "$file": [ + "init.command.js" + ] + }, + " - 编辑{}确定拟支持的语言种类等参数": { + "en": " - Edit language parameters in {}", + "$file": [ + "init.command.js" + ] + }, + " - 运行<{}>扫描提取要翻译的文本": { + "en": " - Run <{}> scan to extract the messages to be translated", + "$file": [ + "init.command.js" + ] + }, + " - 运行<{}>编译语言包": { + "en": " - Run <{}> compile language messages", + "$file": [ + "init.command.js" ] } } \ No newline at end of file diff --git a/packages/tools/package.json b/packages/tools/package.json index 9b9fe62..1fe4836 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -6,7 +6,8 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "extract": "node ./index.js extract -d -e babel-plugin-voerkai18n.js,templates/**", - "compile": "node ./index.js compile -d" + "compile": "node ./index.js compile -d", + "compile:en": "cross-env LANGUAGE=en node ./index.js compile -d" }, "author": "", "bin": { @@ -20,12 +21,15 @@ "art-template": "^4.13.2", "commander": "^9.0.0", "deepmerge": "^4.2.2", - "dotenv": "^16.0.0", "glob": "^7.2.0", "gulp": "^4.0.2", "logsets": "^1.0.8", "readjson": "^2.2.2", "through2": "^4.0.2", - "vinyl": "^2.2.1" + "vinyl": "^2.2.1", + "cross-env": "^7.0.3" + }, + "devDependencies": { + } } diff --git a/packages/tools/stringify.js b/packages/tools/stringify.js index 788d1ee..6224c0b 100644 --- a/packages/tools/stringify.js +++ b/packages/tools/stringify.js @@ -40,7 +40,7 @@ const { isPlainObject } = require("./utils") * @returns */ function escape(str){ - return str.replaceAll('\\','\\\\') + return str.replaceAll(/\\\\(?![trnbvf'"]{1})/g,"\\\\") .replaceAll("\t","\\t") .replaceAll("\n","\\n") .replaceAll("\b","\\b") @@ -48,9 +48,22 @@ const { isPlainObject } = require("./utils") .replaceAll("\f","\\f") .replaceAll("\'","\\'") .replaceAll('\"','\\"') - .replaceAll('\v','\\v') - + .replaceAll('\v','\\v') } +function unescape(str){ + return str + .replaceAll("\\t","\t") + .replaceAll("\\n","\n") + .replaceAll("\\b","\b") + .replaceAll("\\r","\r") + .replaceAll("\\f","\f") + .replaceAll("\\'","\'") + .replaceAll('\\"','\"') + .replaceAll('\\v','\v') + //.replaceAll("\\\\",'\\') +} + + /** * 获取字符串的长度,中文算两个字符 * @param {*} s @@ -67,7 +80,7 @@ function getStringWidth(s){ return realLength; } -function objectToString(obj,{indent=4,alignKey=true}={}){ +function objectStringify(obj,{indent=4,alignKey=true}={}){ function nodeToString(node,level,last=false){ let results = [],beginChar = "",endChar = "" level++ @@ -136,6 +149,10 @@ function objectToString(obj,{indent=4,alignKey=true}={}){ } return nodeToString(obj,0,true) } -module.exports = objectToString - - +module.exports = { + objectStringify, + escape, + escape2, + unescape +} + \ No newline at end of file diff --git a/packages/tools/templates/entry.js b/packages/tools/templates/entry.js index b2abba9..dc70408 100644 --- a/packages/tools/templates/entry.js +++ b/packages/tools/templates/entry.js @@ -1,55 +1,42 @@ {{if moduleType === "esm"}} import messageIds from "./idMap.js" -import { translate,I18nManager } from "@voerkai18n/runtime" -import defaultMessages from "./{{defaultLanguage}}.js" +import { translate,I18nManager,i18nScope } from "@voerkai18n/runtime" import scopeSettings from "./settings.js" +import formatters from "./formatters.js" +import defaultMessages from "./{{defaultLanguage}}.js" +{{if defaultLanguage === activeLanguage}}const activeMessages = defaultMessages +{{else}}import activeMessages from "./{{activeLanguage}}.js"{{/if}} {{else}} const messageIds = require("./idMap") -const { translate,I18nManager } = require("@voerkai18n/runtime") -const defaultMessages = require("./{{defaultLanguage}}.js") +const { translate,I18nManager,i18nScope } = require("@voerkai18n/runtime") const scopeSettings = require("./settings.js") +const formatters = require("./formatters.js") +const defaultMessages = require("./{{defaultLanguage}}.js") +{{if defaultLanguage === activeLanguage}}const activeMessages = defaultMessages +{{else}}const activeMessages = require("./{{activeLanguage}}.js"){{/if}} {{/if}} - -// 自动创建全局VoerkaI18n实例 -if(!globalThis.VoerkaI18n){ - globalThis.VoerkaI18n = new I18nManager(scopeSettings) -} - -let scope = { - defaultLanguage: "{{defaultLanguage}}", // 默认语言名称 +// 语言作用域 +const scope = new i18nScope({ + ...scopeSettings, // languages,defaultLanguage,activeLanguage,namespaces,formatters + id: "{{scopeId}}", // 当前作用域的id,自动取当前工程的package.json的name default: defaultMessages, // 默认语言包 - messages : defaultMessages, // 当前语言包 + messages : activeMessages, // 当前语言包 idMap:messageIds, // 消息id映射列表 - formatters:{}, // 当前作用域的格式化函数列表 - loaders:{}, // 异步加载语言文件的函数列表 - global:{}, // 引用全局VoerkaI18n配置,注册后自动引用 - // 主要用来缓存格式化器的引用,当使用格式化器时可以直接引用,避免检索 - $cache:{ - activeLanguage:null, - typedFormatters:{}, - formatters:{}, + formatters, // 当前作用域的格式化函数列表 + loaders:{ {{each languages}}{{if $value.name !== defaultLanguage}} + {{if $value.name == activeLanguage}}"{{$value.name}}" : ()=>activeMessages{{else}}"{{$value.name}}" : ()=>import("./{{$value.name}}.js"){{/if}}{{if $index !== languages.length - 1}},{{/if}}{{/if}}{{/each}} } -} - -let supportedlanguages = {} - -{{each languages}}{{if $value.name !== defaultLanguage}} -scope.loaders["{{$value.name}}"] = ()=>import("./{{$value.name}}.js") -{{/if}}{{/each}} - +}) +// 翻译函数 const t = translate.bind(scope) -const languages = {{@ JSON.stringify(languages,null,4) }} -// 注册当前作用域到全局VoerkaI18n实例 -VoerkaI18n.register(scope) - {{if moduleType === "esm"}} -export { t, languages,scope,i18nManager:VoerkaI18n, changeLanguage:VoerkaI18n.change.bind(VoerkaI18n),addLanguageListener:VoerkaI18n.on.bind(VoerkaI18n),removeLanguageListener:VoerkaI18n.off.bind(VoerkaI18n) } +export { + t, + scope, + i18nManager:VoerkaI18n, +} {{else}} -module.exports.languages = languages -module.exports.scope = scope module.exports.t = t -module.exports.changeLanguage = VoerkaI18n.change.bind(VoerkaI18n) -module.exports.addLanguageListener = VoerkaI18n.on.bind(VoerkaI18n) -module.exports.removeLanguageListener = VoerkaI18n.off.bind(VoerkaI18n) +module.exports.scope = scope module.exports.i18nManager = VoerkaI18n {{/if}} diff --git a/packages/tools/templates/formatters.js b/packages/tools/templates/formatters.js index da15e61..9ccc6ff 100644 --- a/packages/tools/templates/formatters.js +++ b/packages/tools/templates/formatters.js @@ -47,38 +47,37 @@ * */ -const formatters = { - "*":{ }, // 在所有语言下生效的格式化器 - $types:{ } // 在所有语言下只作用于特定数据类型的格式化器 -{{each languages}} - {{$value.name}}:{ +{{if moduleType === "esm"}}export default {{else}}module.exports = {{/if}}{ + // 在所有语言下生效的格式化器 + "*":{ + //[格式化名称]:(value)=>{...}, + //[格式化名称]:(value,arg)=>{...}, + }, + // 在所有语言下只作用于特定数据类型的格式化器 + $types:{ + + }, +{{each languages}} {{$value.name}}:{ $types:{ - "*":{ + // 所有类型的默认格式化器 + // "*":{ - }, - Date:{ + // }, + // Date:{ - }, - Number:{ + // }, + // Number:{ - }, - String:{ + // }, + // String:{ - }, - Array:{ + // }, + // Array:{ - }, - Object:{ + // }, + // Object:{ - } + // } } - } -{{/each}} -} - -{{if moduleType === "esm"}} -export default formatters -{{else}} -module.exports = formatters -{{/if}} - + }{{if $index !== languages.length - 1}},{{/if}} +{{/each}}} \ No newline at end of file diff --git a/packages/tools/utils.js b/packages/tools/utils.js index 9ef8a54..03fa71c 100644 --- a/packages/tools/utils.js +++ b/packages/tools/utils.js @@ -28,6 +28,31 @@ async function importModule(url){ } } + + +/** + * 读取指定文件夹的package.json文件,如果当前文件夹没有package.json文件,则向上查找 + * @param {*} folder + * @param {*} exclueSelf =true 排除folder,从folder的父级开始查找 + * @returns + */ +function getCurrentPackageJson(folder,exclueSelf=true){ + try{ + let pkgPath =exclueSelf ? + path.join(folder, "..", "package.json") + : path.join(folder, "package.json") + if(fs.existsSync(pkgPath)){ + let pkg = readJson.sync(pkgPath) + return pkg + } + let parent = path.dirname(folder) + if(parent===folder) return null + return getCurrentPackageJson(parent,false) + }catch(e){ + return null + } +} + function createPackageJsonFile(targetPath,moduleType="auto"){ if(moduleType==="auto"){ moduleType = findModuleType(targetPath) @@ -111,12 +136,39 @@ function createJsModuleFile(filename,defaultExports={},namedExports={},moduleTyp } } +function escape(str){ + return str + .replaceAll("\\t","\t") + .replaceAll("\\n","\n") + .replaceAll("\\b","\b") + .replaceAll("\\r","\r") + .replaceAll("\\f","\f") + .replaceAll("\\'","\'") + .replaceAll('\\"','\"') + .replaceAll('\\v','\v') + .replaceAll("\\\\",'\\') +} + +// 翻译函数 +// @voerkai18n/tools工程本身使用了voerkai18n,即@voerkai18n/tools的extract和compile依赖于其自己生成的languages运行时 +// 这样产生了鸡蛋问题,因此在extract与compile调试阶段如果t函数无法使用(即编译的languages无法正常使用),则需要提供t函数 +// 此函数的目的是提供一种容错方式 +let t +try{ + t = require("./languages").t +}catch(e){ + console.warn(e.stack) + t = v=>v +} module.exports = { importModule, findModuleType, createPackageJsonFile, - isPlainObject + isPlainObject, + getCurrentPackageJson, + escape, + t } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5400c22..f090f24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,7 +48,7 @@ importers: packages/demo/apps/app: specifiers: '@voerkai18n/tools': workspace:^1.0.0 - dependencies: + devDependencies: '@voerkai18n/tools': link:../../../tools packages/demo/apps/app/languages: @@ -91,8 +91,8 @@ importers: '@voerkai18n/runtime': workspace:^1.0.0 art-template: ^4.13.2 commander: ^9.0.0 + cross-env: ^7.0.3 deepmerge: ^4.2.2 - dotenv: ^16.0.0 glob: ^7.2.0 gulp: ^4.0.2 logsets: ^1.0.8 @@ -106,13 +106,14 @@ importers: art-template: 4.13.2 commander: 9.0.0 deepmerge: 4.2.2 - dotenv: 16.0.0 glob: 7.2.0 gulp: 4.0.2 logsets: 1.0.8 readjson: 2.2.2 through2: 4.0.2 vinyl: 2.2.1 + devDependencies: + cross-env: 7.0.3 packages/vue: specifiers: @@ -2426,6 +2427,14 @@ packages: /core-util-is/1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + /cross-env/7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + dependencies: + cross-spawn: 7.0.3 + dev: true + /cross-spawn/7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -2569,11 +2578,6 @@ packages: webidl-conversions: 5.0.0 dev: true - /dotenv/16.0.0: - resolution: {integrity: sha512-qD9WU0MPM4SWLPJy/r2Be+2WgQj8plChsyrCNQzW/0WjvcJQiKQJ9mH3ZgB3fxbUUxgc/11ZJ0Fi5KiimWGz2Q==} - engines: {node: '>=12'} - dev: false - /duplexify/3.7.1: resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} dependencies: diff --git a/readme.md b/readme.md index e391164..75d9fa2 100644 --- a/readme.md +++ b/readme.md @@ -6,17 +6,37 @@ 哪么到底是对现有解决方案有什么不满?最主要有三点: -- 大部份均为要翻译的文本信息指定一个`key`,然后在源码文件中使用形如`$t("message.login")`之类的方式,然后在翻译时将之转换成最终的文本信息。此方式最大的问题是,在源码中必须人为地指定每一个`key`,在中文语境中,想为每一句中文均配套想一句符合语义的`英文key`是比较麻烦的,也很不直观。我希望在源文件中就直接使用中文,如`t("中华人民共和国万岁")`,然后国际化框架应该能自动处理后续的一系列麻烦。 +- 大部份均为要翻译的文本信息指定一个`key`,然后在源码文件中使用形如`$t("message.login")`之类的方式,然后在翻译时将之转换成最终的文本信息。此方式最大的问题是,在源码中必须人为地指定每一个`key`,在中文语境中,想为每一句中文均配套想一句符合语义的`英文key`是比较麻烦的,也很不直观不符合直觉。我希望在源文件中就直接使用中文,如`t("中华人民共和国万岁")`,然后国际化框架应该能自动处理后续的一系列麻烦。 -- 要能够比较友好地支持多包`monorepo`场景下的国际化协作,当主程序切换语言时,其他包或库也可以自动切换,并且在开发上每个包或库均可以独立地进行开发,集成到主程序时能无缝集成。这点在现有方案上没有找到比较理想的解决方案。 +- 要能够比较友好地支持多库多包`monorepo`场景下的国际化协作,当主程序切换语言时,其他包或库也可以自动切换,并且在开发上每个包或库均可以独立地进行开发,集成到主程序时能无缝集成。这点在现有方案上没有找到比较理想的解决方案。 - 大部份国际化框架均将中文视为二等公民,大部份情况下您应该采用英文作为第一语言,虽然这不是太大的问题,但是既然要再造一个轮子,为什么不将中文提升到一等公民呢。 -当然,在使用方式上要尽可能简洁,便 + 基于此就开始造出`VoerkaI18n`这个全新的国际化多语言解决方案,主要特性包括: -基于此 +- 简单易用 + +- 符合直觉,不需要手动定义文本`Key`映射。 + +- 完整的自动化工具链支持,包括项目初始化、提取文本、编码语言等。 + +- 支持`babel`插件自动导入t翻译函数。 + +- 支持`nodejs`、浏览器(`vue`/`react`)前端环境。 + +- 采用`工程工具链`与`运行时`分开设计,发布时只需要集成很小和运行时。 + +- 高度可扩展的`复数`、`货币`、`数字`等常用的多语言处理机制。 + +- 通过`格式化器`可以扩展出强大的多语言特性。 + +- 翻译过程内,提取文本可以自动进行同步,并保留已翻译的内容。 + +- 可以随时添加支持的语言 + + # 安装 @@ -44,9 +64,8 @@ - **@voerkai18/formatters** - 可选的,一些额外的格式化器,可以按需进行安装到`dependencies`中 + 可选的,一些额外的格式化器,可以按需进行安装到`dependencies`中,用来扩展翻译时对插值变量的额外处理。 - # 快速入门 @@ -64,29 +83,51 @@ myapp ```javascript // index.js - console.log(t("中华人民共和国万岁")) console.log(t("中华人民共和国成立于{}",1949)) ``` -`t`翻译函数是在哪里声明的?先卖个关子,后续揭晓。 +`t`翻译函数是从`myapp/languages/index.js`文件导出的翻译函数,但是现在`myapp/languages`还不存在,后续会使用工具自动生成。`voerkai18n`后续会使用正则表达式对提取要翻译的文本。 ## 第二步:提取要翻译的内容 接下来我们使用`voerkai18n extract`命令来自动扫描工程源码文件中的需要的翻译的文本信息。 ```shell -myapp>voerkai18n extract --languages cn,en,de,jp --default cn --active cn +myapp>voerkai18n extract -d -lngs cn,en,de,jp -default cn -active cn ``` 以上命令代表: -- 扫描当前文件夹下所有源码文件 +- 扫描当前文件夹下所有源码文件,默认是`js`、`jsx`、`html`、`vue`文件类型,并排除`node_modules`。 - 计划支持`cn`、`en`、`de`、`jp`四种语言 -- 默认语言是中文。(指在源码文件中我们直接使用中文,好象其他方案大部份均要求采用英文) +- 默认语言是中文。(指在源码文件中我们直接使用中文即可) - 激活语言是中文(即默认切换到中文) +- `-d`代表显示扫描调试信息 -执行`voerkai18n extract`命令后,就会在`myapp/languages`通过生成`translates/default.json`文件,该文件就是需要进行翻译的文本信息,形式如下: +执行`voerkai18n extract`命令后,就会在`myapp/languages`通过生成`translates/default.json`、`settings.js`等相关文件。 + +- **translates/default.json** : 该文件就是需要进行翻译的文本信息。 + +- **settings.js**: 语言环境的基本配置信息,可以进行修改。 + +最后文件结构如下: + +```shell +myapp + |-- languages + |-- settings.js // 语言配置文件 + |-- package.json + |-- translates // 此文件夹是所有需要翻译的内容 + |-- default.json // 默认名称空间内容 + |-- package.json + |-- index.js + +``` + +## 第三步:翻译文本 + +接下来就可以分别对`language/translates`文件夹下的所有`JSON`文件进行翻译了。每个`JSON`文件大概如下: ```json { @@ -105,20 +146,15 @@ myapp>voerkai18n extract --languages cn,en,de,jp --default cn --active cn } ``` -最后文件结构如下: +我们只需要修改该文件翻译对应的语言即可。 -```shell -myapp - |-- languages - |-- settings.js // 语言配置文件 - |-- translates // 此文件夹是所有需要翻译的内容 - |-- default.json - |-- package.json - |-- index.js +**重点:如果翻译期间对源文件进行了修改,则只需要重新执行一下`voerkai18n extract`命令,该命令会进行以下操作:** -``` +- 如果文本内容在源代码中已经删除了,则会自动从翻译清单中删除。 +- 如果文本内容在源代码中已修改了,则会视为新增加的内容。 +- 如果文本内容已经翻译了一部份了,则会保留已翻译的内容。 -> **注:**当我们修改了源文件后,可以多次执行`voerkai18n extract`命令,该命令会自动同步合并已翻译的内容,不会导致进行了一半的翻译内容丢失,可以放心执行。 +因此,反复执行`voerkai18n extract`命令是安全的,不会导致进行了一半的翻译内容丢失,可以放心执行。 ## 第三步:编码语言包 @@ -132,6 +168,7 @@ myapp> voerkai18n compile ```javascript |-- languages + |-- package.json |-- settings.js // 语言配置文件 |-- idMap.js // 文本信息id映射表 |-- index.js // 包含该应用作用域下的翻译函数等 @@ -139,65 +176,395 @@ myapp> voerkai18n compile |-- en.js |-- jp.js |-- de.js - |-- translates // 此文件夹是所有需要翻译的内容 + |-- translates // 此文件夹包含了所有需要翻译的内容 |-- default.json |-- package.json |-- index.js ``` - - ## 第四步:导入翻译函数 第一步中我们在源文件中直接使用了`t`翻译函数包装要翻译的文本信息,该`t`翻译函数就是在编译环节自动生成并声明在`myapp/languages/index.js`中的。 - - +```javascript import { t } from "./languages" +``` +因此,我们需要在需要进行翻译时导入该函数即可。但是如果源码文件很多,重次重复导入`t`函数也是比较麻烦的,所以我们也提供了一个`babel插件`来自动导入`t`函数。 +## 第五步:切换语言 -是`myapp/languages/index.js`文件导出的翻译函数,但是现在`myapp/languages`还不存在,后续会使用工具自动生成。 +当需要切换语言时,可以通过调用scope方法来切换语言。 + +```javascript +import { scope } from "./languages" + +// 切换到英文 +await scope.change("en") +// VoerkaI18n是一个全局单例,可以直接访问 +VoerkaI18n.change("en") +``` + +`scope.change`与`VoerkaI18n.change`两者是等价的。 + +一般可能也需要在语言切换后进行界面更新渲染,可以订阅事件来响应语言切换。 + +```javascript +import { scope } from "./languages" + +// 切换到英文 +scope.on((newLanguage)=>{ + ... +}) +// +VoerkaI18n.on((newLanguage)=>{ + ... +}) +``` # 指南 ## 翻译函数 -默认提供翻译函数`t`用来进行翻译。 +默认提供翻译函数`t`用来进行翻译。一般情况下,`t`函数在执行`voerkai18n compile`命令生成在工程目录下的`languages`文件夹中。 ```javascript // 从当前语言包文件夹index.js中导入翻译函数 import { t } from "/languages" +// 不含插值变量 t("中华人民共和国") // 位置插值变量 t("中华人民共和国{}","万岁") t("中华人民共和国成立于{}年,首都{}",1949,"北京") + // 当仅有两个参数且第2个参数是[]类型时,自动展开第一个参数进行位置插值 t("中华人民共和国成立于{year}年,首都{capital}",[1949,"北京"]) - -、 + // 当仅有两个参数且第2个参数是{}类型时,启用字典插值变量 t("中华人民共和国成立于{year}年,首都{capital}",{year:1949,capital:"北京"}) -// 插值变量可以是同步函数,在进行插值时调用。 +// 插值变量可以是同步函数,在进行插值时自动调用。 t("中华人民共和国成立于{year}年,首都{capital}",()=>1949,"北京") // 对插值变量启用格式化器 -t("中华人民共和国成立于{date | year}年",{date:new Date('')}) +t("中华人民共和国成立于{birthday | year}年",{birthday:new Date()}) ``` +**注意:** + +- `voerkai18n`使用正则表达式来提取要翻译的内容,因此`t()`可以使用在任意地方。 + +- +## 插值变量 + +`voerkai18n`的`t`函数支持使用**插值变量**,用来传入一个可变内容。 + +插值变量有`命名插值变量`和`位置插值变量`。 + +### **命名插值变量** + +可以在t函数中使用`{变量名称}`表示一个命名插值变量。 + +```javascript +t("我姓名叫{name},我今年{age}岁",{name:"tom",age:12}) +// 如果值是函数会自动调用 +t("我姓名叫{name},我今年{age}岁",{name:"tom",age:()=>12}) +``` + +仅当`t`函数仅有两个参数且第2个参数是`{}`类型时,启用字典插值变量,翻译时会自动进行插值。 + +### 位置插值变量 + +可以在t函数中使用一个空的`{}`表示一个位置插值变量。 + +```javascript +t("我姓名叫{},我今年{}岁","tom",12) +// 如果值是函数会自动调用 +t("我姓名叫{},我今年{}岁","tom",()=>12}) +// 如果只有两个参数,且第2个参数是一个数组,会自动展开 +t("我姓名叫{},我今年{}岁",["tom",12]) +//如果第2个参数不是{}时就启用位置插值。 +t("我姓名叫{name},我今年{age}岁","tom",()=>12) +``` + + +## 插值变量格式化 + +`voerka-i18n`支持强大的插值变量格式化机制,可以在插值变量中使用`{变量名称 | 格式化器名称 | 格式化器名称(...参数) | ... }`类似管道操作符的语法,将上一个输出作为下一个输入,从而实现对变量值的转换。此机制是`voerka-i18n`实现复数、货币、数字等多语言支持的基础。 + +### **格式化器语法** + +我们假设定义以下格式化器(如果定义格式化器,详见后续)来进行示例。 + +- **UpperCase**:将字符转换为大写 +- **division**:对数字按每n位一个逗号分割,支持一个可选参数分割位数,如`division(123456)===123,456`,`division(123456,4)===12,3456` +- **mr** : 自动添加一个先生称呼 + +```javascript +// My name is TOM +t("My name is { name | UpperCase }",{name:"tom"}) + +// 我国2021年的GDP是¥14,722,730,697,890 +t("我国2021年的GDP是¥{ gdp | division}",{gdp:14722730697890}) + +// 支持为格式化器提供参数,按4位一逗号分割 +// 我国2021年的GDP是¥14,7227,3069,7890 +t("我国2021年的GDP是¥{ gdp | division(4)}",{gdp:14722730697890}) + +// 支持连续使用多个格式化器 +// My name is Mr.TOM +t("My name is { name | UpperCase | mr }",{name:"tom"}) + + +``` + +每个格式化器本质上是一个`(value)=>{...}`的函数,并且能**将上一个格式化器的输出作为下一个格式化器的输入**,格式化器具有如下特性: + +- **无参数格式化器** + + 使用无参数格式化器时只需传入名称即可。例如:`My name is { name | UpperCase }` + +- **有参数格式化器** + + 格式化器支持传入参数,如`{ gdp | division(4)}`、`{ date | format('yyyy/MM/DD')}` + + 特别需要注意的是,格式化器的参数只能支持简单的类型的参数,如`数字`、`布尔型`、`字符串`。 + + **不支持数组、对象和函数参数,也不支持复杂的表达式参数。** + +- **支持连续使用多个格式化器** + + 就如您预期的一样,**将上一个格式化器的输出作为下一个格式化器的输入**。 + + `{data | f1 | f2 | f3(1)}`等效于` f3(f2(f1(data)),1)` + +### 自定义格式化器 + +当我们使用`voerkai18n compile`编译后,会生成`languages/formatters.js`文件,可以在该文件中自定义您自己的格式化器。 + +`formatters.js`文件内容如下: + +```javascript +module.exports = { + // 在所有语言下生效的格式化器 + "*":{ + //[格式化名称]:(value)=>{...}, + //[格式化名称]:(value,arg)=>{...}, + }, + // 在所有语言下只作用于特定数据类型的格式化器 + $types:{ + // [数据类型名称]:(value)=>{...}, + // [数据类型名称]:(value)=>{...}, + }, + cn:{ + $types:{ + // 所有类型的默认格式化器 + "*":{ + }, + Date:{}, + Number:{}, + Boolean:{ }, + String:{}, + Array:{ + + }, + Object:{ + + } + }, + [格式化名称]:(value)=>{.....}, + //..... + }, + en:{ + $types:{ + // [数据类型名称]:(value)=>{...}, + }, + [格式化名称]:(value)=>{.....}, + //.....更多的格式化器..... + } +} +``` + +**说明:** + +#### 格式化器函数 + +**每一个格式化器就是一个普通的同步函数**,不支持异步函数。 + +典型的无参数的格式化器:`(value)=>{....返回格式化的结果...}`。 + +带参数的格式化器:`(value,arg1,...)=>{....返回格式化的结果...}`,其中`value`是上一个格式化器的输出结果。 + +#### 类型格式化器 + +可以为每一种数据类型指定一个默认的格式化器,支持对`String`、`Date`、`Error`、`Object`、`Array`、`Boolean`、`Number`等数据类型的格式化。 + +当插值变量传入时,如果有定义了对应的的类型格式化器,会先调用该格式化器。 + +比如我们定义对`Boolean`类型格式化器, + +```javascript +//formatters.js + +module.exports = { + // 在所有语言下只作用于特定数据类型的格式化器 + $types:{ + Boolean:(value)=> value ? "ON" : "OFF" + } +} +t("灯状态:{status}",true) // === 灯状态:ON +t("灯状态:{status}",false) // === 灯状态:OFF +``` + +在上例中,如果我们想在不同的语言环境下,翻译为不同的显示文本,则可以为不同的语言指定类型格式化器 + +```javascript +//formatters.js +module.exports = { + cn:{ + $types:{ + Boolean:(value)=> value ? "开" : "关" + } + }, + en:{ + $types:{ + Boolean:(value)=> value ? "ON" : "OFF" + } + } +} +// 当切换到中文时 +t("灯状态:{status}",true) // === 灯状态:开 +t("灯状态:{status}",false) // === 灯状态:关 +// 当切换到英文时 +t("灯状态:{status}",true) // === 灯状态:ON +t("灯状态:{status}",false) // === 灯状态:OFF +``` + +**说明:** + +- 完整的类型格式化器定义形式 + + ```javascript + module.exports = { + "*":{ + $types:{...} + }, + cn:{ + $types:{...} + }, + en:{ + $types:{....} + } + } + ``` + + 在匹配应用格式化时会先在当前语言的`$types`中查找匹配的格式化器,如果找不到再上`*.$types`中查找。 + +- `*.$types`代表当所有语言中均没有定义时才匹配的类型格式化。 + +- 类型格式化器是默认执行的,不需要指定名称。 + +- 当前作用域的格式化器优先于全局的格式化器。(后续) + +#### 通用的格式化器 + +类型格式化器只针对特定数据类型,并且会默认调用。而通用的格式化器需要使用`|`管道符进行显式调用。 + +同样的,通用的格式化器定义在`languages/formatters.js`中。 + +```javascript +module.exports = { + "*":{ + $types:{...}, + [格式化名称]:(value)=>{.....}, + }, + cn:{ + $types:{...}, + [格式化名称]:(value)=>{.....}, + }, + en:{ + $types:{....}, + [格式化名称]:(value)=>{.....}, + [格式化名称]:(value,arg)=>{.....}, + } +} +``` + +每一个格式化器均需要指定一个名称,在进行插值替换时会优先依据当前语言来匹配查找格式化器,如果找不到,再到键名为`*`中查找。 + +```javascript +module.exports = { + "*":{ + uppercase:(value)=>value + }, + cn:{ + uppercase:(value)=>["一","二","三","四","五","六","七","八","九","十"][value-1] + }, + en:{ + uppercase:(value)=>["One","Two","Three","Four","Five","Six","seven","eight","nine","ten"][value-1] + }, + jp:{ + + } +} +// 当切换到中文时 +t("{value | uppercase}",1) // == 一 +t("{value | uppercase}",2) // == 二 +t("{value | uppercase}",3) // == 三 +// 当切换到英文时 +t("{value | uppercase}",1) // == One +t("{value | uppercase}",2) // == Two +t("{value | uppercase}",3) // == Three +// 当切换到日文时,由于在该语言下没有定义uppercase格式式,因此到*中查找 +t("{value | uppercase}",1) // == 1 +t("{value | uppercase}",2) // == 2 +t("{value | uppercase}",3) // == 3 +``` + +### 作用域格式化器 + +定义在`languages/formatters.js`里面的格式化器仅在当前工程生效,也就是仅在当前作用域生效。一般由应用开发者自行扩展。 + +关于作用域的概念详见后续介绍。 + +### 全局格式化器 + +定义在`@voerkai18n/runtime`里面的格式化器则全局有效,在所有场合均可以使用,但是其优先级低于作用域内的同名格式化器。目前内置的格式化器有: + +### 扩展格式化器 + +除了可以在当前项目`languages/formatters.js`自定义格式化器和`@voerkai18n/runtime`里面的全局格式化器外,在`@voerkai18n/formatters`中包含了更多的格式化器。 + +作为开源项目,欢迎大家提交贡献更多的格式化器。`@voerkai18n/formatters` + +## 日期时间 + +`@voerkai18n/runtime`内置了对日期时间进行处理的格式化器,可以直接使用,不需要额外的安装。 + +```javascript +// 切换到中文 +t("现在是{d | date}",new Date()) // == 现在是2022年3月12日 +t("现在是{d | time}",new Date()) // == 现在是18点28分12秒 +t("现在是{d | shorttime}",new Date()) // == 现在是18:28:12 +t("现在是{}",new Date()) // == 现在是2022年3月12日 18点28分12秒 + +// 切换到英文 +t("现在是{d | date}",new Date()) // == Now is 2022/3/12 +t("现在是{d | time}",new Date()) // == Now is 18:28:12 +t("现在是{}",new Date()) // == Now is 2022/3/20 19:17:24' +``` ## 复数 -默认情况下,`t`函数仅仅处理单数形式的翻译,当翻译文本内容是一个`数组`时启用复数处理机制。 +当翻译文本内容是一个`数组`时启用复数处理机制。即在`langauges/tranclates/*.json`中的文本翻译项是一个数据。 ### 启用复数处理机制 -假设在index.html文件中具有一个翻译内容 +假设在`index.html`文件中具有一个翻译内容 ```javascript t("我{}一辆车") ``` @@ -215,9 +582,9 @@ t("中华人民共和国成立于{date | year}年",{date:new Date('')}) ```json { "我有{}辆车":{ - "en":["I don't have a car","I have a car","I have two cars","I have {} cars"], - "en":["I don't have a car","I have a car","I have {} cars"], - "en":["I don't have a car","I have {} cars"], + "en":["I don't have car","I have a car","I have two cars","I have {} cars"], + "en":["I don't have car","I have a car","I have {} cars"], + "en":["I don't have car","I have {} cars"], "de":"...." } } @@ -225,13 +592,13 @@ t("中华人民共和国成立于{date | year}年",{date:new Date('')}) 上例中,只需要在翻译文件中将上述的`en:""`更改为`[<0对应的复数文本>,<1对应的复数文本>,...,]`形式代表启动复数机制. - 可以灵活地为每一个数字(`0、1、2、...、n`)对应的复数形式进行翻译 - 数量数字大于数组长度,则总是取最后一个复数形式 -- 复数形式的文本同样机制位置插值和变量插值。 +- 复数形式的文本同样支持位置插值和变量插值。 ### 对应的翻译函数 -启用复数处理机制后,在`t`函数按如下方式进行处理。 +启用复数处理机制后,在`t`函数根据变量值来决定采用单数还是复数,按如下规则进行处理。 - **不存在插值变量且t函数的第2个参数是数字** @@ -249,10 +616,10 @@ t("我有一辆车",100) // == "I have 100 cars" 就中文而言,上述没有指定插值变量是比较别扭的,一般可以引入一个位置插值变量更加友好。 ```javascript -t("我{}一辆车",0) // == "I don't have a car" -t("我{}一辆车",1) // == "I have a car" -t("我{}一辆车",2) // == "I have two cars" -t("我{}一辆车",100) // == "I have 100 cars" +t("我有{}辆车",0) // == "I don't have a car" +t("我有{}辆车",1) // == "I have a car" +t("我有{}辆车",2) // == "I have two cars" +t("我有{}辆车",100) // == "I have 100 cars" ``` - **复数命名插值变量** @@ -308,150 +675,500 @@ t("第{}章",100) // == Chapter 100 ``` -## 插值变量格式化 +## 字典 -voerka-i18n支持对插值变量进行格式化 -{{value | filter}} 过滤器语法类似管道操作符,它的上一个输出作为下一个输入。 -```javascript - -new VoerkaI18n({ - formats:{ - Date:{ // 日期格式 - en:{ - default:(value)=>dayjs(value).format("YYYY/MM/DD"), - time:(value)=>dayjs(value).format("HH:hh:mm"), - // 可以定义多种自定义格式... - }, - cn:{ - default:(value)=>dayjs(value).format("YYYY年MM月DD日"), - time:(value)=>dayjs(value).format("HH:hh:mm"), - // 可以定义多种自定义格式... - }, - }, - String:{ - en:{ - firstUpper:(value)=>value[0].toUpperCase()+value.substr(1) // 首字母大写 - } - } - } -}) -``` -以上代码定义了: -- `Date`类型的英文和中文的两个格式化函数 -- `String`类型变量的`firstUpper`格式化函数 - -接下来,在翻译内容中使用。 +`voerkiai18n`内置一个`dict`格式化器,可以直接使用。 ```javascript - -// languages/translates/default.json -{ - "今天是{date}":{ - en:"Today is {date}", // 使用默认的格式化器 - }, - "现在是北京时间:{date}":{ - en:"Now is { date | time}" // 使用指定的格式化器time - }, - -t("今天是{date}",{date:new Date()}) - -``` - -### 字典 - -当翻译内容是一个{}时,启用字典插值模式。 -```javascript -// 源文件 // 假设网络状态取值:0=初始化,1=正在连接,2=已连接,3=正在断开.4=已断开,>4=未知 - t("当前状态:{status}",{status}) - -// translates/default.json - -{ - "当前状态:{status}":{ - cn:{0:"初始化",1:"正在连接",2:"已连接",3:"正在断开",4:"已断开",unknow:"未知"}, - en:{ - to:"Status:{}", - vars:{"Init","Connecting","Connected","Disconnecting","Disconnected","unknow"} - }, - en:"Status : {status | dict({0:"Init",1:"Connecting",2:"Connected",3:"Disconnecting",4:"Disconnected",5:"unknow"})}" - } -} - - +t("当前状态:{status | dict(0,'初始化',1,'正在连接',2,'已连接',3,'正在断开',4,'已断开','未知') }",status) ``` +## 货币 -### 异步格式化器 -大部分情况下,`formatter`均应该是一个简单的同步函数。VoerkaI18n也支持采用异步`formatter`,也就是说`formatter`函数可以是一个`async`函数。 -但是由于`t`函数是一个普通的同步函数,并且在应用程序中使用翻译时采用异步的意义并不大,比如我们打印到日志。 + +## 名称空间 + +`voerkai18n `的名称空间是为了解决当源码文件非常多时,通过名称空间对翻译内容进行分类翻译的。 + +假设一个大型项目,其中源代码文件有上千个。默认情况下,`voerkai18n extract`会扫描所有源码文件将需要翻译的文本提取到`languages/translates/default.json`文件中。由于文件太多会导致以下问题: + +- 内容太多导致`default.json`文件太大,有利于管理 +- 有些翻译往往需要联系上下文才可以作出更准确的翻译,没有适当分类,不容易联系上下文。 + +因此,引入`名称空间`就是目的就是为了解决此问题。 + +配置名称空间,需要配置`languages/settings.js`文件。 ```javascript - console.log(t("用户{ username | friend }没有权限",username)) -``` -上述语句中的friend格式化器如果是异步不可行的,但是在某此情况下,我们又的的确确需要异步的格式化器。 -比如在使用字典时,以下例子中,假设用户职位采用一个字典描述(`0-普通员工`、`1-主管`、`2-经理`、`3-总经理`)。 -常规情况下,应该由应用程序自行读取该字典 -```javascript - const post = await getDict(1) - console.log(t("您的身份是{ post }",post.name)) -``` - -```javascript - console.log(t("您的身份是{ post | dict }",post)) -``` - - - -用异步`formatter`需要遵循一定的流程约定。 - -- 首先定义一个异步`formatter` - -```javascript -{ - cn:{ - dict:async ()=>{ - return { - - } - } +// 工程目录:d:/code/myapp +// languages/settings.js +module.exports = { + namespaces:{ + //"名称":"相对路径", + “routes”:“routes”, + "auth":"core/auth", + "admin":"views/admin" } } ``` -```javascript +以上例子代表: -t(" {value | dict() }",{value:2}) +- 将`d:\code\myapp\routes`中扫描到的文本提取到`routes.json`中。 +- 将`d:\code\myapp\auth`中扫描到的文本提取到`auth.json`中。 +- 将`d:\code\myapp\views/admin`中扫描到的文本提取到`admin.json`中。 +最终在` languages/translates`中会包括: + +```shell +languages + |-- translates + |-- default.json + |-- routes.sjon + |-- auth.json + |-- admin.json ``` -## 合并第三方库语言 +然后,`voerkai18n compile`在编译时会自动合并这些文件,后续就不再需要名称空间的概念了。 +`名称空间`仅仅是为了解决当翻译内容太多时的分类问题。 + +## 多库联动 + +`voerkai18n `支持多个库国际化的联动和协作,即**当主程序切换语言时,所有引用依赖库也会跟随主程序进行语言切换**,整个切换过程对所有库开发都是透明的。 + +![结构图](images\arch.png) + +当我们在开发一个应用或者库并`import "./languages"`时,在`langauges/index.js`进行了如下处理: + +- 创建一个`i18nScope`作用域实例 +- 检测当前应用环境下是否具有全局单例`VoerkaI18n` + - 如果存在`VoerkaI18n`全局单例,则会将当前`i18nScope`实例注册到`VoerkaI18n.scopes`中 + - 如果不存在`VoerkaI18n`全局单例,则使用当前`i18nScope`实例的参数来创建一个`VoerkaI18n`全局单例。 +- 在每个应用与库中均可以使用`import { t } from ".langauges`导入本工程的`t`翻译函数,该`t`翻译函数被绑定当前`i18nScope`作用域实例,因此翻译时就只会使用到本工程的文本。这样就割离了不同工程和库之间的翻译。 +- 由于所有引用的`i18nScope`均注册到了全局单例`VoerkaI18n`,当切换语言时,`VoerkaI18n`会刷新切换所有注册的`i18nScope`,这样就实现了各个`i18nScope`即独立,又可以联动语言切换。 + +## 自动导入翻译函数 + +使用`voerkai18 compile`后,要进行翻译时需要从`./languages`导入`t`翻译函数。 ```javascript +import { t } from "./languages" +``` -import i18n from "voerka-i18n" -import { t } from "./languages" +由于默认情况下,`voerkai18 compile`命令会在当前工程的`/languages`文件夹下,这样我们为了导入`t`翻译函数不得不使用各种相对引用,这即容易出错,又不美观,如下: + +```javascript +import { t } from "./languages" +import { t } from "../languages" +import { t } from "../../languages" +import { t } from "../../../languages" +``` + +作为国际化解决方案,一般工程的大部份源码中均会使用到翻译函数,这种使用体验比较差。 + +为此,我们提供了一个`babel`插件来自动完成翻译函数的自动引入。使用方法如下: + +- 在`babel.config.js`中配置插件 + +```javascript +const i18nPlugin = require("@voerkai18n/tools/babel-plugin-voerkai18n") +module.expors = { + plugins: [ + [ + i18nPlugin, + { + // 可选,指定语言文件存放的目录,即保存编译后的语言文件的文件夹 + // 可以指定相对路径,也可以指定绝对路径 + // location:"", + + autoImport:"#/languages" + } + ] + ] +} +``` + +这样,当在进行`babel`转码时,就会自动在`js`源码文件中导入`t`翻译函数。 + +`babel-plugin-voerkai18n`插件支持以下参数: + +- **location** + + 配置`langauges`文件夹位置,默认会使用当前文件夹下的`languages`文件。 + + 因此,如果你的`babel.config.js`在项目根文件夹,而`languages`文件夹位于`src/languages`,则可以将`location="src/languages"`,这样插件会自动从该文件夹读取需要的数据。 + +- **autoImport** + + 用来配置导入的路径。比如 `autoImport="#/languages" `,则当在babel转码时,如果插件检测到t函数的存在并没有导入,就会自动在该源码中自动导入`import { t } from "#/languages"` + + 配置`autoImport`时需要注意的是,为了提供一致的导入路径,视所使用的打包工具或转码插件,如`webpack`、`rollup`等。比如使用`babel-plugin-module-resolver` + + ```javascript + module.expors = { + plugins: [ + [ + "module-resolver", + { + root:"./", + alias:{ + "languages":"./src/languages" + } + } + ] + ] + } + ``` + + 这样配置`autoImport="languages"`,则自动导入`import { t } from "languages"`。 + + 如`webpack`、`rollup`等打包工具也有类似的插件可以实现别名等转换,其目的就是让`babel-plugin-voerkai18n`插件能自动导入固定路径,而不是各种复杂的相对路径。 + +## 文本映射 + +虽然`VoerkaI18n`推荐采用`t("中华人民共和国万岁")`形式的符合直觉的翻译形式,而不是采用`t("xxxx.xxx")`这样不符合直觉的形式,但是为什么大部份的国际化方案均采用`t("xxxx.xxx")`形式? + +在我们的方案中,t("中华人民共和国万岁")形式相当于采用原始文本进行查表,语言名形式如下: + +```javascript +// en.js +{ + "中华人民共和国":"the people's Republic of China" +} +// jp.js +{ + "中华人民共和国":"中華人民共和国" +} +``` + +很显然,直接使用文本内容作为`key`,虽然符合直觉,但是会造成大量的冗余信息。因此,`voerkai18n compile`会将之编译成如下: + +```javascript +//idMap.js +{ + "1":"中华人民共和国万岁" +} +// en.js +{ + "1":"Long live the people's Republic of China" +} +// jp.js +{ + "2":"中華人民共和国" +} +``` + +如此,就消除了在`en.js`、`jp.js`文件中的冗余。但是在源代码文件中还存在`t("中华人民共和国万岁")`,整个运行环境中存在两份副本,一份在源代码文件中,一份在`idMap.js`中。 + +为了进一步减少重复内容,因此,我们需要将源代码文件中的`t("中华人民共和国万岁")`更改为`t("1")`,这样就能确保无重复冗余。但是,很显然,我们不可能手动来更改源代码文件,这就需要由babel插件来做这一件事了。 + +`babel-plugin-voerkai18n`插件同时还完成一份任务,就是自动读取`voerkai18n compile`生成的`idMap.js`文件,然后将`t("中华人民共和国万岁")`自动更改为`t("1")`,这样就完全消除了重复冗余信息。 + +所以,在最终形成的代码中,实际上每一个t函数均是`t("1")`、`t("2")`、`t("3")`、`...`、`t("n")`的形式,最终代码还是采用了用`key`来进行转换,只不过这个过程是自动完成的而已。 + +**注意:**如果没有启用`babel-plugin-voerkai18n`插件,还是可以正常工作,但是会有一份默认语言的冗余信息存在。 + +## 切换语言 + +可以通过全局单例或当前作用域实例切换语言。 + +```javascript +import { scope } from "./languages" + +// 切换到英文 +await scope.change("en") +// VoerkaI18n是一个全局单例,可以直接访问 +VoerkaI18n.change("en") +``` + +侦听语言切换事件: + +```javascript +import { scope } from "./languages" + +// 切换到英文 +scope.on((newLanguage)=>{ + ... +}) +// +VoerkaI18n.on((newLanguage)=>{ + ... +}) +``` + +## 加载语言包 + +当使用`webpack`、`rollup`进行项目打包时,默认语言包采用静态打包,会被打包进行源码中。而其他语言则采用异步打包方式。在`languages/index.js`中 + +```javascript +const defaultMessages = require("./cn.js") +const activeMessages = defaultMessages -t("xxxxx") - -VoerkaI18n实例 - -messages:{ - cn:{ - default:{ - text1:"" - }, - namespace:{ - text1:"" - }, - } -} +// 语言作用域 +const scope = new i18nScope({ + default: defaultMessages, // 默认语言包 + messages : activeMessages, // 当前语言包 + .... + loaders:{ + "en" : ()=>import("./en.js") + "de" : ()=>import("./de.js") + "jp" : ()=>import("./jp.js") + }) ``` -## 一语多译 -一语多译指同一句文本在不同的语景下,需要翻译成不同的内容。比如 \ No newline at end of file +# 命令行 + +全局安装`@voerkai18n/tools`工具。 + +```javascript +> npm install -g @voerkai18n/tools +> yarn global add @voerkai18n/tools +> pnpm add -g @voerkai18n/tools +``` + +然后就可以执行: + +```javascript +> voerkai18n init +> voerkai18n extract +> voerkai18n compile +``` + +如果没有全局安装,则需要: + +```javascript +> yarn voerkai18n init +> yarn voerkai18n extract +> yarn voerkai18n compile +--- +> pnpm voerkai18n init +> pnpm voerkai18n extract +> pnpm voerkai18n compile +``` + +## init + +用于在指定项目创建`voerkai18n`国际化配置文件。 + +```shell +> voerkai18n init --help +初始化项目国际化配置 +Arguments: + location 工程项目所在目录 +Options: + -d, --debug 输出调试信息 + -r, --reset 重新生成当前项目的语言配置 + -m, --moduleType [type] 生成的js模块类型,取值auto,esm,cjs (default: "auto") + -lngs, --languages 支持的语言列表 (default: ["cn","en"]) + -h, --help display help for command +``` + +**使用方法如下:** + +首先需要在工程文件下运行`voerkai18n init`命令对当前工程进行初始化。 + +```javascript +//- `lngs`参数用来指定拟支持的语言名称列表 +> voerkai18n init -lngs cn,en,jp,de +``` + +运行`voerkai18n init`命令后,会在当前工程中创建相应配置文件。 + +```javascript +myapp + |-- languages + |-- settings.js // 语言配置文件 + |-- package.json + |-- package.json + |-- index.js +``` + +`settings.js`文件很简单,主要是用来配置要支持的语言等基本信息。 + +```javascript +module.exports = { + // 拟支持的语言列表 + "languages": [ + { + "name": "cn", + "title": "中文" + }, + { + "name": "en", + "title": "英文" + } + ], + // 默认语言,即准备在源码中写的语言,一般我们可以直接使用中文 + "defaultLanguage": "cn", + // 激活语言,即默认要启用的语言,一般等于defaultLanguage + "activeLanguage": "cn", + // 翻译名称空间定义,详见后续介绍。 + "namespaces": {} +} +``` + +**说明:** + +- 您也可以手动自行创建`languages/settings.js`、`languages/package.json`文件。这样就不需运行`voerkai18n init`命令了。 +- 如果你的源码放在`src`文件夹,则需要在`src`文件夹下执行`init`命令。 +- `voerkai18n init`是可选的,直接使用`extract`时也会自动创建相应的文件。 +- `-m`参数用来指定生成的`settings.js`的模块类型: + - 当`-m=auto`时,会自动读取前工程`package.json`中的`type`字段 + - 当`-m=esm`时,会生成`ESM`模块类型的`settings.js`。 + - 当`-m=cjs`时,会生成`commonjs`模块类型的`settings.js`。 +- `location`参数是可选的,如果没有指定则采用当前目录。 + +## extract + +扫描提取当前项目中的所有源码,提取出所有需要翻译的文本内容并保存在到`<工程源码目录>/languages/translates/*.json`。 + +```shell +> voerkai18n extract --help +扫描并提取所有待翻译的字符串到文件夹中 + +Arguments: + location 工程项目所在目录 (default: "./") + +Options: + -d, --debug 输出调试信息 + -lngs, --languages 支持的语言 + -default, --defaultLanguage 默认语言 + -active, --activeLanguage 激活语言 + -ns, --namespaces 翻译名称空间 + -e, --exclude 排除要扫描的文件夹,多个用逗号分隔 + -u, --updateMode 本次提取内容与已存在内容的数据合并策略,默认取值sync=同步,overwrite=覆盖,merge=合并 + -f, --filetypes 要扫描的文件类型 + -h, --help display help for command +``` + +**说明:** + +- 启用`-d`参数时会输出提取过程,显示从哪些文件提取了几条信息。 +- 如果已手动创建或通过`init`命令创建了`languages/settings.js`文件,则可以不指定`-ns`,`-lngs`,`-default`,`-active`参数。`extract`会优先使用`languages/settings.js`文件中的参数来进行提取。 +- `-u`参数用来指定如何将提取的文本与现存的文件进行合并。因为在国际化流程中,我们经常面临源代码变更时需要更新翻译的问题。支持三种合并策略。 + - **sync**:同步(默认值),两者自动合并,并且会删除在源码文件中不存在的文本。如果某个翻译已经翻译了一半也会保留。此值适用于大部情况,推荐。 + - **overwrite**:覆盖现存的翻译内容。这会导致已经进行了一半的翻译数据丢失,**慎用**。 + - **merge**:合并,与sync的差别在于不会删除源码中已不存在的文本。 +- `-e`参数用来排除扫描的文件夹,多个用逗号分隔。内部采用`gulp.src`来进行文件提取,请参数。如 `-e !libs,core/**/*`。默认会自动排除`node_modules`文件夹 +- `-f`参数用来指定要扫描的文件类型,默认`js,jsx,ts,tsx,vue,html` +- `extract`是基于正则表达式方式进行匹配的,而不是像`i18n-next`采用基于`AST`解析。 + +>**重点:** +> +>默认情况下,`voerkai18n extract`可以安全地反复多次执行,不会导致已经翻译一半的内容丢失。 +> +>如果想添加新的语言支持,也`voerkai18n extract`也可以如预期的正常工作。 + +## compile + +编译当前工程的语言包,编译结果输出在.`/langauges`文件夹。 + +```shell +Usage: voerkai18n compile [options] [location] + +编译指定项目的语言包 + +Arguments: + location 工程项目所在目录 (default: "./") + +Options: + -d, --debug 输出调试信息 + -m, --moduleType [types] 输出模块类型,取值auto,esm,cjs (default: "auto") + -h, --help display help for command +``` + +`voerkai18n compile`执行后会在`langauges`文件夹下输出: + +```javascript +myapp + |--- langauges + |-- package.json + |-- index.js // 当前作用域的源码 + |-- idMap.js // 翻译文本与id的映射文件 + |-- formatters.js // 自定义格式化器 + |-- cn.js // 中文语言包 + |-- en.js // 英文语言包 + |-- xx.js // 其他语言包 + |-- ... +``` + +**说明:** + +- 在当前工程目录下,一般不需要指定参数就可以反复多次进行编译。 +- 您每次修改了源码并`extract`后,均应该再次运行`compile`命令。 +- 如果您修改了`formatters.js`,执行compile命令不会修改该文件。 + +# API + +## i18nScope + +每个工程会创建一个`i18nScope`实例。 + +```javascript +import { scope } from "./languages" + +// 订阅语言切换事件 +scope.on((newLanguage)=>{...}) +// 取消语言切换事件订阅 +scope.off(callback) +// 当前作用域配置 +scope.settings +// 当前语言 +scope.activeLanguage // 如cn + +// 默认语言 +scope.defaultLanguage +// 返回当前支持的语言列表,可以用来显示 +scope.languages // [{name:"cn",title:"中文"},{name:"en",title:"英文"},...] +// 返回当前作用域的格式化器 +scope.formatters +// 当前作用id +scop.id +// 切换语言,异步函数 +await scope.change(newLanguage) +// 当前语言包 +scope.messages // {1:"...",2:"...","3":"..."} +// 引用全局VoerkaI18n实例 +scope.global +// 注册当前作用域格式化器 +scope.registerFormatter(name,formatter,{language:"*"}) +``` + +## VoerkaI18n + +当`import {} form "./languages"`时会自动创建全局单`VoerkaI18n` + +```javascript +// 订阅语言切换事件 +VoerkaI18n.on((newLanguage)=>{...}) +// 取消语言切换事件订阅 +VoerkaI18n.off(callback) +// 取消所有语言切换事件订阅 +VoerkaI18n.offAll() + +// 返回当前默认语言 +VoerkaI18n.defaultLanguage +// 返回当前激活语言 +VoerkaI18n.activeLanguage +// 返回当前支持的语言 +VoerkaI18n.languages +// 切换语言 +await VoerkaI18n.change(newLanguage) +// 返回全局格式化器 +VoerkaI18n.formatters +// 注册全局格式化器 +VoerkaI18n.registerFormatter(name,formatter,{language:"*"}) + +``` + +# 常见问题 + + + +# 版本历史 \ No newline at end of file