开发分享 | 简化 i18n 工作的一个方案

在开发插件的时候,最让我头疼甚至恶心的工作就是 i18n。每次需要添加新的文本或者修改现有文本时,我都要经历以下繁琐的步骤:

  1. 在代码中定位需要国际化的文本
  2. 为每个文本创建一个唯一的 key
  3. 将这个 key 添加到各种语言的翻译文件中
  4. 手动翻译或找人翻译这些文本
  5. 在代码中用 i18n 函数调用替换原来的文本

这个过程不仅耗时,而且容易出错。可以说很多时候 i18n 都大大减弱了想要写代码的欲望。

我想要的 i18n 解决方案

现成的自动化 i18n 工具不是没有,但是说实话我用来不是很满意。

首先很多工具操作上并不好用。比如我之前走了一点弯路,尝试使用甚至开发某相关的 vscode 插件,试了两下就觉得插件的操作体验还是有些繁琐,我实在不喜欢鼠标在菜单里面繁琐地点来点去。

最后敲定,一个良好的 i18n 工具应该是基于 cli 程序为好,能直接在命令行里快速运行。(至于什么需要运行额外的 GUI 程序甚至使用 XXX 网站来帮忙 i18n 的还是有多远滚多远吧)。

但是 cli 的自动 i18n 我看了几个,感觉也不是很喜欢。

有些工具很好很强大,但是有点过于强大了:他们会自行维护一个 i18n manager,直接跳过思源插件的机制。这个想了想还是算了吧,没必要为了一个 i18n 引入一些乱七八糟的东西(特别是引入到打包当中,100% 拒绝);keep simple, enough。

还有些工具对源代码文本进行预处理,把指定的文本替换为 i18n 变量——嗯,这个很简单粗暴,我喜欢,但是看了一下他们是把:

<input
    type="二"
    placeholder="一"
    value="s 四 f"
/>

变成:

<input
    type={this.$t('0')}
    placeholder={this.$t('1')}
    value={`s ${this.$t('2')} f`}
/>

可以看到完全丢失了所有的语义信息,看着有点倒胃口了。

考虑了一下,一个(至少对我而言)理想的 i18n 解决方案应该:

  1. 批量处理:可以一次性处理整个项目的 i18n,操作尽可能简单。
  2. 无侵入:不引入任何额外的打包,只作为一个单独的 i18n 工具使用。
  3. 自动抽取:能够自动从代码中提取需要翻译的文本,并转换为对 i18n 文本变量的引用
  4. 保留 key 结构:我不太喜欢那些把所有文本变成扁平列表的方案。我希望能保留 key 的层次结构,最好能映射到一个 interface​ 对象中,这样可以在代码中得到更好的类型提示。
  5. 自动翻译:能自动翻译,而且最好是能自己指定一些翻译的词典。比如之前用翻译软件有的都不知道要怎么翻译「SiYuan」这个词。
  6. 可配置:有些工具,每次运行的时候都要附带一大堆参数,光看着就头疼,我的想法是能写入配置文件保存起来的就不要缝合一堆命令参数进去

auto-i18n

趁着国庆闲着也是闲着,做了一个小工具: auto-i18n。一个基于 Python(\geqslant 3.9) 的命令行工具。使用原理很简单:正则匹配 + GPT。

  1. 自动抽取:在编写源代码的时候,基于一定的格式,例如 ((`xxx`)) 来编写需要转换为 i18n 的文本。运行命令的时候会自动抽取这些文本
  2. key 生成:使用 GPT 为每个提取的文本生成一个合适的 key;并将源代码中的文本替换为对 i18n 变量的引用(直接简单粗暴地文本替换)。
  3. 翻译:再次使用 GPT 将主语言文件中的文本翻译成其他语言。

基本上使用就是下面几个步骤:

  1. 安装

    pip install auto-i18n
    
  2. 在项目根目录初始化

    i18n init
    
  3. 配置 GPT

  4. 在你的代码中使用特定模式标记需要翻译的文本

    console.log(((`这是需要翻译的文本`)));
    
  5. 提取文本并生成 i18n 文件

    i18n extract
    
  6. 翻译到其他语言

    i18n translate
    

具体的详情可以阅读:https://github.com/frostime/py-auto-i18n

使用样例

用法细节请参考:auto i18n 的 README。这里只简单展示一下使用的流程。

现在假定我们在开发某插件,并安装了 auto-i18n​。首先我们运行 i18n init​ 在项目当中创建必要的配置文件。

image

确保 i18n_dir​ 下面已经创建好了我们需要的 i18n 文件。文件在就行,内容为空的也无所谓。

image

在正式的代码当中,使用 i18n_pattern​ 配置的正则语法编写常量文本。比如下面给了三个样例:

image

这里用 (( xxx ))​ 的写法的好处在于不影响编译,从语法上讲外面的两个 () 并不会影响内部表达式的取值。

现在运行:

i18n extract

CLI 命令会扫描所有的代码文件,提取 i18n 文本,并自动转换为一个 i18n 变量引用。

image

回到我们的主 i18n 文件 zh_CN.yaml 中,发现变量也已经写进去了。

image

不过 en_US.yaml 还没有写入,所以我们运行 i18n translate

image

这里需要注意,由于我们在配置中指定的策略为 diff​,在该模式下程序只翻译增量部分,而不会全部翻译(以节省 token 和时间消耗)。

如果有必要,你还可以运行 i18n export​,他会自动导出一个 i18n 文件对应的 interface 到 src/types​ 下:

image

⚠️ 实际引用 i18n

可以看出上面的用法存在的一些问题,他会非常僵化地把问题替换为 i18n.xxx​。问题是 i18n 要从哪里来呢?所以在实际使用的时候,需要有一个模块,可以自动导入 i18n 对象。

<!-- src/sample.svelte -->
<script>
    import { i18n } from 'somewhere-in-your-project';
</script>
<div>
    { i18n.samplesvelte.welcometoautoi18n }
</div>

最简单的办法是在 onload 里面赋值。

export let i18n: I18n = null;

export default class PluginSample extends Plugin {
    async onload() {
        i18n = this.i18n;
    }
}

然后在别的地方引入:

<script>
    import { i18n } from '.';
</script>

讨论

  1. python or node

    1. auto-i18n 是用 python 写的而非 node,这可能最麻烦的一个地方:明明是开发前端项目却需要用 py 环境,这点值得诟病。
    2. 我本来想要写完之后翻译成 node,不过我懒,写完之后不想搞了。有志愿者可以自行 node 化
  2. GPT 的能力问题

    1. 目前这个工具本体最大的一个缺点是,对 gpt 模型的能力有一定要求。
    2. 工具要求 gpt 必须输出 json 格式(实际上可以做到不用 json,但是我懒,不想改了),实测有些比较弱鸡的模型(比如 doubao-4k-lite)会经常违反 prompt 的约束,输出非纯 json 的内容。
  3. CLI vs 编译插件

    1. 目前我是选择了 CLI 方案,在不侵入 JS 项目的编译、运行的前提下,直接替换了源代码。直接替换源码这点可能有些风险(所以你最好和 git 搭配使用)。
    2. 还有另一种方案,是写一个 vite 的插件,将 i18n 变量的提取转换什么的放到编译器完成,不去破坏源代码。
    3. 这两种的优劣见仁见智,但我最后还是倾向于前者。一个是我们这种弱鸡小项目破坏源代码不是啥大问题,git discard 一下就解决了;二个是 CLI 灵活性更好,做成 vite 或 webpack 的插件那绑定的也太死了。
    4. 另外,虽然我们的样例是前端开发,但可以看到 auto-i18n 是完全语言无关的一个工具;这个项目本身的 i18n 都是用它自己来完成的
  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    22337 引用 • 89380 回帖
  • 插件开发
    2 引用 • 7 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • player 1

    感谢,很有启发性。

    如果可以直接生成 ts 代码更好。那样可以利用编译器的检查能力。

    为了让编译器检查代码,确保正确,我目前是这样的:

    做个模板,带有 ai 的提示词。

    export class TomatoI18n {
        conf: Config.IConf;
        init() {
            this.conf = Siyuan.config
        }
        // TypeScript function: translate and fill other languages in the return. 
        // Don't change the function name I provide.
        public get xxxx() {
            switch (this.conf.appearance.lang as ("it_IT" | Config.TLang)) {
                case "zh_CN":
                    return "xxx";
                case "es_ES":
                    return 
                case "fr_FR":
                    return 
                case "ja_JP":
                    return 
                case "zh_CHT":
                    return 
                case "it_IT":
                    return 
                case "en_US":
                default:
                    return
            }
        }
    }
    export const tomatoI18n = new TomatoI18n();
    

    每加一个翻译一次。 (i18n 的 key 用中文名比较显眼,也可加英文前缀。)

    export class TomatoI18n {
        conf: Config.IConf;
        init() {
            this.conf = Siyuan.config
        }
        // TypeScript function: translate and fill other languages in the return. 
        // Don't change the function name I provide.
        public get xxxx() {
            switch (this.conf.appearance.lang as ("it_IT" | Config.TLang)) {
                case "zh_CN":
                    return "xxx";
                case "es_ES":
                    return 
                case "fr_FR":
                    return 
                case "ja_JP":
                    return 
                case "zh_CHT":
                    return 
                case "it_IT":
                    return 
                case "en_US":
                default:
                    return
            }
        }
    
        // TypeScript function: translate and fill other languages in the return. 
        // Don't change the function name I provide.
        public get 移动到文档() {
            switch (this.conf.appearance.lang as ("it_IT" | Config.TLang)) {
                case "zh_CN":
                    return "移动内容到文档";
                case "es_ES":
                    return "mover contenido a documento";
                case "fr_FR":
                    return "déplacer le contenu vers le document";
                case "ja_JP":
                    return "内容をドキュメントに移動";
                case "zh_CHT":
                    return "移動內容到文檔";
                case "it_IT":
                    return "spostare il contenuto al documento";
                case "en_US":
                default:
                    return "move content to document";
            }
        }
    }
    export const tomatoI18n = new TomatoI18n();
      
    

    使用的时候:

    <label>
                <input
                    type="checkbox"
                    class="b3-switch settingBox"
                    bind:checked={$back_link_move_here}
                />
                <span class="b3-label__text">
                    {@html icon("Move", ICONS_SIZE)}</span
                >
                {tomatoI18n.移动到文档}
            </label>
    

    可以配合 vscode 的 ai 功能。手动用 ai 翻译也可以。

    不过我这样做的缺点也明显,就是需要人肉操作的部分比较多。

    补充一个额外的好处,get 可以改为 function,支持复杂一点的信息。

        public 推迟x小时(hours: number) {
            switch (this.conf.appearance.lang as ("it_IT" | Config.TLang)) {
                case "zh_CN":
                    return `推迟${hours.toFixed(1)}小时`;
                case "es_ES":
                    return `Retrasar ${hours.toFixed(1)} horas`;
                case "fr_FR":
                    return `Retarder ${hours.toFixed(1)} heures`;
                case "ja_JP":
                    return `${hours.toFixed(1)}時間遅れる`;
                case "zh_CHT":
                    return `推遲${hours.toFixed(1)}小時`;
                case "it_IT":
                    return
                case "en_US":
                default:
                    return `Delay by ${hours.toFixed(1)} hours`;
            }
        }
    
    1 操作
    player 在 2024-10-08 19:31:58 更新了该回帖
  • 其他回帖
  • 为人类心智服务的良好工具,简单而纯粹。

  • 把 ai 集成到 i18n 流程中,好玩~
    不过我之前是手动 cv 给 ai 的方式让 ai 翻译,结果把思源笔记英译成 ob,这种情况就会有一点点影响流程了。