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,长标题被挤成三四行蚂蚁字,两个小框缩在屏幕中间,既难点又难读。
最终版回到全宽通栏卡片上下排列,但相对初版做了三处优化:
- 卡片内边距加大到 12×14px,整块都是好按的热区;
- 标题字号改用流体值
clamp(14px, 4vw, 16px)——屏幕越宽字越大,375px 机宽下也有 15px,两行内说完; - 日期回归(全宽后空间不再紧张),"下一篇"卡片在手机上统一左对齐,方向改由标签里的
← 上一篇、下一篇 →箭头表达,扫读更顺。
还有一个容易漏的细节:纵排时必须显式写 .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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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、等几秒、手动全局重建。
几个工程上的小心思
- 标题做了 HTML 转义。数据虽然来自自己的仓库,但把标题直接拼进 innerHTML 是坏习惯——哪天标题里有个
<、&,页面就可能破相。一行escapeHtml是对渲染管道的基本尊重; - fetch 失败只 console.warn。JSON 拉不到时文章正文照常阅读,导航悄悄缺席,不弹窗、不挡内容——渐进增强的底线;
cache: 'no-cache'。发新文章后构建会更新 postList.json,避免读者浏览器抱着旧缓存导致"最新一篇没有下一篇入口"(其实本来也没有)之类的状态错乱;- 首尾边界。第 1 篇没有上一篇、最新文没有下一篇,渲染的是
pointer-events:none的半透明占位卡而不是缺一块——布局对称,读者也能明确感知"到头了"。
怎么验证:这个插件 curl 不到
和 TOC、灯箱不同,导航是运行时才生成的:curl 抓 HTML 源码,搜不到任何文章标题,只有一句 fetch。验证要在浏览器里:
- 打开任意文章,滚到底部,确认两块导航卡片;
- 点上一篇/下一篇,沿编号一路走到 #1 和最新一篇,确认两端是灰色占位;
- F12 → Network,确认
postList.json请求 200; - 打开 About 页,确认没有导航、Console 无报错;
- 切换明暗主题,确认卡片配色跟着变。
叶扬在上线前还用 Node 把排序函数单独跑了一遍真实数据:12 篇普通文章(#6 About 被正确排除)、#1 无前项、#13 无后项、#9 的邻居是 #8 和 #10,全对了才发布。
小结
90 行代码,零依赖,一次开发全站生效。这个插件示范了 Gmeek 自研插件的标准套路:postList.json 当数据源、URL 正则定位当前页、Primer 变量搞定主题、评论区前插入。接下来的 G09 阅读时长会复用前两招,G10 归档页会把这份数据玩得更狠。
下一期 G09:字数统计与阅读时长,给每篇文章的标题区挂一个"约 N 字 · 阅读 M 分钟"。
参考
- 本站插件源码:https://github.com/yeyangchen2009/yeyangchen2009.github.io/blob/main/static/plugins/GmeekPrevNext.js
- 数据源说明(sitemap 同源):G04 把门牌号递给搜索引擎
- 单篇/全局 script 注入机制:G06 文章末尾的秘密
- Primer CSS 变量体系:https://primer.style/foundations/color
- 官方 issue #196:自定义 CSS 如何适配明暗主题(Meekdai 本人答复):Meekdai/Gmeek#196
- 系列总揽:Gmeek 插件与功能全景调研(第 0 篇)