博客多语言是怎么实现的
写技术博客有个尴尬: 中文内容国内读者看, 但海外流量也不少. 干脆中英文都上.
这个博客是中英双语的. 同一篇文章有中文版和英文版, 各自有独立的 URL, 互不影响.
i18n 分两层: 一是内容的国际化 (文章本身有中英文版本), 二是界面的国际化 (按钮, 菜单, 提示文字跟着语言切换).
内容国际化
目录结构
Hugo 的 module.mounts 来做语言映射:
module:
mounts:
- source: content/en
target: content
lang: en
- source: content
target: content
lang: zh
excludeFiles: 'en/**'
英文内容从 content/en 取, 中文内容从 content 取, 排除掉 en/ 目录. 这样中文和英文文件互不干扰, 各自在独立的目录下.
中文文章在 content/archive/ 下, 英文文章在 content/en/archive/ 下.
translationKey 配对
用 Hugo 的 translationKey 来配对文章. 同一篇文章的中英文版本用同一个 key:
# 中文文章
translationKey: zh0189
title: Git系列之关于add命令的一些事
slug: git-series-about-add-command
# 英文文章
translationKey: zh0189
title: Git Series: About the Add Command
slug: git-series-about-add-command
Hugo 会自动把这两个页面识别为翻译对. 模板里用 .Translations 拿到对应语言的版本, 用来做语言切换.
我写了个脚本验证配对是否正确. 跑一遍就知道哪些文章没配对, 哪些 slug 不一致:
#!/usr/bin/env python3
import os, sys
def get(path, field):
with open(path) as f:
for line in f:
if line.startswith(field + ':'):
return line.split(':',1)[1].strip().strip('\"\'')
return None
en_by_key = {}
for root, dirs, files in os.walk('content/en/archive'):
for f in files:
if not f.endswith('.md'): continue
path = os.path.join(root, f)
key = get(path, 'translationKey')
slug = get(path, 'slug') or f.replace('.md', '')
if key:
en_by_key[key] = {'slug': slug, 'path': path}
zh_by_key = {}
for root, dirs, files in os.walk('content/archive'):
for f in files:
if not f.endswith('.md'): continue
path = os.path.join(root, f)
key = get(path, 'translationKey')
slug = get(path, 'slug')
if key:
zh_by_key[key] = {'slug': slug, 'path': path}
matched = 0
errors = []
for key, en in en_by_key.items():
if key in zh_by_key:
matched += 1
zh = zh_by_key[key]
if en['slug'] != zh['slug']:
errors.append(f'SLUG [{key}]: EN={en["slug"]} ZH={zh["slug"]}')
zh_orphans = [k for k in zh_by_key if k not in en_by_key]
en_orphans = [k for k in en_by_key if k not in zh_by_key]
print(f'Pairs: {matched}, Slug mismatch: {len(errors)}')
print(f'Orphans: ZH={len(zh_orphans)} EN={len(en_orphans)}')
if errors or zh_orphans or en_orphans:
sys.exit(1)
print('All OK')
URL 策略
中文: /zh/archive/<slug>/
英文: /en/archive/<slug>/
中英文 slug 保持一致. 比如中文文章 slug 是 git-series-about-add-command, 英文也用同一个.
旧 URL 兼容在 static/_redirects 里:
/archive/* /zh/archive/:splat 301
内容组织
文章按分类放在子目录里:
content/archive/
Git/
Git系列之关于add命令的一些事.md
React/
React系列之JSX.md
Hugo/
...
content/en/archive/
Git/
git-series-about-add-command.md
React/
react-series-jsx.md
Hugo/
...
中英文分类目录结构保持一致, 一共四十三个分类.
界面国际化
i18n 文件
界面文案用 Hugo 的 i18n 系统. i18n/en.toml 和 i18n/zh.toml 各存一份:
# i18n/zh.toml
[home]
other = "首页"
[archive]
other = "归档"
[tags]
other = "标签"
[about]
other = "关于"
[articleLink]
other = "文章链接:"
[latestArticles]
other = "# 最新文章"
[noPostsFound]
other = "此标签暂无文章。"
英文版:
# i18n/en.toml
[home]
other = "Home"
[archive]
other = "Archive"
[tags]
other = "Tags"
[about]
other = "About"
[articleLink]
other = "Article Link:"
[latestArticles]
other = "# Latest Articles"
[noPostsFound]
other = "No posts found for this tag."
模板引用
模板里用 {{ i18n "key" }} 引用:
<h2>{{ i18n "articleLink" }}</h2>
<a href='{{ .RelPermalink }}'>{{ .RelPermalink }}</a>
Hugo 根据当前语言自动选择对应的文案. 如果某个 key 在语言文件里没找到, 就显示 key 本身.
语言切换器
导航栏的语言切换器会根据当前页面的翻译状态自动变化:
- 如果当前文章有翻译版本 → 跳文章
- 如果没有翻译版本 → 跳另一种语言的首页
<li class="lang-switcher has-dropdown">
<a href="#">{{ .Site.Language.LanguageName }}</a>
<ul class="dropdown">
{{ range $.Site.Home.AllTranslations }}
<li>
<a href="{{ .Permalink }}">{{ .Language.LanguageName }}</a>
</li>
{{ end }}
{{ if .IsTranslated }}
{{ range .Translations }}
<li>
<a href="{{ .Permalink }}">{{ .Language.LanguageName }}</a>
</li>
{{ end }}
{{ end }}
</ul>
</li>
配置
Hugo 的语言配置在 config.yml 里:
defaultContentLanguage: "en"
defaultContentLanguageInSubdir: true
languages:
en:
weight: 1
locale: en-GB
label: English
title: Time Friend
params:
description: "做时间的朋友,长期主义,记录成长与思考"
menu:
main:
- identifier: home
name: Home
url: /en/
- identifier: archive
name: Archive
url: /en/archive/
zh:
weight: 2
locale: zh-CN
label: 中文
title: Time Friend
params:
description: "Time Friend - Be a friend of time"
menu:
main:
- identifier: home
name: 首页
url: /zh/
- identifier: archive
name: 归档
url: /zh/archive/
关键点:
defaultContentLanguageInSubdir: true— 两种语言都带前缀, 没有 “裸” URL- 每种语言有自己的
params, 包括 description, menu 等 - locale 区别:
en-GBvszh-CN, 影响 Open Graph 等地方
性能考虑
i18n 方案对构建性能影响极小. module mounts 只是文件映射, 没有运行时开销. i18n 文案是编译时静态注入的.
两百多篇文章, 中英文各一套, 构建时间仍然秒级. 多语言带来的额外开销可以忽略.
有一个容易踩的坑: excludeFiles: 'en/**' 必须写对, 否则中文构建会把英文内容也包含进来, 导致内容重复. 刚配好那会儿我花了半天排查为什么文章数量翻了一倍.
另一个: translationKey 如果拼写错了, Hugo 不会报错, 只是两篇文章配不上对. 语言切换器里就看不到对应语言的版本. 所以我才写了个脚本定期跑一遍检查.