From 4b0b959759b034f13763aef89da7fccf5d9bdb52 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Sun, 4 Jul 2021 14:51:35 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E7=BF=BB=E8=AF=91=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + build/config/i18next-scanner.config.js | 55 +++ build/script/buildPublicTranslation.js | 56 +++ build/script/scanTranslation.js | 93 ++++ package.json | 7 +- shared/i18n/index.ts | 81 ++++ shared/i18n/langs/en-US/translation.json | 3 + shared/i18n/langs/zh-CN/translation.json | 3 + shared/i18n/language.ts | 76 ++++ shared/index.tsx | 3 + shared/manager/storage.ts | 10 +- shared/package.json | 5 + shared/utils/consts.ts | 4 + web/package.json | 2 + web/src/routes/Entry/LoginView.tsx | 4 +- web/webpack.config.ts | 13 +- yarn.lock | 542 ++++++++++++++++++++++- 17 files changed, 931 insertions(+), 28 deletions(-) create mode 100644 build/config/i18next-scanner.config.js create mode 100644 build/script/buildPublicTranslation.js create mode 100644 build/script/scanTranslation.js create mode 100644 shared/i18n/index.ts create mode 100644 shared/i18n/langs/en-US/translation.json create mode 100644 shared/i18n/langs/zh-CN/translation.json create mode 100644 shared/i18n/language.ts create mode 100644 shared/utils/consts.ts diff --git a/.gitignore b/.gitignore index 67045665..7a7cf3b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +locales + # Logs logs *.log diff --git a/build/config/i18next-scanner.config.js b/build/config/i18next-scanner.config.js new file mode 100644 index 00000000..011495b7 --- /dev/null +++ b/build/config/i18next-scanner.config.js @@ -0,0 +1,55 @@ +const fs = require('fs'); +const { crc32 } = require('crc'); + +console.log('Scanning Translation in src folder...'); + +module.exports = { + input: [ + 'web/**/*.{ts,tsx}', + 'shared/**/*.{ts,tsx}', + // 'src/shared/i18n/__internal__/__scan__.ts', + // Use ! to filter out files or directories + '!src/**/*.spec.{js,jsx,ts,tsx}', + // '!src/shared/i18n/**', + '!**/node_modules/**', + ], + output: './', //输出目录 + options: { + debug: false, + sort: true, + func: false, + trans: false, + lngs: ['zh-CN', 'en-US'], + defaultLng: 'zh-CN', + resource: { + loadPath: './src/shared/i18n/langs/{{lng}}/{{ns}}.json', //输入路径 + savePath: './src/shared/i18n/langs/{{lng}}/{{ns}}.json', //输出路径 + jsonIndent: 2, + lineEnding: '\n', + endWithEmptyTrans: true, + }, + removeUnusedKeys: true, + nsSeparator: false, // namespace separator + keySeparator: false, // key separator + interpolation: { + prefix: '{{', + suffix: '}}', + }, + }, + transform: function customTransform(file, enc, done) { + //自己通过该函数来加工key或value + 'use strict'; + const parser = this.parser; + const content = fs.readFileSync(file.path, enc); + parser.parseFuncFromString( + content, + { list: ['lang', 't'] }, + (key, options) => { + options.defaultValue = key; + let hashKey = `k${crc32(key).toString(16)}`; + parser.set(hashKey, options); + } + ); + done(); + }, +}; diff --git a/build/script/buildPublicTranslation.js b/build/script/buildPublicTranslation.js new file mode 100644 index 00000000..ed2c50fe --- /dev/null +++ b/build/script/buildPublicTranslation.js @@ -0,0 +1,56 @@ +/** + * 将翻译文件集合到dist目录 + */ +const path = require('path'); +const fs = require('fs-extra'); +const scannerConfig = require('../config/i18next-scanner.config'); +// const utils = require('./utils'); +const langs = scannerConfig.options.lngs; +const distDir = path.resolve(__dirname, '../../'); +// const plugins = utils.getPluginDirs(); + +const filepath = [ + path.resolve(__dirname, '../../shared/i18n/langs/{{lang}}/translation.json'), + // ...plugins.map((plugin) => + // path.resolve( + // __dirname, + // `../../src/plugins/${plugin}/i18n/{{lang}}/translation.json` + // ) + // ), +]; + +console.log('Build locales:', langs); +for (const lang of langs) { + Promise.all( + filepath + .map((p) => { + return p.replace('{{lang}}', lang); + }) + .map((p) => fs.readJSON(p)) + ) + .then((jsons) => { + let res = {}; + for (const json of jsons) { + res = { + ...res, + ...json, + }; + } + + return res; + }) + .then((trans) => { + const filePath = path.resolve( + distDir, + `./locales/${lang}/translation.json` + ); + return fs.ensureFile(filePath).then(() => { + fs.writeJSON(filePath, trans, { + spaces: 2, + }); + }); + }) + .then(() => { + console.log(`Build [${lang}] Success!`); + }); +} diff --git a/build/script/scanTranslation.js b/build/script/scanTranslation.js new file mode 100644 index 00000000..9d40f727 --- /dev/null +++ b/build/script/scanTranslation.js @@ -0,0 +1,93 @@ +const vfs = require('vinyl-fs'); +const fs = require('fs-extra'); +const sort = require('gulp-sort'); +const path = require('path'); +const scanner = require('i18next-scanner'); +// const utils = require('./utils'); +const _ = require('lodash'); +const { crc32 } = require('crc'); +const scannerConfig = require('../config/i18next-scanner.config'); + +const output = path.resolve(__dirname, '../../'); + +// For main +const mainstream = vfs + .src([ + ...scannerConfig.input, + // '!src/plugins/**' + ]) + .pipe(sort()) // Sort files in stream by path + .pipe( + scanner( + { + ...scannerConfig.options, + resource: { + ...scannerConfig.options.resource, + loadPath: './shared/i18n/langs/{{lng}}/{{ns}}.json', //输入路径 + savePath: './shared/i18n/langs/{{lng}}/{{ns}}.json', //输出路径 + }, + }, + scannerConfig.transform + ) + ) + .pipe(vfs.dest(path.resolve(__dirname, output))); + +mainstream.on('finish', () => { + // 主流完毕后进行插件生成 + // console.log('主项目翻译生成完毕, 开始进行子项目翻译生成...'); + console.log('主项目翻译生成完毕!'); + + // For plugins + // utils.getPluginDirs().forEach((plugin) => { + // const stream = vfs + // .src([`src/plugins/${plugin}/**/*.{ts,tsx}`]) + // .pipe(sort()) // Sort files in stream by path + // .pipe( + // scanner( + // { + // ...scannerConfig.options, + // resource: { + // ...scannerConfig.options.resource, + // loadPath: `./src/plugins/${plugin}/i18n/{{lng}}/{{ns}}.json`, //输入路径 + // savePath: `./src/plugins/${plugin}/i18n/{{lng}}/{{ns}}.json`, //输出路径 + // }, + // }, + // scannerConfig.transform + // ) + // ) + // .pipe(vfs.dest(path.resolve(__dirname, output))); + + // stream.on('finish', () => { + // let sharedKeyNum = 0; + // scannerConfig.options.lngs.forEach((lang) => { + // const mainTrans = fs.readJSONSync( + // path.resolve( + // output, + // `./src/shared/i18n/langs/${lang}/translation.json` + // ) + // ); + + // const pluginTransPath = path.resolve( + // output, + // `./src/plugins/${plugin}/i18n/${lang}/translation.json` + // ); + // const pluginTrans = fs.readJSONSync(pluginTransPath); + + // const sharedTransKey = _.intersection( + // Object.keys(pluginTrans), + // Object.keys(mainTrans) + // ); + // sharedKeyNum = sharedTransKey.length; + // sharedTransKey.forEach((key) => { + // delete pluginTrans[key]; + // }); + // fs.writeJsonSync(pluginTransPath, pluginTrans, { + // spaces: 2, + // }); + // }); + // console.log( + // `子项目 [${plugin}] 翻译生成完毕, 自动移除与主项目共享的翻译 ${sharedKeyNum} 条` + // ); + // }); + // }); +}); diff --git a/package.json b/package.json index ec61906c..f2084523 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "private": true, "scripts": { "prepare": "husky install", + "translation": "node build/script/scanTranslation.js", "lint:fix": "eslint --fix './**/*.{ts,tsx}'", "test": "jest" }, @@ -39,10 +40,14 @@ "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^3.4.0", "eslint-plugin-react": "^7.24.0", + "fs-extra": "^10.0.0", + "gulp-sort": "^2.0.0", "husky": "^7.0.0", + "i18next-scanner": "^3.0.0", "jest": "^27.0.6", "lint-staged": "^11.0.0", "prettier": "^2.3.2", - "ts-jest": "^27.0.3" + "ts-jest": "^27.0.3", + "vinyl-fs": "^3.0.3" } } diff --git a/shared/i18n/index.ts b/shared/i18n/index.ts new file mode 100644 index 00000000..e20f516b --- /dev/null +++ b/shared/i18n/index.ts @@ -0,0 +1,81 @@ +import i18next, { TFunction } from 'i18next'; +import { + useTranslation as useI18NTranslation, + initReactI18next, +} from 'react-i18next'; +import { crc32 } from 'crc'; +import { languageDetector } from './language'; +import { useState, useEffect } from 'react'; +import HttpApi from 'i18next-http-backend'; // https://github.com/i18next/i18next-http-backend + +i18next + .use(languageDetector) + .use(HttpApi) + .use(initReactI18next) + .init({ + fallbackLng: 'zh-CN', + load: 'currentOnly', + backend: { + loadPath: '/locales/{{lng}}/{{ns}}.json', + allowMultiLoading: false, + addPath: (...args: any[]) => { + console.log('缺少翻译:', ...args); + }, + }, + } as any); + +/** + * 国际化翻译 + */ +export const t: TFunction = ( + key: string, + defaultValue?: string, + options?: any +) => { + try { + const hashKey = `k${crc32(key).toString(16)}`; + let words = i18next.t(hashKey, defaultValue, options); + if (words === hashKey) { + words = key; + console.info(`[i18n] 翻译缺失: [${hashKey}]${key}`); + } + return words; + } catch (err) { + console.error(err); + return key; + } +}; + +/** + * 设置i18next的语言 + */ +export async function setLanguage(lang: string): Promise { + return new Promise((resolve, reject) => { + i18next.changeLanguage(lang, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); +} + +/** + * fork from i18next/react-i18next/-/blob/src/useTranslation.js + * i18n for react 使用hooks + */ +export function useTranslation() { + const { t: i18nT, ready } = useI18NTranslation(); + + const [_t, _setT] = useState(() => t); + useEffect(() => { + _setT( + () => + (...args: any[]) => + (t as any)(...args) + ); + }, [i18nT]); + + return { t: _t, ready }; +} diff --git a/shared/i18n/langs/en-US/translation.json b/shared/i18n/langs/en-US/translation.json new file mode 100644 index 00000000..d29618e5 --- /dev/null +++ b/shared/i18n/langs/en-US/translation.json @@ -0,0 +1,3 @@ +{ + "kc654b275": "Email" +} diff --git a/shared/i18n/langs/zh-CN/translation.json b/shared/i18n/langs/zh-CN/translation.json new file mode 100644 index 00000000..4b163635 --- /dev/null +++ b/shared/i18n/langs/zh-CN/translation.json @@ -0,0 +1,3 @@ +{ + "kc654b275": "邮箱" +} diff --git a/shared/i18n/language.ts b/shared/i18n/language.ts new file mode 100644 index 00000000..b3e1f497 --- /dev/null +++ b/shared/i18n/language.ts @@ -0,0 +1,76 @@ +import type { LanguageDetectorAsyncModule } from 'i18next'; +import { useRef, useMemo, useCallback } from 'react'; +import _isNil from 'lodash/isNil'; +import { setLanguage as setI18NLanguage } from './index'; +import { getStorage, useStorage } from '../manager/storage'; +import { LANGUAGE_KEY } from '../utils/consts'; + +export const defaultLanguage = 'zh-CN'; + +/** + * 获取当前语言 + */ +export async function getLanguage(): Promise { + return await getStorage().get(LANGUAGE_KEY, defaultLanguage); +} + +/** + * 当前语言管理hook + */ +export function useLanguage() { + const [language, { save }] = useStorage(LANGUAGE_KEY, defaultLanguage); + + const originLanguageRef = useRef(); + + const setLanguage = useCallback( + async (newLanguage) => { + if (_isNil(originLanguageRef.current)) { + originLanguageRef.current = language; + } + + save(newLanguage); + await setI18NLanguage(newLanguage); + }, + [language, save] + ); + + const isChanged = useMemo(() => { + if (_isNil(originLanguageRef.current)) { + return false; + } + + return originLanguageRef.current !== language; + }, [language]); + + return { language, setLanguage, isChanged }; +} + +/** + * 存储语言 + * @param lang 语言代码 + */ +export async function saveLanguage(lang: string) { + await getStorage().save(LANGUAGE_KEY, lang); +} + +/** + * i18n语言检测中间件 + */ +export const languageDetector: LanguageDetectorAsyncModule = { + type: 'languageDetector', + async: true, + init: () => {}, + detect: async (callback) => { + try { + const language = await getLanguage(); + callback(language); + } catch (error) { + callback(defaultLanguage); + } + }, + cacheUserLanguage(language) { + try { + saveLanguage(language); + } catch (error) {} + }, +}; diff --git a/shared/index.tsx b/shared/index.tsx index 5a5919e8..c48c534d 100644 --- a/shared/index.tsx +++ b/shared/index.tsx @@ -15,6 +15,9 @@ export { regField } from './components/FastForm/field'; export { regFormContainer } from './components/FastForm/container'; export type { FastFormContainerComponent } from './components/FastForm/container'; +// i18n +export { t, setLanguage, useTranslation } from './i18n'; + // hooks export { useAsync } from './hooks/useAsync'; export { useAsyncFn } from './hooks/useAsyncFn'; diff --git a/shared/manager/storage.ts b/shared/manager/storage.ts index 099cbcf7..82b3b0d8 100644 --- a/shared/manager/storage.ts +++ b/shared/manager/storage.ts @@ -22,18 +22,16 @@ export const [getStorage, setStorage] = * @param key 存储键 * @param defaultValue 默认值 */ -export function useStorage( +export function useStorage( key: string, defaultValue?: T -): [T, { set: (v: T) => void; save: (v: T) => void }] { - const [value, setValue] = useState(defaultValue); +): [T | undefined, { set: (v: T) => void; save: (v: T) => void }] { + const [value, setValue] = useState(defaultValue); useLayoutEffect(() => { getStorage() .get(key) - .then((data: T) => { - setValue(data ?? defaultValue); - }); + .then((data: T) => setValue(data ?? defaultValue)); }, [key]); const set = useCallback( diff --git a/shared/package.json b/shared/package.json index 41ec597e..3dbd02e7 100644 --- a/shared/package.json +++ b/shared/package.json @@ -9,8 +9,12 @@ "dependencies": { "@reduxjs/toolkit": "^1.6.0", "axios": "^0.21.1", + "crc": "^3.8.0", "formik": "^2.2.9", + "i18next": "^20.3.2", + "i18next-http-backend": "^1.2.6", "lodash": "^4.17.21", + "react-i18next": "^11.11.0", "react-native-storage": "npm:@trpgengine/react-native-storage@^1.0.1", "redux": "^4.1.0", "str2int": "^1.0.0", @@ -18,6 +22,7 @@ "yup": "^0.32.9" }, "devDependencies": { + "@types/crc": "^3.4.0", "@types/lodash": "^4.14.170" }, "peerDependencies": { diff --git a/shared/utils/consts.ts b/shared/utils/consts.ts new file mode 100644 index 00000000..556626d3 --- /dev/null +++ b/shared/utils/consts.ts @@ -0,0 +1,4 @@ +/** + * 系统语言的常量 + */ +export const LANGUAGE_KEY = 'i18n:language'; diff --git a/web/package.json b/web/package.json index 506ac014..2720b89b 100644 --- a/web/package.json +++ b/web/package.json @@ -32,6 +32,7 @@ "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^12.0.0", "@testing-library/react-hooks": "^7.0.1", + "@types/copy-webpack-plugin": "^8.0.0", "@types/mini-css-extract-plugin": "^1.4.3", "@types/node": "^15.12.5", "@types/react": "^17.0.11", @@ -41,6 +42,7 @@ "@types/webpack": "^5.28.0", "@types/webpack-dev-server": "^3.11.4", "autoprefixer": "^10.2.6", + "copy-webpack-plugin": "^9.0.1", "cross-env": "^7.0.3", "css-loader": "^5.2.6", "esbuild-loader": "^2.13.1", diff --git a/web/src/routes/Entry/LoginView.tsx b/web/src/routes/Entry/LoginView.tsx index b3d47fe1..21e77ecd 100644 --- a/web/src/routes/Entry/LoginView.tsx +++ b/web/src/routes/Entry/LoginView.tsx @@ -1,6 +1,6 @@ import { Icon } from '@iconify/react'; import { Divider } from 'antd'; -import { loginWithEmail, useAsyncFn } from 'pawchat-shared'; +import { loginWithEmail, t, useAsyncFn } from 'pawchat-shared'; import React, { useCallback, useState } from 'react'; import { Spinner } from '../../components/Spinner'; import { string } from 'yup'; @@ -61,7 +61,7 @@ export const LoginView: React.FC = React.memo(() => {
-
邮箱
+
{t('邮箱')}
=0.5.1, websocket-driver@^0.7.4: version "0.7.4" resolved "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" @@ -9107,7 +9613,7 @@ xmlchars@^2.2.0: resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xtend@^4.0.2: +xtend@^4.0.2, xtend@~4.0.0, xtend@~4.0.1: version "4.0.2" resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==