1.0.2 • Published 11 months ago

kz-i18n-command v1.0.2

Weekly downloads
-
License
ISC
Repository
github
Last release
11 months ago

i18n-command

i18n-command是一个国际化工具,可以使开发同学减少开发过程中对国际化的关注,尽量使日常开发国际化步骤自动化,优化国际化词条开发、管理、协作的流程。

尝试解决以下问题:

  1. 旧项目想要整体迁移到国际化,但已有项目大,迁移工作量大,重复工作多。

  2. 开发体验差,日常开发业务需要注意把所有文案进行国际化处理。

  3. 国际化词条的管理难,每次更改词条需要业务同步给开发侧由开发同学处理。

开始使用

npm i kz-i18n-command

在项目根目录中新建.i18n-command.js的文件,按照下文参数说明进行配置,例如:

const dirname = __dirname;
const path = require('path');
const globby = require('globby');
const fs = require('fs');

const config = {
	rootPath: dirname,
  // 需要遍历的目录,rootPath的相对路径,minimatch语法:https://github.com/isaacs/minimatch#usage
	includePath: ['./src/modules/i18n-test/**'],
  // 转换排除的路径 https://github.com/isaacs/minimatch#usage
  excludePath: ['**/_i18n', '**/*.xlsx',],
  fileType: ['.js', '.jsx', '.ts', '.tsx', '.html'],
	// 获取模块名方法,也就是i18n中namespace的值,入参为filepath,默认为filepath的basename
  getModuleName: (filePath) => {
		console.log('====', filePath);
    let result = "notfound";
    if (filePath.includes("src/modules/")) {
			const m = filePath.split("src/modules/")[1].split("/")[0]
			// 排除直接是文件
			if (m.includes('.')) return result;
      result = m;
    }
    if (filePath.includes("src/apps/")) {
      result = filePath.split("src/apps/")[1].split("/")[0];
    }
    if (filePath.includes("src/components/")) {
      result = "components";
    }
    if (filePath.includes("src/libs/")) {
      result = "libs";
    }
    if (filePath.includes("packages/shared/")) {
      result = "shared"
    }
    return result;
  },
  i18nStorePath: path.resolve(dirname, './src/i18n-store'),
  i18nConfigPath: path.resolve(dirname, './src/i18n/config'),
	i18nDataSource: 'json',
  // i18n组件的名字
	i18nObject: '$i18next',
	// i18n 调用方法
  i18nMethod: 't',
	// 引用i18n组件的引入目录
  // TODO 目前不能根据当前路径替换为相对路径,只能是alias写法
  i18nObjectPath: '@/i18n',
	// 不需要转换的方法名,比如console.log内的文字就不需要国际化
	excludeFunc: ['dayjs.format', 'I18n.t', 'i18n.t2', 'i18n.t', 'history.push', 'console.log', 'date.format', 'moment.format'],
	// 分隔符 转换后i18n key与中文的分割符 如 module:key{separator}中文
  // 分隔符 转换后i18n key的分割符 如 module:key:中文
	separator: ':',
	// 日志路径配置
	logDir: null,
  autoCompleteImport: true,
	// 只输出国际化脚本覆盖到的词条
  outputOnlyUsed: true,
	// prettier的配置
  prettierOptions: {
    useTabs: true,
  },
	extraOutput: (allPath) => {
    function toCamelCase(name) {
      return name.replace(/\-(\w)/g, function (all, letter) {
        return letter.toUpperCase();
      });
    }

    // 解析命令行中入口文件
    const argvs = process.argv.splice(2)
    // 优先使用命令行中的入口文件 没有就取
    const entryFile = argvs?.[0]?.split('entryFile=')?.[1] || config?.entryFile || './src/index.tsx'
		
    if (entryFile) {
			// 下一个文件夹的路径
      // const pathArray = entryFile?.split('/')
      // const configJsName = pathArray?.[pathArray.length - 1]
			
      // 遍历文件夹下json,自动再生成index.js
      const configFiles = globby.sync(`${config.i18nConfigPath}/*.json`, {
				ignore: ["**/index.js"],
        absolute: true,
      });
      
      const configList = configFiles.map((filePath) =>
        path.basename(filePath, ".json")
      );

      // 从解析的所有文件中 找到需要引入的json文件
      const importList = configList.filter(item => allPath.some(path => {
        if (path.module === 'shared' && item === `shared-${configJsName}`) {
          return true
        }
        return path.module === item
      }))
			console.log('-------', argvs,configFiles, configList,importList);

      fs.writeFileSync(
        `${config.i18nConfigPath}/index.js`,
        `${importList
      .map(
        (fileName) => `import ${toCamelCase(fileName)} from './${fileName}.json';`
      )
      .join("\n")}

export const config = [${importList
        .map((name) => toCamelCase(name))
        .join(", ")}]
`,
      "utf8");
    } else {
      console.warn('\n未找到声明的入口文件,跳过国际化配置文件生成步骤!\n')
    }

    // 执行一个格式化
    const commandText = `npx eslint ${config.includePath.join(
      " "
    )} ./src/i18n/config/**.js --ext .js,.jsx,.ts,.tsx --fix --no-error-on-unmatched-pattern --ignore-path ./.eslintignore`
    shelljs.exec(commandText, { silent: false, async: false });
  },
}

module.exports = config;

配置完毕后运行node_modules/.bin/i18n-command 即可,脚本会按照规则替换源码中的中文并转换为国际化的书写格式,并把词条抽取出来,存入json文件中供后续初始化i18n类使用。

词条管理方式

目前词条管理有两种方式:rainbow-石头远端配置管理,json-本地json文件管理。

石头方式,会把词条以下图格式保存在石头配置中心.

json方式,把词条按照namespace区分,保存在单独的json文件中,并处存在i18nStorePath目录下.

推荐使用石头配置的管理方式,词条存放在石头配置中心可便于业务人员线上进行更新词条。下面简要说明在流水线上的配置示例.

使用石头配置的管理方式运行脚本时,脚本需要接收石头AppID,secretKey等参数,故首先应用石头Rainbow常用操作石头插件读取石头的配置,填入插件相应参数,注意这里建议选择红框内选项。

后续将石头插件读取到的AppID等配置,设置到环境变量中,推荐使用python插件进行如下设置:

import os
import json

try:
    if 'BranchLife' in  os.environ  and os.environ['BranchLife']:
        bf_str = os.environ['BranchLife']
        bf_json_obj = json.loads(bf_str)
        if bf_json_obj:
            dict(bf_json_obj)
            if 'rianbow_appID' in bf_json_obj and bf_json_obj['rianbow_appID']:
                print("setEnv 'rianbow_appID' '{}'".format(bf_json_obj['rianbow_appID']))
            if 'rianbow_userID' in bf_json_obj and bf_json_obj['rianbow_userID']:
                print("setEnv 'rianbow_userID' '{}'".format(bf_json_obj['rianbow_userID']))
            if 'rianbow_secretKey' in bf_json_obj and bf_json_obj['rianbow_secretKey']:
                print("setEnv 'rianbow_secretKey' '{}'".format(bf_json_obj['rianbow_secretKey']))
            if 'rianbow_tableId' in bf_json_obj and bf_json_obj['rianbow_tableId']:
                print("setEnv 'rianbow_tableId' '{}'".format(bf_json_obj['rianbow_tableId']))
            if 'rianbow_groupId' in bf_json_obj and bf_json_obj['rianbow_groupId']:
                print("setEnv 'rianbow_groupId' '{}'".format(bf_json_obj['rianbow_groupId']))
            if 'rianbow_group' in bf_json_obj and bf_json_obj['rianbow_group']:
                print("setEnv 'rianbow_group' '{}'".format(bf_json_obj['rianbow_group']))
            if 'rianbow_creator' in bf_json_obj and bf_json_obj['rianbow_creator']:
                print("setEnv 'rianbow_creator' '{}'".format(bf_json_obj['rianbow_creator']))
            if 'rianbow_envName' in bf_json_obj and bf_json_obj['rianbow_envName']:
                print("setEnv 'rianbow_envName' '{}'".format(bf_json_obj['rianbow_envName']))
    else:
        exit(1)
except Exception as e:
    print(e)
    exit(1)

将需要的配置设置到环境变量中后,只需要使用bash插件调用国际化脚本即可,类似如下:

npm install --verbose

echo '运行脚本'

npm run i18n ${rianbow_appID} ${rianbow_userID} ${rianbow_secretKey} ${rianbow_tableId} ${rianbow_groupId} ${rianbow_group} ${rianbow_creator} ${rianbow_envName}

if [ $? -ne 0 ]
then
echo "i18n fail"
exit 1
else
echo "i18n success"
fi

exit 0

因插件会修改源码,后续也需要在把修改后的源码进行一次commit操作:

commit_path="./"

git status ${commit_path} -s

if [ -n "$(git status ${commit_path} -s)" ];then

    echo "有需提交文件"
    git status ${commit_path} -s

    git config --global push.default matching
    git config --global user.email "xxx.com"
    git config --global user.name "xxx"
    git pull
    git add ${commit_path}
    git commit -m "[*] update file --i18n-command "
    git push origin ${branchname}
else
    echo  "未有需提交文件"
fi

以上为示例步骤,用户也可以根据自己需要设置相应流水线步骤。

生成词条的格式

目前i18n-command生成的国际化格式为i18n.t(模块名:md5值:汉字),例如:I18n.t("demo:91575d2e:只能输入数字");,使用该种格式的原因是:

  1. 便于查找,考虑到日常开发中如果只使用namespace:key的格式,在debug或查找文案时会比较繁琐,需要在json文件中找到中文对应的key,再搜索key找到对应代码的位置,保存中文后可以简化此步骤。
  2. 便于复用翻译,目前是以中文的md5作为key值来保存词条配置,当有新的中文文案需要进行国际化时,脚本可以在整个项目的词条中查找是否有匹配的翻译进行复用。
  3. 词条缺失展示不会异常,原有方案在词条文案缺失时会把key直接展示在界面上,现有格式在没有找到词条配置时可以默认展示该词条的中文。

以上该格式需要对i18n方法进行2次封装,目前主流国际化方案为i18next,以下为一个实例参考:

import i18n from 'i18next';

const t = (text, options) => {
    // 解析word
    const wordArr = text.split(':');
    const namespace = wordArr[0];
    const word = wordArr[1];
    const defaultValue = wordArr[2];
    const key = `${namespace}:${word}`;
    if (i18n.exists(key)) {
      return i18n.t(key, options);
    }
    return defaultValue;
};

旧项目迁移

按照上述使用说明进行配置后,编写迁移方法:migrateFunc, 以下为一个实际例子进行参考:

现存i18n目录结构如下:

├── i18n
│   ├── en
│   │   ├── common.js
│   ├── zh-cn
│   │   ├── common.js

每个文件结构如下(示意):

export default {
	test: '测试',
}

然后编写迁移方法(示意):

migrateFunc: () => {
  	// 查询到每个配置文件
		const configFiles = glob.sync(`${path.resolve(__dirname, './src/i18n')}/**/!(index|test).js`);
		const result = {};
		const langMap = {
			'zh-cn': 'zh',
			en: 'en',
		}
		const moduleMap = {
			'common': {old: 'common', new: 'common'},
		}
    for (const filePath of configFiles) {
      //requireEsm为common引用es6模块的包
			const {default: content} = requireEsm(filePath);
			const fileName = path.basename(filePath, '.js'); // 文件名
			const module = moduleMap[fileName] // 文件映射的module名
			const rawLang = filePath.split('src/i18n/')[1].split('/')[0]; // 文件所属语言
			const lang = langMap[rawLang] || rawLang;
			Object.keys(content).forEach((rawId) => {
				const key = `${module.old}:${rawId}`; // 以module:id 作为储存的key,避免多个模块有相同的id
				const langKey = rawId.includes('_plural') ? `${lang}_plural` : lang;
				if (!result[key]) {
					result[key] = {
						module: module.new, // 老的词条想要映射的模块名
						[langKey]: content[rawId],
					};
				} else {
					result[key][langKey] = content[rawId];
				}
			});
		}
		return result;
	},

返回结果会类似于以下的一个对象:

{
  'common:test': {
    zh: '测试',
    zh_plural: '',
    en: 'test',
    en_plural: '',
    module: 'common—new'
  }
}

'common:test'为老词条的key,在的迁移时脚本会扫描oldi18n.t('common:test')的词条,找到'common:test'对应的翻译,把相应配置导入到新的词条库当中,并把老的国际化方法转为新的写法newi18n.t('common—new:md5xxxx:测试'),并会把该词条存放在'common—new'的命名空间下。

后续步骤同上述使用说明

参数说明

参数必填类型默认值说明
rootPathstringpath.resolve('./')需要运行的项目根路径
includePatharray[]需要遍历的目录,rootPath的相对路径minimatch语法 https://github.com/isaacs/minimatch#usage
excludePatharray[]转换排除的路径
fileTypearray'.js', '.jsx', '.ts', '.tsx', '.html'需要转换文件的类型, 类型续满足fileType要求,如果在includePath中已定义文件格式,这里可为空
getModuleNamefunction获取模块名方法,也就是i18n中namespace的值,入参为filepath,默认为filepath的basename
migrateFuncfunction读取现有i18n配置自定义方法,便于迁移词条时寻找到老词条。方法需要返回一个object {module-id: {zh, en, module,zh_plarul, en_plarul}}
i18nDataSourcejson/rainbow配置的数据源 json / rainbow如果是json,i18nStorePath必填;如果是rainbow,rainbowConfig必填
i18nStorePathstringi18n-store目录,词条的存档
rainbowobject{config: {}, signMethod: 'sha1'}config为石头SDK初始化配置https://git.woa.com/rainbow/nodejs-admin,signMethod为加密方式
i18nConfigPathstring生成i18n json文件路径
angularFilterNamestringi18next2angular 模板i18n过滤器名字
i18nObjectstringI18ni18n组件的名字
i18nMethodstringti18n的调用方法,和i18nObject组成 I18n.t
i18nObjectPathstring@pc/components如果配置自动引用时i18n组件的引入目录,目前不能根据当前路径替换为相对路径,只能是alias写法
excludeFuncarray[]不需要转换的方法名,比如console内的文字不需要国际化配置'console.log', 'console.error'
transformOldI18nWordstring提取原有i18n配置 需要提取的方法名,如配置会把原有配置转为新的i18n配置,如不配置则不会提取
separatorstring:分隔符 转换后i18n key与中文的分割符 如 module​ : key :中文
autoCompleteImportboolfalse检查是否引入国际化i18nObject并自动补充
removeRedundantboolfalse是否清除冗余数据(目前不建议清楚,版本未稳定移除可能造成词条缺失)
prettierOptionsobject{}prettier 配置,主要用于html缩进类型不一样,app一直用空格,pc一直用tab
logDirstringpath.resolve(__dirname, '../logs')脚本输出日志保存的路径
extraOutputfunction输出文件时额外要输出的内容,可以自定义执行一些方法,比如生成i18n json文件后,可以自动生成index.js 引入文件

使用

i18n-command目录下运行npm link

需要国际化的项目根目录下运行npm link i18n-command

复制 config-user-sample 到项目根目录下,修改 workPath 字段,将要提取替换的文件夹加进去。

运行 i18n-command

xlsx与json的转换

如果需要手动把json转为excel,运行 i18n-command -excel <json-path> <excel-file-path>

ex: i18n-command -excel ./i18n-store ./excel.xlsx"

把excel生成为json,运行 i18n-command -json <json-path> <excel-file-path>

ex: i18n-command -json ./i18n-store ./excel.xlsx"

运行demo

根目录下运行node index.js,在demo文件夹下看到输出结果

功能

  • 构建用户配置文件

    • 选择需要国际化代码所在文件夹
    • 选择需要检查的文件类型
    • 创建本地.i18n-command配置
  • 检测配置文件

    • 检测是否存在.i18n-command配置文件
    • 读取本地配置
  • 自动化脚本

    • 创建本地执行文件
    • 自动执行国际化脚本
    • 执行后移除本地执行文件

配置项

国际化忽略

非html文件

忽略一行:在需要忽略的上一行添加@i18n-ignore进行下一行代码的国际化忽略 不推荐 忽略一类方法: excludeFunc 忽略一个文件:加到 excludePath

html文件

忽略一个dom,不包含子类: 加个属性值 i18n-ignore 忽略一个dom,包含子类: 加个属性值 i18n-ignore-children 忽略一个文件:加到 excludePath

国际化转换结果

// 字符串
// const a = '只能输入数字';
const a = I18n.t("demo:91575d2e:只能输入数字");

// 对象
// const b = {a: '只能输入数字',};
const b = { a: I18n.t("demo:91575d2e:只能输入数字") };

// 模板字符串
// const e = `这里有${a}`
const e = I18n.t(`demo:85805805:这里有{{a}}`, { a: a });

// jsx
// const c = <span className="text-12 ml-2 pl-1.5 pr-1.5 bg-red-200 text-red-700 rounded-full">{`还剩${a}天`}</span>
const c = (
  <span className="text-12 ml-2 pl-1.5 pr-1.5 bg-red-200 text-red-700 rounded-full">
    {I18n.t(`demo:8ef4c91d:还剩{{a}}天`, { a: a })}
  </span>
);

// const d = <span className="text-12 ml-2 pl-1.5 pr-1.5 bg-gray-400 text-white rounded-full">已截止</span>;
const d = (
  <span className="text-12 ml-2 pl-1.5 pr-1.5 bg-gray-400 text-white rounded-full">
    {I18n.t("demo:ff7420db:已截止")}
  </span>
);

// 函数调用
// console.error('保存失败')
console.error(I18n.t("demo:6de920b4:保存失败"));

// 属性
/*const f = <input
  options={this.selectOptions}
  optionKey="key"
  optionText="text"
  placeholder="请选择"
/>*/
const f = (
  <input options={this.selectOptions} optionKey="key" optionText="text" placeholder={I18n.t("demo:708c9d6d:请选择")} />
);

html 见 src/angular-template-parser/**/*.test.js

自动引入使用方法:

词条状态init/add/delete/update 更新词条策略: 读取配置->从i18nStorePath中初始化词条库,标记状态为空->从i18nConfigPath读取目前代码中已有的i18n配置,更新状态为init/update->读取i18n找到需要翻译的更新状态为add->其他状态仍然为空的即为冗余词条需要删除

CI/CD

git push -o ci.skip 可以跳过流水线