我的博客演进历程(二):Obsidian + gobog + GitHub Actions

写在前面

之前写过一篇 《我的博客演进历程》,讲了从 CSDN → WordPress → hexo → 自研引擎一路的折腾。两年过去了,写作工具和发布链路又都换过一轮,正好趁着这次重构把整套架构落定,记录一下。

为啥要再折腾一次

老的方案,简单说,是一个跑在云服务器上的 Go 服务(也就是 gobog):

  • 服务直接读一个挂载的目录,里面是 Markdown;
  • 每次内容更新,先在另一个仓库(blog)push,再触发 docker 镜像重建,把博客容器重新部署一次;
  • sbrave.cn 域名解析过去;GitHub Pages 那个 xxx.github.io 仓库基本是手动同步的副本。

跑了一段时间,问题就出来了:

  • 每次写完一篇就要等容器重启,30 秒到 2 分钟不等,体验拉胯;
  • 写作端我从 Typora 换成了 Obsidian,自带的 wikilink、双链、附件管理实在太香,但 gobog 当时是按照"扁平 markdown + 一个 image 目录"设计的,跟 Obsidian 的 vault 结构不完全吻合;
  • 云服务器是按月续费的,但博客访问量根本配不上这个成本,能跑在 GitHub Pages 上就跑在 GitHub Pages 上;
  • 最关键的:每次写完都要打开终端 git add → commit → push,摩擦感很重。我希望写作的最后一步就是按 Ctrl+S,剩下的事让脚本去跑。

于是决定再折腾一次。

现在的整体架构

一张图,先看全貌:

   ┌──────────────────────────┐
   │  本地 Obsidian vault      │
   │   Blog/                  │
   │     post/                │
   │     pages/               │
   │     resource/image/      │
   └────────────┬─────────────┘
                │
        gobog-obsidian 插件
        (push / pull / front-matter 自动填充)
                │
                ▼
   ┌──────────────────────────┐
   │  GitHub: sbraveyoung/blog │  ← markdown 单一可信源
   └────────────┬─────────────┘
                │ push 触发
                │ repository_dispatch
                ▼
   ┌──────────────────────────────────────┐
   │  GitHub Actions @ github.io repo     │
   │   1. checkout sbraveyoung/blog       │
   │   2. checkout sbraveyoung/gobog      │
   │   3. go build && gobog -export       │
   │   4. 替换工作树并 commit              │
   └────────────┬─────────────────────────┘
                │
                ▼
   ┌──────────────────────────┐
   │  sbraveyoung.github.io   │  ← 渲染好的 HTML
   │  (GitHub Pages 自动伺服)  │
   └────────────┬─────────────┘
                │
                ▼
              sbrave.cn

四个仓库,三层职责,一条链路。每一跳都解释一下。

四个仓库各自的职责

1. blog:原始 markdown 的家

只放内容,没有代码。目录契约只有三个:

路径 作用
source/post/ 文章。直接放在底下的是单篇;子目录会被识别为"系列/合集"。
source/pages/ 顶层独立页面。pages/about.md 自动对应 /about 这个 URL。
source/resource/image/ 文章里引用的图片资源。

老的设计里,about 是写死在引擎里的特殊页面。这次直接通用化:你想加 contact、now、projects,往 pages/ 丢一个 markdown 就行,引擎会按文件名生成路由,不需要改任何代码。

2. gobog:Markdown 渲染引擎

用 Go 写的小静态站点生成器。两种模式:

  • Server 模式:起一个 HTTP 服务,监听 vault 目录变化(fsnotify),改完 markdown 浏览器刷新就能看到效果——主要用来本地预览。
  • 静态导出模式(gobog -export ./dist):把整个站点渲染成 HTML/CSS/XML,结果直接 drop-in 到任何静态托管。

这次重构主要做了几件事:

  • 主题系统重写。新增了三个主题:minimal(默认,黑白极简)、sepia(暖色阅读)、ocean(冷蓝)。三个主题共享 HTML 模板和 JS,只是 CSS 颜色 token 不同——这样维护一处改三处生效。每个主题都自带 SVG 图标,并自动跟随系统的浅色/深色模式(也支持手动覆盖,用 localStorage 持久化);
  • 列表页和详情页拆开。原来 group 和 single article 共用一个 index.html,渲染逻辑很别扭。现在分成 index.html(首页)、group.html(系列/标签/搜索结果列表)、post.html(单篇详情)三个模板,各自只负责自己的事;
  • 空 group 不展示。一个目录下如果一篇可发布的文章都没有(比如里面全是 draft),这个目录就不会出现在列表里,更不会渲染一个空的 landing 页;
  • 去掉了阅读人数。原来的 views.json 计数器其实没啥用,每次部署还要同步状态目录,干脆下掉;
  • AI 标签。文章 front-matter 写 ai: true 默认展示"🤖 AI 辅助"角标;想表达"AI 主笔我审稿"写 ai: generated("🤖 AI 生成");"我写 AI 校"写 ai: edited("🤖 AI 校对");想直接给某个模型署名,比如 ai: claude 就直接展示模型名。这套三档分级借鉴的是 IPTC 的 digitalSourceType 词表,也跟少数派、Medium 那边的"AI 辅助/AI 生成"约定对齐。AI 参与的内容应该被标识出来,不是因为它"低人一等",而是读者有知情权。

最后值得一提的是 URL 稳定性。gobog 第一次扫到一篇没有 id / url / create_time 的 markdown 时,会把这些字段写回到原文件的 front-matter 里,之后再改名/移动文件,URL 不变。这个逻辑是从老 gobog 继承下来的,是博客这种"URL 即对外契约"的内容产品里非常关键的一条规则。

3. gobog-obsidian:Obsidian 端的同步插件

这次彻底重写了。老插件做的事是"在 Obsidian 里跑 gobog 把站点 push 到 GitHub Pages",把渲染和同步揉在了一起。新插件只做两件事:

  1. push/pull markdown:本地 vault 里有一个 Blog/ 文件夹,跟 blog 仓库做双向同步。我可以在任何一台机器上 git pull 拿到最新内容继续写;写完不用打开终端,命令面板里 "Push blog repo" 一下,commit + push 一气呵成。可选自动 push(debounced),保存后 30 秒自动提交。

  2. 新文件自动填 front-matter:在 post/ 里新建一个 xxx.md,插件监听到 vault.create 事件,自动给文件头插入:

    ---
    title: xxx
    author: sbraveyoung
    id: a1b2c3d4
    url: /post/a1b2c3d4
    create_time: 2026-05-10 21:00:00
    updated_time: 2026-05-10 21:00:00
    ---
    

    pages/ 下的文件会用 /<basename-slug> 作为 URL(跟 gobog 的页面路由对齐)。已存在的 key 永远不会被覆盖——只补缺失的,这点很关键,否则用户辛辛苦苦写好的 title 被你改成 basename 就尴尬了。

冲突处理上,pull 走 merge --ff-only。本地已经 commit、远端也有新提交,那就直接报错让我手动处理——一个同步插件最不该做的就是悄悄 rebase 重写本地文件。

4. sbraveyoung.github.io:发布目标

这个仓库不是手写的,是被机器管理的。主分支的内容由 GitHub Actions 在每次 deploy 时整体替换。

工作流(.github/workflows/deploy.yml)的触发器有三个:

  • repository_dispatch(事件类型 blog-update):blog 仓库 push 时由它的 notify workflow 主动通知;
  • workflow_dispatch:手动触发,可以传一个 theme 参数临时换主题预览;
  • schedule:每天凌晨跑一次兜底——万一某次 webhook 没送达,至少不会 24 小时落后。

跑起来后做的事:

# 1. checkout 三个仓库
checkout sbraveyoung.github.io  # 自己
checkout sbraveyoung/blog → _blog
checkout sbraveyoung/gobog → _gobog

# 2. 编 gobog
cd _gobog && go build -o ../_bin/gobog ./src

# 3. 写一份临时 toml 配置(主题从 _gobog.json 读)
cat > _gobog.toml <<EOF
[blog]
theme  = "_gobog/themes/${THEME}"
source = "_blog/source"
...
EOF

# 4. 渲染
./_bin/gobog -config _gobog.toml -export _dist

# 5. 替换 working tree + commit + push
rm -rf !(.git|.github|_gobog.json|_dist)
cp -a _dist/. .
git add -A && git commit -m "deploy: theme=$THEME ..." && git push

主题切换通过 repo 根目录的 _gobog.json 控制:

{
  "theme": "minimal",
  "domain": "https://sbrave.cn",
  "title": "sbraveyoung · 字里行间",
  "subtitle": "阅读 · 实践 · 思考",
  "author": "sbraveyoung",
  "cname": "sbrave.cn"
}

改主题就是改这一行 JSON、commit、push。下一次部署生效。

一篇文章从写到上线

把上面这些拼起来,一篇文章的生命周期是:

  1. Obsidian 里 Cmd+N 新建 Blog/post/xxx.md;
  2. 插件监听到 create 事件,0 延迟把 front-matter 填好;
  3. 我在编辑区写正文,wikilink、image.png 嵌图随便用;
  4. Cmd+S。如果开了 auto-push,30 秒内静默 commit + push 到 blog 仓库;否则我手动喊一下 "Push blog repo";
  5. blog 仓库收到 push,notify workflow 触发,对着 sbraveyoung.github.io 发一个 repository_dispatch;
  6. github.io 的 deploy workflow 跑起来,checkout 三仓库、编 gobog、渲染、commit;
  7. GitHub Pages 检测到主分支更新,CDN 几秒内刷新;
  8. sbrave.cn 上看到新文章。

从 Cmd+S 到线上看到,端到端一般 1~2 分钟。比老方案的"等容器重启"快不少,关键是全自动。

几个有意思的设计决策

为什么把渲染放在 github.io,而不是 blog?

直觉上,blog 仓库自己 build 完直接 push HTML 到 github.io 似乎更直接。但反过来更好:

  • secret 的爆炸半径更小。blog 仓库只需要一个能 dispatch 事件的窄 PAT;真正能写入 github.io 的 token 留在 github.io 自己的 workflow 里;
  • github.io 自包含。任何时候打开它的 Actions 页都能手动重跑——比如换主题、强制重建——不需要碰 blog 仓库;
  • 避免循环依赖。blog 仓库 push HTML 到 github.io,github.io 又是 blog 引用图片的地方,这种相互依赖在 CI 里很容易绕进去。把"渲染逻辑"统一收在消费方(github.io),契约只剩下"blog 内容更新了"这一个事件。

为什么不用 hexo / Hugo / 11ty / Astro?

我承认它们都很好。但是:

  • gobog 是我自己写的——出问题我心里有数,要加什么改什么手起刀落。十年之内,"用 hexo 的某个特定版本"未必比"用我自己 200 行的 Go"更可持续;
  • gobog 的 wikilink / Obsidian 嵌图解析跟我的写作习惯完全对齐。换主流引擎要么写一堆 plugin,要么放弃 Obsidian 风格;
  • "自己造一个轮子"——那个老 CSDN 时代起就刻在骨子里的小执拗,改不掉了。

主题为什么要做三个?

实话实说,两个就够了——浅色(minimal)+ 暖色(sepia)。但做到第二个时发现,因为 HTML 模板和 JS 都是抽出来共享的,多加一个 ocean 只是再写 80 行 CSS 变量的事。代价小,可玩性大,就放进来了。

Obsidian 插件为什么不直接用 git CLI 包一层?

社区里已经有 obsidian-git 这种通用插件。但那个插件的工作粒度是整个 vault,对我来说太粗——我的 vault 里除了博客还有 OKR、读书笔记、随手的 scratch,这些东西不该被推到公开仓库。把同步范围限定到"vault 里的某一个子目录"是这次插件存在的核心理由,所以索性自己写。

接下来想做的

  • 评论:现在还没接,可能找个轻量方案,比如 utterances(基于 GitHub Issues);
  • 站内搜索:目前是 server 模式才能用,静态站点上还没接客户端搜索(比如 lunr.js),下一版加;
  • RSS:原本就有 atom.xml,但还没好好测过订阅器兼容性;
  • 移动端:themes 的响应式只到能用的程度,还有调优空间。

折腾博客这件事,从大学第一次开 CSDN 到现在快十年了。每次写"我的博客演进历程"的时候,写作引擎换了,发布方式换了,连"博客"在我心里的定义都换了——从"知识的搬运"到"思考的存档"。

但写下去这件事没变,挺好的。