From ad970ef539f84cbb2f4f654649d239fc617f8c3d Mon Sep 17 00:00:00 2001 From: wxzhang Date: Wed, 23 Feb 2022 19:00:03 +0800 Subject: [PATCH] update --- demodata/a/a1.js | 11 +- demodata/a/a2.js | 6 +- demodata/b/b1.js | 2 +- demodata/c/c1.js | 2 +- demodata/c/c2.js | 2 +- demodata/c/h1.html | 5 + demodata/languages/ddd.js | 3 - package.json | 2 +- pnpm-lock.yaml | 105 +++++----- src/compile.js | 2 + src/extract.plugin.js | 392 ++++++++++++++++++++++++++++++++------ src/extract.test.js | 12 +- src/index.js | 157 +++++++++++++++ 13 files changed, 563 insertions(+), 138 deletions(-) create mode 100644 demodata/c/h1.html delete mode 100644 demodata/languages/ddd.js create mode 100644 src/compile.js diff --git a/demodata/a/a1.js b/demodata/a/a1.js index fb14eee..470d7e4 100644 --- a/demodata/a/a1.js +++ b/demodata/a/a1.js @@ -6,15 +6,16 @@ t('no aaaaa') t("no aaaaa") t('no aaaaa') -t('a1:bbbbb',a,b,c) +t('bbbbb',a,b,c) t("cccc Arrow Function",()=>{},c) - - -t("dddd中国",c) + t("eeeeee", ) -t("x:from a") \ No newline at end of file +t("x:from a") + + +t("中华人民共和国") \ No newline at end of file diff --git a/demodata/a/a2.js b/demodata/a/a2.js index 26b395d..70e6f24 100644 --- a/demodata/a/a2.js +++ b/demodata/a/a2.js @@ -10,11 +10,9 @@ t('fdgfdgfdg',a,b,c) t("cccc Arrow Function",()=>{},c) - - -t("ddddd中国",c) + t("eeeeee", ) -t("x:from a") \ No newline at end of file +t("x::from a") \ No newline at end of file diff --git a/demodata/b/b1.js b/demodata/b/b1.js index 7d346db..8a693ef 100644 --- a/demodata/b/b1.js +++ b/demodata/b/b1.js @@ -13,4 +13,4 @@ t("ddddd中国",()=>{},c) t("eeeeee") t("ddddd中国",()=>{},c) -t("x:from b") \ No newline at end of file +t("x::from b") \ No newline at end of file diff --git a/demodata/c/c1.js b/demodata/c/c1.js index 76f1c59..992392a 100644 --- a/demodata/c/c1.js +++ b/demodata/c/c1.js @@ -6,4 +6,4 @@ t('cccccdc') -t("x:from c",ddd) \ No newline at end of file +t("x::from c1",ddd) \ No newline at end of file diff --git a/demodata/c/c2.js b/demodata/c/c2.js index 26b395d..94498c8 100644 --- a/demodata/c/c2.js +++ b/demodata/c/c2.js @@ -17,4 +17,4 @@ t("eeeeee", ) -t("x:from a") \ No newline at end of file +t("x::from c2") \ No newline at end of file diff --git a/demodata/c/h1.html b/demodata/c/h1.html new file mode 100644 index 0000000..5d88750 --- /dev/null +++ b/demodata/c/h1.html @@ -0,0 +1,5 @@ + + + + + diff --git a/demodata/languages/ddd.js b/demodata/languages/ddd.js deleted file mode 100644 index 7b2f442..0000000 --- a/demodata/languages/ddd.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - "确定":"OK" // a/b/b1.js -} \ No newline at end of file diff --git a/package.json b/package.json index 80f423d..0e55b38 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dependencies": { "deepmerge": "^4.2.2", "gulp": "^4.0.2", - "logsets": "^1.0.2", + "logsets": "^1.0.6", "readjson": "^2.2.2", "through2": "^4.0.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41aad80..96e6ae2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3,14 +3,14 @@ lockfileVersion: 5.3 specifiers: deepmerge: ^4.2.2 gulp: ^4.0.2 - logsets: ^1.0.2 + logsets: ^1.0.6 readjson: ^2.2.2 through2: ^4.0.2 dependencies: deepmerge: 4.2.2 gulp: 4.0.2 - logsets: registry.npmmirror.com/logsets/1.0.2 + logsets: 1.0.6 readjson: registry.npmmirror.com/readjson/2.2.2 through2: 4.0.2 @@ -195,6 +195,14 @@ packages: engines: {node: '>=0.10.0'} dev: false + /bindings/1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + requiresBuild: true + dependencies: + file-uri-to-path: 1.0.0 + dev: false + optional: true + /brace-expansion/1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: @@ -270,7 +278,7 @@ packages: readdirp: 2.2.1 upath: 1.2.0 optionalDependencies: - fsevents: registry.npmmirror.com/fsevents/1.2.13 + fsevents: 1.2.13 dev: false /class-utils/0.3.6: @@ -587,6 +595,12 @@ packages: resolution: {integrity: sha1-5qdUzI8V5YmHqpy9J69m/W9OWvk=} dev: false + /file-uri-to-path/1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + requiresBuild: true + dev: false + optional: true + /fill-range/4.0.0: resolution: {integrity: sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=} engines: {node: '>=0.10.0'} @@ -679,6 +693,18 @@ packages: resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=} dev: false + /fsevents/1.2.13: + resolution: {integrity: sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==} + engines: {node: '>= 4.0'} + os: [darwin] + deprecated: fsevents 1 will break on node v14+ and could be using insecure binaries. Upgrade to fsevents 2. + requiresBuild: true + dependencies: + bindings: 1.5.0 + nan: 2.15.0 + dev: false + optional: true + /function-bind/1.1.1: resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} dev: false @@ -1176,6 +1202,15 @@ packages: strip-bom: 2.0.0 dev: false + /logsets/1.0.6: + resolution: {integrity: sha512-tTtHpJ2pEVC3goIfqswDIaY5wwImPpfkfdKAcuQ6F6KdaDW3HvftL3X5WyFYAn02Ig/MPnLzgd/6DPm+ewFr3g==} + dependencies: + '@babel/runtime-corejs3': registry.npmmirror.com/@babel/runtime-corejs3/7.17.2 + ansicolor: registry.npmmirror.com/ansicolor/1.1.100 + core-js: registry.npmmirror.com/core-js/3.21.1 + deepmerge: registry.npmmirror.com/deepmerge/4.2.2 + dev: false + /make-iterator/1.0.1: resolution: {integrity: sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==} engines: {node: '>=0.10.0'} @@ -1247,6 +1282,12 @@ packages: engines: {node: '>= 0.10'} dev: false + /nan/2.15.0: + resolution: {integrity: sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==} + requiresBuild: true + dev: false + optional: true + /nanomatch/1.2.13: resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==} engines: {node: '>=0.10.0'} @@ -2125,16 +2166,6 @@ packages: version: 1.1.100 dev: false - registry.npmmirror.com/bindings/1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz} - name: bindings - version: 1.5.0 - requiresBuild: true - dependencies: - file-uri-to-path: registry.npmmirror.com/file-uri-to-path/1.0.0 - dev: false - optional: true - registry.npmmirror.com/core-js-pure/3.21.1: resolution: {integrity: sha512-12VZfFIu+wyVbBebyHmRTuEE/tZrB4tJToWcwAMcsp3h4+sHR+fMJWbKpYiCRWlhFBq+KNyO8rIV9rTkeVmznQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/core-js-pure/-/core-js-pure-3.21.1.tgz} name: core-js-pure @@ -2156,60 +2187,12 @@ packages: engines: {node: '>=0.10.0'} dev: false - registry.npmmirror.com/file-uri-to-path/1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz} - name: file-uri-to-path - version: 1.0.0 - requiresBuild: true - dev: false - optional: true - - registry.npmmirror.com/fsevents/1.2.13: - resolution: {integrity: sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/fsevents/-/fsevents-1.2.13.tgz} - name: fsevents - version: 1.2.13 - engines: {node: '>= 4.0'} - os: [darwin] - deprecated: fsevents 1 will break on node v14+ and could be using insecure binaries. Upgrade to fsevents 2. - requiresBuild: true - dependencies: - bindings: registry.npmmirror.com/bindings/1.5.0 - nan: registry.npmmirror.com/nan/2.15.0 - dev: false - optional: true - - registry.npmmirror.com/get-own-enumerable-property-symbols/3.0.2: - resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz} - name: get-own-enumerable-property-symbols - version: 3.0.2 - dev: false - registry.npmmirror.com/jju/1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/jju/-/jju-1.4.0.tgz} name: jju version: 1.4.0 dev: false - registry.npmmirror.com/logsets/1.0.2: - resolution: {integrity: sha512-iA1FVnA89QeicyT3g3YtrWcoNIhwVIOalSAj507VJ2OXKvAMKrfsK/OFv5pCz8NOhWV1Vlo5jZUlPV9dUsrOJw==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/logsets/-/logsets-1.0.2.tgz} - name: logsets - version: 1.0.2 - dependencies: - '@babel/runtime-corejs3': registry.npmmirror.com/@babel/runtime-corejs3/7.17.2 - ansicolor: registry.npmmirror.com/ansicolor/1.1.100 - core-js: registry.npmmirror.com/core-js/3.21.1 - deepmerge: registry.npmmirror.com/deepmerge/4.2.2 - get-own-enumerable-property-symbols: registry.npmmirror.com/get-own-enumerable-property-symbols/3.0.2 - dev: false - - registry.npmmirror.com/nan/2.15.0: - resolution: {integrity: sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/nan/-/nan-2.15.0.tgz} - name: nan - version: 2.15.0 - requiresBuild: true - dev: false - optional: true - registry.npmmirror.com/readjson/2.2.2: resolution: {integrity: sha512-PdeC9tsmLWBiL8vMhJvocq+OezQ3HhsH2HrN7YkhfYcTjQSa/iraB15A7Qvt7Xpr0Yd2rDNt6GbFwVQDg3HcAw==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/readjson/-/readjson-2.2.2.tgz} name: readjson diff --git a/src/compile.js b/src/compile.js new file mode 100644 index 0000000..139597f --- /dev/null +++ b/src/compile.js @@ -0,0 +1,2 @@ + + diff --git a/src/extract.plugin.js b/src/extract.plugin.js index 722bb2f..b9f837e 100644 --- a/src/extract.plugin.js +++ b/src/extract.plugin.js @@ -1,61 +1,62 @@ /** - * 从源文件中提取要翻译的文本 - */ - + * + * Gulp插件,用来提取指定文件夹中的翻译文本并输出到指定目录 + * + * 本插件需要配合gulp.src(...)使用 + * +*/ const through2 = require('through2') const deepmerge = require("deepmerge") const path = require('path') const fs = require('fs') -const readJson = require("readjson") -const { deepStrictEqual } = require('assert') +const readJson = require("readjson") +const createLogger = require("logsets") -// 捕获翻译函数的表达式 -const DefaultTranslateMatcher = /\bt\(\s*("|'){1}(?:((?\w+):))?(?.*?)(((\1\s*\)){1})|((\1){1}\s*(,(\w|\d|(?:\{.*\})|(?:\[.*\])|([\"\'\(].*[\"\'\)]))*)*\s*\)))/gm +const logger = createLogger() + +// 捕获翻译文本的默认正则表达式 +const DefaultTranslateExtractor = String.raw`\b{funcName}\(\s*("|'){1}(?:((?\w+)::))?(?.*?)(((\1\s*\)){1})|((\1){1}\s*(,(\w|\d|(?:\{.*\})|(?:\[.*\])|([\"\'\(].*[\"\'\)]))*)*\s*\)))` + +// 从html文件标签中提取翻译文本 +const DefaultHtmlAttrExtractor = String.raw`\<(?\w+)(.*?)(?{attrName}\s*\=\s*)([\"\'']{1})(?.*?)(\4){1}\s*(.*?)(\>|\/\>)` // 获取指定文件的名称空间 /** * * @param {*} file - * @param {*} namespaces 名称空间配置 {:[path,...,path],:path,:(file)=>{}} + * @param {*} options.namespaces 名称空间配置 {:[path,...,path],:path,:(file)=>{}} */ function getFileNamespace(file,options){ const {output, namespaces } = options - const refPath = file.relative.toLowerCase() - for(let [name,paths] of Object.entries(options.namespaces)){ - if(typeof paths === "string"){ - paths = [paths] - } + const refPath = file.relative.toLowerCase() // 当前文件相对源文件夹的路径 + for(let [name,paths] of Object.entries(options.namespaces)){ for(let path of paths){ - if(refPath.startsWith(path.toLowerCase())){ + if(typeof(path) === "string" && refPath.startsWith(path.toLowerCase())){ return name - } + }else if(typeof path === "function" && path(file)===true){ + return name + } } } return "default" } - /** - * 找出要翻译的文本列表 {namespace:[text,text],...} - * {namespace:{text:{cn:"",en:"",$source:""},...} + * 使用正则表达式提取翻译文本 * @param {*} content - * @param {*} matcher - * @returns + * @param {*} file + * @param {*} options + * @returns {namespace:{text:{cn:"",en:"",...,$file:""},text:{cn:"",en:"",...,$file:""}} */ -function getTranslateTexts(content,file,options){ - - let { matcher,languages,defaultLanguage } = options - - if(!matcher) return - - // 获取当前文件的名称空间 - const namespace = getFileNamespace(file,options) - - let texts = {default:{}} - while ((result = matcher.exec(content)) !== null) { +function extractTranslateTextUseRegexp(content,namespace,extractor,file,options){ + + let { languages,defaultLanguage } = options + + let texts = {} + while ((result = extractor.exec(content)) !== null) { // 这对于避免零宽度匹配的无限循环是必要的 - if (result.index === matcher.lastIndex) { - matcher.lastIndex++; + if (result.index === extractor.lastIndex) { + extractor.lastIndex++; } const text = result.groups.text if(text){ @@ -65,8 +66,8 @@ function getTranslateTexts(content,file,options){ } texts[ns][text] ={} languages.forEach(language=>{ - if(language !== defaultLanguage){ - texts[ns][text][language] = "" + if(language.name !== defaultLanguage){ + texts[ns][text][language.name] = "" } }) texts[ns][text]["$file"]=[file.relative] @@ -74,30 +75,278 @@ function getTranslateTexts(content,file,options){ } return texts } - - -function mergeLanguageFile(langFile,texts,options){ - +/** + * 使用函数提取器 + * @param {*} content + * @param {*} namespace + * @param {*} extractor 函数提取器(content,file,options) + * @param {*} file + * @param {*} options + * @returns {} + */ +function extractTranslateTextUseFunction(content,namespace,extractor,file,options){ + let texts = extractor(content,file,options) + if(typeof(texts)==="object"){ + return texts + }else{ + return {} + } } -module.exports = function(options={}){ +/** + * + * 返回指定文件类型的提取器 + * @param {*} filetype 文件扩展名 + * @param {*} extractor 提取器配置={default:[],js:[],html:[],"sass,css":[],json:[],"*":[]}"} + */ +function getFileTypeExtractors(filetype,extractor){ + if(!typeof(extractor)==="object") return null + let matchers=[] + for(let [key,value] of Object.entries(extractor)){ + if(filetype.toLowerCase()===key.toLowerCase()){ + matchers = value + break + }else if(key.split(",").includes(filetype)){ + matchers = value + break + } + } + // * 适用于所有文件类型 + if("*" in extractor){ + matchers = matchers.concat(extractor["*"]) + } + // 如果没有指定提取器,则使用默认提取器 + if(matchers.length===0){ + matchers = extractor["default"] + } + return matchers +} +/** + * 找出要翻译的文本列表 {namespace:[text,text],...} + * {namespace:{text:{cn:"",en:"",$source:""},...} + * @param {*} content + * @param {*} extractor + * @returns + */ +function getTranslateTexts(content,file,options){ + + let { extractor: extractorOptions,languages,defaultLanguage,activeLanguage,debug } = options + + if(!options || Object.keys(extractorOptions).length===0) return + + // 获取当前文件的名称空间 + const namespace = getFileNamespace(file,options) + const fileExtName = file.extname.substr(1).toLowerCase() // 文件扩展名 + + let texts = {} + // 提取器 + let useExtractors = getFileTypeExtractors(fileExtName,extractorOptions) + + // 分别执行所有提取器并合并提取结果 + return useExtractors.reduce((preTexts,extractor)=>{ + let matchedTexts = {} , extractFunc = ()=>{} + if(extractor instanceof RegExp){ + extractFunc = extractTranslateTextUseRegexp + }else if(typeof(extractor) === "function"){ + extractFunc = extractTranslateTextUseFunction + }else{ + return preTexts + } + try{ + matchedTexts = extractFunc(content,namespace,extractor,file,options) + }catch(e){ + console.error(`Extract translate text has occur error from ${file.relative}:${extractor.toString()}.`,e) + } + return deepmerge(preTexts,matchedTexts) + },texts) +} + + +function normalizeLanguageOptions(options){ options = Object.assign({ - debug : true, // 输出调试信息 - format : "js", // 目标文件格式,取值JSON,JS - languages : ["en","cn"], // 目标语言列表 - defaultLanguage: "cn", // 默认语言 - matcher : DefaultTranslateMatcher, // 匹配翻译函数并提取内容的正则表达式 + debug : true, // 输出调试信息,控制台输出相关的信息 + languages : [ // 支持的语言列表 + {name:"en",title:"英文"}, + {name:"cn",title:"中文",active:true,default:true} // 通过default指定默认语言 + ], + defaultLanguage: "cn", // 默认语言:指的是在源代码中的原始文本语言 + activeLanguage : "cn", // 当前激活语言:指的是当前启用的语言,比如在源码中使用中文,在默认激活的是英文 + extractor : { // 匹配翻译函数并提取内容的正则表达式 + //default : DefaultTranslateExtractor, + "*" : DefaultTranslateExtractor, + "html,vue,jsx" : DefaultHtmlAttrExtractor + }, namespaces : {}, // 命名空间, {[name]: [path,...,path]} - output : null, // 输出目录,如果没有指定,则转让 - merge : true, // 输出文本时默认采用合并更新方式 + output : { + path : null, // 输出目录,如果没有指定则输出到原目录/languages + // 输出文本时默认采用合并更新方式,当重新扫描时输出时可以用来保留已翻译的内容 + // 0 - overwrite 覆盖模式,可能导致翻译了一半的原始内容丢失(不推荐), + // 1 - merge 合并,尽可能保留原来已翻译的内容 + // 2 - sync 同步, 在合并基础上,如果文本已经被删除,则同步移除原来的内容 + updateMode : 'sync', + }, + // 以下变量会被用来传递给提取器正则表达式 + translation : { + funcName : "t", // 翻译函数名称 + attrName :"data-i18n", // 用在html组件上的翻译属性名称 + } },options) - let {debug,output:outputPath,languages} = options - // 输出语言文件 {cn:{default:<文件>,namespace:<文件>},en:{default:{}}} - let outputFiles = languages.map(language=>{}) + // 输出配置 + if(typeof(options.output)==="string"){ + options.output = {path:options.output,updateMode: 'sync'} + }else{ + options.output = Object.assign({},{updateMode: 'sync',path:null},options.output) + } + // 语言配置 languages = [{name:"en",title:"英文"},{name:"cn",title:"中文",active:true,default:true}] + if(!Array.isArray(options.languages)){ + throw new TypeError("options.languages must be an array") + }else{ + if(options.languages.length === 0) throw new TypeError("options.languages'length must be greater than 0") + let defaultLanguage = options.defaultLanguage + let activeLanguage = options.activeLanguage + options.languages = options.languages.map(item=>{ + let language = item + if(typeof item === "string"){ + return {name:item,title:item} + }else if(typeof item === "object"){ + return Object.assign({name:"",title:""},item) + } + if(typeof(item.title)==="string" && item.title.trim().length===0){ + item.title = item.name + } + // 默认语言 + if(item.default===true && item.name){ + defaultLanguage = item.name + } + // 激活语言 + if(item.active ===true && item.name){ + activeLanguage = item.name + } + return item + }) + if(!defaultLanguage) defaultLanguage = options.languages[0].name + options.defaultLanguage = defaultLanguage + options.activeLanguage = activeLanguage + } + // 提取正则表达式匹配 + if(typeof(options.extractor)==="string") options.extractor = new RegExp(options.extractor,"gm") + if(options.extractor instanceof RegExp){ + options.extractor = {default: [options.extractor] } // 默认文件类型的匹配器 + } + // extractor = {default:[regexp,regexp,...],js:[regexp,regexp,...],json:[regexp,regexp,...],"jsx,ts":[regexp,regexp,...],"*":[regexp,regexp,...],...}"} + // 提取器可以是:正则表达式字符串、正则表达式或者是函数 + if(typeof(options.extractor)==="object"){ + if(Object.keys(options.extractor).length === 0){ + throw new TypeError("options.extractor must be an object with at least one key") + } + Object.entries(options.extractor).forEach(([filetype,value])=>{ + if(!Array.isArray(value)) value = [value] + value= value.map(item=>{ + if(typeof(item)==="string"){ // 如果是字符串,则支持插值变量后,转换为正则表达式 + return new RegExp(item.params(options.translation),"gm") + }else if(item instanceof RegExp){ + return item + }else if(typeof(item)==="function"){ + return item + } + }) + options.extractor[filetype] = value + }) + if(("*" in options.extractor) && options.extractor["*"].length===0) options.extractor["*"] = [] + if(("default" in options.extractor) && options.extractor["default"].length===0) options.extractor["default"] = [DefaultTranslateExtractor] + }else{ + options.extractor= {default:[ DefaultTranslateExtractor ]} + } + // 名称空间 + if(typeof(options.namespaces)!=="object"){ + throw new TypeError("options.namespaces must be an object") + }else{ + Object.entries(options.namespaces).forEach(([name,paths])=>{ + if(!Array.isArray(paths)) paths = [paths] + if(typeof(name)==="string" && name.trim().length>0){ + options.namespaces[name] = paths + } + }) + } + + + logger.log("Supported languages\t: {}",options.languages.map(item=>`${item.title}(${item.name})`)) + logger.log("Default language\t: {}",options.defaultLanguage) + logger.log("Active language\t\t: {}",options.activeLanguage) + logger.log("Language namespaces\t: {}",Object.keys(options.namespaces).join(",")) + + return options +} + +/** + 合并更新语言文件 + + 当使用extract提取到待翻译内容并保存到languages目标文件夹后 + 翻译人员就可以对该文件夹内容进行翻译 + 接下来,如果源码更新后,重新进行扫描extract并重新生成语言文件 + 此时,需要将重新扫描后的文件合并到已经翻译了一半的内容,以保证翻译的内容不会丢失 + + + */ +function updateLanguageFile(fromTexts,toLangFile,options){ + const { output:{ updateMode } } = options + + // 默认的overwrite + if(!["merge","sync"].includes(updateMode)){ + fs.writeFileSync(toLangFile,JSON.stringify(targetTexts,null,4)) + return + } + let targetTexts = {} + // 读取原始翻译文件 + try{ + targetTexts = 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.entries(fromTexts).forEach(([text,sourceLangs])=>{ + if(text in targetTexts){ // 合并 + let targetLangs = targetTexts[text] //{cn:'',en:''} + Object.entries(sourceLangs).forEach(([langName,sourceText])=>{ + if(langName.startsWith("$")) return // + const langExists = langName in targetLangs + const targetText = targetLangs[langName] + // 如果目标语言已经存在并且内容不为空,则不需要更新 + if(!langExists){ + targetLangs[langName] = sourceText + }else if(typeof(targetText) === "string" && targetText.trim().length==0){ + targetLangs[langName] = sourceText + }else if(Array.isArray(targetText)){// 当文本内容支持复数时,可以用[单数文本,复数文本]的形式 + if(targetText.length>0){ + targetLangs[langName] = sourceText + }else{ + + } + } + }) + }else{ + targetTexts[text] = sourceLangs + } + }) + fs.writeFileSync(toLangFile,JSON.stringify(targetTexts,null,4)) +} +module.exports = function(options={}){ + options = normalizeLanguageOptions(options) + let {debug,output:{ path:outputPath, updateMode },languages} = options + // 保存提交提取的文本 = {} let results = {} + let fileCount=0 // 文件总数 // file == vinyl实例 return through2.obj(function(file, encoding, callback){ // 如果没有指定输出路径,则默认输出到<原文件夹/languages> @@ -114,26 +363,49 @@ module.exports = function(options={}){ } // 提取翻译文本 - const texts = getTranslateTexts(file.contents.toString(),file,options) - results = deepmerge(results,texts) - + try{ + const texts = getTranslateTexts(file.contents.toString(),file,options) + results = deepmerge(results,texts) + fileCount++ + if(debug){ + const textCount = Object.values(texts).reduce((sum,item)=>sum+Object.keys(item).length,0) + logger.log("Extract <{}>, found [{}] namespaces and {} texts.",file.relative,Object.keys(texts).join(),textCount) + } + }catch(err){ + logger.log("Error while extract text from <{}> : {}",file.relative,err.message) + } + callback() },function(callback){ - - console.log("输出路径:",outputPath) + logger.log("") + logger.log("Extracting finished.") + logger.log(" - Total of files\t: {}",fileCount) + logger.log(" - Output location\t: {}",outputPath) const translatesPath = path.join(outputPath,"translates") if(!fs.existsSync(translatesPath)) fs.mkdirSync(translatesPath) - + // 每个名称空间对应一个文件 for(let [namespace,texts] of Object.entries(results)){ const langFile = path.join(outputPath,"translates",`${namespace}.json`) const isExists = fs.existsSync(langFile) - const langTexts = isExists ? readJson.sync(langFile) : {} - if(isExists && options.merge){ - mergeLanguageFile(langFile,langTexts,options) + const langTexts = {} + if(isExists){ + updateLanguageFile(texts,langFile,options) + logger.log(" Update language file : {}",path.relative(outputPath,langFile)) }else{ fs.writeFileSync(langFile,JSON.stringify(texts,null,4)) - } + logger.log(" Save language file : {}",path.relative(outputPath,langFile)) + } } + // 将元数据生成到 i18n.meta.json + const metaFile = path.join(outputPath,"i18n.meta.json") + const meta = { + languages : options.languages, + defaultLanguage: options.defaultLanguage, + activeLanguage : options.activeLanguage, + namespaces : options.namespaces + } + fs.writeFileSync(metaFile,JSON.stringify(meta,null,4)) + logger.log(" - Generate language metadata : {}",metaFile) callback() }); } \ No newline at end of file diff --git a/src/extract.test.js b/src/extract.test.js index c945316..e6e055a 100644 --- a/src/extract.test.js +++ b/src/extract.test.js @@ -5,12 +5,22 @@ const path = require('path'); const soucePath = path.join(__dirname,'../demodata') + + gulp.src([ soucePath+ '/**', "!"+ soucePath+ '/languages/**' ]).pipe(extract({ + debug:true, // output: path.join(soucePath , 'languages'), - languages: ['en','cn','de','jp'], + languages: [{name:'en',title:"英文"},{name:'cn',title:"中文",default:true},{name:'de',title:"德语"},{name:'jp',title:"日本語"}], + // extractor:{ + // default:[new RegExp()], // 默认匹配器,当文件类型没有对应的提取器时使用 + // "*" : [new RegExp()], // 所有类型均会执行的提取器 + // js:new RegExp(), // 只有一个正则表达式,js文件提取正则表达式 + // html:[new RegExp(),new RegExp()] // 多个表达式可以用数组 + // "js,jsx":[new RegExp(),(content,file)=>{...})] // 提取器也可以是一个函数,传入文件和文件内容,返回提取结果 + // }, namespaces:{ "a":"a", "b":"b", diff --git a/src/index.js b/src/index.js index e69de29..41c9f16 100644 --- a/src/index.js +++ b/src/index.js @@ -0,0 +1,157 @@ +import deepMerge from "deepmerge" + + +let ParamRegExp=/\{\w*\}/g +//添加一个params参数,使字符串可以进行变量插值替换, +// "this is {a}+{b}".params({a:1,b:2}) --> this is 1+2 +// "this is {a}+{b}".params(1,2) --> this is 1+2 +// "this is {}+{}".params([1,2]) --> this is 1+2 +if(!String.prototype.hasOwnProperty("params")){ + + String.prototype.params=function (params) { + let result=this.valueOf() + if(typeof params === "object"){ + for(let name in params){ + result=result.replace("{"+ name +"}",params[name]) + } + }else{ + let i=0 + for(let match of result.match(ParamRegExp) || []){ + if(i{ + if(typeof(callback)=="function"){ + callback.call(this,this.language) + } + }) + } + get language(){ + return this._language + } + set language(value){ + if(value in this.supportedLanguages){ + this._language = value + this._triggerCallback() + } + } + /** + * 当配置更新时调用此方法 + */ + reset(){ + + let settings = { + current:"cn", + default:"cn", + supportedLanguages:["en","cn"] + } + if(VoerkaSettings!==undefined) Object.assign(settings,VoerkaSettings.get("i18n") ) + + this._language = settings.current + this.defaultLanguage = settings.default + this.supportedLanguages = settings.supportedLanguages + globalThis.t = this.translate.bind(this) + globalThis.i18n = this + } + merge(messages){ + this.messages = deepMerge(this.messages,messages) + } + + // 变量插值 + _replaceVars(source,params){ + if(Array.isArray(params)){ + return source.params(...params) + }else{ + return source.params(params) + } + } + /** + * + * translate("要翻译的文本内容") 如果默认语言是中文,则不会进行翻译直接返回 + * translate("I am {} {}","man") == I am man 位置插值 + * translate("I am {p}",{p:"man"}) 字典插值 + * translate("I am {p}",{p:"man",ns:""}) 指定名称空间 + * translate("I am {p}",{p:"man",namespace:""}) + * translate("I am {p}",{p:"man",namespace:""}) + * translate("total {count} items", {count:1}) //复数形式 + * translate({count:1,plurals:'count'}) // 复数形式 + * translate("total {} {} {} items",a,b,c) // 位置变量插值 + * + */ + translate(){ + let content = arguments[0],options={} + try{ + if(arguments.length === 2 && typeof(arguments[1])=='object'){ + Object.assign(options,arguments[1]) + }else if(arguments.length >= 2){ + options=[...arguments].splice(1) + } + // 默认语言是中文,不需要查询加载,只需要做插值变换即可 + if(this.language === this.defaultLanguage){ + return this._replaceVars(content,options) + }else{ + let result = this.messages[this.language][content] + if(content in this.messages[this.language]){ + // 复数形式,需要通过plurals来指定内容中包括的复数插值 + if(Array.isArray(result)){ + let plurals = options.plurals + if(typeof(plurals) == 'string' && (plurals in options)){ + return options[plurals]>1 ? result[1].params(options) : result[0].params(options) + }else{ + return this._replaceVars(result[0],options) + } + }else{ + return this._replaceVars(result,options); + } + }else{ + return this._replaceVars(result,options) + } + } + }catch(e){ + return content + } + } +} + + +const i18nInstance = new i18n() + +export default i18nInstance