hexo-blog-encrypt 插件无法加密很多主题的文章目录,我使用的 hexo-theme-yun 也是如此。这篇文章记录了如何解决这个问题。

Demo:

The hexo-blog-encrypt plugin can’t encrypt the table of contents for many themes, and the hexo-theme-yun I use is no exception. This article documents how to solve this problem.

Demo:

hexo-theme-yun 主题上最新的一个 issue 里面提到的就是这个问题。

对于这个问题的解决,在 hexo-blog-encrypt 插件的 readme 里面已经提到了一些关于加密目录的解决方案,它需要修改主题的源代码,而且对于不同的主题,修改的地方也不一样。

对于 hexo-theme-yun 主题,在我研究了一个下午后,我的基本结论是:

hexo-theme-yun 主题无法使用 hexo-blog-encrypt 插件加密文章目录。

因为我发现 hexo-theme-yun 主题的文章目录是在 sidebar 里面的,而 hexo-blog-encrypt 插件加密的是文章内容,所以这个问题是无法解决的。

好吧,这确实是一个标题党的文章,下面记录一下我是如何优雅解决这个问题的。

问题一:目录无法渲染

在研究代码的过程中,我发现对于设置的密码的文章,hexo-theme-yun 甚至无法正确渲染出文章的目录。

这是因为它使用 page.content 来渲染文章内容,而 hexo-blog-encrypt 插件会加密 page.content,所以导致目录无法正确渲染。

https://github.com/YunYouJun/hexo-theme-yun/blob/89f68d9aa0ddfd25acd3b1001df9ab75e9d770cb/packages/hexo-theme-yun/layout/_partial/sidebar.pug#L14

1
- var toc_content = toc(page.content, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth})

要解决这个问题,我们需要使用 page.origin 来生成文章的目录。

这里我把它改成了首先判断是否加密,如果加密了就使用 page.origin,否则使用 page.content

1
- var toc_content = page.encrypt ? toc(page.origin, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth}) : toc(page.content, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth})

改了这行代码,目录就可以正确渲染了。

问题二:目录无法隐藏

但是,这样做还是有一个问题,就是对于加密了的文章,目录无法正确隐藏。

可以从代码里面看出如果目录里面有内容,就会直接显示目录。

https://github.com/YunYouJun/hexo-theme-yun/blob/89f68d9aa0ddfd25acd3b1001df9ab75e9d770cb/packages/hexo-theme-yun/layout/_partial/sidebar.pug#L78

1
2
3
4
5
if display_toc
#post-toc-wrap.sidebar-panel(class=toc_content.length > 1 ? 'sidebar-panel-active' : '')
.post-toc
.post-toc-content
!= toc_content.length > 1 ? toc_content : theme.toc.placeholder || __('post.toc_empty')

因为目录本身不会被加密,为了实现加密的效果,在生成目录时判断文章是否加密,如果是加密了的文章就用 CSS 的方式把目录隐藏起来。

然后再通过 JS 脚本来监听解密事件,解密后再把目录的 CSS 样式改成显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if display_toc
#post-toc-wrap.sidebar-panel(class=toc_content.length > 1 ? 'sidebar-panel-active' : '')
.post-toc
.post-toc-content
if page.encrypt
#toc-div(style="display:none")
!= toc(page.origin, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth})
script.
window.addEventListener('hexo-blog-decrypt', function() {
var tocDiv = document.getElementById('toc-div');
if (tocDiv) {
tocDiv.style.display = 'block';
}
});
else
!= toc_content.length > 1 ? toc_content : theme.toc.placeholder || __('post.toc_empty')

这样就可以实现目录的隐藏和显示了。

在 hexo-theme-yun 主题的 sidebar 里面,如果目录有内容,那么目录就是 active 的,如果目录没有内容,那么信息页就是 active 的。

在我的这个加密方法里面,目录内容实际上是存在的,只是被 CSS 隐藏了,所以对于加密了的文章,进入文章页后,目录页是 active 的,但却是空空如也。

所以这里我们需要优化一下 sidebar 的 active 逻辑,改为 如果目录有内容且文章没有加密,那么目录页是 active 的

否则,信息页是 active 的。

1
2
3
4
5
6
7
8
9
10
  if display_toc
ul.sidebar-nav
li.sidebar-nav-item.sidebar-nav-toc.hty-icon-button(data-target='post-toc-wrap', title=__('sidebar.toc'), class=(toc_content && !page.encrypt) ? 'sidebar-nav-active' : '')
+icon('ri:list-ordered')
li.sidebar-nav-item.sidebar-nav-overview.hty-icon-button(data-target='site-overview-wrap', title=__('sidebar.overview'), class=(toc_content && !page.encrypt) ? '' : 'sidebar-nav-active')
+icon('ri:passport-line')

...

#post-toc-wrap.sidebar-panel(class=(toc_content.length <= 1 || page.encrypt) ? '' : 'sidebar-panel-active')

完整的 patch

完整的修改如下:

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
diff --git a/themes/hexo-theme-yun/layout/_partial/sidebar.pug b/themes/hexo-theme-yun/layout/_partial/sidebar.pug
index b4214d8..435ebd6 100755
--- a/themes/hexo-theme-yun/layout/_partial/sidebar.pug
+++ b/themes/hexo-theme-yun/layout/_partial/sidebar.pug
@@ -11,15 +11,16 @@ aside.sidebar
//- can not be async
//- https://stackoverflow.com/questions/9237044/async-loaded-scripts-with-domcontentloaded-or-load-event-handlers-not-being-call
script(src=url_for(theme.cdn.pre + '/js/sidebar.js'), type='module')
- - var toc_content = toc(page.content, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth})
+ - var toc_content = page.encrypt ? toc(page.origin, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth}) : toc(page.content, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth})
if display_toc
ul.sidebar-nav
- li.sidebar-nav-item.sidebar-nav-toc.hty-icon-button(data-target='post-toc-wrap', title=__('sidebar.toc'), class=toc_content ? 'sidebar-nav-active' : '')
+ li.sidebar-nav-item.sidebar-nav-toc.hty-icon-button(data-target='post-toc-wrap', title=__('sidebar.toc'), class=(toc_content && !page.encrypt) ? 'sidebar-nav-active' : '')
+icon('ri:list-ordered')
- li.sidebar-nav-item.sidebar-nav-overview.hty-icon-button(data-target='site-overview-wrap', title=__('sidebar.overview'), class=toc_content ? '' : 'sidebar-nav-active')
+ li.sidebar-nav-item.sidebar-nav-overview.hty-icon-button(data-target='site-overview-wrap', title=__('sidebar.overview'), class=(toc_content && !page.encrypt) ? '' : 'sidebar-nav-active')
+icon('ri:passport-line')

- #site-overview-wrap.sidebar-panel(class=display_toc && toc_content.length > 1 ? '' : 'sidebar-panel-active')
+ #site-overview-wrap.sidebar-panel(class=(display_toc && toc_content.length > 1 && !page.encrypt) ? '' : 'sidebar-panel-active')
include ./sidebar/info.pug

nav.site-state
@@ -92,12 +93,28 @@ aside.sidebar
a#toggle-mode-btn.links-item.hty-icon-button(href='javascript:;' title="Mode" style='color: #f1cb64')
+icon('ri:contrast-2-line')

if display_toc
- #post-toc-wrap.sidebar-panel(class=toc_content.length > 1 ? 'sidebar-panel-active' : '')
+ #post-toc-wrap.sidebar-panel(class=(toc_content.length <= 1 || page.encrypt) ? '' : 'sidebar-panel-active')
.post-toc
.post-toc-content
- != toc_content.length > 1 ? toc_content : theme.toc.placeholder || __('post.toc_empty')
-
+ if page.encrypt
+ #toc-div(style="display:none")
+ != toc(page.origin, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth})
+ script.
+ window.addEventListener('hexo-blog-decrypt', function() {
+ var tocDiv = document.getElementById('toc-div');
+ if (tocDiv) {
+ tocDiv.style.display = 'block';
+ }
+ });
+ else
+ != toc_content.length > 1 ? toc_content : theme.toc.placeholder || __('post.toc_empty')
+
if theme.sidebar.tagcloud.enable
.tag-cloud
- var start_color = theme.colors.tag_start_color || '#333'

其他已知问题

这样修改之后,目录的隐藏和显示已经可以正常工作了,但是还有一个问题,就是在正确输入密码之后,虽然目录可以显示出来,但是无法实现点击目录跳转到对应的位置。

需要手动刷新页面,然后这个目录就能正常工作了。

关于 hexo-blog-encrypt 插件目前我还发现了一个问题,就是解密后的文章图片没有办法点击放大查看,这个问题我还没有解决。

By The Way

这个博客主题我已经使用了 4 年。主题的作者开发了一个基于 Vue 的新博客框架,名为 Valaxy。我原计划在 2025 年逐步迁移到这个新框架,希望 Valaxy 的加密功能能解决 Hexo 中遇到的问题,但尚未进行实际测试。

经过这个周末的研究,我修复了 Hexo 加密插件与 Dplayer 以及博客目录之间的冲突,因此迁移的紧迫性也不再那么强烈了。

让它再开发一会儿吧。

The latest issue on the hexo-theme-yun theme is exactly this problem.

Regarding this problem, the readme of the hexo-blog-encrypt plugin already mentions some solutions for encrypting the TOC — they require modifying the theme’s source code, and the modification points differ from theme to theme.

For the hexo-theme-yun theme, after an afternoon of research, my basic conclusion is:

hexo-theme-yun cannot use the hexo-blog-encrypt plugin to encrypt the article TOC.

Because I found that in hexo-theme-yun the article TOC lives in the sidebar, while hexo-blog-encrypt encrypts the article content — so this problem cannot be solved.

Okay, this is indeed a clickbait article. Below I’ll record how I elegantly solved the problem.

Problem 1: TOC fails to render

While studying the code, I discovered that for posts with a password set, hexo-theme-yun can’t even render the article TOC correctly.

That’s because it uses page.content to render the article content, and the hexo-blog-encrypt plugin encrypts page.content, so the TOC can’t be rendered correctly.

https://github.com/YunYouJun/hexo-theme-yun/blob/89f68d9aa0ddfd25acd3b1001df9ab75e9d770cb/packages/hexo-theme-yun/layout/_partial/sidebar.pug#L14

1
- var toc_content = toc(page.content, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth})

To solve this, we need to use page.origin to generate the TOC.

I changed it to first check whether the post is encrypted; if so use page.origin, otherwise use page.content.

1
- var toc_content = page.encrypt ? toc(page.origin, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth}) : toc(page.content, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth})

After this change, the TOC renders correctly.

Problem 2: TOC can’t be hidden

However, there’s still one problem: for encrypted posts, the TOC can’t be hidden properly.

As you can see from the code, if the TOC has content, it will be displayed directly.

https://github.com/YunYouJun/hexo-theme-yun/blob/89f68d9aa0ddfd25acd3b1001df9ab75e9d770cb/packages/hexo-theme-yun/layout/_partial/sidebar.pug#L78

1
2
3
4
5
if display_toc
#post-toc-wrap.sidebar-panel(class=toc_content.length > 1 ? 'sidebar-panel-active' : '')
.post-toc
.post-toc-content
!= toc_content.length > 1 ? toc_content : theme.toc.placeholder || __('post.toc_empty')

Since the TOC itself won’t be encrypted, to achieve an encrypted look, I check whether the post is encrypted when generating the TOC, and if so, hide the TOC with CSS.

Then a JS script listens for the decrypt event, and after decryption, switches the TOC’s CSS back to visible.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if display_toc
#post-toc-wrap.sidebar-panel(class=toc_content.length > 1 ? 'sidebar-panel-active' : '')
.post-toc
.post-toc-content
if page.encrypt
#toc-div(style="display:none")
!= toc(page.origin, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth})
script.
window.addEventListener('hexo-blog-decrypt', function() {
var tocDiv = document.getElementById('toc-div');
if (tocDiv) {
tocDiv.style.display = 'block';
}
});
else
!= toc_content.length > 1 ? toc_content : theme.toc.placeholder || __('post.toc_empty')

This way the TOC can be hidden and shown properly.

Improving sidebar-panel-active

In the hexo-theme-yun sidebar, if the TOC has content, the TOC tab is active; if not, the info tab is active.

With my encryption method, the TOC content actually exists but is merely hidden by CSS, so for encrypted posts, after entering the post page, the TOC tab is active but completely empty.

So I needed to optimize the sidebar’s active logic to: if the TOC has content and the post is not encrypted, the TOC tab is active.

Otherwise, the info tab is active.

1
2
3
4
5
6
7
8
9
10
  if display_toc
ul.sidebar-nav
li.sidebar-nav-item.sidebar-nav-toc.hty-icon-button(data-target='post-toc-wrap', title=__('sidebar.toc'), class=(toc_content && !page.encrypt) ? 'sidebar-nav-active' : '')
+icon('ri:list-ordered')
li.sidebar-nav-item.sidebar-nav-overview.hty-icon-button(data-target='site-overview-wrap', title=__('sidebar.overview'), class=(toc_content && !page.encrypt) ? '' : 'sidebar-nav-active')
+icon('ri:passport-line')

...

#post-toc-wrap.sidebar-panel(class=(toc_content.length <= 1 || page.encrypt) ? '' : 'sidebar-panel-active')

The complete patch

The full changes are as follows:

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
diff --git a/themes/hexo-theme-yun/layout/_partial/sidebar.pug b/themes/hexo-theme-yun/layout/_partial/sidebar.pug
index b4214d8..435ebd6 100755
--- a/themes/hexo-theme-yun/layout/_partial/sidebar.pug
+++ b/themes/hexo-theme-yun/layout/_partial/sidebar.pug
@@ -11,15 +11,16 @@ aside.sidebar
//- can not be async
//- https://stackoverflow.com/questions/9237044/async-loaded-scripts-with-domcontentloaded-or-load-event-handlers-not-being-call
script(src=url_for(theme.cdn.pre + '/js/sidebar.js'), type='module')
- - var toc_content = toc(page.content, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth})
+ - var toc_content = page.encrypt ? toc(page.origin, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth}) : toc(page.content, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth})
if display_toc
ul.sidebar-nav
- li.sidebar-nav-item.sidebar-nav-toc.hty-icon-button(data-target='post-toc-wrap', title=__('sidebar.toc'), class=toc_content ? 'sidebar-nav-active' : '')
+ li.sidebar-nav-item.sidebar-nav-toc.hty-icon-button(data-target='post-toc-wrap', title=__('sidebar.toc'), class=(toc_content && !page.encrypt) ? 'sidebar-nav-active' : '')
+icon('ri:list-ordered')
- li.sidebar-nav-item.sidebar-nav-overview.hty-icon-button(data-target='site-overview-wrap', title=__('sidebar.overview'), class=toc_content ? '' : 'sidebar-nav-active')
+ li.sidebar-nav-item.sidebar-nav-overview.hty-icon-button(data-target='site-overview-wrap', title=__('sidebar.overview'), class=(toc_content && !page.encrypt) ? '' : 'sidebar-nav-active')
+icon('ri:passport-line')

- #site-overview-wrap.sidebar-panel(class=display_toc && toc_content.length > 1 ? '' : 'sidebar-panel-active')
+ #site-overview-wrap.sidebar-panel(class=(display_toc && toc_content.length > 1 && !page.encrypt) ? '' : 'sidebar-panel-active')
include ./sidebar/info.pug

nav.site-state
@@ -92,12 +93,28 @@ aside.sidebar
a#toggle-mode-btn.links-item.hty-icon-button(href='javascript:;' title="Mode" style='color: #f1cb64')
+icon('ri:contrast-2-line')

if display_toc
- #post-toc-wrap.sidebar-panel(class=toc_content.length > 1 ? 'sidebar-panel-active' : '')
+ #post-toc-wrap.sidebar-panel(class=(toc_content.length <= 1 || page.encrypt) ? '' : 'sidebar-panel-active')
.post-toc
.post-toc-content
- != toc_content.length > 1 ? toc_content : theme.toc.placeholder || __('post.toc_empty')
-
+ if page.encrypt
+ #toc-div(style="display:none")
+ != toc(page.origin, {list_number: theme.toc.list_number, max_depth: theme.toc.max_depth, min_depth: theme.toc.min_depth})
+ script.
+ window.addEventListener('hexo-blog-decrypt', function() {
+ var tocDiv = document.getElementById('toc-div');
+ if (tocDiv) {
+ tocDiv.style.display = 'block';
+ }
+ });
+ else
+ != toc_content.length > 1 ? toc_content : theme.toc.placeholder || __('post.toc_empty')
+
if theme.sidebar.tagcloud.enable
.tag-cloud
- var start_color = theme.colors.tag_start_color || '#333'

Other known issues

After these changes, hiding and showing the TOC works fine, but there’s still one issue: after entering the correct password, although the TOC is displayed, clicking a TOC entry doesn’t jump to the corresponding section.

You need to manually refresh the page, and then the TOC works.

I’ve also found another issue with the hexo-blog-encrypt plugin: after decryption, article images can’t be clicked to zoom in. I haven’t solved this one yet.

By The Way

I’ve been using this blog theme for 4 years. The theme’s author has developed a new Vue-based blog framework called Valaxy. I originally planned to gradually migrate to it in 2025, hoping Valaxy’s encryption features would solve the problems I had with Hexo, but I haven’t actually tested it yet.

After this weekend’s research, I fixed the conflicts between the Hexo encryption plugin, Dplayer, and the blog TOC, so the urgency to migrate is no longer that strong.

Let it cook a little longer.