mobile wallpaper 1
mobile wallpaper 2
mobile wallpaper 3
mobile wallpaper 4
2092 words
5 minutes
为 Blog 做内容分离:给 Posts 和 Code 分家
2026-08-21

前言#

Fork 一个博客主题用了几年,文章越写越多,相册、番剧数据也越堆越厚。这时候再去合并上游更新,git merge upstream/master 一回车,满屏的 src/content/posts/、public/images/、src/data/ 都在打架——明明不是上游改的,只是因为我把这些东西也放进了代码仓库。

LyraVoid
/
Mizuki
Waiting for api.github.com...
00K
0K
0K
Waiting...

主题作者早就料到了这种场景,所以 Mizuki 自带一套「内容分离」机制:把文章、数据、图片、个人配置都搬进一个独立的内容仓库,代码仓库只保留主题本身。同步由 scripts/sync-content.js 完成,构建时通过 prebuild 钩子自动拉取。

这一篇就记录我自己的分离过程,从本地准备到推送远程、再到 Cloudflare Pages 部署,一气走通。

整体结构#

分离之后,两个仓库各管各的:

仓库负责什么怎么更新
代码仓库 Mizuki主题、组件、构建、部署推 master 触发 Cloudflare 构建
内容仓库 Mizuki-Content文章、关于/友链、数据、图片、overrides推 main,再回代码仓触发一次部署

代码仓库里的 src/content/posts、src/data、public/images 不再是真实文件,而是指向 content/ 目录的链接(Windows 上是 junction)。日常改文章,改的是 content/,提交也是 content/。

两个仓库和同步方向大致是这样:

graph LR subgraph 代码仓库 Mizuki A[src/config<br/>主题与配置] B[src/content/posts<br/>junction] C[src/data<br/>junction] D[public/images<br/>junction] end subgraph 内容仓库 Mizuki-Content E[posts/] F[spec/] G[data/] H[images/] I[overrides/] end E -.链接.-> B F -.链接.-> C G -.链接.-> C H -.链接.-> D I ==同步复制==> A J[(git push main)] --> E K[(git push master)] --> A K -.触发构建.-> L[(Cloudflare Pages)] L --> M[blog.aegisr.xyz]

准备 .env#

在代码仓库根目录新建一个 .env(已经在 .gitignore 里,不会提交),写三行:

ENABLE_CONTENT_SYNC=true
CONTENT_REPO_URL=git@github.com:your-username/Mizuki-Content.git
CONTENT_DIR=./content

ENABLE_CONTENT_SYNC 是总开关,true 才会从远程仓库同步;CONTENT_REPO_URL 是内容仓库的 Git 地址,支持 HTTPS 和 SSH,私有仓推荐 SSH;CONTENT_DIR 是同步到本地的目录,默认 ./content,一般不用动。

把内容搬进 content/#

主题自带的脚本只负责「同步」,不负责「搬家」。搬家要自己来,把 src/content/posts、src/content/spec、src/data、public/images 整个复制到 content/ 下:

mkdir content
xcopy /E /I src\content\posts content\posts
xcopy /E /I src\content\spec content\spec
xcopy /E /I src\data content\data
xcopy /E /I public\images content\images

复制完,content/ 里就是一份完整的内容镜像。这时候还不要急着删原目录——备份比后悔药便宜。

备份比后悔药便宜

在确认站点正常工作之前,不要删除代码仓库里的原始内容目录。Mizuki 的 sync-content 脚本会自动把它们备份为 *.backup,留着以防万一。

导出 overrides(可选但推荐)#

fork 主题的人大多会改 src/config/ 下的配置:站点名、导航、侧栏、樱花特效……这些个人配置也是和上游冲突的高发区。Mizuki 提供了一个实验性的 export-config 脚本,自动把你和上游不一样的字段抽出来,写成覆盖文件:

实验性功能

配置覆盖(overrides)目前仍处于早期阶段,测试覆盖的场景有限。迁移前请先提交或备份当前配置,迁移后务必完整校验一遍;重要站点建议先观察一段时间再决定是否长期使用。

pnpm export-config --out=content/overrides

跑之前先确认代码仓库里有一个指向上游的 remote,并把它 fetch 下来作为基准:

git remote -v # 确认有 upstream
git fetch upstream # 更新基准

脚本会逐字段比对,自检「覆盖合并回去是否等于你当前的配置」,无法还原时会报出具体原因,不会生成不一致的覆盖文件。生成的文件名和被覆盖的导出常量名一致,比如:

content/overrides/
├── siteConfig.ts
├── profileConfig.ts
├── navBarConfig.ts
└── ...

可覆盖的配置有 18 个,名单见官方文档。文件名不在名单内会在构建期报错并列出合法名单,不会静默失效,这点设计得很踏实。

初始化内容仓库#

content/ 准备好之后,把它当作一个独立仓库初始化:

cd content
git init
git add .
git commit -m "chore: initialize content repository"
git branch -M main
git remote add origin git@github.com:your-username/Mizuki-Content.git
git push -u origin main

推送之前,GitHub 上要先建好对应的空仓库。如果远程已经有自动生成的初始提交(比如 README),用 rebase 合一下就行:

git pull origin main --rebase
git push

私有仓库的话,本机的 SSH 公钥要先加到 GitHub 账号,这一步只能手动,没人能替你登录。

跑一次同步#

回到代码仓库根目录,运行:

pnpm run sync-content

脚本会做这几件事:

  1. git stash 保存 content/ 里的本地改动;
  2. git fetch --all 更新远程引用;
  3. git reset --hard origin/main 把内容仓拉到最新;
  4. 把 content/posts、content/spec、content/data、content/images 链接到代码仓里对应位置(原目录会被备份成 *.backup);
  5. 把 content/overrides 复制到 src/config/overrides/(不是链接,因为覆盖文件带相对 import,链接会让 Vite 解析到内容仓的真实路径)。

这五步的时序大致如下:

sequenceDiagram autonumber participant Dev as 开发者 participant SC as sync-content participant CR as 内容仓库 participant Code as 代码仓库 Dev->>SC: pnpm run sync-content SC->>Code: git stash(保存本地未提交改动) SC->>CR: git fetch --all CR-->>SC: 最新引用 SC->>CR: git reset --hard origin/main CR-->>SC: 内容已更新 SC->>Code: 创建 junction(posts/spec/data/images) Note over Code: 原目录自动备份为 *.backup SC->>Code: 复制 overrides 到 src/config/overrides/ SC-->>Dev: 内容同步完成

输出大致是这样的:

正在同步远程内容(强制模式)...
内容同步成功(分支:main)
正在建立内容链接...
已创建符号链接:src/content/posts -> posts
正在备份已有内容:src/data -> src/data.backup
已创建符号链接:src/data -> data
已复制配置覆盖:overrides -> src/config/overrides

看到 内容同步完成 就稳了。

原目录已被备份

sync-content 把 src/content/posts、src/content/spec、src/data、public/images 链接到 content/ 后,原目录会被自动重命名为 *.backup(如 src/data.backup)。这些备份在 .gitignore 里,不会进入提交历史,确认站点正常后可手动删除。

一个坑:MDX 的相对导入#

分离之后,本地 pnpm dev 一启动,Vite 立刻报了一连串错误:

Failed to load url ../../components/features/markdown/ContentPipelineFixture.astro
in D:/BLOG/Mizuki/content/posts/content-pipeline-fixture.mdx. Does the file exist?

文件明明在 src/components/features/markdown/ 下,怎么找不到?原因和 overrides 必须复制不能链接是同一个:Vite 会穿透 junction,按真实路径解析相对 import。

文章逻辑位置是 src/content/posts/xxx.mdx,相对路径 ../../components/... 算出来是 src/components/...,没问题。但 src/content/posts 是 junction,真实路径是 D:/BLOG/Mizuki/content/posts/,Vite 从这里出发算 ../../components/...,就跑到 content/components/ 去了,那地方当然什么都没有。

两条路径解析的对比如下,左侧能找到组件,右侧找不到:

graph TD Start([MDX 写 import]) Start --> Q{路径写法?} Q -->|相对路径 ../../components| Wrong Q -->|别名 @/components| Right subgraph Wrong [错误:Vite 走 junction 真实路径] W1[逻辑位置 src/content/posts/xxx.mdx] W2[junction 真实路径 D:/BLOG/Mizuki/content/posts/] W3[../../components 算到 content/components/] W4[❌ 文件不存在] W1 --> W2 --> W3 --> W4 end subgraph Right [正确:别名不依赖物理路径] R1[别名 @/ 锚定代码仓 src/] R2[指向 src/components/features/markdown/] R3[✅ 找到 ContentPipelineFixture.astro] R1 --> R2 --> R3 end

修法很简单:MDX 里别用相对路径,改用代码仓配好的别名 @/:

// 改前
import ContentPipelineFixture from "../../components/features/markdown/ContentPipelineFixture.astro";
// 改后
import ContentPipelineFixture from "@/components/features/markdown/ContentPipelineFixture.astro";

@/ 指向 src/,和物理路径无关,Vite 能正确解析。改完提交到内容仓即可。

部署到 Cloudflare Pages#

内容分离在本地跑通之后,还要让线上构建也能拉到内容仓。这里以 Cloudflare Pages 为例。

配置环境变量#

在 Pages 项目的 Settings → Variables and Secrets 里加两个变量(Production 和 Preview 都加):

ENABLE_CONTENT_SYNC=true
CONTENT_REPO_URL=https://<GITHUB_TOKEN>@github.com/your-username/Mizuki-Content.git

注意两点:

  • 不要用本机的 SSH URL,构建机里没有你的私钥;
  • CONTENT_REPO_URL 用 HTTPS + Token 的形式,Token 单独建一个,权限只给内容仓的 Contents: Read。
Token 安全

GitHub Token 一旦泄露需立即在 https://github.com/settings/tokens 撤销并重新生成。CONTENT_REPO_URL 里嵌入的 Token 会被存进 Cloudflare Pages 的构建日志和变量历史,请使用最小权限(仅 Contents: Read)的 fine-grained Token,并定期轮换。

CONTENT_REPO_URL 那个变量要标记为 secret_text,这样在 Dashboard 里不会再明文显示。

触发部署#

Cloudflare Pages 监听的是代码仓库的 master。所以日常发版流程是:

  1. 改文章:在 content/ 里改,git push 到内容仓;
  2. 触发构建:再回到代码仓,做一个空提交或随便改一下,git push origin master;
  3. 等 Cloudflare 构建完成,blog.aegisr.xyz 就更新了。

只推内容仓不会自动发版——这是内容分离的副作用,需要在内容仓里再配一个 trigger-build.yml,用 repository_dispatch 反向触发代码仓构建,这一步留到下一篇文章再写。

只推内容仓不会发版

Cloudflare Pages 监听的是代码仓库的 master 分支。内容仓库的 git push 不会触发线上构建。临时方案是回代码仓做一个空提交再推 master;长期方案见下一篇文章的 repository_dispatch 自动触发。

收尾#

至此内容分离基本走通:

  • 代码仓只管主题,合并上游不再和文章打架;
  • 内容仓私有,草稿、图片、个人配置都和主题解耦;
  • overrides 让跟进上游时配置冲突大幅减少;
  • Cloudflare Pages 用 Token 拉私有内容仓,构建和本地一致。

剩下的可选收尾:

  1. 还原 src/config/ 为上游原版:让 overrides 真正生效,跟上游时配置文件零冲突;
  2. 清理 *.backup:确认站点正常后,删掉 src/content/posts.backup 等备份目录;
  3. 配置内容仓自动触发构建:写一个 repository_dispatch,让内容更新也能自动发版。

这些不影响线上,可以慢慢来。希望这篇能帮到同样在 fork 主题、被合并冲突折磨的朋友 o( ̄▽ ̄)d

Share

If this article helped you, please share it with others!

为 Blog 做内容分离:给 Posts 和 Code 分家
https://blog.aegisr.xyz/posts/content-separation-guide/
Author
Aegisir
Published at
2026-08-21
License
CC BY-NC-SA 4.0

Some information may be outdated

Table of Contents