G10 自研插件(三):凭空造出的时间线归档页
前两弹分别在文章头尾做文章——G08 上一篇/下一篇读
postList.json,G09 阅读时长读页面自身 DOM。这一期叶扬要凭空造一个新页面出来:一个按年份分组的文章档案馆。首页只展示最近 15 篇然后分页,标签页得先知道关键词才能搜,而一个好博客应该有一个入口,让人一眼看尽全部家当。这期的主角不再只是插件,还有框架的固定页机制(singlePage)和 G07 一闪而过的那个"彩蛋"——它其实是生产级基础设施。
先看现场
现在页头应该多了一个档案盒图标(首页右上,about 按钮旁边),点进去就是新建好的 /archive.html:2026 大标题、左侧一根时间竖线、每个文章标题挂在圆点右侧按时间倒序排列,滚动时年份会吸顶;暗色模式一切正常,手机上竖线和圆点也对齐。最妙的是它零维护——以后每发一篇新文,它都会自己排队进时间线,永远不用编辑这个页面。
先给结论(后文明着算一遍):本站目前 14 篇普通文章,#6 是 About、#16 是这个归档页,它们都不进文章流;但在其余数据出口里的待遇各不相同。
一、新页面从哪来:singlePage 读源码确认事实
About 页(issue #6)从建站起就在,但关于它的事实之前大多是"知道能用"。这一期要新增第二个固定页,叶扬把官方构建脚本 Gmeek.py 相关逻辑全读了一遍,逐条实测:
1. 身份判定:第一个标签说了算。 第 316 行:
if issue.labels[0].name in self.blogBase["singlePage"]:
listJsonName='singeListJson' # 官方把 single 拼成了 singe,变量名就是这个
htmlFile='{}.html'.format(self.createFileName(issue,useLabel=True))
gen_Html = self.root_dir+htmlFile
else:
listJsonName='postListJson'
gen_Html = self.post_dir+htmlFile两个结论:issue 的第一个标签若命中 config 的 singlePage 数组,它就脱离文章流,生成到站点根目录的 <标签名>.html(所以标签名就是路径名,只能用 URL 友好的英文);否则一律按普通文章处理。因此创建 archive issue 时标签只能挂一个 archive——若先写"博客"再写"archive",labels[0] 就是"博客",页面会变成 /post/16.html 混进首页文章流。
2. 固定页不进 postList.json,但进 RSS 和 sitemap。 固定页进的是另一个字典 singeListJson(Gmeek.py:326),插件们常读的 postList.json 里没有它。但 Gmeek.py:278 显示 feed 构建时先遍历固定页再遍历普通文章,所以 RSS 里固定页是排在最前面的置顶条目——curl 本站 rss.xml 前两条就是 about.html 和 archive.html;G04 写的 sitemap_gen 读 config.singlePage,也会自动给 archive.html 加一行(实测 17→18 URLs)。三个出口分流如下:
| 数据出口 | 普通文章 | 固定页 |
|---|---|---|
| postList.json(插件数据源) | ✅ | ❌(在 singeListJson) |
| RSS feed | ✅ 时间序 | ✅ 置顶在最前 |
| sitemap.xml | ✅ | ✅(sitemap_gen 自动) |
3. 页头按钮是模板循环出来的,图标按标签名查。 首页模板 plist.html 里有一段 Jinja 循环:
{% for num in blogBase['singeListJson'] -%}
<a href=".../{{ [...]['labels'][0] }}.html" title="{{ postTitle }}">
<svg><path id="{{ postTitle }}"></path></svg>
</a>
{%- endfor %}配套 JS 按标签名填图标:IconList["archive"](plist.html 内联脚本)。IconList 由内置 IconBase 和 config.iconList 合并而成(Gmeek.py:215-216),内置只有 about/link 等少数几个,没有 archive——所以必须自己在 config.iconList 里给一条,否则按钮就是一个空白圆圈(链接和 title 提示仍有效,只是没图案)。
4. 按钮只在首页出现。 这是模板结构决定的:post.html(文章页)和 tag.html(标签页)的 header 里根本没有这段循环。也就是说读者在文章页看不到归档入口,靠首页和 sitemap/RSS 引导。框架限制,合理。
5. 标签得先存在。 gh issue create --label archive 时仓库里还没有这个标签,gh 当场报错 'archive' not found,不会帮你自动建。先 gh label create archive --color 6e40c9(用与 about 相同的紫色),再发 issue。
图标 path 取自 GitHub 自家的 Octicon「archive」16px(primer/octicons,MIT 协议,全站界面图标本来就都是这套),直接贴进 config:
"singlePage": ["about", "archive"],
"iconList": {"github": "…(省略)…", "archive": "M0 2.75C0 1.784.784 1 …(完整 path 见本站 config.json)… Z"}二、插件怎么"粘"到固定页上:彩蛋变身基础设施
页面壳子由 issue 提供,时间线内容却要等浏览器拿到 postList.json 后才能渲染——插件需要一个挂载点。直接在 issue 正文里写 <div> 没用,会被当成文本显示出来。
Gmeek.py:180 有个在 G07 被当作"彩蛋"介绍过的开关:
if '<code class="notranslate">Gmeek-html' in post_body:
post_body = re.sub(r'<code class="notranslate">Gmeek-html(.*?)</code>',
lambda match: html.unescape(match.group(1)),
flags=re.DOTALL)它配合的是 Markdown 的行内反引号。archive issue 的正文里只写了这么一行:
`Gmeek-html<div id="archiveTimeline">⏳ 文章列表加载中…</div>`链路是这样的:GitHub 渲染接口把反引号里的内容转义成 <div …> 并包上 <code class="notranslate">;构建期 Gmeek 正则命中开头的 Gmeek-html 标记,捕获后面的内容再 html.unescape 反转义——最终页面里就是一个真实的 div。G07 用它在正文里塞 kbd 小键盘玩,这一期它是整个页面的地基:
- div 里的"⏳ 文章列表加载中…"是天然的 loading 态(JS 未执行/正在 fetch 时显示);
- 插件成功后用渲染结果整体替换
innerHTML; - fetch 失败时把容器改写成错误提示,不弹 alert、不白屏。
写这个标记有三个精确约束,都是叶扬拿渲染接口预检出来的:
- Gmeek-html 只在开头写一次。正则非贪婪匹配到
</code>就停,结尾再写一次会残留成页面上的裸文本; - 必须是行内反引号,围栏代码块渲染结构不匹配,不生效(G07 实测);
- 不能把这个关键字单独放进行内反引号里做"文字说明"——那会被构建期同一条正则误命中。所以本文里提到它时用裸文本 Gmeek-html,演示语法一律放围栏代码块。
二点五、数据里还藏着一个"假文章"
按惯例先 Object.keys(list) 遍历 postList.json,桩测试立刻露馅:15 个键里有一个不叫 P编号,而叫 labelColorDict——框架存标签颜色用的字典(页头标签 chip 上色就靠它),混在同一个 JSON 里。
处理办法和 G08 一致,用三个字段的有效性过滤:
.filter(function (p) { return p.url && p.num && p.date; })parseInt('labelColorDict'.replace(/^P/, ''), 10) 是 NaN,直接出局。G08 的上下篇插件和 sitemap_gen 其实都被同一道 filter 保护着。写插件遍历这份数据,永远不要假设"里面全是文章"。
三、时间线怎么排、怎么画
排序与分组复用 G08 的熟脸逻辑,方向反过来:组内日期倒序(最新在上),同一天按 issue 编号倒序兜底;再用 date.slice(0,4) 取年份前向输出,2026 年全站 14 篇就是一个大组——等博客写到 2027 年,新组会自动出现。
.sort(function (a, b) {
if (a.date !== b.date) return a.date < b.date ? 1 : -1; // 最新在前
return b.num - a.num;
});时间线视觉只靠 CSS:ul 左边一根 2px 竖线(border-left),每个 li::before 是 8px accent 蓝色圆点,绝对定位压在竖线上,再用 3px 画布底色的外阴影把竖线"咬"出缺口——不用画任何图片:
.gmeek-ar-list{border-left:2px solid var(--color-border-muted,#d0d7de);
padding-left:24px;list-style:none;}
.gmeek-ar-item{position:relative;}
.gmeek-ar-item::before{content:"";position:absolute;left:-29px;top:15px;
width:8px;height:8px;border-radius:50%;
background:var(--color-accent-fg,#0969da);
box-shadow:0 0 0 3px var(--color-canvas-default,#ffffff);}年份标题做吸顶:position:sticky;top:0,这里有个必踩的坑——吸顶元素必须有不透明背景(--color-canvas-default),否则文章标题会从年份条底下透出来叠成一团;再配一条底边和 z-index:1,滚动时"现在在哪一年"始终可见。其余颜色全走 Primer 变量(fg-default 标题、fg-muted 日期、accent-fg 计数 chip),明暗三态自动正确,这是 G08 拿到 issue #196 背书的老纪律。日期列定宽 46px + font-variant-numeric:tabular-nums 等宽数字,不同位数的日期不会让标题列左右抖。
手机端这次没有重排可翻车(G08/G09 攒下的判断力):时间线本身就是单列,375px 宽只需要把竖线内边距 24→20px、圆点 left 同步调、字号微调,结构原封不动。
四、发布顺序:这次最容易翻车的地方
config 和 issue 互相等对方:singlePage 没上线就发 issue,它会被当普通文章;只改 config 不发 issue,首页按钮指向 404。叶扬设计的零事故顺序(也是本文的 SOP):
- 先 push:插件进 static、config 改三处(singlePage 加 archive、iconList 加 path、script 拼接第五个
<script>); - 手动触发一次全局重建并等它成功——确保线上 config 已就位,此时还没有 archive issue,模板里的固定页循环为空,没有按钮也没有 404;
gh label create archive,再创建只挂 archive 一个标签的 issue(自动触发增量构建,生成/archive.html);- 立刻验证身份没跑偏(见下节);
- 再手动触发一次全局重建——按钮在 plist.html 里,而增量构建只重生成当前这一个 issue 的页面,首页必须靠全局重建才会出现档案盒图标。
第 2 步和第 5 步都不能省。跳过第 2 步,issue 触发的构建若抢在 config push 前 checkout,#16 就会作为普通文章写进 postList(虽然下次全局重建能自愈,但窗口期数据是错的);跳过第 5 步,页面在了但全站没有入口。
五、验证:curl 能验的和只能托付浏览器的
| 检查项 | 期望 | 手段 |
|---|---|---|
| /archive.html | 200,挂载点是真实 div 而非 code(彩蛋生效) | curl + grep |
| /post/16.html | 404(固定页不占文章路径) | curl |
| postList.json | 无 P16 键(15 键 = 14 篇文章 + labelColorDict) | curl + node |
| sitemap.xml | 18 URLs,含 archive.html | curl |
| rss.xml | archive 与 about 一起在 feed 最前 | curl |
| 页头按钮 | 首页有(含 IconList["archive"] 填图);tag 页/文章页没有 | curl |
| 插件文件 | /plugins/GmeekArchive.js 200 | curl |
| 时间线交互 | 排序、圆点对齐、年份吸顶、暗色、手机间距 | 浏览器/手机 |
运行时那半依旧托付不了 curl,还是用 node 造境:vm 沙箱里注入假的 location/document/fetch,喂真实 postList.json 跑插件源码,断言 about 页不发 fetch、14 篇全部入列且首条 #15 末条 #1、labelColorDict 不泄漏、fetch reject 时容器变成错误文案。全绿,视觉部分才交给真机。
小结
G08–G10 自研三连至此收官:G08 把 postList.json 变成文末导航,G09 把页面 DOM 变成阅读预算,G10 用 postList.json + singlePage + 彩蛋挂载点凭空造出一整个页面。固定页分流表、labelColorDict 陷阱、彩蛋三约束、双次全局重建的发布顺序,是这一期比代码本身更值钱的四条笔记。
下一期 G11 离开插件题材,回到"放个文件就完事"的轻松环节:robots.txt 与自定义 404 页——给爬虫立规矩,给迷路的读者一个台阶。
参考
- 构建脚本 singlePage/RSS/彩蛋逻辑:Gmeek.py(addOnePostJson 约 314 行、createFeedXml 约 266 行、Gmeek-html 约 180 行)
- 固定页按钮循环:templates/plist.html
- 档案盒图标:octicons archive-16.svg(MIT)
- 本站插件:GmeekArchive.js
- 相关前作:G06 文章末尾的秘密(注入机制)、G07 写作三件套(彩蛋与 Mermaid)、G08 上一篇/下一篇(postList 排序)、G09 阅读时长、G04 SEO 与 sitemap
- 系列总揽:Gmeek 插件与功能全景调研(第 0 篇)