多语言支持完工

本博客目前已经完成了多语言建设,读者可以在导航栏点击“🌐”按钮切换语言。我会尽量保证各个语言的博文同步更新,如果您发现某篇文章没有对应语言的版本,您可以在我们的 GitHub 社区中使用对应的模板发表 Issue,或者,您也可以协助翻译。

Hexo 框架本身的多语言支持(i18n)仅提供菜单等的国际化,并不能实现博客内容的多语言支持。部分主题内置这一功能,但是本站使用的 Fluid 主题很不幸地在这一名单之外。因此,我只能在网络上查找相关的解决方案。我将我的发现与解决步骤记录在这篇文章中,以期能够帮助到其他有意愿建立国际化博客的读者。

维护多套 Hexo 项目,主语言public文件夹中整合其他语言(本站选择)

这一种方式是在博客“Brando 的自留地”发现的。由于原文网页存档)正好介绍的是使用 Fluid 主题时可用的解决方案,且原理易懂,因此,本站选择了这一方式实现博客的国际化。

前期准备

按照原文的思路,将主站点的文件(除了node_modules)复制多份,每一份副本对应一个语言版本。例如,本站支持简体中文、繁体中文(台湾)和英文三种语言,即将最初的简体中文项目另存两份。这样,本博客网站的本地项目源文件目录呈现出如下架构:

1
2
3
4
DailyMinz
|- en
|- main-zh-CN
|- zh-TW

这里,各语言版本的目录命名其实并不重要,理论上甚至可以随便起名,只要自己认识即可。为了突出zh-CN的主要语言地位,我为其增加了main-的前缀,以示区分。

在新增的语言分支中执行npm install,安装依赖。

然后,将main-zh-CN/source下的文章复制到其他语言分支中,并根据目标语言进行翻译。这样便完成了“异地化”的基本步骤。

语言配置

在各语言分支下,修改站点配置_config.yml和主题的语言配置,使之与当前语言适配。

_config.yml:

1
2
# Site
language: <对应语言>

Fluid 的主题语言配置影响主题自带的文字[1],正常情况下已经预先配置。如果想要在主题中加入自己的定制化内容,则需要记得在语言配置文件中添加。例如,我在导航栏中加入了“社区”链接,英文语言配置文件(en.yml)则需加入:

1
2
community:
menu: 'Community'

其他语言分支做类似处理。

切换语言

本博客语言切换按钮设置在导航栏中,以一个“🌐”符号表示。在主题配置文件中,找到有关导航栏的设置。

_config.fluid.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 导航栏的相关配置
# Navigation bar
navbar:
# ...
# 导航栏菜单,可自行增减,key 用来关联 languages/*.yml,如不存在关联则显示 key 本身的值;icon 是 css class,可以省略;增加 name 可以强制显示指定名称
# Navigation bar menu. `key` is used to associate languages/*.yml. If there is no association, the value of `key` itself will be displayed; if `icon` is a css class, it can be omitted; adding `name` can force the display of the specified name
menu:
- { key: "home", link: "/", icon: "iconfont icon-home-fill" }
- { key: "about", link: "/about/", icon: "iconfont icon-user-fill" }
- { key: "archive", link: "/archives/", icon: "iconfont icon-archive-fill" }
- { key: "category", link: "/categories/", icon: "iconfont icon-category-fill" }
- { key: "tag", link: "/tags/", icon: "iconfont icon-tags-fill" }
# - { key: "links", link: "/links/", icon: "iconfont icon-link-fill" }

我在menu下添加了如下代码:

1
2
3
4
5
6
7
8
- { 
key: "lang",
submenu: [
{ key: "zh-CN", name: "简体中文", link: "../" },
{ key: "zh-TW", name: "正體中文", link: "../zh-TW/" },
{ key: "en", name: "English", link: "../en/" }
]
}

使用多级菜单来切换语言。注意各子选单的link部分。

另外,在相应语言配置文件中,记得添加映射。

zh-CN.yml:

1
2
lang:
menu: '🌐'

整合其他语言文章

接下来就是准备部署站点了。在各语言分支下均生成 public 文件,转到主语言分支,在main-zh-CN/public下新建enzh-TW两个文件夹。注意,由于各语言分支的地址已经硬编码在主题配置中,这两个文件夹必须按照设定的路径命名。将另外两个语言分支下的public文件分别复制过去。然后,直接部署即可。

我们可以发现,Hexo 生成的public文件,就是整个网站结构本身。切换至其他语言,也就是访问网站根目录(在本地即为public)的下级目录,即/en//zh-TW/。如果跳出国际化建设本身,我们也可以利用这一点,进行更加自由的个性化操作。例如,当你自己手写了一份 html 文档,想把它连入依靠 Hexo 框架建构的网站时,就直接从public下手即可。比如,如果我想使用自制的主页而不是 Fluid 生成的,我就可以替换掉public/index.html;如果我想添加一个自定义的登录/订阅页面,我可以在其中新建accounts/subscribe子目录,在浏览器中访问,URL 即表现为https://example.com/accounts/page.html

但是要注意,这些自定义项目的源文件必须保存在其他地方,否则,在使用hexo clean命令清理缓存时,public下所有的心血都会付之一炬。

关于这一“自由的”自定义操作,以及相关的自动化维护与部署的话题,我会继续研究相关解决方案,目前是考虑编写一个 Python 程序实现这些操作。

缺点

最明显的一项就是:同时维护多套项目,操作复杂,容易“顾此失彼”。尤其是在改动博客配置文件时,更容易出现问题。

另外一项,则是多语言站点的通病:保持多语言内容的同步更新的确是一件非常考验博主能力与毅力的事情。

维护单 Hexo 项目,多库部署

博客“井说”的相关页面网页存档)介绍了另外一种方法,并提供了自动化部署方案。这种多语言方案只需要维护一套 Hexo 项目,但是仍然需要管理不同语言的配置文件。

作者采取直接在source中建立不同语言文章存放目录的方法,区分不同语言的文章。在自动化部署上,将不同语言分支所属的文件分别推送至不同的库中,并借助 GitHub Pages 的地址特性区别各个语言版本。

这套解决方案与上一套的本质区别是,上一套方案将“多个 GitHub 库”放在本地,即多个 Hexo 项目;而该方案则将各个项目通过自动化方式托管在 GitHub 上,本地仅保留一套项目容纳各个语言分支的文件。

由于本博客未采用该方案,因此不进行详细阐述。但是,原文结尾提出了一个想法,即使用 Google 翻译的 API,在部署过程中翻译文章。作为处理多语言内容的“偷懒”方式,这个想法值得深究(笑)。

修改主页显示方式

如果使用hexo-generator-i18n插件直接通过在_post下建立多语言目录的方式实现多语言内容,则会出现这样一个致命的问题:博客首页会把所有的文章,无论什么语言,统统展示出来。针对这一问题,博客“Linvis”上的一篇文章网页存档)提出了直接修改主题布局的方式。相较于上一种,这一方案更加技术化。这一方案需要hexo-generator-i18n插件,通过在文章的front-matter中插入lang参数区分文章语言。

作者指出需要修改主题的layout/index.ejs,加入如下代码:

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
<main class="app-body">
<% var currentURL = page.path.split('/') %>
<% if (currentURL.length === 1) { %>
<% var lang = page.lang %>
<% } else { %>
<% var lang = currentURL.shift() %>
<% } %>
<% page.posts.each(function(post) { %>
<% if (lang == post.lang) { %>
<article class="article-card">
<h2 class="article-head">
<a href="<%- url_for(post.path) %>"><%- post.title %></a>
</h2>
<p class="article-date"><%- date(post.date, "LL") %></p>
<% if (post.tags && post.tags.length) { %>
<%- partial('_partial/tag', { tags: post.tags }) %>
<% } %>
<div class="article-summary">
<% if (post.excerpt) { %>
<%- post.excerpt %>
<% } else { %>
<%- truncate(strip_html(post.content), { length: 150, omission: ' ...' }) %>
<% } %>
</div>
<a class="more" href="<%- url_for(post.path) %>"><%- theme.excerpt_link %></a>
</article>
<% } %>
<% }) %>

<% if (page.total > 1){ %>
<%- partial('_partial/pager') %>
<% } %>
</main>

但经我的测试,如果直接加入这一代码,会造成主语言主页上出现两个文章列表;经过对比两者结构后,又尝试替换原代码的主体部分,却导致网页无法渲染。由于我目前对 JavaScript 一无所知,所以我请求 ChatGPT 提供帮助,获得代码如下:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<% 
// 假设当前语言存储在 page.lang 或 config.language 中
var currentLang = page.lang || config.language;
// 过滤出符合当前语言的文章
var filteredPosts = page.posts.filter(function(post) {
return post.lang === currentLang; // 确保文章的语言与当前语言一致
});
%>

<% filteredPosts.each(function (post) { %>
<div class="row mx-auto index-card">
<% var post_url = url_for(post.path), index_img = post.index_img || theme.post.default_index_img %>
<% if (index_img) { %>
<div class="col-12 col-md-4 m-auto index-img">
<a href="<%= post_url %>" target="<%- theme.index.post_url_target %>">
<img src="<%= url_for(index_img) %>" alt="<%= post.title %>">
</a>
</div>
<% } %>
<article class="col-12 col-md-<%= index_img ? '8' : '12' %> mx-auto index-info">
<h2 class="index-header">
<% if (theme.index.post_sticky && theme.index.post_sticky.enable && post.sticky > 0) { %>
<i class="index-pin <%= theme.index.post_sticky && theme.index.post_sticky.icon %>" title="Pin on top"></i>
<% } %>
<a href="<%= post_url %>" target="<%- theme.index.post_url_target %>">
<%= post.title %>
</a>
</h2>

<% var excerpt = post.description || post.excerpt || (theme.index.auto_excerpt.enable && !post.encrypt && post.content) %>
<a class="index-excerpt <%= index_img ? '' : 'index-excerpt__noimg' %>" href="<%= post_url %>" target="<%- theme.index.post_url_target %>">
<div>
<%- strip_html(excerpt).substring(0, 200).trim().replace(/\n/g, ' ') %>
</div>
</a>

<div class="index-btm post-metas">
<% if (theme.index.post_meta.date) { %>
<div class="post-meta mr-3">
<i class="iconfont icon-date"></i>
<time datetime="<%= full_date(post.date, 'YYYY-MM-DD HH:mm') %>" pubdate>
<%- date(post.date, config.date_format) %>
</time>
</div>
<% } %>
<% if (theme.index.post_meta.category && post.categories.length > 0) { %>
<div class="post-meta mr-3 d-flex align-items-center">
<i class="iconfont icon-category"></i>
<%- partial('_partials/category-chains', { categories: post.categories, limit: 1 }) %>
</div>
<% } %>
<% if (theme.index.post_meta.tag && post.tags.length > 0) { %>
<div class="post-meta">
<i class="iconfont icon-tags"></i>
<% post.tags.each(function(tag){ %>
<a href="<%= url_for(tag.path) %>">#<%- tag.name %></a>
<% }) %>
</div>
<% } %>
</div>
</article>
</div>
<% }) %>

将上述代码直接替换掉原文件主体部分即可正确运行。

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<% page.posts.each(function (post) { %>
<div class="row mx-auto index-card">
<% var post_url = url_for(post.path), index_img = post.index_img || theme.post.default_index_img %>
<% if (index_img) { %>
<div class="col-12 col-md-4 m-auto index-img">
<a href="<%= post_url %>" target="<%- theme.index.post_url_target %>">
<img src="<%= url_for(index_img) %>" alt="<%= post.title %>">
</a>
</div>
<% } %>
<article class="col-12 col-md-<%= index_img ? '8' : '12' %> mx-auto index-info">
<h2 class="index-header">
<% if (theme.index.post_sticky && theme.index.post_sticky.enable && post.sticky > 0) { %>
<i class="index-pin <%= theme.index.post_sticky && theme.index.post_sticky.icon %>" title="Pin on top"></i>
<% } %>
<a href="<%= post_url %>" target="<%- theme.index.post_url_target %>">
<%= post.title %>
</a>
</h2>

<% var excerpt = post.description || post.excerpt || (theme.index.auto_excerpt.enable && !post.encrypt && post.content) %>
<a class="index-excerpt <%= index_img ? '' : 'index-excerpt__noimg' %>" href="<%= post_url %>" target="<%- theme.index.post_url_target %>">
<div>
<%- strip_html(excerpt).substring(0, 200).trim().replace(/\n/g, ' ') %>
</div>
</a>

<div class="index-btm post-metas">
<% if (theme.index.post_meta.date) { %>
<div class="post-meta mr-3">
<i class="iconfont icon-date"></i>
<time datetime="<%= full_date(post.date, 'YYYY-MM-DD HH:mm') %>" pubdate>
<%- date(post.date, config.date_format) %>
</time>
</div>
<% } %>
<% if (theme.index.post_meta.category && post.categories.length > 0) { %>
<div class="post-meta mr-3 d-flex align-items-center">
<i class="iconfont icon-category"></i>
<%- partial('_partials/category-chains', { categories: post.categories, limit: 1 }) %>
</div>
<% } %>
<% if (theme.index.post_meta.tag && post.tags.length > 0) { %>
<div class="post-meta">
<i class="iconfont icon-tags"></i>
<% post.tags.each(function(tag){ %>
<a href="<%= url_for(tag.path) %>">#<%- tag.name %></a>
<% }) %>
</div>
<% } %>
</div>
</article>
</div>
<% }) %>

但这一方案仍然存在非常严重的缺陷:特殊页面,例如关于页等,无法国际化;此外,在归档页,仍然能看到其他语言的文章。或许hexo-generator-i18n插件可以解决这个问题,但我目前没有从文档和实践中摸清它的运作方式;外加对主体源码修改可能造成的潜在问题的担忧,该方案在经过短暂的使用后被本站迅速废弃了。

写在最后

在研究第三种方案的时候,我很难忍住自己的吐槽:为什么 ejs 的代码如此令人眼花缭乱?

Hexo 框架的国际化方案仍然不成熟,各个主题各行其是,而 Fluid 主题很不幸地不具备内生的多语言内容支持。我曾考虑过把整个架构推倒重来,但顾及已经很庞大的沉没成本,还是选择了死磕到底。

当前这套方案仍有不成熟的地方,例如切换语言时永远只会将读者导向对应语言的首页,本地维护流程管理缺乏系统化,等等。等到我做出完整解决方案,会将其公布在本博客中。

  1. 详见 Fluid 官方文档“语言配置”章节的说明:“不同语言会影响一些主题自带的文字。”

多语言支持完工
https://zh.dailyminz.org/2024/12/03/Multilingual-Support-Completed/
作者
Kawashima Iwami
发布于
2024年12月3日 早上
许可协议