博客多语言是怎么实现的

写技术博客有个尴尬: 中文内容国内读者看, 但海外流量也不少. 干脆中英文都上.

这个博客是中英双语的. 同一篇文章有中文版和英文版, 各自有独立的 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.tomli18n/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/

关键点:

  1. defaultContentLanguageInSubdir: true — 两种语言都带前缀, 没有 “裸” URL
  2. 每种语言有自己的 params, 包括 description, menu 等
  3. locale 区别: en-GB vs zh-CN, 影响 Open Graph 等地方

性能考虑

i18n 方案对构建性能影响极小. module mounts 只是文件映射, 没有运行时开销. i18n 文案是编译时静态注入的.

两百多篇文章, 中英文各一套, 构建时间仍然秒级. 多语言带来的额外开销可以忽略.

有一个容易踩的坑: excludeFiles: 'en/**' 必须写对, 否则中文构建会把英文内容也包含进来, 导致内容重复. 刚配好那会儿我花了半天排查为什么文章数量翻了一倍.

另一个: translationKey 如果拼写错了, Hugo 不会报错, 只是两篇文章配不上对. 语言切换器里就看不到对应语言的版本. 所以我才写了个脚本定期跑一遍检查.

文章链接:

/zh/archive/blog-architecture-i18n/

# 相关文章推荐