写文章和改代码,是两种完全不同的节奏。
改代码是几天一次的大事:想清楚要做什么,写,跑构建,看性能,再对着设计稿修。写文章是随时发生的小事,可能凌晨两点想到一个开头,翻身起来就打开编辑器。
可它们一直挤在同一个仓库里。每次 git status 都能看到十来个不相关的改动混在一起——上午加了个段落,下午修了个渲染 bug,被并排躺在提交记录里,像两份拼在一起的工作日志。更要命的是,写一句话的冲动,要先穿过一整套代码仓库的提交仪式:进仓库、改 Markdown、和组件改动一起提交、触发整站重建。文章越多,这种别扭越明显。
122 篇文章、45 张图。它们该有自己的家了。
拆之前:文章住在源码里
一开始,文章就是源码仓库的一部分。网站目录和文章目录共用同一棵树:
拆之前:文章是源码仓库的一部分
Blog Repo(一个仓库装下全部)
├── src/ # Astro 页面、组件
├── public/ # 静态资源
├── components/ # 复用的 UI
├── config/ # 站点配置
└── articles/ # ← 文章也在这里写一段话,要走的路是这么长:
想写一句话
↓
打开源码仓库
↓
找到 articles/ 下的 Markdown
↓
编辑、提交(和代码改动混在一起)
↓
推送 → 触发整站构建
↓
等构建完成,文章才上线问题不在「能不能写」,而在「写一句话的代价太大」。文章和代码被同一个提交仪式绑在一起,结果两边都别扭:写文章的人要懂构建,改代码的人要看见文章。
拆之后:两个仓库各管各的
现在是两个仓库,都是私有的,只在构建时相遇:
拆之后:两个仓库,各管各的
CloakBlog(源码) blog-Content(内容)
├── src/ ├── 技术/2026/*.md
│ └── content/blog/ ← 构建时拉取 ├── 随笔/2026/*.md
├── scripts/ ├── 教程/2025/*.md
│ ├── fetch-content.mjs ├── img/ ← 45 张图
│ └── sync-content-assets.mjs ├── README.md
├── public/img/ ← 构建时同步 └── .github/workflows/sync.yml
└── build.sh文章上线的方式也清楚了:
文章仓库
↓ git push
GitHub
↓ 触发 Action
博客构建(拉内容 → 同步图片 → astro build)
↓
Cloudflare Pages
↓
lzplus.top三个环节把两边缝起来:
构建前拉内容。 scripts/fetch-content.mjs 用只读 Token 把内容仓库克隆到 src/content/blog。本地已经有内容就跳过,不重复拉。
图片跟着走。 图片源文件在内容仓库的 img/,构建时由 sync-content-assets.mjs 复制到 public/img/,正文里用 /img/xxx.webp 这种绝对路径引用。public/img/ 已经从源码仓库里移除并加进了 .gitignore。
URL 一个都没变。 这条最要紧。文章的 URL 是由文件名派生的(/blog/<文件名>),归档到子目录之后,Astro 默认会拿带路径的 id 去生成路由,链接就全废了。所以内容集合的 loader 里加了一段:
generateId: ({ entry }) =>
entry.split('/').pop().replace(/\.(md|mdx)$/i, '').toLowerCase(),不管文件在 技术/2026/ 还是 随笔/2024/,id 永远是纯文件名。122 条旧链接照常能访问。
坑一:Cloudflare Pages 拉不了私有子模块
第一版方案是老老实实用 git submodule:src/content/blog 挂上 blog-Content,.gitmodules 写清楚。本地跑得好好的,推上去构建就红了:
Submodule 'src/content/blog' (https://github.com/gongjuecloak/blog-Content.git)
registered for path 'src/content/blog'
Cloning into '/opt/buildhome/clone/src/content/blog'...
fatal: could not read Username for 'https://github.com': No such device or address
Failed: error occurred while updating repository submodules注意路径:/opt/buildhome/clone/。子模块的初始化发生在克隆阶段,而克隆阶段在构建命令执行之前——那时候构建环境变量还没注入,任何凭据都读不到。私有的子模块,它一定拉不下来。
三条路摆在那儿:
| 方案 | 代价 |
|---|---|
把 blog-Content 改成公开仓库 | 改动最小,但未发布的草稿会提前挂在 GitHub 上 |
| 构建时用 Token 显式克隆 | 仓库保持私有,代价是加一个环境变量 |
| 换成 GitHub Actions 构建 + wrangler 直传 | 完全绕开 CF 的克隆,但要配 CF API Token、项目切成 Direct Upload |
选了第二条。移除 .gitmodules 和 submodule 指针,内容目录进 .gitignore,改成构建流程里的第一步:
node scripts/fetch-content.mjs # 拉内容
node scripts/sync-content-assets.mjs # 同步图片
node scripts/generate-og.mjs
# ... astro buildfetch-content.mjs 的核心就一句:
git clone --depth 1 --branch master \
https://x-access-token:${TOKEN}@github.com/gongjuecloak/blog-Content.git \
src/content/blogToken 从环境变量 BLOG_CONTENT_TOKEN 读。日志里只打印不带令牌的地址,避免把令牌写进构建日志。
Cloudflare Pages 这边要手动加一个变量
Settings → Environment variables,添加:
BLOG_CONTENT_TOKEN = <对 gongjuecloak/blog-Content 只读的 fine-grained PAT,权限 Contents: Read>Production 和 Preview 都要加。用只读权限的独立 Token,不要复用个人访问令牌。
坑二:Action 脚本退错了目录
内容仓库推上去之后,.github/workflows/sync.yml 会在源码仓库创建一个提交来触发重建。第一版脚本长这样:
cd src/content/blog
git fetch origin master
git checkout origin/master
cd ../.. # ← 问题在这
git add src/content/blogsrc/content/blog 往上退两级,到的是 src,不是仓库根。于是在 src 目录里执行 git add src/content/blog,路径当然不存在:
fatal: pathspec 'src/content/blog' did not match any files改成不依赖当前目录的写法,顺手也把这个步骤从「推进 submodule 指针」换成了「推一个空提交」——内容既然不再以子模块方式挂载,指针就没得推了:
git -C src/content/blog fetch --quiet origin master
git -C src/content/blog checkout --quiet origin/mastergit -C <dir> 比 cd 可靠,不用在脑子里数层级。
坑三:六个脚本静默失效
这个最值得写下来。
文章从仓库根下沉到「分类/年份」两层之后,构建跑出来是没有报错的,但产出不对。逐个查了一遍,六个脚本都假设文章躺在 src/content/blog/ 根目录:
| 脚本 | 归档后发生的事 | 后果 |
|---|---|---|
generate-preview-index.mjs | 顶层的 readdirSync 只看到六个中文目录 | 122 条索引会被写成 {} |
generate-image-meta.mjs | 同上 | image-meta.json 变空,响应式图片的 srcset 全丢 |
generate-og.mjs | slug 变成了 技术/2026/xxx,中文被替换成 - | OG 图落到 /og/--/2026/,页面引用的是 /og/xxx.png,全部 404 |
validate-frontmatter.mjs | 循环零次 | 输出 OK: 0 BAD: 0,看起来是通过 |
assign-growth.cjs | 同上 | 一篇都扫不到 |
scan-posts.sh | shell glob 只匹配顶层 | 定时构建日志统计恒为 0 篇 |
前三个在构建链上,后三个是辅助脚本。共同点是都用了只扫一层的 API——readdirSync 不加 withFileTypes 递归,或者 *.md 的 shell glob。目录结构一改,它们不报错,只是安静地什么都不做。
改目录结构时最容易漏的一步
凡是 readdirSync / glob / shell *.md,都要过一遍。这类失效不会让构建失败,只会让产物变空或者错位,而空产物在页面上表现为「功能没了」,很容易被当成另一个 bug 去查。
除了一层层递归,还做了两件小事:
- 抽了个
walkPosts()递归收集(排除README.md),四个脚本共用同一套逻辑; preview-index.json输出前按 slug 排序。它本来是无序映射,但固定顺序之后,以后再调整文章所在目录,git diff 只反映真实增删,不会把整个文件重排一遍。
还有一个连带问题:文章下沉两层,正文里的相对 import 就断了。components-showcase.mdx 这几篇引用了 src/components/ 下的组件:
import Spoiler from '../../components/Spoiler.astro' // 归档前正确
import Spoiler from '../../../../components/Spoiler.astro' // 归档后要补两级一共 3 个文件 26 处。处理时写了脚本按「是否在代码块围栏内」判断——文章里讲组件用法时,代码块里也写着 ../../components/...,那是示例内容,跟着改反而把文章改错了。最后跳过了 3 处,全部人工确认在 ```astro 围栏里。
现在怎么写文章
流程比改造前简单,只有两处变化。
新建
npm run new "文章标题" --category 技术 --type guide脚本会把文件建到 技术/2026/<slug>.md:
- 分类决定一级目录,必须是已用到的六个之一:技术 / 教程 / 思考 / 游戏 / 随笔 / 工具
- 年份取
pubDate的年份 - 文件名就是 URL。中文标题自动转拼音(
文章标题→wen-zhang-biao-ti),英文标题走 kebab-case,都不带日期前缀
写完内容后,在内容仓库里提交推送:
git add . && git commit -m "content: 新增《文章标题》" && git push推送会触发 Action,在源码仓库创建一个提交,Cloudflare Pages 随即重建。
字段与图片
frontmatter 的必填项还是老样子:title、description、pubDate。可选字段、内容类型(type)、阅读路径(tldr / readNext)的完整说明在 云图札记博文写作规范,这里只说和新结构有关的两条:
图片源文件放内容仓库根目录的 img/,正文用绝对路径引用:
不要再往 public/img/ 里放东西——那个目录现在是构建产物,已经被 .gitignore 忽略。
草稿要靠未来的 pubDate,不是 draft 字段:
pubDate: 2026-11-10 # 未来日期 → 构建时被过滤掉,到日子自动上线npm run new --draft 就是干这个的(默认 +60 天)。draft: true 这个字段 schema 里没有、页面也不读它,写了等于没写。
本地预览
npm run devdev 和 build 都会先跑 fetch-content 和 sync-content-assets。本地如果已经有内容目录,拉取那步会直接跳过;想强制重新拉一遍,加个环境变量:
BLOG_CONTENT_FORCE=1 npm run dev如果你习惯在独立克隆里写文章(比如 D:/Code/blog-Content),把路径指过去就行:
BLOG_CONTENT_DIR=D:/Code/blog-Content npm run new "标题" --category 技术还没解决的事
本机现在有两份内容副本。 一份是独立克隆 blog-Content,一份是 CloakBlog/src/content/blog(构建读的是后者)。改完记得同步,或者固定用一份、用 BLOG_CONTENT_FORCE=1 强制重拉。
growth 字段站点还没用上。 内容侧 122 篇里基本都标了 seed / bud / bloom,assign-growth.cjs 也在按规则批量分配,但题材 schema 里没有这个字段,页面也没渲染它。它的值目前只是内容侧的标记。
拆完之后
现在回头看,这次改造真正想解决的,其实不是什么架构洁癖。
是那种别扭:明明只是想记一句话,却要先在脑子里过一遍工程流程。文章和代码,本来就是两件节奏不同的事——一个随时发生,一个几天一次。把它们塞进同一个仓库,等于强迫两种节奏共用一套提交仪式。
拆开以后,写文章就是写文章,维护博客就是维护博客。两边只在三个脚本处相遇,出问题时范围也小得清楚。这大概就是我做这个决定时,真正想要的东西。