写文章和改代码,是两种完全不同的节奏。

改代码是几天一次的大事:想清楚要做什么,写,跑构建,看性能,再对着设计稿修。写文章是随时发生的小事,可能凌晨两点想到一个开头,翻身起来就打开编辑器。

可它们一直挤在同一个仓库里。每次 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 build

fetch-content.mjs 的核心就一句:

git clone --depth 1 --branch master \
  https://x-access-token:${TOKEN}@github.com/gongjuecloak/blog-Content.git \
  src/content/blog

Token 从环境变量 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/blog

src/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/master

git -C <dir> 比 cd 可靠,不用在脑子里数层级。

坑三:六个脚本静默失效

这个最值得写下来。

文章从仓库根下沉到「分类/年份」两层之后,构建跑出来是没有报错的,但产出不对。逐个查了一遍,六个脚本都假设文章躺在 src/content/blog/ 根目录:

脚本归档后发生的事后果
generate-preview-index.mjs顶层的 readdirSync 只看到六个中文目录122 条索引会被写成 {}
generate-image-meta.mjs同上image-meta.json 变空,响应式图片的 srcset 全丢
generate-og.mjsslug 变成了 技术/2026/xxx,中文被替换成 -OG 图落到 /og/--/2026/,页面引用的是 /og/xxx.png,全部 404
validate-frontmatter.mjs循环零次输出 OK: 0 BAD: 0,看起来是通过
assign-growth.cjs同上一篇都扫不到
scan-posts.shshell 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/,正文用绝对路径引用:

![图片描述](/img/your-image.webp)

不要再往 public/img/ 里放东西——那个目录现在是构建产物,已经被 .gitignore 忽略。

草稿要靠未来的 pubDate,不是 draft 字段:

pubDate: 2026-11-10   # 未来日期 → 构建时被过滤掉,到日子自动上线

npm run new --draft 就是干这个的(默认 +60 天)。draft: true 这个字段 schema 里没有、页面也不读它,写了等于没写。

本地预览

npm run dev

dev 和 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 里没有这个字段,页面也没渲染它。它的值目前只是内容侧的标记。

拆完之后

现在回头看,这次改造真正想解决的,其实不是什么架构洁癖。

是那种别扭:明明只是想记一句话,却要先在脑子里过一遍工程流程。文章和代码,本来就是两件节奏不同的事——一个随时发生,一个几天一次。把它们塞进同一个仓库,等于强迫两种节奏共用一套提交仪式。

拆开以后,写文章就是写文章,维护博客就是维护博客。两边只在三个脚本处相遇,出问题时范围也小得清楚。这大概就是我做这个决定时,真正想要的东西。

继续阅读Continue Reading

关于本文About

评论Comments

留下你的想法

欢迎在下方评论区留下你的看法,一起把话题聊得更深。

COMMENTS