起因很没面子:想更新一篇文章,hexo 命令找不到了。
-g 装回 hexo-cli,npm install,再 hexo gen --deploy,推上去,打开 https://zlotus.github.io/ ——Page not found。
那一瞬间我以为十年的东西没了。(后来发现只是 GitHub Pages 在异步重建,刷新一下就好了,虚惊一场。)
但这一惊也够了:一个我十年没动过的工具链,靠着五个全局依赖和一次 force push 苟活着,哪天真塌了我连怎么塌的都不知道。趁着还没塌,换掉。
顺便,我早就用腻 Hexo 了。
0 定个调子
目标三条,按优先级:
- 换掉 Hexo → Astro
- GitHub Pages → Cloudflare Workers
- 旧 URL 一个都不能死
第三条是硬约束。十年攒下来的外链和搜索引擎收录都指着 /年/月/日/标题/ 这个格式,改了就等于把自己的历史删了。后面会看到,整场搬家里最费劲的就是这一条。
CAUTION选 Workers 不选 Pages。CF 正在把 Pages 吸收进 Workers,新功能只进 Workers,2026 年开新项目官方建议就是 Workers + Static Assets。别再
wrangler pages deploy了。
选 Astro 的理由也很实在:@astrojs/cloudflare 能让预渲染的静态页和 /api/* 动态接口共存在同一个 Worker、同一次部署里。以后想加评论、加阅读计数,不用拆项目。
主题挑了 Fuwari——骨架跟我原来的 icarus 一样(左边 profile、中间正文、右边目录),迁移的心理落差最小。
1 摸底:先别写代码
动手前先把老站摸清楚。这一步一行代码都别写,纯调查,结论如下:
| 项 | 值 |
|---|---|
| Hexo 版本 | 5.4.0(不是 7.x) |
| 文章数 | 83 篇 |
| permalink | :year/:month/:day/:title/,:title 是文件名不是标题 |
| 部署方式 | hexo-deployer-git force-push public/,无 CI |
| 图片 | 19 张,8MB |
| 数学公式 | MathJax 2.7.5,只有行内 $...$ |
| RSS | 压根没有(hexo-generator-feed 就没装过) |
RSS 那条挺打脸的——我一直以为我有 RSS。翻遍 public/ 连个 atom.xml 都没有。没有历史包袱反而轻松,新建一个就行。
然后是这次搬家的头号坑:
CAUTION83 篇文章全部缺少 front-matter 的前导
---。Hexo 允许你省略开头那行分隔符(只有结尾有
---),但 gray-matter / Astro 要求两边都有。不补这一行,整个 front-matter 会被当成正文渲染出来。迁移脚本干的第一件事就是补它。
还有一堆 Hexo 专有的标签插件要处理:
| 标签 | 次数 | 怎么办 |
|---|---|---|
{% raw %} | 83 对 | 里面是 Bulma 提示框 HTML → 转成 Fuwari 的 :::note |
<!-- more --> | 83 | 截断标记 |
{% asset_img %} | 15 | 转成普通 markdown 图片 |
{% link %} | 4 | 转成普通链接 |
{% iframe %} | 1 | 引用的文件早丢了,线上其实一直是坏的 |
最后两个畸形文件名,看着手痒也不能改:
rbp3-upgrade-raspbian.md.md—— URL 真的就是/2018/02/15/rbp3-upgrade-raspbian.md/writing-os-in-rust-on-rpi_.md—— 尾巴上真的挂着一个下划线
改了就是断链。忍住。
2 迁移脚本:能重跑、能空跑、能吐日志
83 篇不可能手改。写个脚本,三个要求:幂等(重跑结果一样)、dry-run(先看不写)、转换日志(改了什么一目了然)。
原始 markdown 是唯一真相,一个字节都不动,转换结果全写到新目录。
node scripts/migrate.mjs --dry-run # 空跑,只看日志node scripts/migrate.mjs --only transformer # 单篇调试node scripts/migrate.mjs --force # 真写(会先清空输出目录,所以要显式 --force)核心是个「遮罩」的把戏。代码块里的东西一个字都不能碰——你总不希望我把你文章里演示 {% raw %} 用法的那段代码给”转换”了。所以先把代码块整段替换成占位符,处理完正文,再塞回去:
// 先把围栏代码块、行内代码换成占位符,正文处理完再塞回来。// ⚠️ 两层遮罩必须用不同的哨兵,否则内层还原时会把外层的占位符一起吃掉。// 哨兵用不可打印的控制字符,保证它绝不可能出现在正文里。const MARK_FENCE = "\u0000";const MARK_INLINE = "\u0001";这个坑我是真踩了:一开始两层用同一个标记,内层一还原,外层占位符全被啃了。更蠢的是我最早用 " N " 这种人类可读的占位符——结果它把正文里的数字也吃了。
front-matter 的字段映射:
# Hexo # Fuwaridate: → published:categories: → category: # 数组变单数,取第一个title/tags/description # 同名,不动 # 外加一行 slug: 2024/01/06/文件名那行 slug: 就是保住旧 URL 的关键,下一节说。
跑完的统计:
admonition=58 assetImg=15 img=1 link=4 iframe=1mathFix=4 fenceFix=1 langFix=163 校验:写校验器比写脚本还重要
脚本跑完了,怎么知道它没偷偷吃掉我的文章?
写了四道校验:
| 校验 | 查什么 |
|---|---|
check-content.mjs | 转换前后,代码块逐字节比对 + 正文可见文字比对 |
check-math.mjs | 每个公式塞进 KaTeX 真渲染一遍 |
check-urls.mjs | 拿旧站全部 URL 发真实 HTTP 请求 |
check-render.mjs | 新旧两站渲染出来的 HTML 比代码块的文字量 |
然后就抓到了这次最阴的一个 bug。
六个反引号
有一篇文章里,代码围栏用了六个反引号。Hexo 用的 marked,遇到任何 ``` 就闭合围栏;而 CommonMark 规定闭合围栏必须不短于开启围栏。
六个反引号开头,三个反引号闭不上它。结果就是:从那一行开始,剩下的大半篇文章全被吞进了代码块。
CAUTION恐怖的地方在于:前三道校验全都没发现它。
因为在源码层面,字符一个不少,代码块内容也一模一样。只有把两边真渲染成 HTML 再比,才看得出来新站的代码块里凭空多出来几千个字。
教训:只在源码层面校验,是看不见渲染器差异的。
我的校验器自己是错的
check-content.mjs 第一版用 /<[^>]+>/ 剥 HTML 标签,然后报了三篇”内容丢失”。
我盯了半天——是 Rust 的泛型:
Pin<&mut Self><&mut Self> 被我的正则当成了一对 HTML 标签,把中间的文字整个吃掉了。误报。
NOTE校验器必须比被校验的东西更简单、更可信。
一个需要你去 debug 的校验器,比没有校验器更糟——它会让你怀疑正确的东西。
顺带一提,迁移顺手修好了两个老站的 bug:一处 \left\{...\right\} 公式(marked 把 \{ 吃了,老站上一直是坏的),以及公式从客户端 MathJax 换成了构建期 KaTeX——首屏不再抖一下了。
4 旧 URL:一个都不许死
老站的 URL 长这样:
https://zlotus.github.io/2024/01/06/transformer-from-scratch-1/Astro 默认会给你 /posts/xxx/。差一个字都是断链。
解法是让 front-matter 里的 slug 直接装下完整路径,再建一个四段的路由文件把它拆回来:
src/pages/[year]/[month]/[day]/[...slug].astroconst [year, month, day, ...rest] = entry.slug.split("/");if (!year || !month || !day || rest.length === 0) { // 宁可构建失败,也不要静默生成一个错的 URL throw new Error(`${entry.id} 的 slug 不是 YYYY/MM/DD/name 格式`);}这里有个陷阱:URL 是改 URL 的地方越少越好。我把全站所有生成文章链接的地方(列表、上一篇/下一篇、RSS、TOC 高亮判断)全都掐到同一个函数上:
export function getPostUrlBySlug(slug: string): string { return url(`/${slug}/`);}漏了两个:RSS 自己拼了 /posts/${slug}/,TOC 用 startsWith("/posts/") 判断当前是不是文章页——后者会让目录永远不显示。
NOTE一个值(这里是 URL 格式)应该只有一个说了算的地方。散落在五个文件里各拼各的,就一定会漏。
至于列表页(/page/2/、/tags/xxx/、/categories/xxx/)这些,我不打算在 Astro 里复刻 Hexo 的分页结构,直接上 301:
/page/:num/ /:num/ 301/tags/:tag/ /archive/?tag=:tag 301/categories/:cat/ /archive/?category=:cat 301/archives/ /archive/ 301最后拿老站的 URL 全集跑一遍新站——注意必须发真实 HTTP 请求,重定向是没法靠”文件存不存在”验证的:
207 个 URL,207 个可达(其中 123 个走 301),0 个 4045 部署:GitHub Actions → Workers
老站是 hexo deploy 一把 force-push。新站换成 CI:push 到 main,GitHub 的机器上构建,wrangler deploy 推到 Cloudflare。
on: push: branches: [main] paths-ignore: # 改这些不影响构建产物,别浪费一次部署 # ⚠️ 不能写 '**.md'——src/content/posts/*.md 是文章,改了必须重新部署! - "CLAUDE.md" - "legacy/**"
jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 # 不写死版本,让它读 package.json - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: node scripts/check-math.mjs src/content/posts # 坏公式别上线 - run: pnpm build - uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: deploy顺便,GitHub Pages 那个”Page not found”也自愈了:Pages 是 force-push 之后异步重建,那一两分钟里访问就是 404;Workers 是原子切换,新版本上线之前老版本一直在服务。
说个丢人的事
配 secret 的时候我这么干的:
gh secret set CLOUDFLARE_API_TOKEN # 然后在提示符里粘贴 token结果我把 token 贴到了名字那一栏。回头 gh secret list 一看:
CFUT_AJQNNMQCIBA9PFAFY01QYD5ZAW7SMUYDVJCJYN1I0365B2F8 ← 这是我的 token……D6B51B94502C5FAA231B1544A3690BA8 ← 这是我的 account id……CAUTIONGitHub secret 的「值」是加密的,「名字」不是。
名字会明晃晃出现在网页、API、日志里。等于我把 API token 公开发布了。
当场去 Cloudflare 吊销重发。想稳妥就别用交互式粘贴:
Terminal window gh secret set CLOUDFLARE_API_TOKEN < token.txt && rm token.txt
6 白嫖能白嫖到什么程度
这是整件事的重点——全部落在免费额度内。
| 项目 | 免费额度 | 我用掉多少 |
|---|---|---|
| 静态资源请求 | 不计入配额 | 纯静态博客 ≈ 0 |
| Worker 调用 | 10 万次/天 | 目前 0(没有动态接口) |
| Worker CPU | 每次 10ms | 没跑运行时逻辑 |
| Workers KV | 每天只有 1000 次写 | 没用 |
| D1 | 5GB / 500 万行读每天 | 还没用上 |
几条自己给自己划的红线:
- 别拿 KV 做阅读计数器——一天 1000 次写,一天就打满。要计数用 D1。
- 别做运行时 OG 图渲染——10ms CPU,satori 之类必超。OG 图构建期生成。
- 别碰 Containers——没有免费额度,要 $5/月。
搜索用 Pagefind,这东西的思路很对我胃口:它不是搜索服务,是个后处理器。astro build 生成完 HTML,它去爬构建产物、切词、建索引,产出一堆静态分片;浏览器搜索时按需下载命中的分片。
全程零后端。 Cloudflare 那边只是当图床发文件——而静态资源请求不计配额。等于白嫖到底。
7 以后怎么写文章
这一节是写给未来的我看的。三个月后我肯定忘光。
新建
cd ~/zlogpnpm new-post my-new-post # 文件名用英文!别手写 front-matter,一定要用这个脚本。因为:
CAUTION
slug:那一行不能少,缺了直接构建失败。旧 URL 是
/年/月/日/文件名/,路由里加了硬校验:slug 不是YYYY/MM/DD/xxx格式就当场throw。这是故意的——宁可构建炸掉,也不要静默生成一个格式不对的 URL 悄悄上线。
Fuwari 自带的
new-post脚本不知道这回事(它是给普通 Astro 站写的),生成的模板没有slug:,直接用会一头撞上这个报错。所以我把脚本改了,现在它会自动填好slug: 2026/07/14/my-new-post。顺便,文件名用英文。中文文件名会变成中文 slug,URL 里会被转义成一长串
%E8%AF%95...。
封面图
两条路,看图有多大:
| 放哪 | front-matter 写 | 结果 |
|---|---|---|
public/img/x.webp | image: /img/x.webp | 原样发布,Astro 不碰它 |
src/content/posts/文章名/cover.png | image: ./文章名/cover.png | Astro 构建期压成 webp |
照片、截图一律走第二条,让构建期去压。走第一条的话,你放多大就发多大——我一开始把一张 2880×1800、334KB 的 PNG 扔进 public/,它就真的原封不动发出去了。
public/ 和 src/ 的区别就在这儿:public/ 是”原样复制”,src/ 才进构建流水线。
(图片优化在 Cloudflare 上是付费功能,但构建期压缩不花一分钱——反正 GitHub Actions 的机器是白送的。)
预览
pnpm dev # localhost:4321,热更新,日常写字用这个改一个字立刻看到。但有三样东西 pnpm dev 测不了,得走完整构建:
pnpm build # 必须先 build——Pagefind 索引是构建期生成的npx wrangler dev # localhost:8787- 搜索:dev 下是假的,会直接告诉你 “This Is a Fake Search Result”
- 301 重定向:
_redirects是 Cloudflare 读的,Astro 不管 - 404 页
NOTE
pnpm preview是坏的,别用。装了
@astrojs/cloudflareadapter 之后,产物是个 Worker,astro preview起不来——它不报错,就是静静地什么都不做,端口也不监听。要预览就
wrangler dev。
发布
git add -Agit commit -m "新文章:十年老blog搬家"git push # ← 就这一下,没了git push 就是发布。 推上去之后:
- GitHub 看到
main分支有新提交,翻出.github/workflows/deploy.yml - 起一台临时 Ubuntu,装依赖、跑公式校验、
pnpm build wrangler deploy把产物推到 Cloudflare- 几分钟后线上就是新的了
想看它跑到哪了:
gh run watch # 盯着这次构建gh run list --limit 3 # 最近三次的结果失败了也不用慌——Workers 是原子切换,新版本没成功之前,线上一直是老版本。构建挂了顶多是”没更新”,不会变成”站崩了”。
NOTE这是和 Hexo 时代最大的区别。
以前是
hexo deploy一把 force-push 到zlotus.github.io的master,GitHub Pages 再异步重建——那一两分钟里访问就是 Page not found。这也是这次搬家的起因:我以为我把十年的博客推没了。现在推的是源码,构建在 CI 里做,产物原子切换。推坏了也只是没上线,不会把线上弄没。
不用再 hexo deploy 了。也不用再祈祷那两分钟的重建窗口。
后记
搬完了。83 篇全在,旧 URL 一个没死,公式代码图片都好,还顺手修了老站两个 bug。
回头看,真正花时间的不是”用 Astro 重建一个博客”——那部分快得离谱。花时间的是:
- 搞清楚老站到底是什么样(Phase 0 那张表)
- 证明新站和老站一模一样(四道校验)
- 保证十年的链接不断(207 个 URL 逐个发请求)
以及一个反复出现的教训:
NOTE代码写完不算完,验证过才算完。
这次所有的坑——六个反引号、TOC 不显示、RSS 拼错路径、
</div></p>、单数字日期——没有一个是我”读代码”读出来的,全是校验器跑出来的。而唯一一个校验器自己出的错(Rust 泛型误报),恰恰是因为校验器写复杂了。
现在挂在 Cloudflare 上,橙云一开,国内访问也没问题。
下一步大概是评论和阅读计数——不过那得等我先用几天,看看到底需不需要。YAGNI。