记一次 Hexo 加密插件与 Butterfly 目录冲突的 Debug 历程。

一直以来,我的博客(Hexo + Butterfly 主题)在使用 hexo-blog-encrypt 插件给文章加密后,侧边栏目录 (TOC) 总会出现匪夷所思的渲染问题:输入密码解密后,目录要么只剩第一级标题,要么层级缩进全无,以及一些严重的像素偏移,看着非常难受。

今天终于有空(其实是重要的事装死看不见)把这个陈年老 bug 修一修,由于不通前端,只能借助 gemini 帮忙。

这里 gemini 的用法是网页端对话(中古 ai 用法),gemini 在这里不能接管我的代码,也不能接管浏览器,思路很容易跑偏,基本上是我在纠偏。

过程

起初,我认为是 Markdown 渲染器的问题,但在不破坏现有依赖环境的前提下,我决定从前端切入。

第一步,理清逻辑:Butterfly 主题默认使用 tocbot.js 生成动态目录。但在加密状态下,页面初次 onload 时文章是隐藏的,tocbot 抓取不到正确的 DOM 树,直接罢工。而解密完成后,主题又不会自动去重新唤醒它。

于是,我尝试通过监听插件自带的前端事件 window.addEventListener('hexo-blog-decrypt', ...),在解密完成后手动重载 tocbot.init()

目录确实出来了,但排版依然处于崩坏状态(顶部留白极大、缺少原有的 1.1. 序号和左侧引导线)。

经过一系列 CSS 参数微调无效后,我打开了 Chrome DevTools(F12),将正常文章的 DOM 与解密后文章的 DOM 放在一起逐行比对。终于,在 div.toc-content 容器上找到原因。

hexo-blog-encrypt 插件在处理加密 DOM 时,会简单粗暴地给目录外层容器强加一个 style="display: inline;" 以及一个多余的 toc-div-class

熟悉前端盒模型的都知道,将一个原本应该包裹 <ol> 块级列表的容器强行变成 inline(内联元素),会导致内部所有的 marginpadding 计算彻底崩溃。这就解释了为什么各种排版和偏移量会如此诡异。

定位到病因后,解法就非常清晰了:破旧立新。

在解密完成的回调函数中,执行以下三步:

  1. 剥离流氓属性: tocContainer.removeAttribute('style') 清除 display: inline,恢复正常的块级盒模型。
  2. 注入专属 CSS: 利用原生 CSS 的 counter 属性重新补齐被遗弃的序号(1.1, 1.2)和缩进引导线。
  3. 唤醒沉睡的 tocbot: 按照 Butterfly 主题的标准参数重新初始化 tocbot,并将 collapseDepth 设为 0,完美恢复按需展开的“折叠”特效与平滑滚动。

一行底层 Nodejs 代码没动,仅靠一段游离于系统之外的前端“补丁”脚本,修复了这个屎山级联动 Bug. 更屎山了。

代码

source/js 下增加文件 decrypt-toc.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
98
99
100
// 监听 hexo-blog-encrypt 的解密完成事件
window.addEventListener('hexo-blog-decrypt', function () {
console.log('博客已解密,准备重载增强版动态目录...');

setTimeout(function() {
var tocContainer = document.querySelector('#card-toc .toc-content');
if (!tocContainer) return;

// ==========================================
// 🚑 核心修复:剥离加密插件残留的破坏性属性
// ==========================================
tocContainer.removeAttribute('style');
tocContainer.classList.remove('toc-div-class');

// 清空原本的残缺目录
tocContainer.innerHTML = '';

// ==========================================
// ✨ 注入专属 CSS
// ==========================================
if (!document.getElementById('decrypt-toc-style')) {
var style = document.createElement('style');
style.id = 'decrypt-toc-style';
style.innerHTML = `
/* 🎛️ 参数调整区 */
#card-toc .toc-content {
--toc-top-gap: 8px;
--toc-left-gap: 8px;
}

#card-toc .toc-content > ol {
margin-top: var(--toc-top-gap) !important;
padding-left: var(--toc-left-gap) !important;
}

#card-toc .toc-content ol {
list-style: none !important;
counter-reset: btf-toc-numbering;
}
#card-toc .toc-content ol li {
list-style: none !important;
}

#card-toc .toc-content .toc-link::before {
content: counters(btf-toc-numbering, ".") ".";
counter-increment: btf-toc-numbering;
margin-right: 6px;
opacity: 0.85;
}

#card-toc .toc-content ol ol {
padding-left: 10px !important;
margin-left: 10px !important;
border-left: 1px solid rgb(202, 202, 202);
}

/* 🏁 终极修复:自动折叠隐藏未激活的子目录 */
#card-toc .toc-content ol.is-collapsed {
display: none !important;
}
`;
document.head.appendChild(style);
}

// ==========================================
// ✨ 初始化目录树
// ==========================================
function initButterflyToc() {
window.tocbot.init({
tocSelector: '#card-toc .toc-content',
contentSelector: '#article-container',
headingSelector: 'h1, h2, h3, h4, h5, h6',
hasInnerContainers: false,
orderedList: true,
listClass: 'toc',
listItemClass: 'toc-item',
linkClass: 'toc-link',
activeLinkClass: 'active',
activeListItemClass: 'active',

// 👇 这里是关键修改:将 6 改为 0,启用智能折叠
collapseDepth: 0,

scrollSmooth: true,
scrollSmoothOffset: 0,
scrollSmoothDuration: 200
});
console.log('✅ 完美版动态目录重载成功!');
}

if (typeof window.tocbot === 'undefined') {
var script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/tocbot@4.18.2/dist/tocbot.min.js';
script.onload = initButterflyToc;
document.head.appendChild(script);
} else {
initButterflyToc();
}
}, 300);
});

同时,在配置文件中:

1
2
3
4
5
inject:
head:
# 不要动原来的
bottom:
- <script src="/js/decrypt-toc.js"></script>

大功告成!