Migration from Jekyll to Hexo: Issue Log

✍🏼 Written on Jan 7, 2025    💡 Updated on Jan 7, 2025
❗️ Note: it has been days since this article was written, please be aware of its timeliness
🖥  Note:This article documents some issues encountered during the migration from the Jekyll blog engine to Hexo.

Preface

As a front-end developer, I’m not particularly familiar with other scripting languages like Ruby. When I first started blogging, I used GitHub Pages as my platform, which defaults to the Jekyll framework. At the time, I believed content mattered more than the framework, so I stuck with it—for a full decade.

Over the years, I tried several themes before settling on the one provided by Hux. It was clean, elegant, and open-source:

黄玄的博客 | Hux Blog "Everything is a process before leaving this world." https://huangxuan.me/

I customized this theme extensively, such as adding custom right-side content, custom data, and rendering using Notion as a data source.

However, with the release of Apple’s M-series chips, I found it increasingly difficult to handle Ruby’s compatibility issues between Intel and Apple silicon. For example, I had to specifically use x86 architecture instructions to occasionally build successfully—and that was only if I didn’t touch any dependencies:

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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
arch -x86_64 bundle exec jekyll server --trace --config=_config.dev.yml --ssl-key local.xheldon.cn.key --ssl-cert local.xheldon.cn.pem
```

This made me realize that if I didn’t switch frameworks soon, I might eventually lose the ability to publish my blog altogether.

## Technical Selection

This section is straightforward. You could essentially think of Hexo as the JavaScript implementation of Jekyll. Most of the concepts—about 95%—are identical, making the migration effortless.

More importantly, someone had already ported Hux’s blog theme to Hexo, so I adopted it directly. Here’s a brief record of the process.

## Migration Process

### Plugin Migration

This was relatively easy. For plugins in Jekyll, I implemented helper functions in Hexo. For example, here’s how I handled the Bookmark tag from Notion (path: `_plugins/add-attribute.rb`):

```ruby
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)
```

In Hexo, I rewrote it like this (path: `scripts/liquid.js`):

```javascript
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,
});
```

### Permalink Issues

For some reason, even though I set `:category/:name.html` in Hexo, it still generated `life/2024-life-xxx.html` (using `-` as a directory separator), when the expected format was `life/xxx.html`. Looking at the source code `name`, it uses `slug` with `basename`, but `slug` generates URLs based on folder paths with hyphens. As a workaround, I had to manually modify filenames.

In Jekyll, the date prefix in filenames (e.g., `2024-02-12-xxx.md`) is ignored, and the title is simply `xxx`. In Hexo, however, the title is read from the `title` field in the post’s front matter. To fix this, I wrote a filter plugin to finalize the permalink:

```javascript
/**
* 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('-')}`;
});
```

Additionally, permalinks can’t be pure numbers—they must be strings:

{% render_caption caption="" img="https://static.xheldon.cn/img/in-post/2025/jekyll-2-hexo/147800e8-7a61-8006-a660-e8ecfbe7da9a.webp" %}
![](https://static.xheldon.cn/img/in-post/2025/jekyll-2-hexo/147800e8-7a61-8006-a660-e8ecfbe7da9a.webp)
{% endrender_caption %}

Otherwise, errors occur (the `.endsWith` method explains why):

{% render_caption caption="" img="https://static.xheldon.cn/img/in-post/2025/jekyll-2-hexo/147800e8-7a61-8032-8e51-d6528f277306.webp" %}
![](https://static.xheldon.cn/img/in-post/2025/jekyll-2-hexo/147800e8-7a61-8032-8e51-d6528f277306.webp)
{% endrender_caption %}

### EJS Template Syntax Issues

Unlike Jekyll’s Liquid syntax, EJS templates don’t allow custom front matter within nested templates. Parameters can only flow from outer to inner layers, not the other way around. For instance, if the `index` layout is `page` and includes front matter, `page.ejs` can’t read it. As a result, I had to pass parameters explicitly wherever needed.

### Markdown Rendering Differences

Hexo’s built-in `marked` renderer behaves differently from Jekyll’s `kramdown`. For example, `marked` ignores empty lines + paragraphs in cases like `## h2 (line break + empty line) paragraph`, while `kramdown` preserves them.

Though minor, this discrepancy affected post excerpts on the homepage, causing slight inconsistencies. To maintain SEO stability and avoid search engines flagging major content changes, I switched to `markdown-it` by installing `hexo-renderer-markdown-it`.

### Liquid Sorting Issues

In Jekyll, I used Liquid sorting like this: `{{ tags | split:'`**`SEPARATOR`**`' | sort }}`:

{% render_caption caption="" img="https://static.xheldon.cn/img/in-post/2025/jekyll-2-hexo/0389dcbb-8bb7-413c-a968-4a77a8b76b71.webp" %}
![](https://static.xheldon.cn/img/in-post/2025/jekyll-2-hexo/0389dcbb-8bb7-413c-a968-4a77a8b76b71.webp)
{% endrender_caption %}

The problem is that Liquid sorts identical `sort` values alphabetically by `href`. This behavior carried over:

{% render_caption caption="" img="https://static.xheldon.cn/img/in-post/2025/jekyll-2-hexo/a24e2d73-ab8b-4b5d-a57d-7e0f2f5f0a66.webp" %}
![](https://static.xheldon.cn/img/in-post/2025/jekyll-2-hexo/a24e2d73-ab8b-4b5d-a57d-7e0f2f5f0a66.webp)
{% endrender_caption %}

### Mermaid Syntax Issues

Mermaid diagrams couldn’t be highlighted properly, so I excluded them in Hexo’s config:

```yaml
exclude_languages:
- mermaid
```

### Markdown Unable to Use EJS Syntax

I haven’t addressed this yet—found a workaround instead.

### Pagination Generation Issues

The `hexo-generator-index` generates pagination in the format `page/2`, `page/3`, whereas Jekyll uses `page2`, `page3`. To address this, I copied the plugin's source code locally and modified it (located at `scripts/pagination.js`):

```javascript
'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,
},
});
});

Strikethrough Implementation Issue

The default strikethrough uses the <s> tag, but Jekyll uses <del>. I resolved this by directly replacing it via after_render:html (located at 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>');
});

Redundant Files After Build

Some files are unnecessary and should be deleted after the build:

Specifically:

  • categories/*

  • i_dont_wanna_use_default_archives/*

  • i_dont_wanna_use_default_tags/*

  • less/*

RSS Issue

I used custom tag styles to render Notion Bookmarks, aiming to match Notion’s aesthetic. However, this caused RSS readers like Reeder to fail in rendering the styles correctly. To fix this, I processed it as follows: in Jekyll, I used template syntax and functions to dynamically replace the custom styles during the build:

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)

In Hexo, I handled this by patching after the build (located at 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 Issue

I removed the service worker because, with every build, the tags section of the pages inevitably changes, causing the HTML pages to update. This frequently required manual page refreshes, which became tedious, so I removed it entirely.

Other Issues

  • Some necessary files were missing and needed to be added after the build, such as ads.txt.

  • Articles with layout values of type post, where the page.path value does not start with /, require special attention.

Final Notes

That’s essentially it. After a line-by-line comparison using BeyondCompare, the migration was optimized to minimize changes.

- EOF -
Originally published at: Migration from Jekyll to Hexo: Issue Log - Xheldon Blog