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

이유는 모르겠지만 Hexo에서 :category/:name.html을 설정했는데도 life/2024-life-xxx.html(- 디렉토리 구분 사용)가 생성되었습니다. 예상대로라면 life/xxx.html이어야 했습니다. 소스 코드 name를 보면 slugbasename를 사용하지만, slug 생성 로직은 folder 경로를 기반으로 하여 -를 추가하는 방식이었습니다. 그래서 파일 이름을 수동으로 수정할 수밖에 없었습니다. Jekyll에서는 파일 이름 앞의 날짜 형식(예: 2024-02-12-xxx.md)에서 2024-02-12는 무시되고 title은 바로 xxx가 됩니다. 하지만 Hexo에서는 post에서 읽는 title이 front matter의 title이기 때문에 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의 레이아웃이 page이고 front-matter도 설정했지만 이 front-matter는 page.ejs 템플릿에서 읽을 수 없습니다. 그래서 사용하는 모든 곳에서 매개변수를 전달해야 했습니다.

Markdown 문법 렌더링 문제

Hexo의 기본 marked 마크다운 렌더러는 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처럼 보기 좋게 렌더링하기 위해 커스텀 태그 스타일을 사용했습니다. 하지만 이렇게 하면 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 문제

서비스 워커를 제거했습니다. 매번 빌드할 때마다 페이지의 tags 부분이 반드시 변경되어 html 페이지가 업데이트되기 때문에 수동으로 페이지를 새로 고쳐야 하는 번거로움이 있었기 때문입니다.

기타 문제

  • 일부 필요한 파일이 포함되지 않아 빌드 후 추가해야 합니다. 예: ads.txt 등.

  • layout 값이 post 유형인 글에서 page.path 값이 /으로 시작하지 않도록 주의해야 합니다.

마무리

기본적으로 이 정도가 주요 변경 사항입니다. BeyondCompare로 줄 단위 비교를 통해 변화를 최소화하면서 마이그레이션할 수 있었습니다.

- EOF -
이 글의 최초 게시: Jekyll에서 Hexo로 마이그레이션 시 발생한 문제 기록 - Xheldon Blog