G08 自研插件(一):文章末尾的"上一篇 / 下一篇"

G04 到 G07 装的都是"别人造好的轮子"。从这一期开始,叶扬要自己写插件了。第一个目标很朴素:读者读完一篇文章,滚到底部,除了评论区无路可走——浏览器的后退键会把人送回 Google,而不是送向下一篇。一个"上一篇 / 下一篇"导航,就是把读者在博客里多留十分钟的那两块踏板。数据源我们早就有了(G04 生成 sitemap 时打过交道),这一期把它接到页面上。

先看现场

不用翻别的文章,你现在滚到这篇文章评论按钮的上方,就能看到两块卡片:左边通向上一篇 G07 写作三件套,右边是一块灰色的"已经到尽头啦"——因为本文发布时它就是最新的一篇。这个导航在全站每篇文章底部都有,颜色自动跟随明暗主题;手机上换成两张全宽通栏大卡上下排列,标题放大、方向箭头保留,拇指怎么点都不会误触。

数据从哪来:postList.json

Gmeek 每次构建都会产出一个 postList.json,它就是整个博客的"文章目录"。打开 https://yeyangchen2009.github.io/postList.json 看结构:

{
  "P13": {
    "labels": ["博客", "Gmeek"],
    "postTitle": "G07 写作三件套:Alert 提示块、数学公式、Mermaid 图表",
    "postUrl": "post/13.html",
    "createdDate": "2026-09-14",
    "dateLabelColor": "#1f883d"
  }
}

键是 P + issue 编号,值里有标题、相对路径、日期。它随构建自动更新,发新文章零维护——G04 的 sitemap 脚本读的是同一份文件。注意两个事实:About 页不进 postList(G01 讲过固定页机制),普通文章才有 postUrl 字段。

设计决策一:插件怎么知道"我是第几篇"

导航要先定位"当前"。文章页的 URL 是规整的 /post/14.html,一个正则就够:

var match = location.pathname.match(/\/post\/(\d+)\.html/);
if (!match) return;
var currentNum = parseInt(match[1], 10);

这个 return 不是可有可无的。叶扬实测过一个反直觉的现象:About 页也会加载文章脚本——config 的 script 字段对 singlePage 同样注入(打开 about.html 看源码,TOC、灯箱脚本都在)。插件不设防的话,About 页也会 fetch 一遍数据、试图渲染导航。正则匹配不到 /post/N.html 就退出,首页、标签页、固定页全部天然免疫。

为什么不直接查 DOM 里的文章标题去反查编号?URL 是页面上最稳定、最早可得的信息,不依赖任何元素渲染,零竞态。

设计决策二:按什么排"上下"

最省事的写法是按 issue 编号排序:13 的上一篇是 12,下一篇是 14。但 G06 讲过 timestamp 能补发旧文——一篇 2020 年的旧文可能挂着编号 #20,插进文章流后,纯编号排序就会把它排到最新面。

所以排序键用日期优先、编号兜底

.sort(function (a, b) {
    if (a.date !== b.date) return a.date < b.date ? -1 : 1; // 先比 createdDate
    return a.num - b.num;                                   // 同一天再比编号
});

这样"上一篇"永远是时间线上更早的邻居,与读者在首页看到的文章流顺序一致。本站目前编号和日期完全重合,两套规则结果相同——但代码要为将来的补发场景买好保险。

顺带定一下语义:中文博客的惯例是上一篇 = 更早、下一篇 = 更新,左侧卡片放上一篇,右侧放下一篇。

设计决策三:插在哪里、长什么样

插入点选评论按钮 #cmButton 的前面——正文、版权小字之后,评论区之前,符合"读完正文→继续逛→想聊两句再评论"的动线;找不到评论按钮(比如评论被关)时退化为追加到内容容器:

var anchor = document.getElementById('cmButton') || document.getElementById('comments');
anchor.parentNode.insertBefore(nav, anchor);

样式上全部借用站点已加载的 Primer CSS 变量(就是 G07 Alert 配色的同一套体系),不写死任何颜色:

用途 变量 暗色下
卡片边框 --color-border-muted 自动变浅灰
正文文字 --color-fg-default 自动变白
次要文字 --color-fg-muted 自动变中灰
hover 强调 --color-accent-fg + --color-accent-subtle 自动变蓝边蓝底

主题切换靠 <html data-color-mode="dark"> 钩子,Primer 内部接管变量,插件连"监听主题按钮"都省了——对比 G07 的 mermaid 必须手动监听重绘,CSS 变量方案是真·躺赢。其余细节:标题超长两行省略、hover 上浮 1px、方向箭头、以及一套专门的手机端样式。

这个做法有官方背书。 社区 issue #196 有人问作者"自定义 CSS 怎么适配明暗主题",Meekdai 给了两条路:用 @media (prefers-color-scheme: light) 定义变量,或者直接引用 primer.css 里各主题对应的颜色变量——本插件走的是第二条。同 issue 里一位用户选第一条路踩了坑:他按系统偏好写 media 查询,手动切到暗色却不生效。原因是 Gmeek 的主题是亮 → 暗 → 跟随系统三态循环,当用户手动指定暗色、而系统偏好仍是亮色时,prefers-color-scheme 与实际显示对不上;Primer 变量则挂在 html[data-color-mode="dark"] 选择器上,三态全部正确响应。写自研插件时记住这个结论:颜色一律用 Primer 变量,不要自己写 prefers-color-scheme

手机端样式是改了两轮才定的。 初版在 600px 以下直接纵向堆叠全宽卡片,叶扬主观觉得竖屏太长、想省纵向空间,于是第二版改成左右两栏紧凑排列——结果真机一塌糊涂:375px 宽的屏幕扣掉边距,每栏只剩约 170px,长标题被挤成三四行蚂蚁字,两个小框缩在屏幕中间,既难点又难读。

最终版回到全宽通栏卡片上下排列,但相对初版做了三处优化:

  1. 卡片内边距加大到 12×14px,整块都是好按的热区;
  2. 标题字号改用流体值 clamp(14px, 4vw, 16px)——屏幕越宽字越大,375px 机宽下也有 15px,两行内说完;
  3. 日期回归(全宽后空间不再紧张),"下一篇"卡片在手机上统一左对齐,方向改由标签里的 ← 上一篇下一篇 → 箭头表达,扫读更顺。

还有一个容易漏的细节:纵排时必须显式写 .gmeek-pn-item{flex:0 0 auto},否则桌面端的 flex:1 1 0 会沿纵轴把两张卡强行拉成等高。教训是响应式不能靠想象,要看真机——叶扬在桌面 DevTools 里拖窄窗口觉得两栏"挺精致",上手机就翻车了。

完整代码

新建 static/plugins/GmeekPrevNext.js(约 90 行,零依赖):

/* GmeekPrevNext —— 文章底部"上一篇/下一篇"导航
 * 数据源:/postList.json(Gmeek 每次构建自动生成)
 * 排序:createdDate 优先、issue 编号兜底(兼容 timestamp 补发旧文)
 */
(function () {
    var match = location.pathname.match(/\/post\/(\d+)\.html/);
    if (!match) return; // 首页、/about.html 等固定页不生成

    var currentNum = parseInt(match[1], 10);
    injectStyle();

    fetch('/postList.json', { cache: 'no-cache' })
        .then(function (r) {
            if (!r.ok) throw new Error('postList fetch failed');
            return r.json();
        })
        .then(function (list) {
            var posts = Object.keys(list).map(function (key) {
                var item = list[key];
                return {
                    num: parseInt(key.replace(/^P/, ''), 10),
                    title: item.postTitle,
                    url: item.postUrl ? '/' + item.postUrl : '',
                    date: item.createdDate || ''
                };
            }).filter(function (p) { return p.url && p.num; })
              .sort(function (a, b) {
                  if (a.date !== b.date) return a.date < b.date ? -1 : 1;
                  return a.num - b.num;
              });

            var idx = posts.map(function (p) { return p.num; }).indexOf(currentNum);
            if (idx < 0) return;

            var older = posts[idx - 1] || null;
            var newer = posts[idx + 1] || null;

            var nav = document.createElement('nav');
            nav.className = 'gmeek-pn';
            nav.innerHTML = cell('上一篇', older, true) + cell('下一篇', newer, false);

            var anchor = document.getElementById('cmButton') || document.getElementById('comments');
            if (anchor && anchor.parentNode) {
                anchor.parentNode.insertBefore(nav, anchor);
            } else {
                document.getElementById('content').appendChild(nav);
            }
        })
        .catch(function (e) { console.warn('GmeekPrevNext:', e && e.message); });

    function cell(label, post, isLeft) {
        var cls = 'gmeek-pn-item' + (isLeft ? '' : ' gmeek-pn-next')
                              + (post ? '' : ' is-disabled');
        if (post) {
            return '<a class="' + cls + '" href="' + post.url + '">'
                + '<span class="gmeek-pn-label">' + label + '</span>'
                + '<span class="gmeek-pn-title">' + escapeHtml(post.title) + '</span>'
                + '<span class="gmeek-pn-date">' + post.date + '</span></a>';
        }
        return '<div class="' + cls + '">'
            + '<span class="gmeek-pn-label">' + label + '</span>'
            + '<span class="gmeek-pn-title">已经到尽头啦</span></div>';
    }

    function escapeHtml(s) {
        return String(s).replace(/[&<>"]/g, function (c) {
            return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
        });
    }

    function injectStyle() {
        var style = document.createElement('style');
        style.textContent =
            '.gmeek-pn{display:flex;gap:10px;margin:28px 0 10px;}'
          + '.gmeek-pn-item{flex:1 1 0;min-width:0;display:flex;flex-direction:column;gap:4px;'
          + 'padding:11px 12px;border:1px solid var(--color-border-muted,#d0d7de);'
          + 'border-radius:12px;color:var(--color-fg-default,#1f2328);text-decoration:none;'
          + 'transition:border-color .15s,background-color .15s,transform .15s;}'
          + '.gmeek-pn-item:hover{border-color:var(--color-accent-fg,#0969da);'
          + 'background:var(--color-accent-subtle,#ddf4ff);transform:translateY(-1px);}'
          + '.gmeek-pn-next{align-items:flex-end;text-align:right;}'
          + '.gmeek-pn-label{font-size:12px;color:var(--color-fg-muted,#656d76);white-space:nowrap;}'
          // 可点击卡片才显示方向箭头,禁用占位不加
          + '.gmeek-pn-item:not(.is-disabled) .gmeek-pn-label::before{content:"\\2190  ";}'
          + '.gmeek-pn-next:not(.is-disabled) .gmeek-pn-label::before{content:"";}'
          + '.gmeek-pn-next:not(.is-disabled) .gmeek-pn-label::after{content:"  \\2192";}'
          + '.gmeek-pn-date{font-size:12px;color:var(--color-fg-muted,#656d76);}'
          + '.gmeek-pn-title{font-weight:600;line-height:1.4;display:-webkit-box;'
          + '-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;}'
          + '.is-disabled{opacity:.45;pointer-events:none;}'
          // 手机端:全宽通栏卡片上下排列,放大字号与热区
          + '@media (max-width:600px){.gmeek-pn{flex-direction:column;gap:8px;margin:22px 0 8px;}'
          + '.gmeek-pn-item{flex:0 0 auto;padding:12px 14px;border-radius:10px;gap:3px;}'
          + '.gmeek-pn-next{align-items:flex-start;text-align:left;}'
          + '.gmeek-pn-label{font-size:12px;}'
          + '.gmeek-pn-title{font-size:clamp(14px,4vw,16px);line-height:1.4;}'
          + '.gmeek-pn-date{display:block;font-size:11px;}}';
        document.head.appendChild(style);
    }
})();

挂到 config 的 script 字段,第三个标签拼在后面(多插件写法 G05 讲过):

"script": "<script src='/plugins/GmeekTOC.js'></script><script src='/plugins/lightbox.js'></script><script src='/plugins/GmeekPrevNext.js'></script>"

改 config 走老规矩:push、等几秒、手动全局重建。

几个工程上的小心思

  1. 标题做了 HTML 转义。数据虽然来自自己的仓库,但把标题直接拼进 innerHTML 是坏习惯——哪天标题里有个 <&,页面就可能破相。一行 escapeHtml 是对渲染管道的基本尊重;
  2. fetch 失败只 console.warn。JSON 拉不到时文章正文照常阅读,导航悄悄缺席,不弹窗、不挡内容——渐进增强的底线;
  3. cache: 'no-cache'。发新文章后构建会更新 postList.json,避免读者浏览器抱着旧缓存导致"最新一篇没有下一篇入口"(其实本来也没有)之类的状态错乱;
  4. 首尾边界。第 1 篇没有上一篇、最新文没有下一篇,渲染的是 pointer-events:none 的半透明占位卡而不是缺一块——布局对称,读者也能明确感知"到头了"。

怎么验证:这个插件 curl 不到

和 TOC、灯箱不同,导航是运行时才生成的:curl 抓 HTML 源码,搜不到任何文章标题,只有一句 fetch。验证要在浏览器里:

  1. 打开任意文章,滚到底部,确认两块导航卡片;
  2. 点上一篇/下一篇,沿编号一路走到 #1 和最新一篇,确认两端是灰色占位;
  3. F12 → Network,确认 postList.json 请求 200;
  4. 打开 About 页,确认没有导航、Console 无报错;
  5. 切换明暗主题,确认卡片配色跟着变。

叶扬在上线前还用 Node 把排序函数单独跑了一遍真实数据:12 篇普通文章(#6 About 被正确排除)、#1 无前项、#13 无后项、#9 的邻居是 #8 和 #10,全对了才发布。

小结

90 行代码,零依赖,一次开发全站生效。这个插件示范了 Gmeek 自研插件的标准套路:postList.json 当数据源、URL 正则定位当前页、Primer 变量搞定主题、评论区前插入。接下来的 G09 阅读时长会复用前两招,G10 归档页会把这份数据玩得更狠。

下一期 G09:字数统计与阅读时长,给每篇文章的标题区挂一个"约 N 字 · 阅读 M 分钟"。

参考

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