我的博客演进历程(二):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",把渲染和同步揉在了一起。新插件只做两件事:
-
push/pull markdown:本地 vault 里有一个
Blog/文件夹,跟blog仓库做双向同步。我可以在任何一台机器上git pull拿到最新内容继续写;写完不用打开终端,命令面板里 "Push blog repo" 一下,commit + push 一气呵成。可选自动 push(debounced),保存后 30 秒自动提交。 -
新文件自动填 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。下一次部署生效。
一篇文章从写到上线
把上面这些拼起来,一篇文章的生命周期是:
- Obsidian 里
Cmd+N新建Blog/post/xxx.md; - 插件监听到 create 事件,0 延迟把 front-matter 填好;
- 我在编辑区写正文,wikilink、
image.png嵌图随便用; Cmd+S。如果开了 auto-push,30 秒内静默 commit + push 到blog仓库;否则我手动喊一下 "Push blog repo";blog仓库收到 push,notify workflow 触发,对着sbraveyoung.github.io发一个repository_dispatch;- github.io 的 deploy workflow 跑起来,checkout 三仓库、编 gobog、渲染、commit;
- GitHub Pages 检测到主分支更新,CDN 几秒内刷新;
- 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 到现在快十年了。每次写"我的博客演进历程"的时候,写作引擎换了,发布方式换了,连"博客"在我心里的定义都换了——从"知识的搬运"到"思考的存档"。
但写下去这件事没变,挺好的。