Going Multilingual: i18n for My Blog
Writing a tech blog has an awkward problem: Chinese content serves domestic readers, but there’s also considerable overseas traffic. So I went with both languages.
This blog is bilingual, Chinese and English. The same article has both versions, each with its own URL, independent of each other.
i18n has two parts: content internationalization (articles in both languages) and UI internationalization (buttons, menus, text switches with language).
Content Internationalization
Directory Structure
Using Hugo’s module.mounts for language mapping:
module:
mounts:
- source: content/en
target: content
lang: en
- source: content
target: content
lang: zh
excludeFiles: 'en/**'
English content comes from content/en, Chinese content from content with en/ excluded. They don’t interfere with each other, each in their own directory.
Chinese articles are in content/archive/, English in content/en/archive/.
translationKey Pairing
Using Hugo’s translationKey to pair articles. Same key for both language versions:
# Chinese article
translationKey: zh0189
title: Git系列之关于add命令的一些事
slug: git-series-about-add-command
# English article
translationKey: zh0189
title: Git Series: About the Add Command
slug: git-series-about-add-command
Hugo automatically recognizes these as translation pairs. In templates, use .Translations to get the other language version for language switching.
I wrote a script to verify pairing correctness:
#!/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')
Run it and you’ll know which articles are not paired and which slugs don’t match.
One gotcha: if translationKey is misspelled, Hugo won’t complain. The two articles just won’t be paired. The language switcher won’t show the other language version. That’s why I wrote this script to check regularly.
URL Strategy
Chinese: /zh/archive/<slug>/
English: /en/archive/<slug>/
Slugs are consistent across languages. Old URL compatibility in static/_redirects:
/archive/* /zh/archive/:splat 301
Content Organization
Articles organized by category in subdirectories:
content/archive/
Git/
Git系列之关于add命令的一些事.md
React/
React系列之JSX.md
content/en/archive/
Git/
git-series-about-add-command.md
React/
react-series-jsx.md
Both languages share the same category structure, 43 categories total.
UI Internationalization
i18n Files
UI text uses Hugo’s i18n system. i18n/en.toml and 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."
Template Usage
Templates use {{ i18n "key" }}:
<h2>{{ i18n "articleLink" }}</h2>
<a href='{{ .RelPermalink }}'>{{ .RelPermalink }}</a>
Hugo automatically picks the right text based on current language. If a key is missing, it displays the key itself.
Language Switcher
The language switcher in the nav bar adapts based on translation status:
<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>
Configuration
Hugo’s language config in config.yml:
defaultContentLanguage: "en"
defaultContentLanguageInSubdir: true
languages:
en:
weight: 1
locale: en-GB
label: English
title: Time Friend
menu:
main:
- identifier: home
name: Home
url: /en/
zh:
weight: 2
locale: zh-CN
label: 中文
title: Time Friend
menu:
main:
- identifier: home
name: 首页
url: /zh/
Key points:
defaultContentLanguageInSubdir: true— both languages have prefixes, no bare URLs- Each language has its own
params, including description and menu - Locale difference:
en-GBvszh-CN, affects Open Graph and other places
Performance
i18n has minimal impact on build performance. module mounts is just file system mapping, no runtime cost. i18n text is compiled in at build time.
200+ articles in both languages, build time is still seconds. Overhead is negligible.
One thing to watch: excludeFiles: 'en/**' must be correct, otherwise the Chinese build will include English content, doubling article count. It took me half a day to figure this out when I first set it up.