diff --git a/packages/runtime/src/errors.ts b/packages/runtime/src/errors.ts new file mode 100644 index 0000000..a2b3b20 --- /dev/null +++ b/packages/runtime/src/errors.ts @@ -0,0 +1 @@ +export class InvalidLanguageError extends Error{} \ No newline at end of file diff --git a/packages/runtime/src/formatterRegistry.ts b/packages/runtime/src/formatterRegistry.ts index 629f657..1acbd1e 100644 --- a/packages/runtime/src/formatterRegistry.ts +++ b/packages/runtime/src/formatterRegistry.ts @@ -24,6 +24,7 @@ const EmptyFormatters:any = { $config:{}, $types:{} } + export class VoerkaI18nFormatterRegistry{ // 由于语言的格式化器集合允许是一个异步加载块,所以需要一个ready标志 // 当语言格式化器集合加载完成后,ready标志才会变为true @@ -101,6 +102,36 @@ export class VoerkaI18nFormatterRegistry{ getConfig(language?:string){ return language ? getByPath(this.#formatters,`${language}.$config`,{defaultValue:{}}) : {} } + /** + 获取指定语言中为每个数据类型指定的格式化器 + */ + getTypes(language?:string){ + return language ? getByPath(this.#formatters,`${language}.$types`,{defaultValue:{}}) : {} + } + /** + 获取指定语言中为每个数据类型指定的格式化器 + */ + getFormatters(language?:string){ + return language ? getByPath(this.#formatters,language,{defaultValue:{}}) : {} + } + /** + * 更新指定语言的格式化器配置参数 + * @param language + * @param config + */ + updateConfig(language:string,config:VoerkaI18nFormatterConfigs){ + if(language in this.#formatters){ + const formatters = this.#formatters[language] + if(typeof(formatters)=='function'){ + throw new FormattersNotLoadedError(language) + }else{ + if(!formatters.$config){ + formatters.$config = {} + } + Object.assign(formatters.$config,config) + } + } + } /** * 返回指定语言是否具有格式化器集合 * @param language @@ -151,4 +182,13 @@ export class VoerkaI18nFormatterRegistry{ if(!this.#ready) throw new FormattersNotLoadedError(this.#language) return (this.#formatters[this.#language] as VoerkaI18nFormatters).$config as VoerkaI18nFormatterConfigs } + get types(){ + if(!this.#ready) throw new FormattersNotLoadedError(this.#language) + return (this.#formatters[this.#language] as VoerkaI18nFormatters).$types as VoerkaI18nFormatterConfigs + } + get items(){ + if(!this.#ready) throw new FormattersNotLoadedError(this.#language) + return this.#formatters[this.#language] as VoerkaI18nFormatterConfigs + } + } \ No newline at end of file diff --git a/packages/runtime/src/interpolate.ts b/packages/runtime/src/interpolate.ts index fbd8f44..66e620d 100644 --- a/packages/runtime/src/interpolate.ts +++ b/packages/runtime/src/interpolate.ts @@ -173,23 +173,24 @@ function getDataTypeDefaultFormatter(scope:VoerkaI18nScope, activeLanguage:strin if (scope.cache.activeLanguage === activeLanguage) { if (scope.cache.typedFormatters && dataType in scope.cache.typedFormatters) return scope.cache.typedFormatters[dataType]; - } else { - // 当语言切换时清空缓存 - resetScopeCache(scope, activeLanguage); + } else { + resetScopeCache(scope, activeLanguage); // 当语言切换时清空缓存 } const fallbackLanguage = scope.getLanguage(activeLanguage)?.fallback; // 先在当前作用域中查找,再在全局查找 const targets = [ - scope.activeFormatters, - fallbackLanguage ? scope.formatters[fallbackLanguage] : null, // 如果指定了回退语言时,也在该回退语言中查找 - scope.global.formatters[activeLanguage], - scope.global.formatters["*"], + scope.formatters.types, + scope.formatters.getTypes(fallbackLanguage), + scope.formatters.getTypes("*"), + scope.global.formatters.types, + scope.global.formatters.getTypes(fallbackLanguage), + scope.global.formatters.getTypes("*"), ]; + for (const target of targets) { if (!target) continue; if(target){ if (isPlainObject(target.$types) && isFunction(target.$types?.[dataType])) { - return (scope.cache.typedFormatters[dataType] = target.$types[dataType]); } } @@ -201,6 +202,9 @@ function getDataTypeDefaultFormatter(scope:VoerkaI18nScope, activeLanguage:strin * * 查找逻辑 * - 在当前作用域中查找 + * - 当前语言 + * - 回退语言 + * - 全局语言 * - 在全局作用域中查找 * * @param {*} scope @@ -210,30 +214,32 @@ function getDataTypeDefaultFormatter(scope:VoerkaI18nScope, activeLanguage:strin */ function getFormatter(scope:VoerkaI18nScope, activeLanguage:string, name:string) { // 1. 从缓存中直接读取: 缓存格式化器引用,避免重复检索 - if (!scope.$cache) resetScopeCache(scope); - if (scope.$cache.activeLanguage === activeLanguage) { - if (name in scope.$cache.formatters) - return scope.$cache.formatters[name]; + if (!scope.cache) resetScopeCache(scope,activeLanguage); + if (scope.cache.activeLanguage === activeLanguage) { + if (name in scope.cache.formatters) + return scope.cache.formatters[name]; } else { // 当语言切换时清空缓存 resetScopeCache(scope, activeLanguage); } - const fallbackLanguage = scope.getLanguage(activeLanguage).fallback; + const fallbackLanguage = scope.getLanguage(activeLanguage)?.fallback; // 2. 先在当前作用域中查找,再在全局查找 formatters={$types,$config,[格式化器名称]:()=>{},[格式化器名称]:()=>{}} const range = [ - scope.activeFormatters, - scope.formatters[fallbackLanguage], // 如果指定了回退语言时,也在该回退语言中查找 - scope.formatters["*"], - scope.global.formatters[activeLanguage], // 适用于activeLanguage全局格式化器 - scope.global.formatters[fallbackLanguage], - scope.global.formatters["*"], // 适用于所有语言的格式化器 + scope.formatters.formatters, + scope.formatters.getFormatters(fallbackLanguage), + scope.formatters.getFormatters('*'), + + scope.global.formatters.formatters, + scope.global.formatters.getFormatters(fallbackLanguage), + scope.global.formatters.getFormatters('*'), ]; for (const formatters of range) { if (!formatters) continue; if (isFunction(formatters[name])) { - return (scope.$cache.formatters[name] = formatters[name]); + return (scope.cache.formatters[name] = formatters[name]); } } } + /** * Checker是一种特殊的格式化器,会在特定的时间执行 * @@ -441,7 +447,7 @@ export function replaceInterpolatedVars(this:VoerkaI18nScope,template:string, .. // 读取模板字符串中的插值变量列表 // [[var1,[formatter,formatter,...],match],[var2,[formatter,formatter,...],match],...} let varValues = args[0]; - return forEachInterpolatedVars(template,(varname, formatters, match) => { + return forEachInterpolatedVars(template,(varname:string, formatters, match) => { let value = varname in varValues ? varValues[varname] : ""; return getFormattedValue(scope,activeLanguage,formatters,value,template); } @@ -452,7 +458,7 @@ export function replaceInterpolatedVars(this:VoerkaI18nScope,template:string, .. const params =args.length === 1 && Array.isArray(args[0]) ? [...args[0]] : args; if (params.length === 0) return template; // 没有变量则不需要进行插值处理,返回原字符串 let i = 0; - return forEachInterpolatedVars(template,(varname, formatters, match) => { + return forEachInterpolatedVars(template,(varname:string, formatters, match) => { if (params.length > i) { return getFormattedValue(scope,activeLanguage,formatters,params[i++],template); } else { diff --git a/packages/runtime/src/scope.ts b/packages/runtime/src/scope.ts index 7b61fd6..c4e8953 100644 --- a/packages/runtime/src/scope.ts +++ b/packages/runtime/src/scope.ts @@ -19,12 +19,12 @@ import type { VoerkaI18nScopeCache, VoerkaI18nTranslate, VoerkaI18nLoaders, -VoerkaI18nTypesFormatters, -VoerkaI18nFormatters, -VoerkaI18nDynamicLanguageMessages, -VoerkaI18nTypesFormatterConfigs + VoerkaI18nTypesFormatters, + VoerkaI18nFormatters, + VoerkaI18nDynamicLanguageMessages, } from "./types" import { VoerkaI18nFormatterRegistry } from './formatterRegistry'; +import { InvalidLanguageError } from "./errors" export interface VoerkaI18nScopeOptions { id?: string @@ -292,7 +292,7 @@ export class VoerkaI18nScope { * @param {*} language * @returns */ - private getLanguage(language:string):VoerkaI18nLanguageDefine | undefined{ + getLanguage(language:string):VoerkaI18nLanguageDefine | undefined{ let index = this.languages.findIndex((lng) => lng.name == language); if (index !== -1) return this.languages[index]; } @@ -311,7 +311,42 @@ export class VoerkaI18nScope { this.#options.messages = this.default; this.#options.activeLanguage = this.defaultLanguage; } - + /** + * 语言信息包可以是: + * - 简单的对象{} + * - 或者是一个返回Promise的异步函数 + * + * + * + * @param language 语言名称 + * @returns + */ + private async loadLanguageMessages(language:string):Promise{ + if(!this.hasLanguage(language)) throw new InvalidLanguageError(`Not found language <${language}>`) + // 非默认语言可以是:语言包对象,也可以是一个异步加载语言包文件,加载器是一个异步函数 + // 如果没有加载器,则无法加载语言包,因此回退到默认语言 + let loader = this.loaders[language]; + let messages:VoerkaI18nLanguageMessages = {} + if (isPlainObject(loader)) { // 静态语言包 + messages = loader as unknown as VoerkaI18nLanguageMessages; + } else if (isFunction(loader)) { // 语言包异步chunk + messages = (await loader()).default; + } else if (isFunction(this.global.defaultMessageLoader)) { + // 从远程加载语言包:如果该语言没有指定加载器,则使用全局配置的默认加载器 + const loadedMessages = (await this.global.loadMessagesFromDefaultLoader(language,this)) as unknown as VoerkaI18nDynamicLanguageMessages; + if(isPlainObject(loadedMessages)){ + // 需要保存动态语言包中的$config,合并到对应语言的格式化器配置 + if(isPlainObject(loadedMessages.$config)){ + this.formatters.updateConfig(language,loadedMessages.$config!) + delete loadedMessages.$config + } + messages = Object.assign({ + $remote : true // 添加一个标识,表示该语言包是从远程加载的 + },this.default,loadedMessages); // 合并默认语言包和动态语言包,这样就可以局部覆盖默认语言包 + } + } + return messages + } /** * 刷新当前语言包 * @param {*} newLanguage @@ -324,50 +359,31 @@ export class VoerkaI18nScope { this.#options.messages = this.default; await this._patch(this.#options.messages, newLanguage); // 异步补丁 await this._changeFormatters(newLanguage); + this.#refreshing = false return; - } - // 非默认语言需要异步加载语言包文件,加载器是一个异步函数 - // 如果没有加载器,则无法加载语言包,因此回退到默认语言 - let loader = this.loaders[newLanguage]; - try { - let newMessages, useRemote =false; - if (isPlainObject(loader)) { // 静态语言包 - newMessages = loader; - } else if (isFunction(loader)) { // 语言包异步chunk - newMessages = (await loader()).default; - } else if (isFunction(this.global.defaultMessageLoader)) { // 从远程加载语言包:如果该语言没有指定加载器,则使用全局配置的默认加载器 - const loadedMessages = (await this.global.loadMessagesFromDefaultLoader(newLanguage,this)) as unknown as VoerkaI18nDynamicLanguageMessages; - if(isPlainObject(loadedMessages)){ - useRemote = true - // 需要保存动态语言包中的$config,合并到对应语言的格式化器配置 - if(isPlainObject(loadedMessages.$config)){ - this.formatters[newLanguage] = { - $config : loadedMessages.$config as any - } - delete loadedMessages.$config + }else{ // 非默认语言可以是静态语言包也可以是异步加载语言包 + try{ + let messages = await this.loadLanguageMessages(newLanguage) + if(messages){ + this.#options.messages = messages + this.#options.activeLanguage = newLanguage; + // 打语言包补丁, 如果是从远程加载语言包则不需要再打补丁了 + // 因为远程加载的语言包已经是补丁过的了 + if(!messages.$remote) { + await this._patch(this.#options.messages, newLanguage); } - newMessages = Object.assign({},this.default,loadedMessages); + // 切换到对应语言的格式化器 + await this._changeFormatters(newLanguage); + }else{ + this._fallback(); } - } - if(newMessages){ - this.#options.messages = newMessages - this.#options.activeLanguage = newLanguage; - // 打语言包补丁, 如果是从远程加载语言包则不需要再打补丁了 - if(!useRemote) { - await this._patch(this.#options.messages, newLanguage); - } - // 切换到对应语言的格式化器 - await this._changeFormatters(newLanguage); - }else{ + }catch(e:any){ + if (this.debug) console.warn(`Error while loading language <${newLanguage}> on i18nScope(${this.id}): ${e.message}`); this._fallback(); - } - - } catch (e) { - if (this.debug) console.warn(`Error while loading language <${newLanguage}> on i18nScope(${this.id}): ${e.message}`); - this._fallback(); - } finally { - this.#refreshing = false; - } + } finally { + this.#refreshing = false; + } + } } /** * 当指定了默认语言包加载器后,会从服务加载语言补丁包来更新本地的语言包 @@ -386,8 +402,8 @@ export class VoerkaI18nScope { Object.assign(messages, pachedMessages); this._savePatchedMessages(pachedMessages, newLanguage); } - } catch (e) { - if (this.debug) console.error(`Error while loading <${newLanguage}> patch messages from remote:`,e); + } catch (e:any) { + if (this.debug) console.error(`Error while loading <${newLanguage}> patch messages from remote:`,e.message); } } /** diff --git a/packages/runtime/src/translate.ts b/packages/runtime/src/translate.ts index ed5892b..ff879d3 100644 --- a/packages/runtime/src/translate.ts +++ b/packages/runtime/src/translate.ts @@ -66,8 +66,8 @@ function getPluraMessage(messages:any,value:number){ * translate("要翻译的文本内容") 如果默认语言是中文,则不会进行翻译直接返回 * translate("I am {} {}","man") == I am man 位置插值 * translate("I am {p}",{p:"man"}) 字典插值 -* translate("total {$count} items", {$count:1}) //复数形式 -* translate("total {} {} {} items",a,b,c) // 位置变量插值 +* translate("total {$count} items", {$count:1}) 复数形式 +* translate("total {} {} {} items",a,b,c) 位置变量插值 * * this===scope 当前绑定的scope * @@ -75,37 +75,41 @@ function getPluraMessage(messages:any,value:number){ export function translate(this:VoerkaI18nScope,message:string,...args:any[]) { const scope = this const activeLanguage = scope.global.activeLanguage - let content:string = message - let vars=[] // 插值变量列表 - let pluralVars= [] // 复数变量 - let pluraValue = null // 复数值 + // 如果内容是复数,则其值是一个数组,数组中的每个元素是从1-N数量形式的文本内容 + let result:string | string[] = message + let vars=[] // 插值变量列表 + let pluraValue = null // 复数值 if(!(typeof(message)==="string")) return message try{ // 1. 预处理变量: 复数变量保存至pluralVars中 , 变量如果是Function则调用 - if(arguments.length === 2 && isPlainObject(arguments[1])){ - const dictVars:Record={} - Object.entries(arguments[1]).forEach(([name,value])=>{ - if(typeof(value)=="function"){ + if(arguments.length === 2 && isPlainObject(arguments[1])){// 字典插值 + const dictVars:Record=arguments[1] + for(const [name,value] of Object.entries(dictVars)){ + if(isFunction(value)){ try{ dictVars[name] = value() }catch(e){ dictVars[name] = value } } - // 以$开头的视为复数变量 - if(name.startsWith("$") && typeof(dictVars[name])==="number") pluralVars.push(name) - }) - vars = [arguments[1]] - }else if(arguments.length >= 2){ + // 以$开头的视为复数变量,记录下来 + const isNum:boolean = typeof(dictVars[name])==="number" + if((pluraValue==null && isNum) || name.startsWith("$") && isNum){ + pluraValue = dictVars[name] + } + } + vars = [dictVars] + }else if(arguments.length >= 2){ // 位置插值 vars = [...arguments].splice(1).map((arg,index)=>{ try{ - arg = isFunction(arg) ? arg() : arg - // 位置参数中以第一个数值变量为复数变量 - if(isNumber(arg)) pluraValue = parseInt(arg) - }catch(e){ } + arg = isFunction(arg) ? arg() : arg + // 约定:位置参数中以第一个数值变量作为指示复数变量 + if(isNumber(arg)) pluraValue = parseInt(arg) + }catch(e){ + return String(arg) + } return arg - }) - + }) } // 3. 取得翻译文本模板字符串 @@ -114,37 +118,35 @@ export function translate(this:VoerkaI18nScope,message:string,...args:any[]) { // 如果当前语言就是默认语言,不需要查询加载,只需要做插值变换即可 // 当源文件运用了babel插件后会将原始文本内容转换为msgId // 如果是msgId则从scope.default中读取,scope.default=默认语言包={:} - if(isMessageId(content)){ - content = scope.default[content] || message + if(isMessageId(result)){ + result = scope.default[result] || message } }else{ // 2.2 从当前语言包中取得翻译文本模板字符串 // 如果没有启用babel插件将源文本转换为msgId,需要先将文本内容转换为msgId - let msgId = isMessageId(content) ? content : scope.idMap[content] - content = scope.messages[msgId] || content + let msgId = isMessageId(result) ? result : scope.idMap[result] + result = scope.messages[msgId] || result } // 2. 处理复数 // 经过上面的处理,content可能是字符串或者数组 // content = "原始文本内容" || 复数形式["原始文本内容","原始文本内容"....] // 如果是数组说明要启用复数机制,需要根据插值变量中的某个变量来判断复数形式 - if(Array.isArray(content) && content.length>0){ + if(Array.isArray(result) && result.length>0){ // 如果存在复数命名变量,只取第一个复数变量 - if(pluraValue!==null){ // 启用的是位置插值,pluraIndex=第一个数字变量的位置 - content = getPluraMessage(content,pluraValue) - }else if(pluralVar.length>0){ - content = getPluraMessage(content,parseInt(vars(pluralVar[0]))) + if(pluraValue!==null){ // 启用的是位置插值 + result = getPluraMessage(result,pluraValue) }else{ // 如果找不到复数变量,则使用第一个内容 - content = content[0] + result = result[0] } } // 进行插值处理 if(vars.length==0){ - return content + return result }else{ - return replaceInterpolatedVars.call(scope,content,...vars) + return replaceInterpolatedVars.call(scope,result as string,...vars) } }catch(e){ - return content // 出错则返回原始文本 + return result // 出错则返回原始文本 } } \ No newline at end of file