这篇文章最初写于 2026-06-04,当时目标很直接:域名已经托管在 Cloudflare 上,但打开还是空的;本地有一套 Obsidian 笔记,希望以后能挑选其中一部分文章发布到公开博客;写作设备不只一台,iPhone、iPad 和 MacBook 都应该能参与写作和发布。
最初落地的主干架构没有变:
Obsidian / 本地编辑器 / Sveltia CMS ↓GitHub 私有仓库保存源码、Markdown 和上传资源 ↓Cloudflare Pages 拉取 main 分支并构建 Astro ↓Cloudflare 自定义域名访问但后面真实运行了一段时间后,博客已经不只是“能打开、能写文章”。现在它还补上了几个更接近生产站点的环节:
- Sveltia CMS 恢复成 GitHub OAuth 登录,token 只作为应急 fallback。
- 文章 frontmatter 有稳定 schema,支持分类、系列、评论、封面图和侧栏。
draft: true不只从 Astro 列表过滤,还会生成 Cloudflare Pages_redirects,避免历史 HTML 继续被访问。npm test和npm run build变成发布前守门。- Cloudflare cache purge 用 GitHub Actions 自动做。
- 评论系统用 Giscus,评论数据放在一个公开 Discussions 仓库,主内容仓库仍保持 private。
- 站点还支持 Gallery,但照片原图不进仓库,只保存网页展示图和 Markdown 元数据。
所以这篇文章也重新整理成一篇完整部署手册:如果你想搭一个类似的个人博客,可以按这里的顺序复刻;如果只是看架构取舍,也可以跳着读。
最终架构
当前站点可以拆成五层。
写作层 - Obsidian:原始笔记和草稿池 - 本地编辑器:批量整理、改代码、迁移文章 - Sveltia CMS:浏览器、iPad、临时设备上的 Git-based CMS
内容层 - src/content/blog:博客 Markdown / MDX - src/content/gallery:照片条目 Markdown - public/uploads:公开图片和附件 - src/config/site.toml:站点配置、导航、评论、搜索
构建层 - Astro content collections 校验 frontmatter - Astro 静态生成页面 - Pagefind 生成站内搜索索引 - scripts/generate-draft-redirects.mjs 生成 draft 隐藏规则 - scripts/validate-site.mjs 做发布前校验
部署层 - GitHub 私有仓库 lxy1992/inkstone - Cloudflare Pages 连接 main 分支 - Build command: npm run build - Output directory: dist
访问层 - lvxinyan.com 作为主域名 - www.lvxinyan.com 301 跳到主域名 - /admin/ 加载 Sveltia CMS - GitHub Actions 发布后清理 Cloudflare 缓存这里没有传统数据库。文章、图片、页面结构和发布历史都跟着 Git 走。对个人博客来说,这是一个很重要的取舍:可迁移性比后台功能更重要。
为什么不是 WordPress 或 Obsidian Publish
WordPress 的优势是完整的后台和成熟生态,但它也带来数据库、插件、安全更新、登录后台和服务端运维。对一个个人技术博客来说,运行面偏大。
Obsidian Publish 的优势是和 Obsidian 原生体验贴合,但它更像“公开笔记库”。这里的目标不是把整个 vault 变成数字花园,而是把挑选后的内容整理成独立博客文章。公开文章应该是整理过的结果,不是私人笔记库的镜像。
Quartz / Digital Garden 适合 Obsidian 场景,但这次还希望首页有个人主页属性,并且可以自由调整视觉、路由、RSS、评论、搜索、CMS 和图片展示,所以最终选了 Astro。
Astro + Cloudflare Pages + Sveltia CMS 的组合,本质是:
静态站点生成器 + Git 托管内容 + 免费静态部署 + 浏览器写作后台它不是功能最多的方案,但长期维护成本低。
仓库结构
当前仓库的关键结构是:
.├── astro.config.mjs├── package.json├── public/│ ├── _redirects│ ├── admin/│ │ └── config.yml│ ├── images/│ └── uploads/├── scripts/│ ├── generate-draft-redirects.mjs│ ├── import-gallery.mjs│ ├── new-content.ts│ └── validate-site.mjs├── src/│ ├── config/│ │ └── site.toml│ ├── content.config.ts│ ├── content/│ │ ├── blog/│ │ ├── gallery/│ │ └── projects/│ ├── pages/│ │ ├── admin/│ │ ├── blog/│ │ ├── gallery/│ │ └── rss.xml.js│ └── components/└── .github/ └── workflows/ └── purge-cloudflare-cache.yml如果你只是复刻一个最小博客,必须理解的目录只有四个:
src/content/blog/:博客文章。public/uploads/:上传图片。public/admin/config.yml:Sveltia CMS 配置。src/content.config.ts:Astro content collection schema。
后面补上的 scripts/ 和 .github/workflows/ 不是为了炫技,而是为了让发布、draft、缓存、图片引用这些问题不靠记忆维护。
初始化 Astro 项目
新项目可以从 Astro 官方模板开始:
npm create astro@latest这个站点现在要求 Node.js 不低于:
Node.js >= 22.12.0常用依赖大致分几类:
astro 静态站点生成@astrojs/rss RSS@astrojs/sitemap sitemap@astrojs/mdx MDX 支持astro-expressive-code 代码块增强remark-math / rehype-katex 数学公式pagefind 静态站内搜索tailwindcss 样式工具sharp Gallery 图片处理package.json 里最关键的是这些脚本:
{ "scripts": { "dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev", "redirects:generate": "node scripts/generate-draft-redirects.mjs", "test": "npm run redirects:generate && node scripts/validate-site.mjs", "build": "npm run redirects:generate && ASTRO_TELEMETRY_DISABLED=1 astro check && ASTRO_TELEMETRY_DISABLED=1 astro build && pagefind --site dist --output-subdir pagefind --root-selector main --exclude-selectors \"[data-pagefind-ignore]\"", "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview", "post:new": "node --experimental-strip-types scripts/new-content.ts blog" }}这里有两个设计点:
npm test不跑完整构建,但会生成_redirects并检查站点结构。npm run build一定先生成_redirects,再执行 Astro build 和 Pagefind。
这样 draft 隐藏规则不会变成一个“记得就跑、不记得就漏”的手工步骤。
Astro 配置
astro.config.mjs 的核心配置是站点地址、MDX、sitemap、代码块和 Markdown 插件:
import mdx from '@astrojs/mdx';import sitemap from '@astrojs/sitemap';import { defineConfig } from 'astro/config';import expressiveCode from 'astro-expressive-code';import remarkMath from 'remark-math';import rehypeKatex from 'rehype-katex';
export default defineConfig({ site: 'https://lvxinyan.com', integrations: [ expressiveCode({ frames: { showCopyToClipboardButton: true, }, }), mdx(), sitemap(), ], markdown: { processor: unified({ remarkPlugins: [remarkMath], rehypePlugins: [rehypeKatex], }), },});真实仓库里还处理了 GitHub Actions / GitHub Pages 的 SITE_URL、SITE_BASE 兼容,但如果你只部署到 Cloudflare Pages 自定义域名,最小配置可以先固定 site。
内容模型
Astro content collections 是这个博客的边界层。所有 Markdown 不是随便写 frontmatter,而是先经过 schema 校验。
博客文章支持这些字段:
{ title: string; description?: string; summary?: string; date: Date; updated?: Date; draft?: boolean; heroImage?: string; showHeroImage?: boolean; tags?: string[]; categories?: string[]; series?: string[]; comments?: boolean; sidebar?: { enable?: boolean; toc?: boolean; relatedPosts?: boolean; };}当前约定是:文章必须有 description 或 summary,最后都会归一成页面 description。
一篇完整文章的 frontmatter 可以这样写:
---title: 'Example Post'description: 'A short page description for SEO and article cards.'date: '2026-06-04T12:00:00+08:00'updated: '2026-06-12T12:20:00+08:00'draft: falseheroImage: '/uploads/example-post/cover.png'showHeroImage: truetags: - 'Astro' - 'Cloudflare'categories: - 'Cloudflare / 建站'series: []comments: truesidebar: enable: true toc: true relatedPosts: true---几个字段的语义:
draft: true表示不公开。heroImage只允许/uploads/...、/images/...或远程 HTTP 图片。showHeroImage: false表示有封面图元数据,但文章页不展示顶部大图。categories适合少量归档分组。tags适合细粒度关键词。series用来串连续文章。comments: false可以关闭单篇文章评论。
这里的一个小坑是日期。静态构建、RSS、页面渲染可能会涉及时区转换。如果你只写 2026-06-04,在一些 UTC 语境里可能会出现前一天/后一天的展示差异。我的经验是给文章时间写明确的 +08:00,必要时用中午时间,避免跨日边界。
页面路由和 draft 过滤
博客详情页、列表页、分类页、系列页和 RSS 都只读取非 draft 内容。核心写法是:
const posts = await getCollection('blog', ({ data }) => !data.draft);这一步负责“最新构建不生成 draft 页面”。但它不能解决所有问题。
Cloudflare Pages 作为静态托管平台,曾经出现过一个现象:一篇文章已经变成 draft: true,最新构建也不再生成它,但 custom domain 上的历史尾斜杠 URL 仍然能看到旧 HTML。普通的 Cloudflare Purge Everything 和 Cache Purge API 也没有立刻清掉。
这件事最后推动了一个更稳的设计:draft 隐藏不能只靠“最新构建不生成页面”,还要让部署产物显式告诉 Pages 这些 URL 不应该展示旧内容。
构建时生成 _redirects
Cloudflare Pages 支持部署产物里的 _redirects 文件。这个博客在构建前扫描 src/content/blog/,为每篇 draft 文章生成三条规则:
/blog/<slug> /.draft-hidden/blog/<slug> 302/blog/<slug>/ /.draft-hidden/blog/<slug> 302/blog/<slug>/index.html /.draft-hidden/blog/<slug> 302目标路径 /.draft-hidden/... 在仓库里不存在,所以最终返回 404。用 302 是因为 draft 是可逆状态,文章重新公开后下一次构建会自动移除这些规则。
生成脚本的核心逻辑是:
const draftSlugs = walkFiles(blogDir) .filter((filePath) => /\.(md|mdx)$/i.test(filePath)) .filter((filePath) => isDraft(readFrontmatter(readFileSync(filePath, 'utf8')))) .map(toBlogSlug) .sort();
const redirectLines = draftSlugs.flatMap((slug) => { const target = `${draftTargetPrefix}/${slug}`; return [ `/blog/${slug} ${target} 302`, `/blog/${slug}/ ${target} 302`, `/blog/${slug}/index.html ${target} 302`, ];});这不是为了代替 Astro 的 draft 过滤,而是补上 Cloudflare Pages 历史资源保留可能带来的边界问题。
Sveltia CMS 配置
/admin/ 页面本身非常薄,只加载 Sveltia CMS:
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="robots" content="noindex" /> <title>Inkstone CMS</title> <script src="https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js" defer></script> </head> <body> <noscript>Inkstone CMS requires JavaScript.</noscript> </body></html>CMS 配置在 public/admin/config.yml。当前使用 Sveltia CMS Authenticator + GitHub OAuth 登录:
backend: name: github repo: lxy1992/inkstone branch: main base_url: https://sveltia-cms-auth.20210503blog.workers.dev
media_folder: public/uploadspublic_folder: /uploads最初我为了快速稳定发布,曾经把 CMS 切成 token 登录。那条路部署最简单,但手机、iPad 或临时设备上每次都要找一串很长的 token,长期体验不合适。当前站点恢复为 OAuth 登录,原因很直接:
- 手机和临时设备上可以直接点 GitHub 授权。
- token 不再是日常登录入口,只作为 OAuth Worker 出问题时的应急 fallback。
- Sveltia CMS 保存文章的本质仍然是往 GitHub 提交 Markdown。
- 代价是需要维护一个轻量的 Cloudflare Worker 和 GitHub OAuth App。
如果你也使用 private GitHub repo,又只有自己一个技术用户,fine-grained token 仍然是最省事的方案。但如果你需要频繁换设备登录,OAuth Worker 更符合 CMS 的使用体验。
Repository access: only selected repositoriesSelected repository: <owner>/<blog-repo>Permissions: Contents: Read and write不要把 token 写进仓库、文档、issue 或聊天记录。它只应该存在于你登录 CMS 的浏览器里,或者由密码管理器保存。
Blog collection
CMS 里的 Blog collection 对应 src/content/blog:
collections: - name: blog label: Blog folder: src/content/blog create: true slug: '{{year}}-{{month}}-{{day}}-{{slug}}' fields: - label: Title name: title widget: string - label: Summary name: summary widget: text - label: Date name: date widget: datetime - label: Updated name: updated widget: datetime required: false - label: Hero Image name: heroImage widget: image required: false - label: Show Hero Image name: showHeroImage widget: boolean default: true required: false - label: Tags name: tags widget: list required: false - label: Categories name: categories widget: list required: false - label: Series name: series widget: list required: false - label: Draft name: draft widget: boolean default: false required: false - label: Comments name: comments widget: boolean default: true required: false - label: Body name: body widget: markdownCMS 保存后会产生一次 GitHub commit。Cloudflare Pages 看到 main 分支变化后自动构建。也就是说,CMS 并不是一个独立数据库后台,它只是一个更友好的 Git 写作界面。
Gallery 是可选模块
当前站点还支持 Gallery:
src/content/gallery/public/uploads/gallery/Gallery 的设计原则是:仓库只保存网页展示图和 Markdown 元数据,不保存 NAS 原图、RAW 或超大 JPG。
CMS 里可以维护:
- 标题
- 描述
- 日期
- 展示图
- 相册照片
- 地点
- 分类
- 相机
- 是否 featured
- 是否 draft
如果要批量从 NAS 导入照片,可以用脚本生成多尺寸 WebP/JPG:
npm run gallery:import -- /Volumes/photos/export.jpg \ --title "Late light by the river" \ --date 2026-05-12 \ --location "Shanghai" \ --category City \ --camera "Fujifilm X100VI" \ --featured如果你只想搭博客,可以先跳过 Gallery。它不是主架构必需品。
Cloudflare Pages 部署
Cloudflare Pages 连接 GitHub 仓库部署。当前站点的关键配置是:
Project name: inkstoneProduction branch: mainBuild command: npm run buildBuild output directory: distRoot directory: repository rootProduction domain: https://lvxinyan.comPages hostname: https://inkstone-9ev.pages.dev复刻时按这个顺序做:
- 把 Astro 项目推到 GitHub 仓库。
- 在 Cloudflare Pages 里选择 Connect to Git。
- 授权 Cloudflare 访问这个仓库。
- Production branch 选
main。 - Build command 填
npm run build。 - Output directory 填
dist。 - 如果 Cloudflare 默认 Node 版本过低,在 Pages 环境变量里设置
NODE_VERSION为22.12.0或更高。 - 首次部署成功后再绑定自定义域名。
Cloudflare Pages 可以连接 private GitHub repo。真正需要注意的是 GitHub 授权范围:Cloudflare 必须能读取这个仓库,否则构建时拉不到源码。
自定义域名和 www 跳转
这个站点使用主域名:
https://lvxinyan.comwww.lvxinyan.com 只做 301 跳转。
这里有两个容易漏的点:
第一,DNS 记录必须让 www 请求进入 Cloudflare。否则 Redirect Rules 根本没有执行机会。
第二,如果 www 要支持 HTTPS,证书也必须覆盖 www.lvxinyan.com。最稳妥的做法是把 www.lvxinyan.com 也加到 Pages 项目的 Custom domains,等 Cloudflare 处理好证书,再用 Redirect Rules 统一跳到 apex。
规则形态是:
https://www.example.com/* -> https://example.com/${1}http://www.example.com/* -> https://example.com/${1}并保留 query string。
验证:
curl -I https://example.comcurl -I https://www.example.comcurl -I "https://www.example.com/blog/?a=1"期望:
- 主域名返回
200。 www返回301。- 路径和 query string 不丢。
评论系统:Giscus
主博客仓库是 private,但 Giscus 评论需要访客能读取 GitHub Discussions。因此评论数据放在一个单独的公开仓库:
lxy1992/inkstone-comments站点配置在 src/config/site.toml:
[config.comments]enabled = trueprovider = "giscus"show_on_posts = true
[config.comments.giscus]repo = "lxy1992/inkstone-comments"repo_id = "R_kgDOS0-dgA"category = "General"category_id = "DIC_kwDOS0-dgM4C-y5q"mapping = "pathname"strict = "0"reactions_enabled = "1"emit_metadata = "0"input_position = "top"light_theme = "light"dark_theme = "dark"lang = "zh-CN"loading = "lazy"复刻时要做三件事:
- 新建一个公开 comments 仓库。
- 在这个仓库启用 GitHub Discussions。
- 安装并配置 Giscus GitHub App,然后把生成的
repo_id、category_id写入站点配置。
用 pathname 做映射的好处是迁移成本低。只要文章 URL 不变,评论就能继续按路径关联。
发布流程一:本地 Git 发布
适合在 Mac 上整理文章、批量迁移 Obsidian 内容、改主题或改站点结构。
npm installnpm run dev写完后先本地验证:
npm testnpm run buildnpm run preview发布:
git statusgit add <files>git commit -m "publish blog update"git push origin main推送到 main 后,Cloudflare Pages 自动构建并发布。构建完成后,再验证线上:
curl -I https://lvxinyan.comcurl -I https://lvxinyan.com/blog/curl -I https://lvxinyan.com/rss.xmlcurl -I https://lvxinyan.com/admin/如果是新文章,再打开文章 URL 验证页面内容和图片。
发布流程二:Sveltia CMS 发布
适合在浏览器、iPad 或临时设备上写文章。
入口:
https://lvxinyan.com/admin/流程:
- 打开
/admin/。 - 选择
Sign in with Token。 - 粘贴只授权博客仓库的 GitHub fine-grained token。
- 进入 Blog collection。
- 新建或编辑文章。
- 上传图片到
public/uploads。 - 保存。
- 到 GitHub 仓库确认 CMS commit。
- 等 Cloudflare Pages 自动构建。
- 验证线上 URL。
CMS 发布和本地 Git 发布最终走的是同一条链路:
写入 GitHub main -> Cloudflare Pages build -> dist -> lvxinyan.com区别只是写 Git 的界面不同。
发布后自动清理 Cloudflare 缓存
Cloudflare Pages 部署完成后,浏览器或边缘节点有时会继续看到旧内容。这个站点用 GitHub Actions 做发布后缓存清理。
workflow 触发方式:
on: push: branches: - main deployment_status: workflow_dispatch:实际行为:
push到main后等待 180 秒,给 Cloudflare Pages 留出构建时间。- 调用 Cloudflare Cache Purge API。
- 先执行一次
purge_everything。 - 再按本次提交中变化的 blog / gallery Markdown 生成精确 URL 清理。
- 手动触发时可以额外输入一个
purge_url。
需要在 GitHub repository secrets 里配置:
CF_ZONE_IDCF_API_TOKENCF_API_TOKEN 不要用 Global API Key。给它最小权限即可:
Zone: lvxinyan.comPermission: Cache Purge注意:这个 workflow 是发布后的刷新辅助,不应该承担“文章是否公开”的最终语义。draft 是否可见,仍然由 Markdown frontmatter、Astro 构建和 _redirects 共同决定。
发布前校验
这个项目把一些容易出错的事写进了 scripts/validate-site.mjs。它会检查:
- 必要文件是否存在。
- CMS 是否仍指向正确仓库。
- CMS 是否使用 OAuth Worker,而不是只剩 token 登录。
heroImage、showHeroImage等字段是否暴露给 CMS。- Giscus 配置是否存在。
- Gallery collection 是否完整。
- Cloudflare cache purge workflow 是否存在。
- draft 文章是否都有对应
_redirects。 - Markdown 里的本地图片路径是否真实存在。
- 文章有正文图片时是否有
heroImage或显式关闭 hero 展示。
这类脚本看起来琐碎,但对个人博客很有价值。因为个人项目最容易坏在“我以为我记得”的地方:图片路径、draft 状态、缓存、CMS 字段、评论仓库权限。
常见故障
/admin/ 能打开,但看不到文章或保存失败
先看线上 CMS 配置:
curl https://lvxinyan.com/admin/config.yml确认它指向正确仓库,并且有:
auth_methods: [token]如果配置正确,再检查 GitHub token:
- 是否是 fine-grained token。
- 是否授权了正确仓库。
- 是否有
Contents: Read and write。 - token 是否过期。
Cloudflare Pages 构建失败
先在本地跑:
npm testnpm run build如果本地也失败,优先修本地错误。如果只有 Cloudflare 失败,重点查:
- Node.js 版本是否满足
>=22.12.0。 - Build command 是否仍是
npm run build。 - Output directory 是否仍是
dist。 - 是否提交了
package-lock.json。 - 是否有图片路径或 frontmatter schema 错误。
文章保存了,但首页或列表看不到
检查 frontmatter:
draft: falsedate: '2026-06-04T12:00:00+08:00'summary: ...常见原因:
draft: true。- 缺少
summary或description。 - 日期格式无法被 Astro 解析。
- 图片路径不是
/uploads/...或/images/...。 - Cloudflare Pages 最新部署还没完成。
文章改成 draft 后旧 URL 还能访问
不要先猜 CMS 没保存。按顺序查:
curl -I https://lvxinyan.com/blog/<slug>/curl -I https://lvxinyan.com/blog/<slug>curl -I https://lvxinyan.com/blog/<slug>/index.htmlcurl -I https://<project>.pages.dev/blog/<slug>/如果 Pages 默认域名已经 404,但 custom domain 仍能看到旧 HTML,多半是 Cloudflare Pages 历史资源或边缘缓存问题。此时应该确认:
- 最新构建里的
public/_redirects是否包含这个 slug。 npm run redirects:generate是否已执行。- GitHub Actions cache purge 是否成功。
- 是否存在旧的 Cloudflare Dashboard 单篇 Redirect Rule 还没清理。
不要把单篇文章的发布状态长期维护在 Cloudflare Dashboard 里。状态源应该回到 GitHub 仓库。
最小复刻清单
如果你只想搭一个类似博客,可以按这个清单走:
- 准备一个 GitHub 仓库,可以是 private。
- 创建 Astro 项目。
- 建立
src/content/blog和 content collection schema。 - 写 blog 列表、详情、RSS。
- 添加
/admin/页面加载 Sveltia CMS。 - 配置
public/admin/config.yml,使用 GitHub backend 和 OAuth Worker。 - 在 Cloudflare Pages 连接 GitHub 仓库。
- 设置
Build command: npm run build、Output directory: dist。 - 绑定 apex 域名。
- 把
www也加到 Pages custom domain,再做 301 跳转。 - 加
draft字段,并在所有公开路由过滤。 - 生成
_redirects,防止历史 draft URL 继续展示。 - 添加
npm test/npm run build作为发布前检查。 - 可选:接入 Giscus、Pagefind、Gallery、Cloudflare cache purge workflow。
做到第 10 步,你已经有一个可访问的静态博客。做到第 14 步,它才更像一个长期可维护的个人发布系统。
这套方案的边界
这不是一个完整内容平台。它没有复杂的多用户权限,没有数据库后台,没有审核流,也不适合团队级内容运营。
它适合的是个人博客:
- 文章是 Markdown。
- Git 是备份和版本历史。
- Cloudflare Pages 是静态托管。
- Sveltia CMS 是浏览器里的 Git 写作界面。
- Giscus 把评论放到公开 Discussions 仓库。
- 所有关键发布语义尽量回到仓库,而不是散落在 Dashboard 手工配置里。
这个边界反而是优点。只要仓库还在,文章就还在;只要 Markdown 还在,以后从 Astro 换到 Hugo、Quartz 或其他系统都不难。
Comments
Quiet notes for this article.