从 Hexo 迁移到 Astro:图片找回、旧链接、欢迎页重写与双站点部署
这个博客 2016 年用 Hexo 搭起来,写到 2018 年就基本停了。这几年它悄悄坏掉了:图床域名过期,所有文章的图片都打不开;本地的 Hexo 环境早就没了,想改点东西都得先折腾半天。
这次干脆整体迁到 Astro,顺便把这些年欠下的问题一起解决。这篇文章记录整个过程,以及每一步背后的原理,方便以后自己回头查。
一、原来的状况
- 框架:Hexo + NexT 主题,源文件在
hexoWorkspace仓库,生成的静态网站推到tracerZzz.github.io仓库,由 GitHub Pages 托管。 - 写作:Typora 写 Markdown,图片用 PicGo 上传到七牛云,文章里引用七牛的外链。
- 域名:博客用
tracerzzz.cn(已备案),图片用的是另一个域名,已经过期。 - 首页:一个用 SVG + GSAP 补间动画做的欢迎页,点击里面的按钮才进入文章列表。动画代码当年是用 webpack 手动打包压缩的。
问题很明显:文章和图片分在两个地方,图床一挂,文章就废了一半。
二、目标和选型
- 图片跟着文章走:图片和 Markdown 放在一起,存进同一个 git 仓库。不再依赖任何图床。
- 旧链接不能断:以前分享出去的文章地址要能继续打开。
- 欢迎页保留:但要改成”改了源码就能自动打包”的形式,不再手动 webpack。
- 主站放国内,再留一个备用:主站用阿里云 ESA(边缘安全加速)的 Pages,国内访问快;GitHub Pages 作为备用站点。
框架选 Astro。主题一开始用的是 Retypeset:排版干净,以文字为主。上线后觉得太素,文章列表也不能显示图片,又换成了 Firefly(基于 Fuwari 二次开发):卡片式布局,有封面图、首页横幅、分类和侧边栏。换主题的过程见第十二节。
图片目录沿用 Typora 的习惯:每篇文章旁边一个同名的 .assets 文件夹,文章里用相对路径引用。
src/content/posts/git/├── git常用命令.md└── git常用命令.assets/ └── 20180514152628079993927.png这样做的好处是:Typora 粘贴图片时会自动放进这个文件夹;以后换 Obsidian 之类的编辑器,相对路径也照样能用;Astro 构建时会自动把这些图片压缩、转成 webp。
三、从七牛找回图片
图片的域名过期了,但七牛的存储空间还在,文件都在里面,只是没有可用的域名能下载。
思路:绕过域名,直接用 API 下载
七牛提供 S3 兼容接口,可以像操作 AWS S3 一样,用 AccessKey/SecretKey 签名请求,直接列出和下载空间里的文件,不需要绑定任何域名。
macOS 自带的 curl 8.x 内置了 AWS 签名功能:
curl --aws-sigv4 "aws:amz:cn-east-1:s3" \ --user "$AK:$SK" \ "https://s3.cn-east-1.qiniucs.com/<空间名>/<文件名>" -o 文件名写了一个脚本:先列出空间里所有文件,再逐个下载。AK/SK 运行时手动输入,不写进脚本,也不保存。
踩坑 1:签名一直报 SignatureDoesNotMatch
所有区域都返回 403 签名错误。原因是控制台上看到的”空间名称”,不一定是 S3 接口里用的 bucket 名。先调用 ListBuckets 接口列出真实的 bucket 名,再用它去请求,就好了。
原理:AWS SigV4 签名会把请求方法、路径、主机名、时间、区域等信息一起做 HMAC 计算。路径里的 bucket 名写错,服务端算出来的签名就和你的对不上,它不会告诉你”bucket 不存在”,只会说签名不匹配。所以遇到签名错误,先怀疑参数,而不是密钥。
踩坑 2:invalid args
改完后又报 invalid args。打印出来一看,AK/SK 两个变量的长度都是 0:在一个终端里 export 的变量,换了窗口就没了。改成脚本里用 read 读取,就解决了。
踩坑 3:macOS 的 sed 不认 \n
解析 S3 返回的 XML 列表时,用 sed 把标签替换成换行,在 Linux 上没问题,在 macOS 上却不生效。macOS 自带的是 BSD sed,替换内容里不支持 \n、\t 这种写法。改用 perl 解析,两边行为一致。
踩坑 4:中文文件名下载失败
178 个文件里有 6 个下载失败,全是中文文件名。macOS 自带的 bash 还是 3.2 版本,用它写的 URL 编码函数会按字符处理,不会按 UTF-8 的字节处理,中文就编码错了。签名用的路径和实际请求的路径对不上,又是签名错误。同样换成 perl 做 URL 编码,问题解决。
最后 178 个文件全部下载成功,文章实际用到的几十张图片一张不少。
四、转换文章
写了一个一次性的 Python 脚本(scripts/migrate-hexo.py),把 Hexo 文章转成 Astro 的格式。
文章开头的元信息(front matter)
三套写法对比(Hexo 是原来的,Retypeset 是第一版,Firefly 是现在用的):
| Hexo | Retypeset | Firefly(现在) | 说明 |
|---|---|---|---|
title | title | title | 不变 |
date | published | published | 统一加上 +08:00 时区 |
| 无 | updated | updated | 更新时间,可选 |
excerpt(里面嵌着 HTML 图片标签) | description | description | 去掉 HTML,只留纯文字。不写的话主题会从正文截取 |
tags | tags | tags | 不变 |
categories(可以多个) | 没有 | category(只能一个) | Retypeset 没有分类;Firefly 有,按文章所在文件夹自动填上 |
| 无 | 没有 | image | 封面图,显示在文章列表里;文章详情页不显示。后来全部不用了,见下面”封面怎么写” |
| 无 | pin: 1 | pinned: true | 置顶 |
_draft 目录 | draft: true | draft: true | 草稿不会发布 |
permalink 规则(全局) | abbrlink | slug | 文章网址,比如 git-commands 对应 /posts/git-commands/。Retypeset 用的是自己的 abbrlink 字段;换到 Firefly 后改用 Astro 自带的 slug,见第十二节 |
现在一篇文章开头的完整写法:
---title: git常用命令published: 2018-04-15T16:37:11+08:00description: 在开发过程中 git 已经是必不可少的版本控制工具……tags: - gitcategory: Gitimage: ./git常用命令.assets/20180514152628079993927.pngdraft: falseslug: git-commands---必填的只有 title 和 published,其他都可以省略。
封面怎么写
封面要在 image 里手动指定,主题不会自动拿正文里的图:
image: ./文章名.assets/cover.png- 路径:相对这篇
.md文件,和正文里插图的写法一样。用 Typora 粘贴图片后,把正文里那张图的路径复制过来就行;也可以专门放一张不在正文里的图。 - 不填或者留空:这篇没有封面,列表里只显示文字。
pnpm new-post生成的模板默认是image: ''。主题有”随机封面”功能(image: "api",从网上的随机图接口取图),没开。 - 会出现在哪:文章列表的卡片配图、分享海报、搜索引擎和社交软件的预览图(
og:image)。文章详情页不显示。 - 为什么详情页不显示:Firefly 默认会在文章标题下面再放一张大封面(
src/config/coverImageConfig.ts里的enableInPost)。迁移时脚本给 11 篇有图的文章填的封面都是正文第一张图,开着这个选项,进文章就会先看到封面、紧接着又是同一张图。所以把enableInPost改成了false。这是主题给用户改的配置项,不用动源码,也不用删文章里的图。以后如果想让某篇文章顶部有大图,就直接把图放在正文开头。 - 后来决定全部不用封面(2026-10-31):试过给两篇新文章画专门的封面插画,但主题的封面框是固定比例的(电脑端列表约 1.5 : 1,手机端网格 2 : 1),图片按填满方式显示,不同比例下总要裁掉一部分,画面里的文字很容易被切掉。最后把所有文章的
image都删了(10 篇,封面原本都是正文里的第一张图,正文里的图都还在),列表只显示文字;想要配图的文章,把图放在正文开头。pnpm new-post模板里的image: ''保留,以后哪篇确实想要封面,再手动填上。代价是分享海报和社交软件的预览里也就没有图了。
有个小坑:Hexo 的 excerpt 里直接写了没加引号的 HTML,YAML 解析会报错。脚本遇到解析失败时,先去掉 excerpt 再重试。
正文里的图片
- 正文里的
<img src="...">统一转成 Markdown 的写法,Astro 才会处理这些图片(压缩、生成多种尺寸)。 - 所有指向旧图床的地址,改成
./文章名.assets/文件名,同时把图片复制到对应文件夹。 - 转换完后检查,确认没有残留的旧域名。
文件命名
最开始文件名用的是英文(和网址一致),后来改成了文章标题,比如 git常用命令.md。因为网址由开头的 slug 决定,和文件名、文件夹都无关(迁移时叫 abbrlink,换主题后改成了 Astro 自带的 slug),文件名用中文在编辑器里更好认,移动文件或改名也不会影响网址。文件夹按主题分,统一用英文。
文件名里要避开两类字符:
- 空格:Markdown 图片链接里的路径不能有空格,有空格就得用
<>包起来,很容易出错。 +:在 URL 的查询参数里,+会被当成空格解码。开发模式下图片地址正好是通过查询参数传递的。
五、旧链接怎么不失效
Hexo 的网址格式是 /年/月/日/分类/标题/,比如 /2018/04/15/git/git常用命令/。新博客是 /posts/git-commands/。
静态托管没有服务器可以配置重定向规则(至少不能每个平台都配),所以用了最通用的办法:在旧地址生成一个只做跳转的页面。
<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>git常用命令</title><link rel="canonical" href="/posts/git-commands/"><meta name="robots" content="noindex"><meta http-equiv="refresh" content="0; url=/posts/git-commands/"></head><body><p>文章已搬到新地址:<a href="/posts/git-commands/">git常用命令</a></p></body></html>这些页面放在 Astro 的 public/ 目录里,构建时原样复制。
meta refresh让浏览器立即跳转。canonical告诉搜索引擎,正式地址是新的那个。noindex让搜索引擎不要收录这个跳转页。
这个方案和主题、部署平台都无关,换主题、换平台都不用改。
后来(2026-10-25)整理旧文章时,把一批内容单薄的文章设成了草稿,不再发布。它们的旧地址如果还指向 /posts/...,就会跳到一个不存在的页面,构建检查也会报错。处理方式是:内容被合并到其他文章的,跳转改指向新文章(比如 webbench 压测那篇合并进了 Nginx 那篇);单纯下线的,跳转改指向归档页 /archive/,页面上说明这篇旧文已下线。
六、欢迎页:从手动打包到 Astro 自动打包
原来的欢迎页由几部分组成:一个 316KB 的 HTML,里面塞满了内联 SVG;动画用 GSAP 1.x 的 TimelineMax;还依赖 jQuery、jquery-ui 的滑块、drawsvg 描边插件、randomColor 随机颜色。当年用 webpack 手动打包,每次改都要重新打包一遍。
改写方式
- 新建一个
src/pages/welcome.astro,放在/welcome/。最初是不套博客主题布局的独立页面,后来为了让音乐不中断,套进了主题的页面框架(见第十二节)。 - 大幅 SVG 插画拆成组件,放在
src/components/welcome/。 - 动画逻辑写成 TypeScript,放在页面的
<script>里。Astro(底层是 Vite)会自动打包压缩,以后改完源码直接pnpm build就行。
为什么没用 GSAP
当时我的开发环境装不了 npm 依赖,就算装上了,也会和项目锁定的依赖版本对不上。所以干脆自己写了一个两百多行的小补间库(tween.ts),刚好够用:
- 时间线采用”确定性渲染”:给定任意时间点 t,从初始状态出发,按开始时间依次计算每个补间在 t 时刻的值。这样拖动进度条到任意位置都能正确显示,循环播放也只是
t % 总时长。 - 每个补间的起始值预先算好:等于它开始那一刻,前面所有补间叠加出来的值,相当于 GSAP 在补间第一次渲染时才记录起始值的行为。
- SVG 的旋转和缩放以元素中心为原点:用
getBBox()求出中心,拼成translate(x y) rotate(r ox oy) translate(ox oy) scale(s) translate(-ox -oy),等同于 GSAP 的transformOrigin: "50% 50%"。 - 描边动画:用
stroke-dasharray从0 L过渡到L 0(L 是路径总长)。 - 缓动函数:照着 GSAP 的公式实现,比如 Power3.easeInOut 就是四次方的缓入缓出,Back.easeInOut 带回弹。
- jquery-ui 的滑块换成原生的
<input type="range">,背景圆点的颜色变化直接用 CSS transition。 - 菜单里的音乐按钮,后来从”跳到网易云主页”一路改成了”全站共用一个播放器、切换页面音乐不断”,过程见第十二节的”音乐”一段。
用截图对比来保证还原度
为了确认效果和原来一样,用 Playwright 打开新旧两个页面,用它的虚拟时钟把动画快进到同样的时间点,然后截图对比。这个过程中发现了几个原版里”看起来是这样,实际不是”的细节:
- 花瓶插画其实没有描边动画。原代码对它调用了描边函数,但参数里有个
fill: undefined,GSAP 处理时出错,那个补间实际没生效。花瓶的线条其实是填充出来的形状,看到的变色是填充色在变。 - 右侧中心图案的变色看不出来。原代码改的是外层
<g>的 fill,但里面每条 path 自己都设了 fill 属性,会覆盖从父元素继承来的颜色。 - 背景圆点一直是半透明的。原代码同时写了
opacity: 1和css: {...}。在 GSAP 1.x 里,一旦提供了css对象,外层的属性就不再被当作 CSS 处理,所以opacity: 1根本没生效,圆点一直保持 CSS 里的 0.22。
这些”bug”都按原样还原了,因为最终要的是看起来一样。
七、本地环境的小插曲
- 不是管理员账号:这台 Mac 没有管理员权限,Homebrew 找不到现成的安装包,只能从源码编译 Node,结果连 LLVM 一起编译,几个小时都编译不完。改用 nvm 安装 Node:nvm 下载官方编译好的版本,装在用户目录下,不需要管理员权限,几分钟就好。
- 私有仓库 clone 要登录:这和 git config 没关系,git config 里的用户名和邮箱只用来给提交署名。在 GitHub 生成一个只授权这个仓库的细粒度访问令牌(Fine-grained token),clone 时当作密码输入,macOS 钥匙串会记住它。
Command "dev" not found:在错误的目录里运行了pnpm dev。那个目录里没有项目的package.json,pnpm 当然找不到dev命令。
八、开发模式和构建模式不一样
本地运行 pnpm dev 时,发现图片有时显示不出来,页面样式也乱了。但运行 pnpm build && pnpm preview 就一切正常。
pnpm dev | pnpm build + pnpm preview | |
|---|---|---|
| 页面 | 打开时才生成 | 一次性生成到 dist/ |
| 图片 | 第一次请求时才转 webp,大图要等几秒 | 构建时全部转好 |
| 样式 | 边扫描边生成(Retypeset 用的 UnoCSS),偶尔会缺 | 一次性生成完整 |
| 草稿 | 显示 | 不显示 |
| 用途 | 写作时即时预览 | 确认上线后的样子 |
图片还带着 loading="lazy",滚到附近才开始加载。开发模式下,懒加载再加上第一次现场转码,看起来就像图片打不开。遇到这种情况刷新一下就好,线上不会这样。
九、自动检查
光看构建成功还不够:图片少了一张、旧链接跳转到不存在的页面、草稿被误发布……这些都不会让构建失败。所以写了 scripts/verify-build.sh,每次构建后自动检查:
- 每篇已发布的文章都生成了页面;
- 每篇文章页面里的图片数量,不少于 Markdown 里引用的数量;
- 页面里不再出现过期的旧图床域名;
- 草稿没有被发布;
- 每个旧链接跳转页都在,并且跳转目标存在;
- 欢迎页存在,菜单链接和打包后的脚本都在。
检查脚本先用”故意弄坏”的情况测试过,确认它真的能发现问题。然后接入 GitHub Actions:每次推送都执行一遍构建和检查。
一个小细节:检查欢迎页有没有 <script type="module"> 时,第一次检查失败了。原因是 HTML 压缩工具会调整属性顺序、去掉属性的引号,构建出来的实际是 <script src=... type=module>。所以检查规则要写得和属性顺序、引号都无关。
十、分支整理
- 原来的
master是 Hexo 生成的旧网站,改名为hexo-site留作存档。 - 新的 Astro 项目从
astro分支改名为master,作为默认分支。
默认分支改了以后,GitHub 的 Dependabot 安全提醒(之前报的都是旧 Hexo 的依赖)也随之消失了,因为它只检查默认分支。
十一、部署
GitHub Pages(备用站)
私有仓库用 GitHub Pages 要付费(GitHub Pro)。博客内容本来就是公开的,所以直接把仓库改成了公开。公开前确认过:仓库里没有密钥,草稿被别人看到也无所谓。
接着又碰到一个报错:GitHub 自带的 Jekyll 构建失败,报 Invalid YAML front matter in .../NoteIcon.astro。
原因:Pages 的来源还是”Deploy from a branch”,GitHub 会把分支内容当成 Jekyll 网站来构建,它把 .astro 文件开头的 --- 当成了 YAML 元信息。解决:把来源改成”GitHub Actions”,由我们自己的工作流构建 Astro,再用 actions/deploy-pages 发布。
Custom domain 留空,用默认地址 tracerzzz.github.io。因为一个域名同一时间只能解析到一个地方,tracerzzz.cn 留给主站 ESA。
阿里云 ESA(主站)
三方的关系
访客输入 tracerzzz.cn │ ▼① 域名注册商(万网):登记"这个域名由谁来解析" │ ▼② DNS 解析:把域名翻译成服务器地址 │ ▼③ ESA:边缘节点 + Pages 托管网站- ESA 站点是
tracerzzz.cn在 ESA 里的总管理处:DNS 解析、HTTPS 证书、缓存、安全防护、跳转规则都在这里。 - ESA Pages 项目是网站本身:从 GitHub 拉代码,构建,发布。
- 关系是:Pages 产出网站,站点把网站挂到域名上。
NS 接入
接入方式有两种:
- CNAME 接入:DNS 留在原来的服务商,每个子域名手动加一条 CNAME 指向 ESA。
- NS 接入:把域名的 DNS 服务器整体交给 ESA。
选了 NS 接入:在万网把 tracerzzz.cn 的 DNS 服务器改成 ESA 给的两个地址,以后所有解析都在 ESA 里管理。根域名也能直接用,Pages 绑定域名时会自动生成解析记录。
改完 NS,本机 dig 查到的还是旧的。分层排查:
# 直接问 .cn 注册局,不走任何缓存dig NS tracerzzz.cn @a.dns.cn +norec注册局返回的已经是 ESA 的 NS(*.ns.atrustdns.com),TTL 是 86400 秒。也就是说修改已经生效,只是各级 DNS 的缓存最长要 24 小时才会过期。ESA 验证时查的是权威结果,所以不用等缓存,可以直接验证通过。
Pages 构建失败两次
Unknown command: "build":控制台里填了npm build,npm 没有这个命令,应该是npm run build。而且项目用的是 pnpm,安装和构建都应该用 pnpm。ESA 的构建环境里没有预装 pnpm,所以安装命令要写成npm install -g pnpm && pnpm install:先用 npm 把 pnpm 装上,再用 pnpm 按锁文件安装依赖。Both assets and function js file are not found:构建其实成功了,但没告诉 ESA 发布哪个目录。
为了不再依赖控制台设置,在仓库根目录加了 esa.jsonc:
{ "installCommand": "npm install -g pnpm && pnpm install", "buildCommand": "pnpm build", "assets": { "directory": "./dist", "notFoundStrategy": "404Page" }}有了这个文件,ESA 会优先用它,控制台的设置就不起作用了。配置跟着代码走,修改也有记录可查。
绑定域名:为什么 DNS 记录列表是空的
在 Pages 项目里绑定 tracerzzz.cn 后,站点的 DNS 记录列表里却显示 0 条。原来 Pages 绑定的域名不会显示成普通的 A 或 CNAME 记录,而是单独列在”边缘函数记录”里。
背后的原理:
- NS 已经交给 ESA,所以任何人查询
tracerzzz.cn,最终都会问到 ESA 的 DNS。 - ESA 的 DNS 发现这个域名绑定了 Pages,就返回离访客最近的边缘节点 IP。
- 浏览器连到这个节点,请求里带着
Host: tracerzzz.cn。节点根据它找到对应的 Pages 项目,再去找当前部署版本里的文件。比如请求/posts/git-commands/,就返回dist/posts/git-commands/index.html。 - 文件第一次被请求时从 ESA 的存储读取,之后缓存在节点上。
整个过程中没有一台属于我自己的服务器。
根域名和 www
tracerzzz.cn(根域名,主机记录 @)和 www.tracerzzz.cn(子域名,主机记录 www)在 DNS 里是两个独立的域名,解析、证书都要分别配置。博客的正式地址是不带 www 的那个,www 也绑到同一个 Pages 项目上,两个地址打开的是同一个网站。本来打算再加一条 301 规则把 www 跳到根域名,后来觉得没必要:文章里的 canonical 链接和 RSS 写的都是根域名,搜索引擎会按根域名收录,www 能打开就够了。
HTTPS
ESA 免费版自带 Let’s Encrypt 免费证书,自动续期。申请到的是泛域名证书,证书里登记的域名(SAN)同时包含 *.tracerzzz.cn 和 tracerzzz.cn。这一点要确认:泛域名 *.tracerzzz.cn 本身不包含根域名,* 只匹配”某个前缀 + 点”,有些平台签发时不会自动带上根域名。
还开了两个选项:
- 强制 HTTPS:http 请求用 301 跳转到 https。
- OCSP Stapling:浏览器要确认证书没被吊销,不开的话得自己去问证书颁发机构。开了以后,ESA 提前准备好”证书有效”的证明,随证书一起发给浏览器,首次连接更快。
HSTS 暂时没开:它会让浏览器在设定的时长内只认 HTTPS,一旦配置有误很难撤回,等稳定运行一段时间再说。
十二、换主题:从 Retypeset 到 Firefly
为什么能换得这么轻松
前面的结构从一开始就和主题无关:文章和图片在 src/content/posts/,旧链接跳转页在 public/,欢迎页是独立页面,ESA 配置和检查脚本也和主题没关系。真正要处理的只有两件事:文章开头的字段名,和文章网址的生成规则。
换主题时没有直接在 master 上改,而是开了一个 theme-firefly 分支:把 Firefly 的代码放进去,再把文章、图片、跳转页、欢迎页、ESA 配置、检查脚本搬回来。GitHub Actions 会构建这个分支、跑检查,但不发布;本地 pnpm dev 看满意了,再合并到 master。
保持网址不变:从 abbrlink 换成 slug
Firefly 用文件路径生成文章网址,比如 git/git常用命令.md 会变成 /posts/git/git常用命令/。直接这样用的话,所有网址都会变,旧链接跳转页也全部失效。
第一版做法:沿用 Retypeset 时代的 abbrlink 字段,在内容集合里自定义文章 ID:
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts", // 有 abbrlink 就用 abbrlink 当 ID,没有才用文件路径 generateId: ({ entry, data }) => typeof data.abbrlink === "string" && data.abbrlink ? data.abbrlink : entry.replace(/\.(md|mdx)$/i, ""),}),主题里所有生成文章链接的地方用的都是这个 ID,所以网址都变回了 /posts/git-commands/。能用,但这是改了主题的代码,以后升级主题时,这几行可能会被覆盖,或者和新版本冲突。
现在的做法:后来发现根本不用改代码。Astro 的 glob 加载器本身就有这个规则:文章开头写了 slug,就用它当 ID;没写,才用文件路径。Firefly 自带的 pnpm new-post 生成的也是 slug 字段。于是把所有文章的 abbrlink 改名为 slug,再删掉上面那段自定义代码,content.config.ts 就和主题原版一模一样了。网址一个没变。
slug 只用小写字母、数字和连字符,不要带 /,带了网址里就会多出一层目录。
分类和封面
- 分类:用脚本给每篇文章补上
category,值按所在文件夹来,比如git/对应”Git”,database/对应”数据库”。 - 封面:用正文里的第一张图(后来发现详情页会重复显示,关掉了详情页的封面,见第四节”封面怎么写”)。这里又踩了一次”代码里的示例被当成真图片”的坑:迁移这篇文章里有一句举例用的
,被脚本当成了封面,构建时找不到这个文件。改成跳过代码块里的内容就好了。
进出欢迎页时的残影
Firefly 用 swup 做页面过渡:切换页面时不整页刷新,而是只替换页面里几块固定的区域(横幅、正文、侧边栏),再配上淡入淡出。欢迎页是独立页面,没有这几块区域,所以进出欢迎页时只换掉了一部分,剩下的就成了残影。
解决办法是让 swup 跳过欢迎页:
swup({ // ... ignore: (url) => new URL(url, window.location.href).pathname.startsWith("/welcome") || window.location.pathname.startsWith("/welcome"),}),目标是欢迎页,或者当前就在欢迎页,都按普通方式整页跳转;博客内部各页面之间照样有过渡动画。
后来为了让音乐在进出欢迎页时不中断,把欢迎页套进了博客的页面框架,残影的根源没了,这条规则也随之删掉,见下面”音乐”一段。
分页后横幅位置不一样
文章列表翻到第二页,横幅的壁纸取景和第一页不一样。原因是 Firefly 把横幅分成两种:首页的横幅更高、带大标题;其他页面的横幅会整体上移一截,留出更多空间给正文。主题只把 / 当首页,第二页的地址是 /2/,被当成了普通页面。
把分页地址(/2/、/3/……)也算作首页就好了。这个判断主题里有三处:生成页面时一处,浏览器里 swup 切换页面时两处。三处都要改,否则直接打开第二页正常,从第一页点过去又会不一样。
备用站上的分享链接还是主站域名
在 tracerzzz.github.io 上点分享,复制出来的却是 tracerzzz.cn 的地址。原因是网站是同一份代码,两边各构建一次,构建时文章的完整地址都按配置里的主域名写进了页面。
修法是在浏览器里打开页面时,把分享用的地址换成当前访问的域名:
function shareUrl(): string { const u = new URL(url, window.location.href); return window.location.origin + u.pathname + u.search + u.hash;}一共三处:分享弹窗里的”复制链接”、分享海报上的二维码、文章底部版权信息里的”本文链接”。
页面里给搜索引擎看的”正式地址”(<link rel="canonical">)和 RSS 里的链接故意没改,还是指向 tracerzzz.cn。两个站点内容完全一样,这样搜索引擎只会收录主站,不会把备用站当成重复内容。
音乐:从”跳到网易云主页”到”全站切换不断播”
这一块前后改了六版,每一版都是被上一版的问题逼出来的。
1. 原来的链接是死链。 欢迎页的音乐按钮链接到我的网易云个人主页。用没登录的浏览器打开,直接是 404。给别人看等于没有。
2. 网易云外链播放器 + 弹窗。 网易云提供官方的外链播放器(music.163.com/outchain/player?type=0&id=歌单ID),不用登录就能播放公开歌单。先做成点击音乐图标弹出一个窗口,关闭窗口时把播放器整个移除,音乐也就停了。问题是弹窗一直挡在页面中间,不友好。外链播放器还放不了 VIP 歌曲,歌单里只有免费的歌能完整播放。
3. 改成悬停浮出。 鼠标移到音乐图标上,旁边浮出一个小播放器;移开后隐藏,音乐继续放。几个细节:
- 鼠标移开后延迟 0.4 秒再隐藏,否则从图标移向播放器的途中就消失了。
- 隐藏用
visibility: hidden,不用display: none,也不移除 iframe,保证声音不断。 - 手机上没有悬停,改成点一下显示、再点一下隐藏。
- 浮窗做成半透明的毛玻璃(
backdrop-filter: blur()),边框换成很淡的白线。中间那条播放条的底色是网易云页面决定的,跨域改不了。
4. 想显示”正在播放”,碰上了同源策略。 播放器在 iframe 里,地址是 music.163.com,外面的欢迎页是 tracerzzz.cn。浏览器的同源策略禁止一个页面读取另一个域名页面的内容,我们拿不到它的播放状态、当前歌曲,也收不到播放和暂停事件。跨域页面之间唯一正规的通信方式是 postMessage,但这需要网易云在播放器里主动发消息,它没有提供这样的接口。
不过网易云播放器的完整版自带当前歌曲和歌单列表。用了一个小技巧来加”播放列表”按钮:始终嵌入完整版,平时用外框裁到只露出顶部的播放条;点按钮时把外框拉高,露出下面的歌单。因为一直是同一个 iframe,展开、收起时音乐不会中断。
5. 博客里也要能听,而且要和欢迎页共享进度。 Firefly 自带一个音乐播放器,能通过 Meting 接口读取网易云歌单,在导航栏和侧边栏播放。Meting 是第三方搭的接口,主题里配了几个备用地址,一个失效会自动换下一个;VIP 歌曲会被接口跳过。只改了配置文件(歌单 ID 和开关),没改主题代码。博客页面之间切换时音乐不中断,因为 swup 只替换正文,播放器所在的部分一直保留着。
欢迎页的外链播放器在跨域的 iframe 里,读不到进度,没法和博客共享,所以欢迎页也改用了同一个播放器(主题的 MusicManager),另做了一个迷你界面:封面、歌名、进度条、上一首 / 播放 / 下一首、可展开的歌单。界面通过主题播放器公开的 window.__fireflyMusic 接口控制,监听它发出的 fm:track、fm:time 等事件更新显示。
但欢迎页当时还是独立页面,进出欢迎页都是整页刷新,音乐会断。于是加了 music-resume.ts:离开页面(pagehide)前把”第几首、播到第几秒、是否在播放”存进 sessionStorage,新页面的播放器就绪后跳到同一首、同一位置继续。这里有个时序坑:主题播放器初始化时先发 fm:init 事件、再加载第一首,如果在 fm:init 里马上恢复,会被它紧接着的”加载第一首”覆盖,所以要晚一拍再恢复。
6. 还是会卡一下,最后让欢迎页”不换页”。 整页跳转时,浏览器会把旧页面连同正在播放的音频整个销毁,新页面要重新加载、缓冲、跳转进度,中间总会停一两秒。这个停顿任何技巧都绕不开,想真正无缝只能不换页。考虑过几种办法:
| 办法 | 能否无缝 | 问题 |
|---|---|---|
弹出一个独立的播放器小窗口(window.open) | 能 | 体验老派,可能被拦截弹窗,手机上不好用 |
| 预渲染(Speculation Rules)+ 缓存音频地址 + 淡入 | 不能,只是停顿变短 | 只有 Chrome 系浏览器支持预渲染 |
| 把欢迎页拆进主题模板 | 能 | 要改主题代码,升级麻烦 |
| 把欢迎页整个套进博客的页面框架 | 能 | 改动都在自己的文件里 |
最后选了最后一种:
welcome.astro改成和”关于”页一样用主题的MainGridLayout,原来的欢迎页内容不拆,整体放进一个铺满全屏、盖在最上层的图层#welcome-root里。播放器在页面框架里,swup 切换时只换内容区域,播放器不销毁,音乐完全不断。- 残影的根源(欢迎页没有 swup 需要的容器)也随之消失,前面那条”欢迎页不走 swup”的规则删掉了,对主题配置的修改反而少了一处。
- swup 切换时不会重新执行页面脚本,所以欢迎页的动画改成了
mountWelcome():进入时启动,返回一个清理函数;离开时停掉时间线和定时器,并用AbortController一次性移除挂在window、document上的监听。由一个注入到每个页面的controller.ts负责调用:新内容里出现#welcome-root就挂载;离开时先淡出,新内容换上后再卸载。 #welcome-root挂载时会被移到<body>下面。因为position: fixed的元素,如果某个祖先设置了transform,就会相对那个祖先定位,而不是相对窗口;主题切换页面时会给内容区域加transform做动画。- 样式全部限定在
#welcome-root里。原来针对html, body的”禁止滚动”改成html:has(#welcome-root) { overflow: hidden }:欢迎页在的时候生效,换走了自然失效,不用手动恢复。几个通用的元素 ID(#main、#menu等)加了wl-前缀,避免和主题冲突。 - 被盖住的博客页面会在后台照常加载。看起来多加载了东西,其实相当于提前把博客的样式和壁纸缓存好了:访客在欢迎页看动画的时候它们就加载完了,点进博客反而更快。
- 又踩了一个时序坑:
controller.ts要等 swup 初始化好才能注册钩子,而主题的 swup 是在页面空闲时才初始化的,比这个脚本晚。光监听swup:enable事件有时会错过,最后加了定时检查兜底。
欢迎页上的 GitHub 按钮是外部链接,在当前页面打开会把整个页面换掉,音乐也就停了,所以改成在新标签页打开(target="_blank")。博客导航栏和横幅上的 GitHub 链接,主题本来就是在新标签页打开的。
现在只有按 F5 刷新页面才会整页重新加载,music-resume.ts 留着处理这种情况,会停一两秒再接着放。浏览器的自动播放策略也要考虑:大多数情况下 Chrome 允许接着放,Safari 可能拦下来,这时点一下播放,会从原来的位置继续。
进入欢迎页时博客一闪而过
欢迎页并进博客的页面框架以后,出现了一个新问题:刷新欢迎页,或者从博客点按钮进欢迎页,都会先看到博客页面一闪,然后才是黑色的欢迎页。原因是欢迎页的黑色图层要等脚本跑完才能铺满窗口,而且铺上以后还从透明开始淡入,这段时间里底下的博客就露出来了。具体分三处:
- 刷新时,脚本执行前盖不住。 欢迎页在 HTML 里位于主题的
#content-wrapper里面,这个元素带主题的入场动画:一开始透明度为 0,还有transform位移。里面的欢迎页跟着透明,而且前面说过,祖先有transform时position: fixed相对祖先定位,只能盖住中间的内容栏。把欢迎页移到<body>下的controller.ts是模块脚本,要等 HTML 解析完才执行,在这之前导航栏、横幅壁纸已经画出来了。 - 从博客切换过来时,下载期间什么都没盖。 swup 先把旧内容淡出、去下载欢迎页,导航栏和横幅一直在;内容换上后,因为欢迎页不算首页,主题还会把横幅从首页的高度收成普通页面的高度。
- 欢迎页自己从透明淡入。
#welcome-root上有 0.3 秒的淡入,淡入时透出底下正在变化的博客。
对应的改法,都在自己的文件里,没碰主题代码:
#welcome-root的第一个子元素是一小段内联脚本(<script is:inline>),浏览器一解析到这里,就用document.currentScript.parentElement找到欢迎页、移到<body>下,不等模块脚本。放在最前面而不是最后面,是因为欢迎页里有 300KB 的内联 SVG,放最后要等它们都解析完。元素移走后,浏览器后面解析出的内容照样会放进这个元素里。同时加了一条 CSS:#content-wrapper:has(#welcome-root)关掉入场动画和transform,双保险。这条规则只在欢迎页还在内容区里的那一瞬间生效;一开始写成了html:has(#welcome-root) #content-wrapper,范围太大,欢迎页移走后也一直生效。- 黑色背景一开始就不透明,淡入只作用在里面的内容(
#wl-main)上。 controller.ts在 swup 的visit:start里判断:如果要去欢迎页,立刻盖上一层黑色遮罩,挡住下载等待和横幅收缩;欢迎页铺满窗口后撤掉,visit:end时再检查一次,保证切换失败时遮罩不会一直留着。遮罩的样式直接写在元素上,因为在博客页面上还没加载欢迎页的样式表。
花瓶的线条不”长”了
上线后对比旧版,发现左边的花瓶少了一个效果:旧版是线条沿着轮廓慢慢”长”出来,新版只是整体淡入,看着发虚。
查了一下,是移植时漏掉了一行。旧代码里花瓶有两组动画:#gflower 的透明度和颜色变化,以及 drawToTimeline("#flower", tl, { duration: 38, strokeWidth: 1, position: 5 }),后者从第 5 秒开始、用 38 秒把描边一点点画出来。新版只搬了前一组。
描边”生长”的原理是 SVG 的虚线属性:stroke-dasharray: 已画长度 剩余长度。用 getTotalLength() 拿到路径总长 L,从 0 L(实线段长 0,全是空白)补间到 L 0(整条都是实线),线条就像沿着路径画出来一样。花瓶整幅图其实是一条 260KB 的 <path>,所以是从一处开始,沿着轮廓一路画下去。
补回这一行时还顺手修了补间引擎的一个问题:描边动画原来用的是 to,开始之前读到的起始值是”已画完”,于是开始前会先显示完整的描边,到点才清空再画;每轮循环重新开始时也一样。改成 fromTo 并明确起点 draw: 0 后,开始之前一直保持”还没画”的状态。右边圆形图案的描边也跟着受益。
评论:从 Gitment 到 Giscus
旧博客用的是 Gitment:每篇文章对应仓库里的一个 GitHub Issue,访客用 GitHub 账号登录后评论,评论就是 Issue 下面的回复。翻了一下,当年每篇文章都建了 Issue,真正有评论的只有两篇,各一条,这次就不迁了。Gitment 本身早就不能用了,它依赖作者搭的一个登录中转服务,那个服务已经停了。
Firefly 内置了五种评论系统,改 src/config/commentConfig.ts 就能切换:
| 方案 | 评论存在哪 | 访客要登录吗 | 要自己搭服务吗 | 国内访问 |
|---|---|---|---|---|
| Giscus | 仓库的 GitHub Discussions | 要,GitHub 账号 | 不用 | 能用,偶尔慢 |
| Twikoo | 自己部署的云函数 + 数据库 | 不用 | 要 | 好 |
| Waline | 自己部署的后端 + 数据库 | 不用 | 要 | 看部署在哪 |
| Artalk | 自己的服务器 | 不用 | 要 | 好 |
| Disqus | Disqus 公司 | 要 | 不用 | 基本打不开 |
选了 Giscus。它和 Gitment 是一个思路,借 GitHub 存评论,只是从 Issues 换成了 Discussions。不用部署后端,不花钱,数据在自己仓库里,不怕第三方服务关停;技术博客的读者大多也有 GitHub 账号。代价是没有 GitHub 账号的人没法评论,想让任何人都能匿名评论,就得换 Twikoo 这类要自己搭后端的方案。
配置步骤:
- 仓库 Settings → General → Features 里打开 Discussions。Giscus 要求仓库是公开的,前面为了 GitHub Pages 已经改成公开了。
- 安装 Giscus 的 GitHub App,只授权这一个仓库。
- 在
commentConfig.ts里把type改成giscus,填上仓库名、仓库 ID、分类和分类 ID。分类用的是 Announcements,这是 Giscus 推荐的:这个分类只有仓库管理员和 Giscus 能新建讨论,别人没法在里面乱发帖。 mapping用pathname:按文章网址找对应的讨论。主站和备用站的同一篇文章网址路径一样,所以两边共用一组评论;网址是slug决定的、不会变,评论也就不会对不上。
仓库 ID 和分类 ID 一般是去 giscus.app 填仓库名自动生成的。这次是直接从 GitHub 的接口查到的:仓库信息里的 node_id 就是仓库 ID,分类 ID 从开启 Discussions 时自动生成的那条欢迎帖里能看到。
顺带处理了一个安全问题:旧的 Hexo 配置文件里写着 Gitment 用的 GitHub OAuth 应用密钥(client_secret),它在 hexoWorkspace 仓库里,也被打包进了 hexo-site 分支的每个页面。仓库公开后谁都能看到,所以把那个 OAuth 应用删掉了。教训是:往仓库里提交配置前,先确认里面没有密钥。前端页面里出现的东西,就当作是公开的。
其他小事
- 横幅壁纸换成了饥荒联机版的加载界面,开了轮播,淡入淡出切换。头像换回了原来 Hexo 博客用的柴犬。
- 壁纸换成了无损原图:两张壁纸是游戏里的加载界面 Open the Gateway 和 Axiom Visus。本来想换 4K 的,查了一圈发现没有:官方动画短片在 YouTube 上最高只有 1080p;游戏文件里的加载界面原图也就 1920×1080 和 1820×1024,饥荒 Wiki 上收录的一百多张加载界面里,最大就是 1920×1080,网上标着 4K 的都是放大的。能改进的只有画质:原来用的是有损压缩过的 WebP,主题构建时还会再压一次;换成 Wiki 上从游戏里提取的无损原图(转成无损 WebP 存进仓库,像素完全一致,文件比 PNG 小),构建时只压一次。主题在电脑上最大只输出 1920 宽的壁纸,所以原图有 1920 宽就够了,再大也用不上。
- 一个小插曲:Wiki 会根据浏览器支持的格式返回图片,网址是
.png,用浏览器”存储图像为”存下来的却可能是 WebP。文件头是RIFF....WEBPVP8L,VP8L表示无损 WebP;如果是VP8(后面带空格)就是有损的。
- 一个小插曲:Wiki 会根据浏览器支持的格式返回图片,网址是
- 关掉了用不上的功能:友链、留言板、动态、相册、公告、打赏、看板娘。在
src/config/siteConfig.ts里都是一个开关。 - 删掉了主题仓库自带的 GitHub 配置:Issue 模板、赞助按钮、Dependabot。Dependabot 是给主题作者自己的仓库自动升级依赖用的,留着它会在我的仓库里不停建分支。
pnpm new-post也改了:生成的slug只取文件名部分(不带文件夹),日期带上+08:00时区。- 文章日期加了文字:主题在文章页标题下会同时显示发布日期和更新日期,但只用两个图标区分,并排放着分不清。改了
PostMeta.astro,在日期前加上「发布于」「更新于」(主题自带这两条文案,直接复用)。首页列表卡片本来就不显示更新日期,保持不变。 - 点 PDF 链接回不去:文章里的专利 PDF 链接,点了以后地址栏变成
.pdf,页面却没换,浏览器返回也乱了。原因是主题用 swup 做无刷新页面切换,它默认拦截所有站内链接,把 PDF 也当成网页去加载。在astro.config.mjs的 swup 配置里加了ignore,PDF、压缩包、Office 文档、音视频这类文件交给浏览器正常打开。 - 导航栏图标闪一下:导航栏的 logo 直接用
favicon.svg,这个 SVG 只写了 viewBox,没写宽高;<img>的尺寸又全靠 CSS 类。整页刷新时,CSS 加载完之前浏览器会按默认尺寸把它画出来,就会看到一个大图标一闪而过。给<img>直接写上width="28" height="28"就好了。 - 博客名改回朴素的写法:原来叫
À Çraftman,A 和 C 头上加了法语的变音符号,当时觉得有意思,后来看着有点非主流:不好打、不好搜,“Craftman” 本身也拼错了。改成了 A Craftsman(工匠),和描述”想当木匠的程序员!“对得上。站点标题、横幅标题、欢迎页描述、图标注释一起改了。 - 页脚补上备案号:tracerzzz.cn 在阿里云做过 ICP 备案,按规定网站底部要显示备案号并链接到工信部的备案系统。旧 Hexo 博客的页脚有这一行,迁移时漏掉了。主题留了
src/config/FooterConfig.html专门放自定义页脚,加一行京ICP备18058118号-1就行。用的是网站备案号(主体备案号后面带-1,旧博客只写了主体号)。旧博客链接的www.miitbeian.gov.cn早已停用,现在的地址是https://beian.miit.gov.cn/。链接只能指向首页,不能带上备案号直接跳到查询结果:备案系统是单页应用,查询要先过滑块验证,结果页没有可分享的网址。 - 本地预览的小坑:切到新主题的分支后,再运行一次
pnpm dev,如果旧主题的预览服务还开着,新的会自动换到 4322 端口。这时去 4321 端口看到的还是旧主题。
网站图标:丁字尺
原来的网站图标(favicon)是 Hexo 时代留下的,导航栏左上角是主题图标库里的锤子扳手,两者也不统一。这次一起换成了自己设计的”丁字尺”:
- 为什么是丁字尺:tracerZzz 的 T,本身就是一件工具。丁字尺是画直线用的 T 形尺子,木工、制图都用,竖杆上有刻度,正好对应博客名 A Craftsman(工匠)。小尺寸下刻度看不见,就是一个干净的 T。
- 试过的其他方向:榫卯(燕尾榫、银锭榫、透榫,平面和立体都画过)、把 tracerZzz 的 Z 做进榫卯里、一笔画的 T。榫卯最有手艺感,但要让人一眼看出是榫卯,就得画成立体、加细节,到 16px 的标签页图标里全糊了;反而最简单的丁字尺在每个尺寸都清楚。图标设计最后比的是小尺寸下还认不认得出来。
- 一套图标三个文件,都在
public/下:favicon.svg:矢量图,任意大小都清晰,现代浏览器优先用它;导航栏的 logo 也直接用这个文件。favicon.ico:里面打包了 16、32、48 三种尺寸的位图,给不支持 SVG 图标的老浏览器。apple-touch-icon.png:180×180,苹果设备”添加到主屏幕”时用。这张不带圆角,因为 iOS 会自己按系统的形状裁圆角,自带圆角反而会在四个角留下底色。
- 怎么生成的:先手写 SVG(一个圆角方块加两个矩形,刻度是四条短线),再用无头浏览器把 SVG 渲染成各尺寸的 PNG,最后用 Python 的 Pillow 把 16/32/48 三张合成一个
.ico。 - 配置:在
src/config/siteConfig.ts的favicon里列出这三个文件;navbar.logo从图标库的icon类型改成image类型,指向/favicon.svg。都是主题提供的配置项,不用改源码。
加粗紧挨中文标点时不生效
写文章时发现,有些加粗在页面上原样显示成了星号,比如 **OCLP 版本:2.5.1。**下载时……、是**「需要时启用」**开着。
这不是主题的问题,而是 Markdown 规范本身的规定。CommonMark 判断一对 ** 能不能算加粗,要看它两边的字符:开头的 ** 如果后面紧跟标点,那它前面必须是空格或标点;结尾的 ** 如果前面紧挨标点,那它后面也必须是空格或标点。这套规则是照着英文设计的,英文单词之间有空格,几乎不会撞上;中文不用空格,。**下 这种「标点 + 星号 + 汉字」的组合就被判成了普通文字。
解决办法是装 remark-cjk-friendly 插件,它专门修正中日韩文字的这条规则,放在 astro.config.mjs 的 remarkPlugins 第一个:
pnpm add remark-cjk-friendlyimport remarkCjkFriendly from "remark-cjk-friendly";// ...remarkPlugins: [remarkCjkFriendly, /* 其他插件 */],装之前也可以绕过:在星号外侧加个空格,或者把标点挪到加粗外面。但一来容易忘,二来 Typora、Obsidian 预览时是正常的,只有发布后才发现,所以还是在构建这一层统一解决。
把对主题的修改记成清单
上面这些修复,有几处是主题没有提供配置、只能改源码的。这个仓库的主题代码是复制进来的,不是从主题仓库 fork 的,以后升级主题要手动合并,改过的文件可能和新版本冲突。
所以在仓库根目录加了一份 CUSTOMIZATIONS.md,记录和主题原版不一样的每一处:
| 类型 | 内容 |
|---|---|
| 主题源码(升级时最容易冲突) | astro.config.mjs(注入自己的两个脚本:刷新后接着放音乐、欢迎页的挂载和卸载)、layout-utils.ts / navigation-utils.ts / swup-transitions.ts(分页算首页)、SharePoster.svelte / License.astro(分享用当前域名)、new-post.js(slug 和时区) |
| 配置文件 | src/config/ 下的站点信息、导航、头像、壁纸、侧边栏、音乐播放器(换成自己的网易云歌单) |
| 自己加的文件 | 欢迎页(套在主题的 MainGridLayout 里)、music-resume.ts、旧链接跳转页、ESA 配置、检查脚本(升级不会冲突,但欢迎页依赖主题的页面框架和 #swup-container,升级后要看一眼) |
| 删掉的文件 | 主题仓库自己的 CI、Issue 模板、Dependabot、Vercel / Cloudflare 配置 |
每一项都写了改了什么、为什么改、升级后怎么检查,最后附上升级步骤,以及当前所基于的主题版本(Firefly v6.16.8)。规则是:以后每改一次主题源码,都要同步更新这份清单。
网址规则这类问题,自动检查能兜底:网址变了构建就会失败。但残影、横幅、分享链接这些是显示上的问题,检查脚本发现不了,只能靠清单里的”升级后怎么检查”逐项人工看一遍。
主题和 Astro 要不要经常升级
Astro 更新很勤:大版本大约一年一次(会有不兼容的改动),小版本大约一个月一次,补丁几乎每周都有。但个人博客不需要追:
- 线上只是构建好的静态文件,没有服务器程序,Astro 的漏洞一般影响不到已经发布的网站;
- Astro 只在构建时运行,跑完就结束,不对外提供服务;
- Firefly 的
package.json里把 Astro 锁在一个精确版本,主题作者是基于这个版本测试的,单独升级 Astro 反而可能让主题出问题。
所以策略是不单独升级 Astro,只跟着主题升级,几个月看一次 Firefly 有没有新版本,有需要的功能再升;遇到严重的安全公告再单独处理。删掉 Dependabot 也是这个考虑:它会每周自动提交依赖升级,对博客来说是干扰。
另外还定了一条规矩:以后只要改了博客的设定,都同步更新这篇文章,和 CUSTOMIZATIONS.md 一起,作为这个博客的”施工记录”。
十三、写作工具配置
图片跟着文章走的前提是:编辑器粘贴图片时,要自动存到 文章名.assets/ 文件夹,并写成相对路径。否则图片会落到别的地方,或者变成本机绝对路径,换台电脑就打不开了。
Typora
偏好设置(Cmd + ,)→ 图像:
- 插入图片时:下拉框里直接选 Typora 自带的 **复制图片到 ./{filename}
是 Typora 的变量,代表当前文件名(不带.md`)。 - 勾选 对本地位置的图片应用上述规则:从电脑里拖进来的图片也会复制过去,原文件留在原地。
- 勾选 对网络位置的图片应用上述规则:粘贴网上的图片时,会下载到本地,不留外链。以后不会再出现图床失效的问题。
- 勾选 优先使用相对路径。
- 勾选 为相对路径添加
./:和现有文章的写法./xxx.assets/xxx.png保持一致。 - 插入时自动转义图片 URL:不勾。勾了会把中文文件名转成
%E4%BB%8E...这样的编码,不方便阅读,Astro 不需要它。 - 允许根据 YAML 设置自动上传图片:不勾。
- 上传服务:选”无”。不再用 PicGo 上传图床。
配置好后,粘贴一张截图,Typora 会生成 ,文件夹不存在会自动创建。
注意:先保存文件再粘贴图片。新建的文件还没有文件名,Typora 不知道该把图片放到哪里。
Obsidian
库(vault)只打开 src/content/posts,不要打开整个项目,否则它会去索引 node_modules 里几万个文件。Obsidian 会在这里生成 .obsidian/ 配置文件夹,已经写进 .gitignore,不会被提交。
设置 → 文件与链接:
- 内部链接类型:选”基于当前笔记的相对路径”。
- 使用 Wiki 链接:关闭。Astro 不认识
[[ ]]和![[图片]],必须是标准 Markdown 链接。 - 自动更新内部链接:打开。在 Obsidian 里改文件名、移动文件时,引用它的链接会跟着改。
图片位置:Obsidian 自带的”附件默认存放路径”只能选一个固定名字的子文件夹(比如统一叫 attachments),做不到”每篇文章一个同名 .assets”。需要装社区插件 Custom Attachment Location:
- 附件位置:
./${noteFileName}.assets - 附件文件名:
${date:YYYYMMDDHHmmssSSS},用时间戳命名。Obsidian 默认的Pasted image 2026...png带空格,放在 Markdown 链接里容易出问题。 - 插件的变量写法可能随版本变化,以插件设置页里的说明为准。
文章模板:用 Obsidian 自带的”模板”核心插件,存一份开头的元信息,新建文章时插入。注意 published 要写成带时区的格式,比如 2026-10-23T10:30:00+08:00。Obsidian 属性面板里的日期默认不带时区,会被当成 UTC,显示的时间差 8 小时。
两个编辑器的约定一致,同一篇文章用 Typora 写还是用 Obsidian 写都可以,图片位置和链接写法都一样。
十四、现在的发布流程
本地写文章 → git push 到 master │ ├──► GitHub Actions:构建 → 检查 → 发布到 tracerzzz.github.io │ └──► ESA Pages:拉取 → 构建 → 发布到 tracerzzz.cn日常写作就是:
pnpm new-post git/新文章标题 # 生成带元信息的新文件pnpm dev # 边写边预览git add -A && git commit -m "新文章" && git push两三分钟后,两个站点都更新了。构建失败不会替换线上版本;ESA 的部署记录里也可以一键回滚到之前的版本。
换一台电脑写博客
部署全在云端,本地只负责写、预览和推送,所以换一台电脑只要装这几样:
| 工具 | 要求 | 用途 |
|---|---|---|
| Git | 任意新版本,macOS 自带 | 拉代码、提交、推送 |
| Node.js | 22.23 以上(package.json 里的 engines) | 跑 Astro |
| pnpm | 11.22.0(package.json 里的 packageManager) | 装依赖、预览、构建 |
| GitHub 登录 | 个人访问令牌,或 gh auth login | 推送 |
corepack enable # Node 自带,按 packageManager 自动用对的 pnpm 版本npm config set registry https://registry.npmmirror.com # 国内 npm 镜像mkdir -p ~/blog && cd ~/bloggit clone https://github.com/tracerZzz/tracerZzz.github.io.git astrocd astro && pnpm install && pnpm devgit config --global user.name "tracerZzz"git config --global user.email "tracerZzz@users.noreply.github.com"用令牌推送时,第一次 git push 输入用户名和令牌(不是登录密码),macOS 自带的 git 会把它存进钥匙串,以后不用再输。
在一台用 OCLP 升到 Sonoma 的 2016 款 MacBook Pro 上搭这套环境时,遇到两个坑:
- Homebrew 装 Node 要现场编译几个小时:
brew install node@22之后开始一个个编译 openssl、python、cmake。Homebrew 已经不再为 Intel 芯片 + Sonoma 这种组合提供编译好的安装包(bottle),只能从源码编。苹果芯片的 Mac 不会这样。解决:Node 不走 Homebrew,用官方.pkg安装包(国内可以从 npmmirror 镜像的latest-v22.x目录下载)或者 nvm,几分钟就好。 - Homebrew 是另一个用户装的:Intel Mac 上 Homebrew 装在
/usr/local,所有者是当初安装它的用户,换了用户就没有写入权限。用sudo chown -R $(whoami):admin $(brew --prefix)/*改一下所有权。
十五、还没做的
- 正式切换到 Obsidian 管理所有 Markdown。
- 草稿慢慢整理发布。
原本还列了两项,后来决定不做:www 跳转到根域名(www 已经在 ESA 上绑定,能直接访问,canonical 指向根域名,不需要再跳);访问统计(ESA 控制台自带基本的访问数据,够用了,不再额外接第三方统计脚本)。
回头看
这次迁移最大的收获,是把”文章依赖外部服务”变成了”所有东西都在一个仓库里”:文章、图片、欢迎页、构建配置、部署配置,全部跟着 git 走。图床过期、平台变动、换主题、换编辑器,都不会再让博客坏掉。后来真的换了一次主题,从开分支试用到正式上线只用了一个下午,所有网址一个没变,算是验证了这个结构。
另一个体会是:大部分问题都出在”两个东西之间的约定”上。比如空间名和 bucket 名不是一回事,BSD sed 和 GNU sed 行为不同,bash 3.2 不能按字节处理 UTF-8,Jekyll 会去解析 .astro 文件,泛域名证书不包含根域名。每个问题单独看都不难,难的是知道该往哪个方向怀疑。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!




