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.
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.
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 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, }); ```
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" %}  {% 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" %}  {% endrender_caption %}
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.
Hexo’s built-in `marked` renderer behaves differently from Jekyll’s `kramdown`. For example, `marked` ignores empty lines + paragraphs in cases like `
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`.
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" %}  {% 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" %}  {% endrender_caption %}
Mermaid diagrams couldn’t be highlighted properly, so I excluded them in Hexo’s config:
```yaml exclude_languages: - mermaid ```
I haven’t addressed this yet—found a workaround instead.
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, }, }); });
|