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 渲染接口把反引号里的内容转义成 &lt;div …&gt; 并包上 <code class="notranslate">;构建期 Gmeek 正则命中开头的 Gmeek-html 标记,捕获后面的内容再 html.unescape 反转义——最终页面里就是一个真实的 div。G07 用它在正文里塞 kbd 小键盘玩,这一期它是整个页面的地基:

写这个标记有三个精确约束,都是叶扬拿渲染接口预检出来的:

  1. Gmeek-html 只在开头写一次。正则非贪婪匹配到 </code> 就停,结尾再写一次会残留成页面上的裸文本;
  2. 必须是行内反引号,围栏代码块渲染结构不匹配,不生效(G07 实测);
  3. 不能把这个关键字单独放进行内反引号里做"文字说明"——那会被构建期同一条正则误命中。所以本文里提到它时用裸文本 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):

  1. 先 push:插件进 static、config 改三处(singlePage 加 archive、iconList 加 path、script 拼接第五个 <script>);
  2. 手动触发一次全局重建并等它成功——确保线上 config 已就位,此时还没有 archive issue,模板里的固定页循环为空,没有按钮也没有 404
  3. gh label create archive,再创建只挂 archive 一个标签的 issue(自动触发增量构建,生成 /archive.html);
  4. 立刻验证身份没跑偏(见下节);
  5. 再手动触发一次全局重建——按钮在 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 页——给爬虫立规矩,给迷路的读者一个台阶。

参考

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