稍微升级了一下Blog的插件

不知不觉这个Blog也用了一段时间了,一直也算是将就着用的样子。用的是Fluid主题,大体上还是比较满意的。

前段时间把网站从GitHub Pages搬移到了Vercel,相应的,感觉可以做的事情也更多了呢。还是考虑到我自己本身身处欧盟境内,GDPR也总是比较头疼的事情。因为我很讨厌Cookie弹窗,也就想了一下对策。

更新CDN源

首先第一步是检测了一下网站的进出流量,大概可以分为来自中国境内的baomitu和来自美国的Vercel。Vercel这方面我没法改,不过既然我本来的受众也不是中国大陆用户,那么感觉可以先把CDN全部换掉。首先考虑的就是jsDelivr。

切换的过程还是很顺利的,因为Fluid的所有引入的第三方JS/CSS库的链接都放在主题的配置文件(_config.yml)里的static_prefix项目下。唯一需要做的就是对每个依赖库找到在jsDelivr上的对应链接。

像busuanzi这种analytics插件我就直接没管了,因为配置文件是disable的,所以也不会真的生成进静态文件里。倒是有个叫typed的JS库好像jsDelivr上面没有,所以用了CloudFlare的源,以下是当前版本的引用:

1
typed: https://cdnjs.cloudflare.com/ajax/libs/typed.js/2.0.12/

添加评论区

下一步是添加评论区,Fluid本身提供了好几种不同的评论插件,这里选择了Twikoo,也是看中了可以很方便自部署的特点。

既然已经搬移到了Vercel那自然还是用Vercel了,这大概也是目前而言相比GitHub的一个好处。Twikoo的官方文档的描述也很清晰了,简单说就是先注册一个MongoDB账户,然后把代码仓库推送到Vercel上部署一个云函数,最后再把运行参数连起来。

不过这里有个小细节,「一键部署」因为仓库链接进了Twikoo的Git Tree的子目录下,所以clone会失败。我为了图省事就直接把Twikoo的主仓库clone下来,然后自己cd到vercel-min目录下,init了一个新的git目录然后push到Vercel上面了。这个思路大概还是有风险的,毕竟不能无缝升级了,也就姑且用着好了。

最后在主题的配置文件下启用评论插件就完事了。不过也需要把后端连接的API也标明。

1
2
3
4
5
6
7
8
9
10
# 在post或者page下
comments:
enable: true
type: twikoo

# twikoo的根配置
twikoo:
# 必须用https://开头否则会被解析到腾讯云
envId: https://xxxxxx-xxxxxx-xxxxxx-xxxxxx.vercel.app
path: window.location.pathname

评论区开启之后,也可以进一步去配置一些东西,比如Akismet什么的,这里就不多说了。

其实感觉Twikoo还是可以再魔改一下的,说不定可玩性也不少,不过大概也就先告一段落了。

更新Fluid主题到最新版本

感觉也做了不少事情。该考虑一下是不是要深度定制这个Hexo主题了。不过这样一来,也许还是先看看GitHub那边的情况好了。

稍微检查一下,当前的Fluid主题版本是1.8.12,算起来也是2021年的老版本了。这个主题倒是一直都有更新,最新版是1.9.5。看来需要来一次大更新了呢。接下来的问题就是,当初开始这个blog的时候,是直接下载压缩包来用的,如果下载新的压缩包,那么我之前自定义的许多代码和配置都会丢失。

解决方法还是git。就先在主题文件夹下初始化了一个git仓库,然后把所有内容一股脑commit进去了。这样就有了一个基准历史记录,如果升级失败也可以回滚回来。然后就是把Fluid的主题仓库作为remote添加进来。

这时候无论pull还是merge pull都会失败,因为commit的是从压缩包里面解压出来的文件,等于说和仓库的历史记录没有任何关系。Git会在这个时候报错。这里解释了为什么会出现这样的情况,也给出了解决方案,也就是加一个--allow-unrelated-histories参数。

把不相关的历史记录合并起来对git来说是一件非常稀有并且应该被避免的事情,不过这里的情况就是一个很典型的使用场景了。

当然了,把一个2021年的代码和2023年的上游仓库merge起来会造成大量conflict,这也就是需要手动逐个排查解决的过程了。我的思路是,在每次resolve中,先apply上游的所有改动,然后检查本地的变动引入了什么内容(比如新的变量或者函数等)并apply到更新过的版本上,最后再把文件commit掉。

大概就是这样了。也就是说在这之后,如果Fluid主题还有什么更新,也可以很方便的pull下来更新我自己的blog。

顺便一提,我自己blog现在的组织方式大概是这样:

1
2
3
4
5
6
7
8
9
10
\
.git
source
themes
.. # 其他主题
fluid
.git
.. # 其他Fluid主题相关的文件

.. # 其他Hexo相关的目录和文件

也就是说有两个嵌套的git仓库,一个用于记录更新我自己的Blog,一个用于维护这个Blog使用的主题。

That's how I use git for a simple and casual project.

i18n

既然更新到了最新版本,感觉可以做进一步的改动了,比如i18n。不过也许还是先commit一下留个底以防万一。

那么,i18n应该怎么做呢。首先当然是UI的文字应该能够根据用户需求而变动,这个Fluid已经提供了,但是Fluid所能提供的也就只有这么多。

有个叫Hexo-i18n-generator的插件提供了进一步的配置,具体来说就是可以生成不同语言的首页、索引页和标签页。不过这个插件倒是据说很不方便,也很久没更新了。我倒是无所谓,大不了就改源代码嘛。

首先第一步是在blog根目录下的_config.yml启用i18n插件:

1
2
3
4
language: [zh-CN, zh-TW, en]
i18n:
type: [page, post]
generator: [index, archive, category, tag]

当然,也要把模版文档里面的url_for函数替换为url_for_lang工具函数,不过这里也要注意不能全替换了。就Fluid来说,需要替换的函数大概在以下文件里:

1
2
3
4
5
6
7
8
9
10
11
12
13
layout/_partials/
- header/
navigation.ejs
- post/
meta-bottom.ejs
archive-list.ejs
category-chains.ejs
category-list.ejs
404.ejs
about.ejs
categories.ejs
index.ejs
post.ejs

然后发现切换到zh-TWen的时候提示404,似乎没找到哪里有提到这件事。不过解决方案是在source文件夹下为每个语言建立独立的文件夹,并且把对应的archivecategories等文件夹建立起来。每个文件夹里都要加一个index.md,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
source
.. # 其他文件夹
en # English
.. # 其他英语页面
categories
index.md
tags
index.md
zh-TW # 繁体中文
.. # 其他繁体中文页面
categories
index.md
tags
index.md
..

这样一来就不会404了,但是紧接着会发现tags和categories永远是空的。问题在于Fluid用来生成categories和tags的生成器默认只考虑根目录,没有为多语言设计。所以需要进一步修改生成器里对tags、categories和links的处理逻辑,大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// generate tags Page
hexo.extend.generator.register('_tags', function(locals) {

let langs = this.config.i18n.languages.slice(1);

if (this.theme.config.tag.enable !== false) {
return [{
path : 'tags/index.html',
data : locals.theme,
layout: 'tags'
},...langs.map((lan) => {
return {
path : `${lan}/tags/index.html`,
data : locals.theme,
layout: 'tags'
}
})];
}
});

原本的生成器只会返回上面那个对象,这里做的就是首先从config里面提取出i18n注册的语言,然后用一个Array.map函数仿照第一个对象为每个注册了的非默认语言添加一个类似的对象,这样就可以为不同语言生成tags、categories和links页面了。

不过到这里会发现问题还是存在,因为无论哪个语言,都只会显示一样的tags和categories,感觉对非第一语言使用者来说并不是多友好的事情。

Tags和Categories

试了一下,Hexo的front-matter里面的categories和tags似乎只能是字符串列表,专门写个特殊字段存感觉也不是多好的解决方案,所以我的想法大概是,假定所有的tag都不会以:开头,那么就可以用比如说:en:这样的前缀来识别这个tag是哪个语言的。例如说,:en:This is a tag的意思就是这是en语言下的This is a tag的tag。如果用户在zh-CN或者zh-TW下访问的时候,这个tag就会被隐藏。

这个思路有点绕,不过也不是很难实现,困难的地方大概在于找出所有渲染tag的地方并把它们正确的筛选并解析出来(也就是过滤掉无关locale,然后去掉前缀)。

meta-bottom.ejs里面的一个例子大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<% 
var langList = ['zh-CN', 'en', 'zh-TW'];
var currLang = langList.find(ll => path.startsWith(ll)) ?? langList[0];
var isDefault = currLang === langList[0];
%>
<%
var thisTags =
isDefault
? page.tags.filter(t => !t.name.startsWith(':'))
: page.tags.filter(t => t.name.startsWith(`:${currLang}:`))
.map(oldTag => {
var newTag = Object.assign({}, oldTag);
newTag.name = oldTag.name.substring(currLang.length + 2);
return newTag;
});
%>
<% thisTags.forEach(function(tag) { %>

这段代码替换的是<% page.tags.each(function(tag) { %> 的源文件代码。简单说就是先判断当前使用哪一个语言(判断逻辑和hexo-i18n-generator的逻辑一致),然后做过滤。

需要注意的一点是Hexo用一种很奇怪的Query语法,也就是说像是page.tags之类的变量返回的不是一个列表而是一个_Query开头的Document,如果直接当数组处理(也就是直接返回{ …value })的话,得到的是一个数组,也就失去了相关的能力了。所以解决办法就是只能先用Object.assign复制一遍变量然后直接改字段,不然程序压根跑不起来。

对categories的处理和tags相似,这个阶段修改了以下文件:

1
2
3
4
5
6
7
8
9
layout/
- _partials/
- post/
meta-bottom.ejs
category-chains.ejs
category-list.ejs
categories.ejs
index.ejs
tags.ejs

这里也有两问题,一个就是语言的列表是硬编码的,最好还是能够直接从config里面读,第二个问题是这几个文件里面添加的代码片段基本上大同小异,要是可以写成helper函数就最好了。不过这也不是现阶段的重点就是。

到现在,应该就可以给不同的文章对不同语言设定不同的标签和分类了。当然,如果日后需要真的为一篇文章单独写两个语言的版本的时候,也可以预料到现在的实现会出问题,不过这个就等到时候再看了。

词云

按照同样的道理来处理词云,只保留当前语言下的tag,同样也遇到了_Query导致的奇怪语法的问题。

1
2
3
4
5
6
7
8
let preparedTags

if (isDefault) {
preparedTags = site.tags.filter(tag => !tag.name.startsWith(':'))
} else {
preparedTags = site.tags.filter(tag => tag.name.startsWith(`:${currLang}:`))
preparedTags.data.forEach(doc => doc.name = doc.name.slice(currLang.length + 2))
}

相当于把过滤后的tag逐个修改一遍了。问题解决。

语言切换开关

既然有了i18n,那自然也需要一个开关来切换不同的语言。这里就直接给实现代码好了。

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
<% if(config.i18n.languages && Array.isArray(config.i18n.languages)) {%>
<% if(config.i18n.languages.length > 1) {%>
<% let langList = config.i18n.languages; %>

<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" target="_self" href="javascript:;" role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 512 512"><path fill="currentColor" d="m478.33 433.6l-90-218a22 22 0 0 0-40.67 0l-90 218a22 22 0 1 0 40.67 16.79L316.66 406h102.67l18.33 44.39A22 22 0 0 0 458 464a22 22 0 0 0 20.32-30.4ZM334.83 362L368 281.65L401.17 362Zm-66.99-19.08a22 22 0 0 0-4.89-30.7c-.2-.15-15-11.13-36.49-34.73c39.65-53.68 62.11-114.75 71.27-143.49H330a22 22 0 0 0 0-44H214V70a22 22 0 0 0-44 0v20H54a22 22 0 0 0 0 44h197.25c-9.52 26.95-27.05 69.5-53.79 108.36c-31.41-41.68-43.08-68.65-43.17-68.87a22 22 0 0 0-40.58 17c.58 1.38 14.55 34.23 52.86 83.93c.92 1.19 1.83 2.35 2.74 3.51c-39.24 44.35-77.74 71.86-93.85 80.74a22 22 0 1 0 21.07 38.63c2.16-1.18 48.6-26.89 101.63-85.59c22.52 24.08 38 35.44 38.93 36.1a22 22 0 0 0 30.75-4.9Z"/></svg>
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<% for(const langEach of config.i18n.languages || []) { %>
<% var langDict = {"zh-CN": "简体中文", "zh-TW": "正體中文", "en": "English"}; %>
<% var frags = path.split('/') %>
<%
var langDefault = langList[0];
var currLang = langList.find(ll => path.startsWith(ll + '/')) ?? langDefault;

let localizedFrags;
if (currLang === langEach) { // [abc] --> [abc] or [en, abc] --> [en, abc]
localizedFrags = frags;
} else {
localizedFrags = frags;
if (currLang === langDefault) { // [abc] -en-> [en, abc]
localizedFrags = [langEach, ...localizedFrags]
} else { // [en,abc] -en>
localizedFrags =
langEach === langDefault
? localizedFrags.slice(1)
: [langEach, ...localizedFrags.slice(1)]
}
} %>

<% var dest = localizedFrags.join('/') %>
<a class="dropdown-item" onclick="window.location = '/<%=dest%>'" target="_self">
<span>
<%if (currLang === langEach) {%>
<b><%- langDict[langEach] %></b>
<%} else {%>
<%- langDict[langEach] %>
<% }%>
</span>
</a>

<% } %>
</div>
</li>

<% }%>
<% }%>

简单说就是只在i18n开启的时候启用,通过当前路径推断出当前使用的语言环境,然后用一个有点麻烦的思路来重新组装切换语言后会访问的页面路径。

这里遇到两个JavaScript的坑:

  • false || false可能等于true
  • true && undefined会返回undefined

虽然其实也是讨论JavaScript里面的真值表和隐式转换的时候老生常谈的事情了……之前也有见过一种说法,说JavaScript的||&&实际上更像是一种控制流的手段,不过这里先不跑题了。

总而言之,所以中间构建localizedFrags的过程才会显得这么奇怪。当然,硬编码是不好的,我也承认这一点。

不过总归是也做完了。

外语提示

不管怎么说,做了这么多也只是UI方面的活,如果用户访问一个页面,切换语言后,页面本身也不会改变语言。总的来说还是不太友好。

我倒是不打算接入自动翻译的功能,所以一个想法就是在文章里面标记文章支持的语言。如果用户访问时的语言并没有归入其中,并且也不是默认语言的话,会显示一个小横幅来提醒用户。

实现起来倒也不难,大概是这样吧,首先注册一个字典:

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
var langList = ['zh-CN', 'en', 'zh-TW'];
var langs = {
'zh-CN': {
name: '简体中文',
desc: '这篇文章并非使用中文书写。'
}, 'en': {
name: 'English',
desc: 'This article was not written in English.'
}, 'zh-TW': {
name: '正體中文',
desc: '此文章並非使用正體中文書寫。'
}
}

var currLang = langList.find(ll => path.startsWith(ll)) ?? langList[0];

let displayNotification = true;

if (currLang === langList[0]) {
displayNotification = false;
}
if (page.language) {
displayNotification = true;
if (page.language.includes(currLang)) {
displayNotification = false;
}
}

var isDefault = currLang === langList[0];

然后在文章列表前加个模版:

1
2
3
4
5
6
7
8
9
10
11
12
<% if(displayNotification) {%>
<div class="mx-auto mb-3">
<div class="mx-auto">
<div class="p-2 border rounded foreign-lang-highlighted">
<span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M13 13h-2V7h2m0 10h-2v-2h2M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2Z"/></svg>
<%- langs[currLang].desc %>
</span>
</div>
</div>
</div>
<% }%>

需要在source/css/_pages/_index/index.styl里面添加这个新加的样式,简单说就是把背景色搬过来:

1
2
.foreign-lang-highlighted
background-color var(--body-bg-color)

同样的思路稍微改一下也可以放在文章列表页面用来高亮显示还没本地化的文章。不过这里我用的是简化的逻辑:

1
2
3
<% let shouldHighlight
= (!isDefault && post.lang === undefined) || (!isDefault && (post.lang !== undefined) && post.lang[currLang]);
%>

毕竟前面这个代码片段也太长了。总觉得越写越难看。

不过我也在想该怎么把硬编码的这些玩意外置出来,不过先不考虑了。

标题

结合了前面提到的这些东西,对标题的改造也相对简单了,我的方法是在_config.fluid.ymlnavbar属性下除开blog_title,也加个i18n_title属性,例如这样:

1
2
3
4
5
navbar:
blog_title: ...
i18n_title:
en: ...
zh-TW: ...

标题的话,navigation.ejs里面改造成这样就可以了(需要先知道当前的语言环境):

1
<strong><%= (isDefault ? theme.navbar.blog_title : theme.navbar.i18n_title[currLang]) || config.title %></strong>

对于slogan的改造同理,这里不在赘述。

最后

感觉好像更好理解了ejs、javascript和hexo的工作原理了呢。

最后也试了一下,发现这个i18n-generator插件好像只能把同一个文章复制到不同语言的子目录下,如果想要同一篇文章提供不同语言的版本,就做不到了呢。大概就只剩下两种可能性:去掉这个插件或者把这个插件的生成逻辑修改一下。

不过暂时先不管这么多了,也就先到这里吧。


稍微升级了一下Blog的插件
http://inori.moe/2023/11/22/just-updated-the-blog/
作者
inori
发布于
2023年11月22日
许可协议