把博客文章搬进私有仓库:一次内容分离的踩坑记录

2712 字
14 分钟
把博客文章搬进私有仓库:一次内容分离的踩坑记录
把博客文章搬进私有仓库:一次内容分离的踩坑记录

为什么想搞这个#

我的博客是 fork 的 CuteLeaf/Firefly,一直有个隐隐的别扭:文章和主题代码挤在同一个仓库里。

平时没事,一合上游就难受。上游挺勤快的,示例文章、文档、demo 项目三天两头动一次;我这边 src/content 里躺着的是自己写的一堆文章和配图。两个东西住在一个目录下,git merge upstream/master 的时候基本就是解冲突体力活。

所以想做个内容分离:文章归文章,主题归主题,各放各的仓库。以后合上游只跟主题代码打交道,src/content 保持上游原样,再也不会打架。

先看别人是怎么做的#

动手之前先去翻了翻别人怎么实现的。

有个博客主题叫 Shirone,它的文档里专门有一章讲内容分离,讲得挺细。看完发现它的做法跟我一开始想的完全不一样——我本来以为要用 git submodule,结果它压根没用。

它的思路是「构建时同步」:

  • 主仓库(主题代码)里完全不含内容
  • 内容放在另一个仓库,靠一个配置文件声明位置:
{
"source": {
"type": "git",
"url": "https://github.com/OWNER/shirone-content.git",
"ref": "main"
},
"mounts": {
"content": "src/content",
"data": "src/data",
"assets": "src/assets",
"public": "public"
}
}
  • 构建前跑一次同步,把内容仓库浅克隆下来,挂到对应目录
  • 私有仓库就用带 token 的 URL 访问,token 放在环境变量里

关键发现:它也没绕开 token。文档原文写得很直白——「需一次性配置访问令牌或部署钩子」。

也就是说,「内容私有 + 云端构建」这两个条件同时存在时,token 是躲不掉的。区别只在于用哪种机制把内容搬进来。

第一版:submodule,白做了#

我一开始还是走了 submodule 这条路,因为看着最”正统”:主仓库里挂个子模块,指向内容仓库,克隆的时候自动拉下来。

做起来也确实不难:

Terminal window
cd content
git init -b main
git add -A && git commit -m "初始化内容仓库"
git remote add origin https://github.com/hy4962/firefly-content.git
git push -u origin main
# 回主仓库挂上
git rm -r --cached content
git submodule add https://github.com/hy4962/firefly-content.git content

本地跑得好好的。推到 GitHub,Vercel 构建——失败。

去看 Vercel 文档,一句话写得明明白白:

Git submodules that are private or requested over SSH will fail during the Build step.

私有 submodule 在 Vercel 上必挂。 给 Vercel 的 GitHub App 授权也没用,社区里一堆人试过了,结论一致:得自己用 token 拉。

也就是说,submodule 这条路的唯一优势(不用管凭证)根本不存在,反而多了一堆毛病:本地克隆要加 --recurse-submodules、主仓库里多了个 .gitmodules、写文章得进子仓库 commit 再回主仓库提交指针。

那还不如照 Shirone 的做法来。

改成构建时同步#

方案换成了:主仓库完全不含内容,构建前用一个脚本把内容仓库拉进 src/site-content/。

脚本很简单,读环境变量、浅克隆、完事:

const DEST = path.join(ROOT, "src", "site-content");
const URL = (process.env.CONTENT_REPO_URL ?? "").replace(/\s+/g, "");
// 本地已经有一份就直接跳过,不用每次开发都重新拉
if (hasContent && !FORCE) {
log("src/site-content/ 已存在,跳过同步(本地开发模式)。");
process.exit(0);
}
execFileSync("git", ["clone", "--depth=1", "--branch", REF, URL, DEST], {
stdio: "inherit",
});

配套的改动是三处:

  • src/content.config.ts 里四个 collection 的 glob base 指向新目录
  • .gitignore 把内容目录加进去(这样主仓库不再跟踪任何文章)
  • vercel.json 的 buildCommand 前置这个脚本

我在 Cloudflare 也有一个部署,那边没有配置文件这一说,构建命令得去面板里手填(设置 → 构建 → 构建命令),填成一样的内容。

注意:这里的路径处理有个细节——脚本用 replace(/\s+/g, "") 把 URL 里所有空白字符清掉了。原因后面踩坑那节讲。

踩坑记录#

坑一:内容目录放错位置,封面图全没了#

改完跑起来,首页一看:所有文章卡片的封面都是空白。

去查产物,首页 929KB 的 HTML 里只有 8 个 <img>,全是壁纸和 logo,文章卡片一个都没有。

翻主题源码才搞明白,问题出在图片解析的写法上。ImageWrapper.astro 里是这么找图片的:

const files = import.meta.glob<ImageMetadata>(
"../../**/*.{png,jpg,jpeg,webp,avif}",
{ import: "default" },
);

这个文件在 src/components/common/ 下,所以 ../../ 解析出来就是 src/。它只能看见 src/ 目录里的图片。

我一开始把内容放在了仓库根的 content/,图片全在 src/ 外面,glob 一个都扫不到。同理 schema-image.ts 里写的是 /src/**/*,也是同样的问题。

改法很直接——内容目录搬回 src/ 里面,叫 src/site-content/:

src/
├── site-content/ ← 内容在这里(.gitignore 忽略)
│ ├── posts/
│ ├── spec/
│ ├── dynamic/
│ └── projects/
├── content/ ← 上游的示例文件,原样保留
├── components/
└── ...

顺带验证了一下 Vite 会不会因为 .gitignore 跳过这个目录——翻了一遍 Vite 的实际代码,没有 gitignore 相关的处理,所以可以放心忽略。

坑二:环境变量里混进一个换行#

这个坑最阴。

Vercel 构建日志:

warning: url contains a newline in its path component
fatal: credential url cannot be parsed

Cloudflare 那边报的是另一句话:

remote: Invalid username or token.
fatal: Authentication failed

同一个病根:粘贴长 URL 的时候,末尾带上了一个换行符。Vercel 直接说”URL 里有换行”,Cloudflare 语言更含蓄——换行把 token 从中间截断了,GitHub 收到一个残缺的 token,于是报认证失败。

回想起来,Vercel 那个环境变量输入框右下角一直挂着一个橙色警告图标,我当时没当回事。那就是在提示这个。

除了重新粘贴,我在脚本里也加了兜底,把 URL 里的空白字符统一清掉,以后就算粘脏了也不会挂。

坑三:本地那份内容目录不能删#

一开始我想着内容都搬到独立仓库了,项目里那份是不是可以删掉。

不行。pnpm dev 需要它——本地开发时脚本会跳过同步(因为目录已存在),直接读里面的文件。删掉的话本地预览直接空掉。

线上倒是不受影响,构建时脚本会自己拉一份进来。

所以最后是两份副本共存:

位置作用
D:\Users\XOS\Documents\GitHub\firefly-content写作目录,独立仓库,写文章在这里 commit + push
Firefly\src\site-content\项目内的副本,pnpm dev 的内容来源

注意别两边都改,不然会分叉——这个”分叉”不是假设,后面很快就真发生了一次。

最后一块:内容仓库推送也得能触发部署#

到这一步,文章已经独立了,但它有个新问题:Vercel 和 Cloudflare 只监听主仓库,往内容仓库推文章,它们完全没反应。

解决办法是 Deploy Hook。两个平台都能生成一个 URL,往那个 URL 发个 POST 就会触发一次部署。

在 Vercel:Settings → Git → Deploy Hooks,分支要填对(我填的 HY),不然会部署到别的分支去。

在 Cloudflare:设置 → 构建 → 部署挂链。

然后给内容仓库加一个 workflow,push 的时候去戳这两个地址:

on:
push:
branches: [main]
jobs:
trigger:
runs-on: ubuntu-latest
steps:
- name: 通知构建平台
env:
VERCEL_HOOK: ${{ secrets.VERCEL_DEPLOY_HOOK }}
CF_HOOK: ${{ secrets.CF_DEPLOY_HOOK }}
run: |
if [ -n "${VERCEL_HOOK:-}" ]; then
curl -fsS -X POST "$VERCEL_HOOK" > /dev/null && echo "Vercel 已触发"
fi
if [ -n "${CF_HOOK:-}" ]; then
curl -fsS -X POST "$CF_HOOK" > /dev/null && echo "Cloudflare 已触发"
fi

两个 URL 存进仓库 secret,不写在文件里——curl 出去的地址是能反复触发部署的,明文放在仓库里终究不合适。

顺手把本地流程也改了#

两份副本共存的状态用着别扭,毛病也很快来了:两边都是完整的 git 仓库,我在这边提交、那边还停在旧版本,push 的时候直接被拒(non-fast-forward)。

回头又去翻 Shirone 的文档,发现它的本地开发是这么设计的:

  • 用 CONTENT_DIR 指向本地内容仓库,content:sync 一次性把内容同步进代码仓
  • 想边写边看,再开一个终端跑 content:watch,增量同步、保存即热重载
  • 关键:它的 src/content 是同步脚本物化出来的副本,不是独立的 git 克隆——所以根本没有”两份副本分叉”这回事

我照着改了一版:

  • sync-content.mjs 增加 CONTENT_DIR 支持(优先级高于 CONTENT_REPO_URL,本地直接复制、不走网络)
  • 新增 watch-content.mjs:监视内容仓库,改动增量同步到 src/site-content/
  • 把这两个动作写进本地的 dev.sh / dev.bat(这俩在 .gitignore 里):先同步 → 后台起监听 → 起 dev server

现在开发一条命令:

Terminal window
./dev.sh

在内容仓库里保存 Markdown,watch 立刻同步过去,浏览器热重载。项目里那份 src/site-content/ 彻底变成生成物——删了也无所谓,下次同步就回来了,也不再有两边版本对不上的问题。

现在的样子#

写文章的流程:

Terminal window
cd D:\Users\XOS\Documents\GitHub\firefly-content
# 写文章
git add . && git commit -m "新文章:xxx" && git push

推完不用管,Vercel 和 Cloudflare 各自会重新拉一遍内容、构建、上线。

本地想预览效果:

Terminal window
cd D:\Users\XOS\Documents\GitHub\Firefly
./dev.sh

主仓库那边现在是什么状态:git ls-files src/site-content 返回 0,一个文章文件都不跟踪,只留主题代码和配置。以后 git merge upstream/master 只跟主题较劲,src/content 是上游的原样,不会再打架。

几个结论#

如果只是给主题仓库做内容分离,有几点值得先知道:

  1. 私有 submodule 在 Vercel 上不能用,这是官方明确写的限制。别像我一样先做完再发现。

  2. token 躲不开。内容要私有、又要云端构建,凭证是必须的。区别只是用 submodule 还是构建时同步。

  3. 内容目录必须落在 src/ 里面。主题的图片解析是写死扫 src/ 的(import.meta.glob("../../**")),放外面封面和配图会全部消失。

  4. 粘贴环境变量后检查一眼。输入框角落的警告图标不是装饰,一个换行符就能让你排查半小时。

  5. 早年提交过的内容还留在历史里。这套改动只管往后——主仓库从今以后不跟踪任何文章,但之前提交过的那些依然在历史记录中。要不要一并清理是另一个决策:重写历史意味着所有 commit hash 变化、必须 force push,跟上游的关联也会变复杂。我的情况没必要,就留着了。

  6. 本地开发别用”两份克隆”。一开始我在项目里放了个独立克隆当预览源,结果两边各写各的、push 时撞车。改成 CONTENT_DIR 指向外部目录 + 一个 watch 脚本之后,项目内那份变成纯粹的生成物,问题就没了。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
把博客文章搬进私有仓库:一次内容分离的踩坑记录
https://www.9ll.uk/posts/blog/contentseparation/
作者
HY
发布于
2026-10-05
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
把 Firefly 魔改了五十一版:一份完整的功能清单
博客博客是 fork 的 CuteLeaf/Firefly,从 V1.0 到 V1.49 一共 51 版改动。这篇不再讲故事,直接把改过的功能全部列出来:首页那套贴纸卡片、自己加出来的那批页面、友链与书签的悬停预览和自助申请、全站卡片描边与页脚统计、评论区的折叠与引用、右栏那几个自建组件、一长串性能优化,还有内容与主题分离这类基础设施。每条都附上它到底干什么、关键数字是多少。
2
给博客装了个朋友圈:把友链的更新聚成一条时间线
博客羡慕 rainzt.cn 的朋友圈,把它搬进自己的 Firefly:构建时抓取所有友链的 RSS 合流成时间线,快照兜底,GitHub Actions 每天自动刷新。纯静态,不用服务器,只新增文件不碰主题源码。后来又给时间线加了层来源配额,不让聚合站霸榜。
3
给博客首页加了张可拖拽贴纸卡片:一次合并友好的魔改
博客看到 rainzt.cn 的首页毛玻璃卡片和能拖动的贴纸羡慕了,扒下来装到自己的 Firefly 上。全程只新增文件不动主题源码,以后还能正常合并上游更新,顺便踩了一堆 CSS 的坑。后来又按性能数据把入场动画拆成两段、关掉了 Swup 视口预取;最后一轮重做了贴纸排布,桌面并成一排、手机收成一行,还加了张动图,并把贴纸改成延迟加载——手机端从 149 KB 降到 47 KB。最近又给卡片加了「开场两秒后自动收起」、右下角的贴纸开关,并让底部那排改成随机抽 10 张、左右各 5 张分列,把正中央让给下滑箭头。
4
把动态页改成了朋友圈的样子
博客动态页想换个样子,参照的是另一个同主题的站 —— 但数据源不变,还是继续接 memos。扒完两边才发现,上游动态卡片的 DOM 本来就齐 —— 头像、作者、时间、地点、置顶徽章、图片网格、评论按钮一个不缺,差的只是样式。于是这轮改造零改动主题源文件:一份只在动态页加载的样式表,加一个把页脚外壳搬进正文的小脚本。中间踩了三个安静的坑,都是「不报错、但就是不生效」那种。
5
给博客加了张更新日志页
博客羡慕 rainzt.cn 的博客日志页,把它的页面结构扒下来,用 Firefly 自己的组件体系重做了一遍。全程只新增三个文件,不碰主题源码,以后合并上游更新不受影响。顺便把折叠动画从 max-height 换成了 grid-template-rows。
随机文章随机推荐

评论区

Profile Image of the Author
HY
Hello, I'm HY.
公告
迷茫.... 为了访问速度,本站将会在11月上旬至中旬迁移到0dm.cn并完成备案
分类
标签
最新动态
站点统计
文章
74
分类
8
标签
192
总字数
161,979
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Vercel
博客版本
Firefly v6.16.8
文章许可
CC BY-NC-SA 4.0
文章目录