运营个人博客时,可能会遇到这样的需求:希望网站能够支持多语言,让来自不同地区的读者都能便捷地阅读我们的内容。

这就需要用到网站的国际化(也就是 i18n)功能。

对于使用 Hexo 搭建的博客而言,实现国际化不仅需要考虑内容的翻译,还要处理模板渲染等问题。

  • 本文使用的主要插件是 hexo-generator-plus

    kiwirafe/hexo-generator-plus - GitHub

    在开始配置之前,请确保已经卸载以下插件以避免冲突:

    • hexo-generator-archive
    • hexo-generator-category
    • hexo-generator-index
    • hexo-generator-tag
  • 本文使用的模板语言是 Pug 语言。

  • 对于导航栏中切换语言的方案,我只实现了双语言的逻辑。

基础配置

为了避免困扰:

  1. Hexo 根目录下的 _config.yml 将被称呼为 Hexo 配置项
  2. themes/**/_config.yml 将被称呼为 主题配置项

首先,我们需要在 Hexo 的配置文件中进行一些基本设置。这些设置将决定网站的语言选项和 URL 结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
language: [zh, en]  # 支持的语言列表,第一个为默认语言
new_post_name: :title.md # 新文章的命名方式

# 如果你安装了 hexo-abbrlink
permalink: posts/:abbrlink.html # 文章的永久链接格式
abbrlink:
rep: hex # 使用十六进制作为文章的唯一标识

# hexo-generator-plus 的配置项
generator_plus:
language: [zh, en] # 生成器支持的语言列表
pagination_dir: 'page' # 分页目录
generator: ["index", "archive", "category", "tag"] # 需要生成的页面类型

# 首页生成器配置
index_generator:
per_page: 10 # 这些数字和顺序可以自定义
order_by: -date

# 归档页面配置
archive_generator:
per_page: 25
order_by: -date

# 分类页面配置
category_generator:
per_page: 25
order_by: -date
enable_index_page: true # 如果你想要分类列表页的话

# 标签页面配置
tag_generator:
per_page: 25
order_by: name
enable_index_page: true # 如果你想要标签列表页的话

接着在主题配置项里配置想要的菜单链接:

1
2
3
4
5
6
7
8
menu: 
home: /
archive: /archives
categories: /categories
tags: /tags
about: /about
GitHub: https://github.com/cytrogen
RSS: /atom.xml

目录结构

以下是必需的目录结构:

1
2
3
4
5
6
7
8
9
10
11
source/
├── _posts/ # 默认语言的所有博文
│ └── *.md # 不能有子目录
├── en/ # 英文版特定内容
│ └── 与 source 目录结构相同
├── archives/ # 归档页面
│ └── index.md # layout: archive
├── categories/ # 分类页面
│ └── index.md # layout: category-index
└── tags/ # 标签页面
└── index.md # layout: tag-index

当然,你也可以选择其他语言,但其他语言的目录需要和对应的 themes/**/languages/*.yml 的名称相同。

请确保每个 *.md 的 Front-Matter 中都有 lang: **

语言文件配置

网站的固定文本(如导航菜单、按钮文字等)需要通过语言文件来实现国际化。这些文件需要放在 themes/**/languages/ 目录下。

这是我的例子:

  1. zh.yml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    menu:
    home: 首页
    archive: 归档
    tags: 标签
    categories: 分类
    about: 关于
    friendlinks: 友情链接
    archive_title: 归档
    tags_title: 标签
    categories_title: 分类
    prev: 上一页
    next: 下一页
    prev_post: 上一篇
    next_post: 下一篇
    more: ...阅读全文
    translated: 翻译 · 原文地址
  2. en.yml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    menu:
    home: HOME
    archive: ARC
    tags: TAGS
    categories: CATE
    about: ABOUT
    friendlinks: Friend Links
    archive_title: Archive
    tags_title: Tags
    categories_title: Categories
    prev: PREV
    next: NEXT
    prev_post: PREV POST
    next_post: NEXT POST
    more: ...MORE
    translated: Translate · Original Link

模板文件实现

从这里开始,只写关于我的博客网站所用的方案。

请按照自己的主题,自行进行修改。

分类页面模板

分类页面需要两个模板:分类列表页面和具体分类页面。

  1. 分类列表页面(category-index.pug):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    extends partial/layout

    block container
    .archive
    // 标题内容优先使用 page.title
    // 如果不存在则使用国际化函数 __() 获取 categories_title 的翻译
    h2.archive-title= page.title || __('categories_title')
    .category-list
    // 获取所有分类数据
    each category in get_categories().data
    // 对每个分类计算符合当前语言的文章数量
    - var postCount = category.posts.data ? category.posts.data.filter(post => post.lang === page.lang).length : 0
    if postCount > 0
    .category-item
    // 每个分类显示为一个链接,包含分类名称、文章数量计数
    // url_for_lang() 生成多语言支持的 URL
    - var categoryPath = category.slug || category.name
    a.post-title-link(href=url_for_lang(page.lang, 'categories/' + categoryPath))
    = category.name
    span.category-count= ` (${postCount})`
  2. 具体分类页面(category.pug):

    1
    2
    3
    4
    5
    6
    7
    extends partial/layout

    block container
    include mixins/post
    .archive
    h2.archive-title= page.category
    +postList()

    这里文章显示相关的功能被封装成了一系列混入(mixins),方便在不同页面重用(mixins/post.pug):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    mixin postInfo(item)
    .post-info
    != full_date(item.date, 'l')
    // 如果文章有 from 属性且当前页面是首页或者文章页
    if item.from && (is_home() || is_post())
    // 显示一个表示文章翻译来源的链接
    a.post-from(href=item.from target="_blank" title=item.from)!= __('translated')

    mixin posts()
    ul.home.post-list
    // 遍历所有的文章
    - for (var post of page.posts.data || [])
    // 只显示与当前页面语言匹配的文章
    - if (post.lang == page.lang)
    li.post-list-item
    article.post-block
    h2.post-title
    a.post-title-link(href= url_for(post.path))
    != post.title
    +postInfo(post)
    // 如果有摘要,就显示摘要和“阅读全文”链接
    if post.excerpt
    .post-content
    != post.excerpt
    a.read-more(href= url_for(post.path))!= __('more')
    else
    .post-content
    != post.content

标签页面模板

和分类页面模板近乎一致:

  1. tag-index.pug

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    extends partial/layout

    block container
    include mixins/post
    .archive
    h2.archive-title= page.title || __('tags_title')
    .tag-list
    each tag in get_tags().data
    - var postCount = tag.posts.data ? tag.posts.data.filter(post => post.lang === page.lang).length : 0
    if postCount > 0
    .tag-item
    - var tagPath = tag.slug || tag.name
    a.post-title-link(href=url_for_lang(page.lang, 'tags/' + tagPath))
    = tag.name
    span.tag-count= ` (${postCount})`

    block pagination
    include mixins/paginator
    +home()

    block copyright
    include partial/copyright
  2. tag.pug

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    extends partial/layout

    block container
    include mixins/post
    .archive
    h2.archive-title= page.tag
    +postList()

    block pagination
    include mixins/paginator
    +home()

    block copyright
    include partial/copyright

归档页面模板

归档页面只需要一个 archive.pug

1
2
3
4
5
6
7
8
9
10
11
12
extends partial/layout

block container
include mixins/post
+postList()

block pagination
include mixins/paginator
+home()

block copyright
include partial/copyright

它调用的混入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
mixin postList()
.archive
// 是否有文章数据
if page.posts
// 确保文章列表存在且不为空
- var posts = page.posts.data || page.posts
if posts && posts.length
// 创建一个年份对象用于存储分组
// 只处理与当前页面语言相匹配的文章
// 使用文章日期获取年份
// 将文章添加到对应年份的数组中
- var years = {}
- for (var post of posts)
- if (post.lang == page.lang)
- var year = new Date(post.date).getFullYear()
- if (!years[year]) years[year] = []
- years[year].push(post)
// 将年份按降序排列(先显示年份最大的)
- Object.keys(years).sort((a, b) => b - a).forEach(function(year) {
h2.archive-year!= year
- years[year].forEach(function(post) {
.post-item
+postInfo(post)
a.post-title-link(href= url_for(post.path))
!= post.title
- })
- })

导航栏实现

导航栏是实现语言切换的关键接口(nav.pug)。

由于我的博客主题中,导航栏并不宽,因此我写了顶底部来区分开部分链接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
ul.nav.nav-list
// 顶层
div.nav-list-top
// 遍历主题配置项中 menu 的所有键值
each path, key in theme.menu
// 排除掉想要放在底部的链接,我这里写的 GitHub 和 RSS
if key !== 'GitHub' && key !== 'RSS'
li.nav-list-item
// 检测是否是外部链接
// 如果是,就在新标签页打开
// 英文页面的路径前添加 /en 前缀
// 判断当前页面是否激活(用于高亮显示)
- var re = /^(http|https):\/\/*/gi;
- var tar = re.test(path) ? "_blank" : "_self"
- var fullPath = page.lang === 'en' ? '/en' + path : path
- var act = !re.test(path) && "/" + page.current_url === fullPath
a.nav-list-link(class={active: act} href=url_for(fullPath) target=tar)
!= __(('menu.' + key))
// 底部
div.nav-list-bottom
// 语言切换按钮
li.nav-list-item.lang-switch
if page.lang == 'en'
a.nav-list-link(href=url_for('/')) 中文
else
a.nav-list-link(href=url_for('/en')) ENGLISH

使用方法

完成上述配置后,创建新文章时需要在 Front-Matter 中指定语言:

1
2
3
4
5
---
title: 文章标题
date: 2024-01-01
lang: zh
---

如果想创建同一篇文章的其他语言版本,只需要创建一个新的 Markdown 文件,指定相应的 lang,并在 Front-Matter中通过 from 字段关联原文:

1
2
3
4
5
6
---
title: Article Title
date: 2024-01-01
lang: en
from: /posts/original-post.html
---