2327 字
12 分钟
在已经部署好的博客上写博客,然后自动流回 GitHub

上一篇讲了怎么把博客部署上去。部署完有个很朴素的困惑:

站点已经是 Cloudflare 上的静态 HTML 了,我怎么在这个网页上直接写新文章?

总不能每次都 vim 一遍、git add、git commit、git push 吧 —— 虽然我实际上就是这么干的(因为要给 AI 写 AGENTS.md 协作)。

答案是一个很漂亮的闭环设计:

┌──────────────────────────────────────────────┐
│ │
▼ │
Cloudflare Pages(静态站点) │
│ │
│ /admin/ 下跟着站点一起部署了 CMS 的 index.html │
│ │
└────────────┬─────────────────────┘ │
│ │
│ 保存文章 │
▼ │
GitHub 仓库(唯一真源) │
│ │
│ git push │
└────────────────────────────────────┘

CMS 跟着站点走,但写入目标是仓库。 站点既是发布产物,也是编辑器。这条回路走通之后,写文章就真的变成”打开网址 → 写 → 点保存”了。


一、为什么选 Sveltia 而不是自己写后台#

先说清楚我不想干什么:

方案问题
自己写后台 + 数据库那就不是”静态博客”了,要维护服务、要备份数据库
Notion / 飞书文档直接同步内容不在 Git 里,仓库不再是唯一真源
直接在服务器上编辑文件Pages 没有文件系统给你编辑

我要的是:一个纯前端、零后端、直接调用 GitHub API 的 CMS。GitHub 就是数据库,commit 就是历史,PR 就是预览。

Sveltia CMS(Decap CMS 的活跃 fork)正好干这个。它是一个单文件 JS,加载就跑,所有读写都走 GitHub API。


二、挂进站点:它只是一个静态文件#

CMS 不需要单独部署,把它当普通文件丢进 public/admin/:

public/admin/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<title>Blog Admin</title>
</head>
<body>
<script src="https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js"></script>
</body>
</html>

就这些。Cloudflare 构建时把它原样拷到 dist/admin/index.html,于是:

https://ycxdev.com/admin/ → CMS

meta robots: noindex 别忘了 —— 否则搜索引擎会收录你的后台。


三、配置:告诉它写到哪去#

public/admin/config.yml(这是我博客的真实配置):

backend:
name: github
repo: devycx/MyBlog
branch: main
base_url: https://sveltia-cms-auth.<你的-workers-子域>.workers.dev
site_url: https://ycxdev.com
# 顶层媒体目录(供资源库 / Asset Library 使用)
media_folder: src/content/posts/images
public_folder: /src/content/posts/images
collections:
- name: posts
label: Posts
folder: src/content/posts
media_folder: images # ← 集合级,相对路径
public_folder: images # ← 集合级,相对路径
create: true
slug: "{{slug}}"
extension: md
summary: "{{title}} - {{published}}"
fields:
- { name: title, label: Title, widget: string }
- name: published
label: Published
widget: datetime
date_format: "YYYY-MM-DD"
time_format: false
format: "YYYY-MM-DD"
- { name: description, label: Description, widget: text, required: false }
- { name: image, label: Cover Image, widget: image, required: false }
- { name: tags, label: Tags, widget: list, required: false, default: [] }
- { name: category, label: Category, widget: string, required: false }
- { name: draft, label: Draft, widget: boolean, default: false }
- { name: body, label: Body, widget: markdown }

folder: src/content/posts 正好是 Astro content collection 的目录。所以:

CMS 保存 → GitHub 上多一个 .md 文件
Astro 构建 → 读同一个目录 → 生成一个页面

中间没有任何转换层。 这是整套设计最舒服的地方。

NOTE

那个 draft 开关是真的有用的。站点里过滤草稿的逻辑就一行:

src/utils/content-utils.ts
getCollection("posts", ({ data }) =>
import.meta.env.PROD ? data.draft !== true : true
);

PROD 下过滤草稿,本地 dev 时不过滤。所以草稿能在本地预览、发出去也不会上线 —— 我现在手上这批文章就全是 draft: false 状态。


四、OAuth 中转 Worker:为什么必须搞一个#

GitHub backend 需要 OAuth 登录。麻烦在于 GitHub OAuth App 的 callback URL 是注册时写死的绝对地址,而你现在是通过 https://ycxdev.com/admin/ 访问的。

回调被 GitHub 打到一个不是 CMS 的 URL,Sveltia 拿不到 code,整个流程断掉。

解决办法是插一个 Worker 在中间:

sequenceDiagram autonumber participant B as 浏览器(CMS) participant W as OAuth 中转 Worker participant GH as GitHub participant U as 我(浏览器) B->>B: 点 "Login with GitHub" B->>W: GET /auth (带 redirect_uri 与 state) Note over W: state 存 KV / 签名 Cookie,防 CSRF W->>GH: 302 → github.com/login/oauth/authorize
(client_id + redirect_uri = Worker) GH-->>U: 用户授权 GH->>W: 302 回调 ?code=... W->>GH: POST /login/oauth/access_token(code 换 token) GH-->>W: access_token W->>B: 302 回 /admin/,把 token 交给 CMS Note over B: token 存 localStorage

Worker 的职责就三件事:

1. 提供一个固定的、已注册的 callback URL
2. 完成 code → token 的交换(这一步必须在服务端做,
因为要把 client_secret 藏起来)
3. 把 token 安全地交回浏览器
WARNING

Worker 必须持有 client_secret 并在服务端完成 code 交换。 如果把 secret 放进 config.yml,那等于公开了这个 OAuth App —— 任何人拿到 secret 就能冒充你的应用换 token。

这个 Worker 跑在 Cloudflare Workers 免费额度里(10 万请求/天),完全够用。命名上我用的是 sveltia-cms-auth-<Pages 项目名>,和站点同名,纯粹为了好记 —— 你随便起,只要和 GitHub OAuth App 里登记的 callback URL 一致就行。


五、保存一篇文章的完整时序#

现在把 OAuth 之后的部分补完 —— 这是”反向同步”的核心:

sequenceDiagram autonumber participant B as 浏览器 CMS participant API as GitHub API participant R as GitHub 仓库 participant P as Cloudflare Pages B->>API: GET /repos/devycx/MyBlog/contents/src/content/posts API-->>B: 文件列表 + 每个文件的 sha Note over B: sha 是乐观锁,用来防覆盖 B->>B: 我在编辑器里写 Markdown
(正文字段 → 文件正文) B->>API: POST /media(上传图片) API-->>B: 图片入库,写回 frontmatter Note over API: 图片直接进仓库 src/content/posts/images/ B->>API: PUT .../contents/src/content/posts/xxx.md
{ message, content(base64), sha } API->>R: 创建 commit(branch: main) API-->>B: 201 Created,新 sha R-->>P: Webhook(push 事件) P->>R: 拉取该 commit P->>P: pnpm install && pnpm build P-->>U: 原子切换到新版本 Note over B,U: 整个过程无感,URL 没变

几个关键理解:

1. 乐观锁靠 sha。 GitHub 的 contents API 要求更新文件时带上当前的 blob sha。不带就报 422。这样两台设备同时编辑同一篇时会明确冲突,而不是静默覆盖。

2. 图片进仓库,不是图床。 CMS 上传图片走的是 POST /repos/.../contents/<path>,落到 src/content/posts/images/。这正好和我的约定一致 —— 内容以仓库为唯一来源,构建产物可随时重建。

CAUTION

图片进仓库的代价是仓库体积不可逆地增长。GitHub 单文件 100MB、仓库建议 <1GB。放原图的话几十篇文章就能顶满。正确做法是上传前压缩(WebP / AVIF,或长边限制在 1600px)。

3. public_folder 的相对/绝对是个真坑。 见下节。


六、卡了我两天的那个坑#

当时上传的封面图全都 404。文章在 src/content/posts/,frontmatter 里写的是 images/xxx.jpg,Fuwari 主题里是这样解析的:

src/pages/posts/[...slug].astro
<ImageWrapper src={entry.data.image}
basePath={path.join("content/posts/", getDir(entry.id))} />

也就是说它把 images/xxx.jpg 拼成 content/posts/<目录>/images/xxx.jpg,然后通过 Astro 的图片管线去引用。

但问题是:如果 frontmatter 里写的是 /images/xxx.jpg(带前导斜杠),Astro 会当成 public 目录下的资源去找,当然找不到。

所以关键是 config.yml 里 集合级的 media_folder / public_folder 要用相对路径:

# ❌ 顶层用绝对路径 → frontmatter 被写成 /src/content/posts/images/xxx.jpg
media_folder: src/content/posts/images
public_folder: /src/content/posts/images
# ✅ 集合级用相对路径 → frontmatter 被写成 images/xxx.jpg
collections:
- name: posts
media_folder: images
public_folder: images
WARNING

顺便提醒一个 API 名字的坑:Sveltia / Decap CMS 里这个选项叫 public_folder,不是 public_path。写错了不报错,只是 frontmatter 里路径不对,然后你在主题里死活找不到文件 —— 我就是在这上面浪费了一个下午。

我在 config.yml 里直接把原因写成注释了,因为半年后我一定会忘:

# 集合级【相对路径】是关键:
# media_folder: images -> 图片实际存到 src/content/posts/images/
# public_folder: images -> frontmatter 写成相对路径 images/xxx.jpg

七、这套方案的边界#

说优点也要说缺点,不然是软文。

优点

  • 零后端成本,CMS 就是一个静态文件
  • 内容、代码、历史全在一个仓库里,git log 就是写作历史
  • CMS 挂了不影响发布(git push 照样能上线)
  • 换静态托管只需要换部署配置,内容文件一个字不用改

局限

  • 写长文体验一般。 Markdown 编辑器就是个文本框,没有大纲导航、没有实时预览、没有类似 Typora 的所见即所得。写代码文章够用,写散文就一般。
  • 依赖第三方 CDN。 unpkg.com 挂了后台就白屏。可以自己 host 一份 sveltia-cms.js 到 public/admin/,我暂时没这么做。
  • 鉴权粒度粗。 一个 GitHub PAT/OAuth 就是全部权限。多人协作得靠 GitHub 仓库权限管。
  • 不是移动端优先。 手机上写长文体验很差。
TIP

我的实际用法是混合的:长文用编辑器/AI 写好 Markdown,push 上去;短文、需要图和快速发布的用 CMS。 CMS 的价值不是”取代编辑器”,是”把发布这一步的摩擦降到零”。


小结#

  • CMS 跟着静态站点一起部署,一个 index.html + 一个 config.yml 就完事
  • 写入目标是 GitHub 仓库,靠 folder 直接对上 Astro 的 content collection,中间零转换
  • OAuth 中转 Worker 解决 callback URL 固定的问题,并且必须把 client_secret 留在服务端
  • 每次保存 = 一个 commit = 一次 Pages 重新构建,这就是”反向同步”的全部含义
  • public_folder 必须是集合级相对路径,顶层绝对路径会让图片 404
  • 图片进仓库,记得压缩,仓库体积不可逆

整个系统的本质就一句话:GitHub 是数据库,commit 是历史,CMS 是个前端,Pages 是个编译器。 四个组件各司其职,谁都不是数据库,但组合起来就是一个完整的 CMS。

在已经部署好的博客上写博客,然后自动流回 GitHub
https://ycxdev.com/posts/sveltia-cms-reverse-sync-github/
作者
YCX
发布于
2026-10-13
许可协议
CC BY-NC-SA 4.0