上一篇讲了怎么把博客部署上去。部署完有个很朴素的困惑:
站点已经是 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/:
<!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/ → CMSmeta 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/imagespublic_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 在中间:
(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 URL2. 完成 code → token 的交换(这一步必须在服务端做, 因为要把 client_secret 藏起来)3. 把 token 安全地交回浏览器WARNINGWorker 必须持有
client_secret并在服务端完成 code 交换。 如果把 secret 放进config.yml,那等于公开了这个 OAuth App —— 任何人拿到 secret 就能冒充你的应用换 token。
这个 Worker 跑在 Cloudflare Workers 免费额度里(10 万请求/天),完全够用。命名上我用的是 sveltia-cms-auth-<Pages 项目名>,和站点同名,纯粹为了好记 —— 你随便起,只要和 GitHub OAuth App 里登记的 callback URL 一致就行。
五、保存一篇文章的完整时序
现在把 OAuth 之后的部分补完 —— 这是”反向同步”的核心:
(正文字段 → 文件正文) 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 主题里是这样解析的:
<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.jpgmedia_folder: src/content/posts/imagespublic_folder: /src/content/posts/images
# ✅ 集合级用相对路径 → frontmatter 被写成 images/xxx.jpgcollections: - name: posts media_folder: images public_folder: imagesWARNING顺便提醒一个 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。