实现 i18n
这个需求比较重要,所以单独拿出来说。
前情提要
i18n 的实现,是本次工具链升级里我比较得意的一个方面。看过切页面的历史的同学应该知道,类似企业官网这种项目,大部分页面都是静态 HTML,工具脚手架也集中处理周边资源,对 HTML 本身着墨不多。升级前的工具链基于 Gulp,也存在这个问题。
如果想 i18n,必须解决两个问题:
- 能够快速从页面中提取要翻译的文案。这些文案可能是
textContent
,也可能是标签的title
,甚至某些自定义标签。 - (可选)可以使用大家熟悉的 i18n 工具链,比如 gettext。
- 能够让普通用户方便的添加、修改、维护内容。
- 能够把翻译后的字段准确插回页面。
升级前的方案,我是这么做的:
- 选择中文作为基础文案(因为移交给我的时候是中文)。
- 选择 gettext 方案,即 WordPress 等流行产品采用的方案。
- 用 cheerio 抽取文案,很丑,各种 hardcode。
- 生成 po 文件,翻译之。
- 继续用 cheerio,把翻译插入,生成翻译后的网页。
最后的方案在大部分时间也能正常工作,但是整个过程会生成大量的临时文件,而且代码很多,很难读懂。
新方案
好,接下来开始秀新方案。
(1)(2)(4)不变,主要改变的是“提取待翻译的文案”和“将翻译后的文案插入页面”这两步。
修改模板
前面说过,Pug 支持模板内表达式,除了简单的加减乘除、三元运算,还可以调用函数。所以我们可以把所有文案都替换成 __('some text')
这样的翻译函数输出内容。(__
似乎是个 gettext 风格,比如 WordPress 里就很常见。)
随便摘抄一段代码,是这样的:
a.feature-item(href='./edge/#feature1', target='_blank')
img.edge-item-img(
src=require('../svg/subnav1.svg'),
alt=__('Distributed Load Balancer'),
)
p #{__('Distributed Load Balancer')}
h2 !{__('History of OpenResty<sup>®</sup>')}
首先,属性部分,上一章说过,直接用属性语法 attr=
调用。其次,文本中可以使用 #{}
调用表达式并使用其返回值。最后,如果文案当中有一些特殊标签,不想拆成多段,可以考虑用 !{}
,Pug 会把里面的字符串当成 HTML 填充进去。这个时候注意防 XSS 哦。
这次改版升级,HTML 仍然是由设计同事完成的。所以我会用 html2pug 把 HTML 转换成 pug,然后把里面的文案用上面说的方法修改(如果有需要的话,将来这个步骤可以交给设计同事,其中没有什么特别难理解的东西)。
实现 __()
翻译函数 __()
实现其实很简单。首先,它需要放在全局上下文,因为 Pug 在编译时只会从全局环境执行表达式。其次,我也不需要它功能很强大,因为功能强大必然变得复杂,一旦它变得复杂,移交给设计同事就变得很困难。
// 这里保存的是一些 meta 信息
const translation = require(`../lang/${lang}`);
// 这个变量里保存的是整站的翻译,现在还是空的,等下会填充
let po;
// 因为编译过程在 Node.js 里执行,所以全局是 `global`
// 其它就是有翻译输出翻译,没翻译输出原文
global.__ = function (value) {
if (!po || !po[value]) {
return value;
}
let translation = po[value];
translation = isArray(translation) ? translation.join('') : translation;
return translation || value;
};
修改 Webpack 配置
翻译存在 PO 文件里,我们要把它读出来,然后放到 po
变量里。这个过程是异步的,怎么放在配置文件里执行呢?Webpack 已经提前做好了准备,配置文件除了导出包含配置的对象外,还可以导出 Promise,Webpack 会等到 Promise resolve 后再用其返回值继续运行。
所以我们可以这样修改配置文件:
// 先是各种配置
const config = { /*....*/ };
// 前面的 __ 定义
global.__ = function (value) { /*....*/ };
// 导出 Promise
module.exports = Promise.resolve()
.then(() => {
// 读取 PO 文件
const poPath = path.resolve(__dirname, `../lang/${lang}.po`);
if (!existsSync(poPath)) {
return Promise.reject('PO file not founded.');
}
return readFile(poPath, 'utf8');
})
.then(poData => {
po = po2json.parse(poData);
})
.catch(error => {
// Promise.reject 时,error 是字符串
// 没有 PO 文件是合理的,表示输出原文本,也就是模板里自带的英文
if (isString(error)) {
console.log('[Log]', error);
return;
}
throw error;
})
.then(() => {
return config;
});
生成 PO 文件
我们只要稍微把 __()
修改一下,就可以生成 PO 文件了。Webpack 除了通过命令行执行,还可以放在 JS 里运行。完整的提取文本生成 PO 文件的代码如下:
const {promises: {writeFile, readFile}} = require('fs');
const path = require('path');
const {promisify} = require('util');
const webpack = require('webpack');
// 这是为提取文本写的配置文件,主要目的是避免生成文件
const config = require('./build/webpack.config.i18n');
/* global __dirname */
// 覆盖前面的 `__`,把文本放入一个 `Set` 备用
let messages = new Set();
global.__ = function(value) {
messages.add(value);
return value;
};
// `webpack` 导入后是个 Node.js 风格的函数:`wepback(config, (err, value))`
// 所以可以用 `promisify` 把它改造成返回 Promise 的函数
const webpackP = promisify(webpack);
// 接下来就把文案放到一个 `Set` 里,然后把它写到 PO 文件里即可:
config
.then(config => {
return webpackP(config);
})
.then(async() => {
console.log('start creating en.pot: ', messages.size, 'messages');
let base = await readFile(path.resolve(__dirname, './lang/base.pot'), 'utf8');
messages = [...messages].map(message => {
message = message.replace(/"/g, '\\"');
return `msgid "${message}"
msgstr ""`;
});
base = base.replace('{{create_time}}', (new Date()).toDateString());
messages.unshift(base);
await writeFile(path.resolve(__dirname, './lang/en.pot'), messages.join('\n\n'), 'utf8');
console.log('en.pot created successfully');
});
总结
最后总结一下新的 i18n 方案:
- 设计同事完成设计和 HTML 制作
- 使用 html2pug 转换成 pug
- 将其中要翻译的文案替换成
__('文案')
- 在项目目录运行
node extract-texts
或者npm run i18n:extract
- 运行
msgcat -o cn.po cn.po en.pot
把新生成的未翻译文本与已翻译文本合并 - 使用 POEdit 翻译
- 运行
TARGET_LANG=cn NODE_ENV=production webpack --config build/webpack.config.prod.js --mode=production
生成指定语言版本