diff --git a/.eslintrc.js b/.eslintrc.js index ed9f0ce9..84d6f6de 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,6 +3,9 @@ module.exports = { env: { node: true }, + globals: { + j18n: true + }, extends: [ 'plugin:vue/vue3-essential', 'plugin:vue/vue3-strongly-recommended', diff --git a/.vscode/launch.json b/.vscode/launch.json index 627e8a73..f66603b0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,13 @@ { "version": "0.2.0", "configurations": [ + { + "type": "pwa-node", + "request": "launch", + "name": "I18n Render Json", + "skipFiles": ["/**"], + "program": "${workspaceFolder}/script/render.js" + }, { "name": "Launch Program", "program": "${file}", diff --git a/package.json b/package.json index 7fba6d05..d9dc1f2b 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "conventional-changelog-cli": "^2.1.1", "cross-env": "^7.0.2", "cross-spawn": "^7.0.3", + "crypto-js": "^4.0.0", "cypress": "^6.2.0", "dayjs": "^1.9.4", "electron": "^12.0.4", diff --git a/script/core/core.js b/script/core/core.js new file mode 100644 index 00000000..0e1566a2 --- /dev/null +++ b/script/core/core.js @@ -0,0 +1,363 @@ +const Type = { + CHAR: 'CHAR', + HTML: 'HTML', + JSX: 'JSX' +} + +const renderKey = (filePath, rootPath) => { + const key = filePath + .replace(rootPath, '') + .slice(1) + .replace(/\//g, '__') + .replace(/\..+$/, '') + return key +} + +const checkInExpression = (type, content, start) => { + const isVue = false + const beforeContent = content.slice(0, start) + const isAttributeExpression = isVue + ? /:[a-zA-Z-]*=".*/.test(beforeContent) + : /[a-zA-Z]={/.test(beforeContent) + return isAttributeExpression +} + +const checkJSXExpression = (type, content, start) => { + const beforeContent = content.slice(0, start) + return /.*{$/.test(beforeContent) +} + +const charParser = (content, regexp, line, isAttribute, type, cb) => { + const match = content.match(regexp) + const variable = [] + const chinese = /[\u4e00-\u9fa5]/ + const attr = isAttribute > 0 + let i = 0 + if (!match) { + return null + } + const r = match + .filter(item => chinese.test(item)) + .map(item => { + const start = content.indexOf(item) + const end = start + item.length + + const isAttributeExpression = attr + ? checkInExpression(type, content, start) + : false + const isJSXNodeExpression = !attr + ? checkJSXExpression(type, content, start) + : false + + // 处理模版字符串 + const output = item.replace(/\$\{.+?\}/g, match => { + variable.push(match.slice(2, -1)) + return `{${i++}}` + }) + return { + source: content, + line, + output, + variable, + start, + end, + type: Type.CHAR, + isAttribute: attr, + isAttributeExpression, + isJSXNodeExpression + } + }) + cb() + return r +} + +const tagParser = (content, regexp, isVue, type, line, isAttribute, cb) => { + const variable = [] + let i = 0 + const match = content.match(regexp) + let output = match[0] + const start = match.index + const end = start + output.length + if (isVue) { + // 处理 vue html 里的变量 + output = output.replace(/\{\{.+?\}\}/g, match => { + variable.push(match.slice(2, -2)) + return `{${i++}}` + }) + } + cb() + return { + source: content, + line, + output, + variable, + start, + end, + isVueTag: isVue, + type: type, + isAttribute: isAttribute > 0 + } +} + +const renderVueExpressionCode = (source, code, start, end) => { + const tags = source + .split('') + .map((item, index) => { + if (item === ' ') { + return index + } + return null + }) + .filter(item => typeof item === 'number') + .filter(item => item < start) + const tagIndex = tags[tags.length - 1] + const rewriteCode = + source.slice(0, tagIndex) + + ' :' + + source.slice(tagIndex + 1, start) + + code + + source.slice(end) + return rewriteCode +} + +exports.renderOutputCode = (normalized, file) => { + // { + // source: ' title: `{{${code}.${autoSheetOptions(event)[0].value}}} 异常警告`,', + // rewriteCode: ' title: j18n.expand(j18n.load('src__components__automationDrawer__model__action___199', code, autoSheetOptions(event)[0].value)),', + // fileType: 'js', + // type: 'CHAR', + // isVueTag: false, + // output: '`{{{0}.{1}}} 异常警告`', + // filePath: '/Users/inkl/Desktop/work/console/src/components/automationDrawer/model/action.js', + // line: 199, + // code: 'j18n.expand(j18n.load('src__components__automationDrawer__model__action___199', code, autoSheetOptions(event)[0].value))' + // variable?: ['code', 'autoSheetOptions(event)[0].value'], + // src__components__automationDrawer__model__action___199: '`{{{0}.{1}}} 异常警告`', + // key: 'src__components__automationDrawer__model__action___199' + // } + if (normalized.length) { + const code = file.split('\n') + for (let index = 0; index < normalized.length; index++) { + const item = normalized[index] + if (normalized[index + 1] && item.line === normalized[index + 1].line) { + // 处理相同行数的中文 + const [current, next] = [item, normalized[index + 1]] + const { source, start, end } = current + const rCode = + source.slice(0, start) + + current.code + + source.slice(end, next.start) + + next.code + + next.source.slice(next.end) + code[item.line] = rCode + // 跳过下一行的写入 + index++ + } else { + code[item.line] = item.rewriteCode + } + } + return code.join('\n') + } + return null +} + +exports.trackNormalized = (filetrack, rootPath) => { + const json = filetrack.map((track, index) => { + const prefixKey = renderKey(track.filePath, rootPath) + // 处理头尾带引号的字符串 + const output = track.output.replace(/^["'`]/, '').replace(/["'`]$/, '') + // 同一行中有两个中文字符串 + if (filetrack[index + 1] && track.line === filetrack[index + 1].line) { + const key = `${prefixKey}___${track.line}____${index}` + return { + [key]: output, + key: key, + output + } + } + const key = `${prefixKey}___${track.line}` + return { + [key]: output, + key: key, + output + } + }) + return json.map((item, index) => { + let code, + filling = '', + rewriteCode = '' + const itemTrack = filetrack[index] + const { + variable, + fileType, + type, + start, + end, + source, + isVueTag, + isAttribute, + isAttributeExpression, + isJSXNodeExpression + } = itemTrack + if (Array.isArray(variable) && variable.length) { + // filling expressions + filling = `, ${variable.join(', ')}` + } + if (fileType === 'tsx' || fileType === 'ts') { + if (type === Type.CHAR && !isAttribute) { + code = `j18n.load('${item.key}'${filling})` + } else { + // isJSXNodeExpression 表示当前内容是否为 {`xxx`} 模式 + if ((isAttribute && isAttributeExpression) || isJSXNodeExpression) { + code = `j18n.load('${item.key}'${filling})` + } else { + code = `{ j18n.load('${item.key}'${filling}) }` + } + } + } + rewriteCode = + rewriteCode || source.slice(0, start) + code + source.slice(end) + + return { + ...itemTrack, + ...item, + code: code, + rewriteCode: rewriteCode + } + }) +} + +exports.parserCore = (content, path) => { + // const regexp = /["'`]?[\u4e00-\u9fa5]+(.+)?[\u4e00-\u9fa5]+(\?|!)?["'`)]?/; + const double = /".*?"/g + const single = /'.*?'/g + // eslint-disable-next-line no-unused-vars + const backtick = /`.*?`/g + const charRegexp = /["'`].*?["'`]/g + const htmlChineseRegexp = /(?<=>).{0,}[\u4e00-\u9fa5].{0,}(?=<)/ + const allChineseRegexp = /[\u4e00-\u9fa5]{1,}.*?[\u4e00-\u9fa5]{1,}/ + const type = path.match(/\.(tsx|ts|js)$/)[0].slice(1) + + const isVue = false + + let isAttribute = 0, + processed = false + if ( + charRegexp.test(content) || + htmlChineseRegexp.test(content) || + allChineseRegexp.test(content) + ) { + return content + .split('\n') + .map((lineContent, line) => { + // jsx / vue 中的语义处理 < 中的属性值(中文字符串),应该加上 ‘{}’ 符号 + if (/<[a-zA-Z]+/.test(lineContent)) { + isAttribute++ + } + + const tagClose = () => { + // 处理完当前行了之后再决定标签是否闭合 + const close = + /<.+>/.test(lineContent) || + /^>/.test(lineContent.trim()) || + /\/>/.test(lineContent) + if (close) { + isAttribute = isAttribute > 0 ? isAttribute - 1 : 0 + } + } + + if ( + isVue && + isAttribute && + double.test(lineContent) && + single.test(lineContent) + ) { + // vue template ExpressionStatement + // 总共只有13处需要处理,手动替换 + return charParser( + lineContent, + single, + line, + isAttribute, + type, + tagClose + ) + } + + // 处理 <><> tag 中的中文 + if (htmlChineseRegexp.test(lineContent)) { + return tagParser( + lineContent, + htmlChineseRegexp, + isVue, + Type.HTML, + line, + isAttribute, + tagClose + ) + } + + if (backtick.test(lineContent)) { + return charParser( + lineContent, + backtick, + line, + isAttribute, + type, + tagClose + ) + } + + // 处理字符串中的中文 + if (charRegexp.test(lineContent)) { + const parser = charParser( + lineContent, + charRegexp, + line, + isAttribute, + type, + tagClose + ) + processed = true + if (parser.length) { + return parser + } + } + + // 处理 jsx / vue 中的中文 + if ( + allChineseRegexp.test(lineContent) && + !lineContent.includes('//') && + !lineContent.includes('* ') + ) { + return tagParser( + lineContent, + allChineseRegexp, + isVue, + Type.JSX, + line, + isAttribute, + tagClose + ) + } + + // 没有匹配到中文也需要检查标签是否已关闭 + !processed && tagClose() + + // 当前行处理完,标记量回退 + processed = false + + return null + }) + .filter(item => { + if (Array.isArray(item)) { + return item.length > 0 + } + return !!item + }) + .flat() + .map(item => ({ ...item, filePath: path, fileType: type })) + } + + return [] +} diff --git a/script/core/index.js b/script/core/index.js new file mode 100644 index 00000000..8b0a5252 --- /dev/null +++ b/script/core/index.js @@ -0,0 +1,117 @@ +const fs = require('fs') +const path = require('path') +const glob = require('glob') +const prettier = require('prettier') +const { parserCore, trackNormalized, renderOutputCode } = require('./core') + +// Todo remove +const rootPath = path.join(__dirname, '../..') + +const isDir = path => { + try { + const stat = fs.lstatSync(path) + return stat.isDirectory() + } catch (e) { + return false + } +} + +const readAllFile = root => { + const suffix = ['ts', 'tsx', 'js'] + let paths = [] + return new Promise((resole, reject) => { + for (let i = 0; i < suffix.length; i++) { + const mime = suffix[i] + glob( + `**/*.${mime}`, + { + cwd: root, + ignore: ['node_nodules'] + }, + (err, matches) => { + if (err) { + reject() + return console.error(err) + } + const absolutePath = matches.map(filename => { + return path.join(root, filename) + }) + paths = paths.concat(absolutePath) + if (i === suffix.length - 1) { + resole(paths) + } + } + ) + } + }) +} + +const writeHandle = err => { + if (err) { + console.log('写入失败', err) + return false + } + return true +} + +exports.normalizedJsonToFile = normalized => { + const normalizedJson = {} + if (normalized.length) { + normalized.forEach(item => { + Object.assign(normalizedJson, { + [item.key]: item.output + }) + }) + } + return normalizedJson +} + +exports.parser = pathOrFile => { + const merge = (file, path) => { + const filetrack = parserCore(file, path) + const normalized = trackNormalized(filetrack, rootPath) + const pathSplit = path.split('/') + const filename = pathSplit[pathSplit.length - 1] + const type = normalized.length ? normalized[0].fileType : null + return { + [path]: normalized, + key: path, + filename: filename, + type: type + } + } + + if (isDir(pathOrFile)) { + return readAllFile(pathOrFile).then(filePath => { + return filePath + .map(absolutePath => { + const file = fs.readFileSync(absolutePath, { + encoding: 'utf8' + }) + return merge(file, absolutePath) + }) + .filter(item => { + return item[item.key].length > 0 + }) + }) + } + + const file = fs.readFileSync(pathOrFile, { + encoding: 'utf8' + }) + return merge(file, pathOrFile) +} + +exports.writeOutputCode = (path, code) => { + return fs.promises.writeFile(path, code).then(writeHandle) +} + +exports.writeLanguageZhCn = (content, filePath) => { + const fileLinkPath = filePath || path.join(rootPath, 'src/locale/zh-cn.json') + const source = prettier.format(JSON.stringify(content), { + parser: 'json-stringify' + }) + return fs.promises.writeFile(fileLinkPath, source).then(writeHandle) +} + +exports.renderOutputCode = renderOutputCode diff --git a/script/render.js b/script/render.js new file mode 100644 index 00000000..79976484 --- /dev/null +++ b/script/render.js @@ -0,0 +1,51 @@ +/* eslint-disable no-unused-vars */ +const path = require('path') +const fs = require('fs') +const { + parser, + renderOutputCode, + normalizedJsonToFile, + writeLanguageZhCn, + writeOutputCode +} = require('./core/index') + +const rootPath = path.join(__dirname, '..') +const v1 = path.join(rootPath, 'src') + +const startTime = Date.now() + +// 正式 +async function start() { + const [v1Normalized] = await Promise.all([parser(v1)]) + const test = [].concat(v1Normalized).flat() + + const mergeJson = [] + const errorFile = [] + test.map(async json => { + const normalized = json[json.key] + const code = renderOutputCode( + normalized, + fs.readFileSync(json.key, { encoding: 'utf8' }) + ) + mergeJson.push(...normalized) + try { + await writeOutputCode(path.normalize(json.key), code, json.type) + } catch (e) { + // console.warn(e.stack); + errorFile.push({ + name: json.key, + error: e.stack + }) + } + }) + const json = normalizedJsonToFile(mergeJson) + await writeLanguageZhCn({ + ...json + }) + if (errorFile.length) { + await writeLanguageZhCn(errorFile, path.join(rootPath, 'script/error.json')) + } + console.log('Script time:', Date.now() - startTime, 'ms') +} + +start() diff --git a/src/locale/en.js b/src/locale/en.js new file mode 100644 index 00000000..19058a34 --- /dev/null +++ b/src/locale/en.js @@ -0,0 +1,85 @@ +const request = require('request') +const CryptoJS = require('crypto-js') +const json = require('./zh-cn.json') + +function truncate(q) { + const len = q.length + if (len <= 20) return q + return q.substring(0, 10) + len + q.substring(len - 10, len) +} + +const run = v => { + const appKey = '4b890a8255b97ae0' + const key = 'E4426hoRNnOeiRiveXtvNvJJQ6I2gnuC' + + const salt = new Date().getTime() + const curtime = Math.round(new Date().getTime() / 1000) + // 多个query可以用\n连接 如 query='apple\norange\nbanana\npear' + const from = 'zh-CHS' + const to = 'en' + const str1 = appKey + truncate(v) + salt + curtime + key + const sign = CryptoJS.SHA256(str1).toString(CryptoJS.enc.Hex) + + return new Promise(resolve => { + request.post('http://openapi.youdao.com/api', { + form: { + q: v, + appKey: appKey, + salt: salt, + from: from, + to: to, + sign: sign, + signType: 'v3', + curtime: curtime + }, + callback: (error, res) => { + const translation = JSON.parse(res.body).translation + resolve(translation) + } + }) + }) +} + +const v = Object.values(json) + +// eslint-disable-next-line prefer-const +let vJson = [], + tp = '' +v.forEach(item => { + tp += item + '\n' + if (tp.length > 4000) { + vJson.push(tp.slice(0, -1)) + tp = '' + } +}) +vJson.push(tp.slice(0, -1)) +;(async function main() { + const j = [] + while (vJson.length) { + const json = vJson.shift() + const data = await run(json) + const r = data[0].split('\n') + const zh = json.split('\n').length + if (r.length !== zh) { + console.warn('translation error', r.length) + } else { + console.log('youdao', r.length, 'zh-cn', zh) + } + j.push(r) + } + const r = j.flat() + + const rJson = Object.keys(json).reduce((pre, key, index) => { + pre[key] = r[index] + return pre + }, {}) + + console.log('rJson', Object.values(rJson).length) + const source = require('prettier').format(JSON.stringify(rJson), { + parser: 'json-stringify' + }) + require('fs').writeFileSync( + require('path').join(__dirname, 'en.json'), + source + ) +})() diff --git a/src/locale/j18n.ts b/src/locale/j18n.ts new file mode 100644 index 00000000..f7ade8f0 --- /dev/null +++ b/src/locale/j18n.ts @@ -0,0 +1,22 @@ +import zh from './zh-cn.json' + +type Locale = Record + +window.j18n = { + expand(txt: string) { + return txt + }, + load(key: string, ...args: any[]) { + const value = (zh as Locale)[key] + if (!value) { + console.warn('j18n.load not find key: ', key) + } + + const r = value.replace(/\{\d+?\}/g, match => { + const n = match.match(/\d+/) + const index = n ? Number(n[0]) : -1 + return args[index] + }) + return r + } +} diff --git a/yarn.lock b/yarn.lock index 72c1245e..c46bcf1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9052,6 +9052,11 @@ crypto-browserify@^3.11.0: randombytes "^2.0.0" randomfill "^1.0.3" +crypto-js@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/crypto-js/download/crypto-js-4.0.0.tgz#2904ab2677a9d042856a2ea2ef80de92e4a36dcc" + integrity sha1-KQSrJnep0EKFai6i74DekuSjbcw= + crypto-random-string@^2.0.0: version "2.0.0" resolved "https://registry.npm.taobao.org/crypto-random-string/download/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"