为什么选这套方案

我想要的很简单:在 Obsidian 里写笔记,标记 publish: true,push 后自动变成博客

调研了一圈,最终选择了:

  • Obsidian — 本地 Markdown 编辑,双链、标签、模板一应俱全
  • Quartz V4 — 专为 Obsidian 设计的静态站点生成器,原生支持 wikilinks、callouts
  • Cloudflare Pages — 免费全球 CDN,私有仓库也能用
  • GitHub Actions — 自动化构建部署

架构

两个 Private 仓库完全隔离,私有内容永远不会泄露:

[Private] my-vault (Obsidian vault)
    ↓  push 触发 GitHub Actions
[Private] my-blog (Quartz V4)
    ↓  自动构建部署
[Live]    my-blog.pages.dev
仓库角色存什么
vault内容仓库Markdown 笔记、图片、附件
blog引擎仓库Quartz 配置、构建流水线、主题样式

内容的 Single Source of Truth 是 vault,不是 blog。blog 仓库不应该对内容有任何”记忆”。content/ 是两者的交汇点——它只在 CI 的临时环境中被填充,用完即销毁。

核心机制:ExplicitPublish

Quartz 的 ExplicitPublish 过滤器是这套方案的关键。只有 frontmatter 包含 publish: true 的 Markdown 文件才会被构建,其余全部忽略。

// quartz.config.ts
filters: [Plugin.ExplicitPublish()]

注意:这个过滤器只作用于 .md 文件。图片、PDF 等附件需要用 ignorePatterns 额外排除。

发布流程

整个流程由两个 GitHub Actions workflow 串联:

Vault 仓库trigger-blog.yml:push 到 main 时,向 blog 仓库发送 repository_dispatch 事件。

Blog 仓库deploy.yml:收到事件后,克隆 vault、复制内容到 Quartz 的 content/ 目录、构建、部署到 Cloudflare Pages。

关键是只复制需要发布的目录,而非整个 vault:

- name: Copy vault content to Quartz
  run: |
    mkdir -p ./content
    rm -rf ./content/*
    cp -r ./vault/index.md ./content/
    for dir in Blog Knowledge Attachments; do
      [ -d "./vault/$dir" ] && cp -r "./vault/$dir" ./content/
    done

从写完文章到上线,大约 1-2 分钟。

踩过的坑

1. Cloudflare Pages 项目要先创建

wrangler pages deploy 不会自动创建项目,需要提前通过 Cloudflare Dashboard 或 API 建好,否则报 Project not found

2. Wrangler 的 --commit-dirty--branch

CI 环境中 wrangler 检测到 uncommitted changes 会把部署当成 preview 而不是 production。需要加两个参数:

command: pages deploy public --project-name=my-blog --commit-dirty=true --branch=main

3. index.md 必须在 vault 根目录

Quartz 的首页是 content/index.md。如果你把它放在子目录(比如 Blog/index.md),复制到 content/Blog/index.md 后首页会 404。解决方法是把 index.md 放在 vault 根目录,单独复制。

4. 不要把整个 vault 复制到 Quartz

早期用 cp -r ./vault/* ./content/ 会把 CLAUDE.md、README、docs/ 等私有文件都复制过去。虽然 ExplicitPublish 会过滤掉没有 publish: true 的 .md 文件,但非 .md 文件(如配置文件)不受过滤器保护。必须显式选择要复制的目录。

5. 不要在 .gitignore 中忽略 content/

这是最隐蔽的一个坑,直接导致了首页 404。

双仓库架构下,blog 仓库的 content/ 只在 CI 运行时被填充,本地始终是空的。很自然会想把它 gitignore 掉:

# 看起来很合理,实际会翻车
content/*
!content/.gitkeep

但这会导致 Quartz 构建出 0 个页面。 原因是 Quartz 内部使用 globby 扫描 content/ 目录,并且默认开启了 gitignore: true

// quartz/util/glob.ts
await globby(pattern, {
  cwd,
  ignore: ignorePatterns,
  gitignore: true,  // ← 这行是关键
})

gitignore: true 意味着 globby 会读取仓库根目录的 .gitignore,把匹配的文件排除。content/* 会让 globby 忽略 content 下的所有文件——即使这些文件是 CI 运行时动态复制进去的,根本不受 Git 追踪。

实际表现:CI 构建日志显示 Found 0 input files from content,Quartz 输出一个空壳网站(只有 CSS/JS/404 页面),首页 404。

解决方案:不要在 .gitignore 中提及 content/。CI 在 runner 的临时环境中操作 content/,这些文件从来不会被 git add,不需要 gitignore。本地开发时手动复制 vault 内容到 content/ 做预览,用完后清理即可。

延伸教训.gitignore 不只是 Git 在用。globby、ripgrep、fd、ESLint 等工具都会读取它。当你在 .gitignore 里写下一条规则,你可能同时在告诉构建工具”别处理这些文件”。经验法则:如果一个目录的内容需要被构建工具处理,就不要把它放进 .gitignore

iPhone 同步:iCloud 方案

搭好桌面端之后,自然想在手机上也能看笔记。Obsidian 在 iOS 上支持 iCloud 同步,配置很简单。

原理

把 vault 目录移到 Obsidian 的 iCloud 容器里,在原位置放一个软链接:

~/my-vault  →  symlink  →  ~/Library/Mobile Documents/iCloud~md~obsidian/Documents/my-vault

Mac 端通过软链接访问,路径不变,Git 照常工作。iPhone 上 Obsidian 通过 iCloud 自动同步。

操作步骤

# 1. 移到 iCloud Obsidian 目录
mv ~/my-vault ~/Library/Mobile\ Documents/iCloud~md~obsidian/Documents/my-vault
 
# 2. 在原位置创建软链接
ln -s ~/Library/Mobile\ Documents/iCloud~md~obsidian/Documents/my-vault ~/my-vault

然后在 iPhone 的 Obsidian 里创建 vault 时选择 Store in iCloud,就能看到 vault 了。

注意事项

  • iCloud 会同步 .git 目录,但只要只在 Mac 上执行 Git 操作,不会有问题
  • 大文件(图片、PDF)同步可能需要几秒到几分钟
  • 如果遇到冲突文件,以 Mac 端为准

最终效果

在 Obsidian 里写笔记,设 publish: true,push。等一两分钟,文章就在线了。手机上也能随时翻阅所有笔记。

整套方案的维护成本几乎为零——不需要管服务器,不需要手动构建,写完就发。