
HY
折腾进行时
Hello, I'm HY.








我的博客是 fork 的 CuteLeaf/Firefly,一直有个隐隐的别扭:文章和主题代码挤在同一个仓库里。
平时没事,一合上游就难受。上游挺勤快的,示例文章、文档、demo 项目三天两头动一次;我这边 src/content 里躺着的是自己写的一堆文章和配图。两个东西住在一个目录下,git merge upstream/master 的时候基本就是解冲突体力活。
所以想做个内容分离:文章归文章,主题归主题,各放各的仓库。以后合上游只跟主题代码打交道,src/content 保持上游原样,再也不会打架。
动手之前先去翻了翻别人怎么实现的。
有个博客主题叫 Shirone,它的文档里专门有一章讲内容分离,讲得挺细。看完发现它的做法跟我一开始想的完全不一样——我本来以为要用 git submodule,结果它压根没用。
它的思路是「构建时同步」:
1{2 "source": {3 "type": "git",4 "url": "https://github.com/OWNER/shirone-content.git",5 "ref": "main"6 },7 "mounts": {8 "content": "src/content",9 "data": "src/data",10 "assets": "src/assets",11 "public": "public"12 }13}关键发现:它也没绕开 token。文档原文写得很直白——「需一次性配置访问令牌或部署钩子」。
也就是说,「内容私有 + 云端构建」这两个条件同时存在时,token 是躲不掉的。区别只在于用哪种机制把内容搬进来。
我一开始还是走了 submodule 这条路,因为看着最”正统”:主仓库里挂个子模块,指向内容仓库,克隆的时候自动拉下来。
做起来也确实不难:
1cd content2git init -b main3git add -A && git commit -m "初始化内容仓库"4git remote add origin https://github.com/hy4962/firefly-content.git5git push -u origin main6
7# 回主仓库挂上8git rm -r --cached content9git 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/。
脚本很简单,读环境变量、浅克隆、完事:
1const DEST = path.join(ROOT, "src", "site-content");2const URL = (process.env.CONTENT_REPO_URL ?? "").replace(/\s+/g, "");3
4// 本地已经有一份就直接跳过,不用每次开发都重新拉5if (hasContent && !FORCE) {6 log("src/site-content/ 已存在,跳过同步(本地开发模式)。");7 process.exit(0);8}9
10execFileSync("git", ["clone", "--depth=1", "--branch", REF, URL, DEST], {11 stdio: "inherit",12});配套的改动是三处:
src/content.config.ts 里四个 collection 的 glob base 指向新目录.gitignore 把内容目录加进去(这样主仓库不再跟踪任何文章)vercel.json 的 buildCommand 前置这个脚本我在 Cloudflare 也有一个部署,那边没有配置文件这一说,构建命令得去面板里手填(设置 → 构建 → 构建命令),填成一样的内容。
注意:这里的路径处理有个细节——脚本用 replace(/\s+/g, "") 把 URL 里所有空白字符清掉了。原因后面踩坑那节讲。
改完跑起来,首页一看:所有文章卡片的封面都是空白。
去查产物,首页 929KB 的 HTML 里只有 8 个 <img>,全是壁纸和 logo,文章卡片一个都没有。
翻主题源码才搞明白,问题出在图片解析的写法上。ImageWrapper.astro 里是这么找图片的:
1const files = import.meta.glob<ImageMetadata>(2 "../../**/*.{png,jpg,jpeg,webp,avif}",3 { import: "default" },4);这个文件在 src/components/common/ 下,所以 ../../ 解析出来就是 src/。它只能看见 src/ 目录里的图片。
我一开始把内容放在了仓库根的 content/,图片全在 src/ 外面,glob 一个都扫不到。同理 schema-image.ts 里写的是 /src/**/*,也是同样的问题。
改法很直接——内容目录搬回 src/ 里面,叫 src/site-content/:
1src/2├── site-content/ ← 内容在这里(.gitignore 忽略)3│ ├── posts/4│ ├── spec/5│ ├── dynamic/6│ └── projects/7├── content/ ← 上游的示例文件,原样保留8├── components/9└── ...顺带验证了一下 Vite 会不会因为 .gitignore 跳过这个目录——翻了一遍 Vite 的实际代码,没有 gitignore 相关的处理,所以可以放心忽略。
这个坑最阴。
Vercel 构建日志:
1warning: url contains a newline in its path component2fatal: credential url cannot be parsedCloudflare 那边报的是另一句话:
1remote: Invalid username or token.2fatal: 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 的时候去戳这两个地址:
1on:2 push:3 branches: [main]4
5jobs:6 trigger:7 runs-on: ubuntu-latest8 steps:9 - name: 通知构建平台10 env:11 VERCEL_HOOK: ${{ secrets.VERCEL_DEPLOY_HOOK }}12 CF_HOOK: ${{ secrets.CF_DEPLOY_HOOK }}13 run: |14 if [ -n "${VERCEL_HOOK:-}" ]; then15 curl -fsS -X POST "$VERCEL_HOOK" > /dev/null && echo "Vercel 已触发"16 fi17 if [ -n "${CF_HOOK:-}" ]; then18 curl -fsS -X POST "$CF_HOOK" > /dev/null && echo "Cloudflare 已触发"19 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现在开发一条命令:
1./dev.sh在内容仓库里保存 Markdown,watch 立刻同步过去,浏览器热重载。项目里那份 src/site-content/ 彻底变成生成物——删了也无所谓,下次同步就回来了,也不再有两边版本对不上的问题。
写文章的流程:
1cd D:\Users\XOS\Documents\GitHub\firefly-content2# 写文章3git add . && git commit -m "新文章:xxx" && git push推完不用管,Vercel 和 Cloudflare 各自会重新拉一遍内容、构建、上线。
本地想预览效果:
1cd D:\Users\XOS\Documents\GitHub\Firefly2./dev.sh主仓库那边现在是什么状态:git ls-files src/site-content 返回 0,一个文章文件都不跟踪,只留主题代码和配置。以后 git merge upstream/master 只跟主题较劲,src/content 是上游的原样,不会再打架。
如果只是给主题仓库做内容分离,有几点值得先知道:
私有 submodule 在 Vercel 上不能用,这是官方明确写的限制。别像我一样先做完再发现。
token 躲不开。内容要私有、又要云端构建,凭证是必须的。区别只是用 submodule 还是构建时同步。
内容目录必须落在 src/ 里面。主题的图片解析是写死扫 src/ 的(import.meta.glob("../../**")),放外面封面和配图会全部消失。
粘贴环境变量后检查一眼。输入框角落的警告图标不是装饰,一个换行符就能让你排查半小时。
早年提交过的内容还留在历史里。这套改动只管往后——主仓库从今以后不跟踪任何文章,但之前提交过的那些依然在历史记录中。要不要一并清理是另一个决策:重写历史意味着所有 commit hash 变化、必须 force push,跟上游的关联也会变复杂。我的情况没必要,就留着了。
本地开发别用”两份克隆”。一开始我在项目里放了个独立克隆当预览源,结果两边各写各的、push 时撞车。改成 CONTENT_DIR 指向外部目录 + 一个 watch 脚本之后,项目内那份变成纯粹的生成物,问题就没了。
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!
部分内容可能已过时
分享你的想法,与大家交流讨论