Gmeek 右侧"文章目录"导航栏:插件原理与接入全过程

长文没有目录,读者只能靠滚动条判断自己读到了哪。Gmeek 官方文档页右侧挂着一个随滚随用的"文章目录",本文拆解它的实现原理,并记录把它接入自己博客的完整过程——看完这篇文章时,你可以直接在右侧观察它的实际效果。

为什么长文需要一个目录

上一篇 《用 GitHub Issues 写博客》 讲完整个搭建过程后,叶扬回头查资料时先遇到的问题就是:文章一长,想跳到某个小节只能 Ctrl+F。成熟博客一般都会在正文旁侧放一个 TOC(Table of Contents,文章目录),作用有三个:

  1. 导航:点击标题直接跳转到对应章节;
  2. 定位:滚动时知道自己处在文章的什么位置;
  3. 预览:点开文章先扫一遍目录,就知道这篇值不值得读完。

Gmeek 官方文档站(blog.meekdai.com)的文章页右侧就有一个,叶扬打开开发者工具扒了一遍,发现它正是 Gmeek 源码仓库 plugins/ 目录里自带的插件——GmeekTOC.js。整个插件只有 100 行原生 JavaScript,零依赖。

两个候选插件

官方仓库的 plugins 目录 里其实有两个目录插件:

插件 实现方式 依赖 特点
GmeekTOC.js 纯手写原生 JS 官方文档站自己在用;目录 + 回顶按钮
GmeekTocBot.js 封装开源库 tocbot cdnjs CDN 上的 tocbot 滚动时高亮当前章节,层级缩进更美观

两者定位完全相同:把一个固定的目录盒子挂在正文右侧,窄屏时自动收起到文章顶部。区别只在体验细节:

叶扬选择前者。这个博客所有静态资源都坚持放在自己仓库里(连 Mermaid 都是本地化的),不把可用性押在第三方 CDN 上。

实现原理:100 行代码做了什么

读完源码,它一共干了四件事。

一、扫描标题,生成目录树

页面 DOMContentLoaded 之后,插件找到 id="content" 的正文容器,扫描里面所有的 h1h6

const headings = contentContainer.querySelectorAll('h1, h2, h3, h4, h5, h6');
if (headings.length === 0) {
    return;  // 没有标题就不生成目录
}

对每个标题,如果 GitHub 渲染时没给它自动生成锚点 id,就用标题文字自己造一个(空白替换成连字符),然后创建一个指向该锚点的 <a> 加进目录盒子。层级缩进靠的是标题级别——h1 不缩进,每深一级多缩进 10px:

link.style.paddingLeft = `${(parseInt(heading.tagName.charAt(1)) - 1) * 10}px`;

最后在盒子尾部追加一个 "Top" 链接,点击平滑滚动回页面顶部。

二、用 fixed 定位把目录"钉"在正文右侧

目录盒子的定位是整个插件最巧妙的一段 CSS。正文容器在页面中居中,目录要贴在它右边,于是先移动到页面中线,再向右平移半个正文宽度多一点:

.toc {
    position: fixed;
    top: 130px;
    left: 50%;
    transform: translateX(50%) translateX(320px);
    width: 200px;
    max-height: 70vh;
    overflow-y: auto;
}

position: fixed 让它不随页面滚动;max-height: 70vh 加上 overflow-y: auto,标题再多也只会在盒子内部滚动,不会撑爆屏幕。

三、响应式:窄屏自动收为正文内块

宽屏挂右侧,手机上右边没有空间怎么办?一行媒体查询解决——屏幕宽度小于 1249px 时,目录从固定定位变回普通文档流,显示在文章开头:

@media (max-width: 1249px) {
    .toc {
        position: static;
        transform: none;
        margin-bottom: 20px;
    }
}

四、暗色模式零成本适配

插件没有写死任何颜色,全部引用 GitHub Primer 设计系统的 CSS 变量,比如:

.toc a:hover {
    background-color: var(--color-select-menu-tap-focus-bg);
}

Gmeek 的明暗主题切换本质上就是切换这套变量,所以目录的配色自动跟着站点主题走,插件自己一行暗色适配都不用写。

另外那个 "Top" 按钮默认隐藏,监听滚动事件:页面向下滚动超过 20px 时才显示,回到顶部附近又自动隐藏。

接入全过程:三步

原理清楚后,接入反而非常简单。

第一步:把插件放进 static/ 目录

在博客仓库根目录建立 static/plugins/ 文件夹,把官方的 GmeekTOC.js 放进去:

static/
├── GmeekVercount.js
├── mermaid.min.js
├── mermaid-init.js
└── plugins/
    └── GmeekTOC.js

为什么必须是 static/ 目录?Gmeek 的构建脚本 Gmeek.py 在每次生成站点时,会把 static/ 下的所有文件和子目录原样复制到发布目录 docs/

if os.path.isdir(src):
    shutil.copytree(src, dst)   # static/plugins -> docs/plugins

所以本地的 static/plugins/GmeekTOC.js,发布后对应的 URL 就是 https://用户名.github.io/plugins/GmeekTOC.js

第二步:在 config.json 里注入 script

Gmeek 没有专门的 "plugin" 配置项,插件统一通过往页面注入 <script> 标签生效。打开仓库根目录的 config.json,在已有的 allHead 旁边增加一个 script 字段:

{
    "allHead": "<script src='/GmeekVercount.js'></script>",
    "script": "<script src='/plugins/GmeekTOC.js'></script>"
}

注意这里有一个必须避开的坑:不要图省事把 TOC 插件塞进 allHead。两个字段的注入范围完全不同:

配置字段 注入位置 加载页面
allHead 每个页面的 <head> 全站(首页、标签页、文章页)
script 文章页模板底部 仅文章页
indexScript 列表页模板 仅首页等列表页

访问统计需要在每个页面待命,所以 GmeekVercount.js 放在 allHead;而 GmeekTOC.js 启动第一行代码就是:

var contentContainer = document.getElementById('content');
const headings = contentContainer.querySelectorAll('h1, h2, h3, h4, h5, h6');

首页和标签页根本不存在 id="content" 的元素,contentContainernull,再调用 .querySelectorAll 直接抛 TypeError,控制台一片红。这也是官方文档站把它放在文章页底部、而不是 <head> 里的原因。

第三步:手动全局重建

修改 config.json 不会自动触发构建(平时新建 Issue 才会)。需要到 GitHub 仓库手动跑一次工作流:

Actions → build Gmeek → Run workflow → 选择 main 分支

GitHub CLI 则只需一行命令,不用点开网页:

gh workflow run "build Gmeek"
gh run list --limit 3          # 查看构建状态

约一分钟后构建完成,打开任意一篇含多个小标题的文章,右侧即可看到目录。

验证清单

接入后可以按这几项检查:

小结

这个插件值得拆开看一遍,因为它体现了 Gmeek 生态里"插件"的标准形态:一个自包含的 JS 文件,放 static/ 目录,用 script/allHead 字段注入,自己注入所需 CSS,不改动上游模板。这种方式的好处是 Gmeek 本身升级时,你的自定义功能不受影响——删掉那一行配置就能完整回退。

下一篇准备系统盘点一下:一个博客除了评论和互动,还需要哪些基础功能,以及叶扬给这个博客定下的建设路线图。

✍️ 原创文章,转载请注明出处~叶扬谢谢你来过 ❤️