JekyllからHexoへの移行時の問題記録

✍🏼 作成日 2025年01月07日    💡 更新日 2025年01月07日
❗️ 注意:この記事が作成されてから既に 日が経過しています。情報の鮮度にご注意ください
🖥  説明:この記事は、JekyllブログエンジンからHexoへの移行プロセスで遭遇したいくつかの問題を記録しています。

はじめに

私はフロントエンド開発者であるため、Rubyのような他のスクリプト言語には詳しくありません。ブログを書き始めた当初はGithub Pagesをブログプラットフォームとして使用し、デフォルトでJekllyフレームワークが採用されていました。コンテンツがフレームワークよりも重要だと考え、そのまま使い続け、気がつけば10年が経っていました。

その間いくつかのテーマを変更しましたが、最終的にHuxが提供するテーマに落ち着きました。シンプルで洗練されており、しかもオープンソースだったからです:

黄玄的博客 | Hux Blog 「世界を去る前は すべてが過程である」 https://huangxuan.me/

このテーマに対し、右サイドバーのカスタマイズ、データのカスタマイズ、Notionをデータソースとして使用したレンダリングなど、多くのカスタマイズを施しました。

しかし、AppleのMシリーズチップの登場により、IntelとAppleチップ間でのRubyの差異に対処することがますます困難になりました。例えば、ビルド時にx86アーキテクチャの命令を特別に使用しなければ偶然正しくビルドできる程度で、しかも依存関係を一切変更できない状況でした:

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

Permalink問題

理由はわかりませんが、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ではpost内のtitle front matterから読み取られるため、最終的にpermalinkアドレスを決定するfilterプラグインを作成する必要がありました:

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で、front-matterも設定していますが、このfront-matterはpage.ejsテンプレートから読み取ることができないため、使用するすべての場所でパラメータを下に渡す必要がありました。

Markdown構文レンダリング問題

Hexoに組み込まれているmarkedのmarkdownレンダリングは、jekyllのkarmarkdownレンダリングと異なります。前者は、## h2(改行+空行)段落内の空行+段落を無視しますが、後者は無視しません。

大した問題ではありませんが、これによりホームページでコンテンツの要約にスペースが1つ足りず完全に一致しないことがあり、私は可能な限り完全に一致させたいと考えました。そうすることで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 と同じように見栄えを良くしようとしましたが、これにより Reeder などの RSS リーダーでスタイルが正しくレンダリングされなくなりました。そのため、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