Jekyll 迁移到 Hexo 问题记录

✍🏼 写于 2025年01月07日    💡 更新于 2025年01月07日
❗️ 注意:离本文创建时间已经过去了 天,请注意时效性
🖥  说明:本文记录了从 Jekyll 博客引擎迁移到 Hexo 过程中遇到的一些问题。

前言

我是一名前端开发人员,因此对其他的一些脚本语言如 Ruby 并不熟悉。刚开始写博客的时候使用了 Github Pages 作为博客平台,它默认使用的是 Jeklly 框架,想着内容比框架更重要,因此也就用着了,这一用就是 10 年。

期间换过一些主题,后来锁定了 Hux 提供的主题,简洁大方好看,而且是开源的:

黄玄的博客 | Hux Blog 「离开世界之前 一切都是过程」 https://huangxuan.me/

我针对这个主题做了很多的定制化内容,如自定义右侧内容、自定义数据、使用 Notion 作为数据源渲染等。

但随着苹果的 M 系列芯片的发布,我越来越难以处理 Ruby 在 Intel 和 Apple 芯片之间的差异,举个例子来说,我构建的时候需要特定使用 x86 架构的指令才能偶然正确 build,这还是我不能随便动任意一个依赖的前提下:

1
arch -x86_64 bundle exec jekyll server --trace --config=_config.dev.yml --ssl-key local.xheldon.cn.key --ssl-cert local.xheldon.cn.pem

因此我意识到如果再不尽快更换框架,我可能将来就完全无法发布博客了。

技术选型

这一节没有什么好说的,你基本可以认为,Hexo 是 Jekyll 的 JavaScript 实现。里面的很多概念,95% 的都相同,因此迁移上手无难度。

更重要的是,Hexo 中也有人做了 Hux 的博客主题模板,因此我就直接拿来用了,在这个过程中简单记录一下过程。

迁移过程

插件迁移

这个属于比较容易的,在 Jekyll 中的插件,在 Hexo 中我是用辅助函数实现,如下是我处理来自 Notion 的 Bookmark 的标签的函数(路径是 _plugins/add-attribute.rb:)

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
module Jekyll
class RenderBookMarkBlock < Liquid::Block
def initialize(tag_name, attr, tokens)
super
# 普通的链接没有 yid 和 bid
attrs = attr.scan(/url\=\"(.*)\"\stitle\=\"(.*)\"\simg\=\"(.*)\"\syid\=\"(.*)\"\sbid\=\"(.*)\"/)
if !attrs.empty?
@url = attrs[0][0]
@title = attrs[0][1]
@img = attrs[0][2]
@yid = attrs[0][3]
@bid = attrs[0][4]
@firstChar = @title.empty? ? "" : (@title)[0].upcase
@error = ""
else
attrs = attr.scan(/url\=\"(.*)\"\stitle\=\"(.*)\"\simg\=\"(.*)\"/)
@url = attrs[0][0]
@title = attrs[0][1]
@img = attrs[0][2]
@firstChar = @title.empty? ? "" : (@title)[0].upcase
@error = ""
end
end
def render(context)
@desc = super
if !@yid.nil? && !@yid.empty?
"<p class='embed-responsive embed-responsive-16by9'><iframe src='https://www.youtube.com/embed/#{@yid}?rel=0' title='YouTube video player' frameborder='0' allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture' allowfullscreen></iframe></p>"
elsif !@bid.nil? && !@bid.empty?
"<p class='embed-responsive embed-responsive-16by9' style='border-bottom: 1px solid #ddd;'><iframe src='//player.bilibili.com/player.html?bvid=#{@bid}&high_quality=1&as_wide=1' scrolling='no' border='0' frameborder='no' framespacing='0' allowfullscreen></iframe></p>"
else
"<p><a class='link-bookmark' href='#{@url}' target='_blank'><span data-bookmark-img='#{@img}' data-bookmark-title='#{@firstChar}'><img src='#{@img}'/></span><span><span>#{@title}</span><span>#{@desc}</span><span>#{@url}</span></span></a></p>"
end
end
end
end

Liquid::Template.register_tag('render_bookmark', Jekyll::RenderBookMarkBlock)

而在 Hexo 中我是这么写的(路径是 scripts/liquid.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
hexo.extend.tag.register('render_bookmark', function(args, content) {
const [url, title, img, yid, bid] = args.map(getValue);
const firstChar = title ? title[0].toUpperCase() : '';
const strip_html = hexo.extend.helper.get('strip_html').bind(hexo);
const trim = hexo.extend.helper.get('trim').bind(hexo);
if (yid) {
return `<p class='embed-responsive embed-responsive-16by9'><iframe src='https://www.youtube.com/embed/${yid}?rel=0' title='YouTube video player' frameborder='0' allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture' allowfullscreen></iframe></p>`
} else if (bid) {
return `<p class='embed-responsive embed-responsive-16by9' style='border-bottom: 1px solid #ddd;'><iframe src='//player.bilibili.com/player.html?bvid=${bid}&high_quality=1&as_wide=1' scrolling='no' border='0' frameborder='no' framespacing='0' allowfullscreen></iframe></p>`;
}
return `<p><a class='link-bookmark' href='${url}' target='_blank'><span data-bookmark-img='${img}' data-bookmark-title='${firstChar}'><img src='${img}'/></span><span><span> ${title}</span><span> ${strip_html(trim(content))}</span><span> ${url}</span></span></a></p>`;
}, {
ends: true,
});

不知道为什么,我在 Hexo 中设置了 :category/:name.html 但是依然给我生成的是 life/2024-life-xxx.html(使用 -目录分割) 而预期应该是 life/xxx.html,看源码 name 使用了 slug,的 basename,但是 slug 生成逻辑是基于 folder 路径然后加上 - 的,因此我只能自己手动修改文件名。Jekyll 中,文件名前的日期格式,如 2024-02-12-xxx.md 中,2024-02-12 会被忽略,title 直接就是 xxx,但是在 Hexo 中 title 在 post 中读取的 title front matter,所以只能写一个 filter 插件来最终确定 permalink 地址:

1
2
3
4
5
6
7
8
9
10
/**
* permalink 中的 name 不符合预期,对于 _posts/life/2015/xxx.md 来说,在文档中 :name 表示的是 xxx,但是实际是 life-2015-xxx
*/
hexo.extend.filter.register('post_permalink', function (data) {
// 在这里修改 post.name 的值
const arr = data.split('/').filter(Boolean);
const categories = arr[0];
const name = arr[1];
return `${categories}/${name.split('-').filter(Boolean).slice(3).join('-')}`;
});

另外,permalink 不能是纯数字,得用字符串:

不然报错(看 .endsWith 就知道为什么报错啦):

EJS 模板语法问题

ejs 模板嵌套的时候,与 Jekyll 的 liquid 语法不同的是,它不能在模板中自定义 front-matter。也就是说,模板之间的传参,只能外层到内层,不能从内到外。比如我 index 的 layout 是 page,同时还设置了 fornt-matter,但是此 front-matter 无法被 page.ejs 这个模板读取到,因此我只能每个用到的地方都往下传参。

Markdown 语法渲染问题

Hexo 自带的 marked 的 markdown 渲染跟 jekyll 的 karmarkdown 渲染有出入,前者会把诸如 ## h2 (换行+空行)段落 中的空行+段落给忽略,而后者不会。

虽然无伤大雅,但是这样会导致我在首页的时候,内容摘要会少一个空格导致不完全一致,而我希望尽可能的完全一致,这样 SEO 才不会降权,才能让搜索引擎不会认为我的内容有巨大改动。

于是我使用了 markdown-it 进行处理,安装 hexo-renderer-markdown-it 即可。

Liquid 排序问题

我 Jekyll 的 liquid 的排序是这么用的: {{ tags | split:'`**`SEPARATOR`**`' | sort }} :

问题是,Liquid 的排序在 sort 部分相同后,会按照后面的 href 字符串排序的,因此继承过来:

Mermaid 语法问题

mermaid 不能被 highlight 高亮,需要在 hexo 的 config 中排除掉:

1
2
exclude_languages:
- mermaid

Markdown 无法使用 EJS 语法问题

暂时没管,曲线绕。

分页生成问题

hexo-generator-index 的 pagination 生成的是 page/2 page/3 但是 jekyll 中的格式是 page2, page3 所以复制了该插件源码放到本地进行修改(位于 scripts/pagination.js):

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
'use strict';
const pagination = require('hexo-pagination');
hexo.config.index_generator = Object.assign(
{
per_page:
typeof hexo.config.per_page === 'undefined' ? 10 : hexo.config.per_page,
order_by: '-date',
},
hexo.config.index_generator
);
hexo.extend.generator.register('index', function (locals) {
const config = this.config;
const posts = locals.posts.sort(config.index_generator.order_by);
posts.data.sort((a, b) => (b.sticky || 0) - (a.sticky || 0));
const paginationDir = config.pagination_dir || 'page';
const path = config.index_generator.path || '';
return pagination(path, posts, {
perPage: config.index_generator.per_page,
layout: ['index', 'archive'],
format: paginationDir + '%d/',
data: {
__index: true,
},
});
});

删除线实现问题

默认删除线是 s 标签,但是 jekyll 是 del,用 after_render:html 直接替换(位于 scripts/tag-del.js):

1
2
3
hexo.extend.filter.register('after_render:html', function (str) {
return str.replace(/<s>/g, '<del>').replace(/<\/s>/g, '</del>');
});

构建后多余文件问题

有些文件是多余的,构建后删除掉:

具体有:

  • categories/*

  • i_dont_wanna_use_default_archives/*

  • i_dont_wanna_use_default_tags/*

  • less/*

RSS 问题

我使用了自定义的标签样式来渲染来自 Notion Bookmark 的,以期望跟 Notion 的 Bookmark 一样好看,但是如此一来,RSS 阅读器如 Reeder 就无法正确渲染出样式了,因此我处理了以下,在 Jekyll 中使用的是模板语法处理函数,也即在构建的时候动态替换掉自定义的样式:

1
2
3
4
5
6
7
8
9
module Jekyll
module BookmarkFilter
def bookmark_filter(input)
input.gsub(/^\<p\>\<a\s+class=\"link-bookmark\"\shref=(.*)\starget=\"_blank\"\>\<span\>(.*)\<\/span\>\<span\>\<span\>(.*)\<\/span\>\<span\>\n(.*)\n\<\/span\>\<span\>(.*)\<\/span\>\<\/span\>\<\/a\>\<\/p\>$/, '<p><a href=\1 target="_blank">\3</a></p>');
end
end
end

Liquid::Template.register_filter(Jekyll::BookmarkFilter)

而在 Hexo 中是在构建之后打补丁的方式处理(位于 scripts/rss-gene.js):

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
const fs = require('fs');
const path = require('path');
const ejs = require('ejs');
const rootDate = new Date();

function getDate(_date) {
const date = _date ? new Date(_date) : rootDate;
// 获取各个部分
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
const dayName = days[date.getDay()];
const day = String(date.getDate()).padStart(2, '0');
const month = months[date.getMonth()];
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');

// 获取时区偏移(以分钟为单位)
const timezoneOffset = -date.getTimezoneOffset();
const sign = timezoneOffset >= 0 ? '+' : '-';
const offsetHours = String(
Math.floor(Math.abs(timezoneOffset) / 60)
).padStart(2, '0');
const offsetMinutes = String(Math.abs(timezoneOffset) % 60).padStart(2, '0');

// 格式化为 RFC 2822 格式的字符串
return `${dayName}, ${day} ${month} ${year} ${hours}:${minutes}:${seconds} ${sign}${offsetHours}${offsetMinutes}`;
}

hexo.extend.generator.register('xml', function (locals) {
// 仿照 Liquid 内置的日期格式写法
// 注意如果前面不加这个 \uFEFF 则不会被识别为 xml
const template =
'\uFEFF' +
`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Xheldon Blog</title>
<description>The Answer to Life, the Universe and Everything is...</description>
<link>https://www.xheldon.com</link>
<atom:link href="https://www.xheldon.com/feed.xml" rel="self" type="application/rss+xml" />
<pubDate><%= getDate() %></pubDate>
<lastBuildDate><%= getDate() %></lastBuildDate>
<generator>Hexo v<%= version %></generator>
<% for (post of posts.sort((a, b) => (new Date(b.date).getTime()) - (new Date(a.date).getTime())).slice(0, 10)) { %>
<item>
<title><%= post.title %></title>
<description><%= bookmark_filter(post.content) %></description>
<pubDate><%= getDate(post.date) %></pubDate>
<link><%= post.permalink %></link>
<guid isPermaLink="true"><%= post.permalink %></guid>
<% for (tag of post.tags.data) { %>
<category><%= tag.name %></category>
<% } %>
<% for (cat of post.categories.data) { %>
<category><%- escape_html(cat.name) %></category>
<% } %>
</item>
<% } %>
</channel>
</rss>`;

const bookmark_filter = hexo.extend.helper.get('bookmark_filter').bind(hexo);
const escape_html = hexo.extend.helper.get('escape_html').bind(hexo);

const data = {
posts: locals.posts.toArray(),
getDate,
version: hexo.version,
escape_html,
bookmark_filter,
};

const jsonContent = ejs.render(template, data);

const outputPath = path.join('source/_posts', 'feed.xml');
fs.writeFileSync(outputPath, jsonContent, { encoding: 'utf8' });

return {
path: 'feed.xml',
data: jsonContent,
};
});

Service-Worker 问题

移除了 service worker,因为每次构建,页面的 tags 部分一定会变,导致 html 页面一定会更新,会导致经常需要手动刷新页面,不胜其烦,因此直接移除了。

其他问题

  • 有些文件是需要的但是没放进入,构建后放入,如 ads.txt 等。

  • layout 值是 post 类型的文章, page.path 的值不以 / 开头,这点要注意。

后话

基本就是这么多,使用 BeyondCompare 逐行对比后基本可以最小化变化的迁移过去。

- EOF -
本文最先发布在: Jekyll 迁移到 Hexo 问题记录 - Xheldon Blog