从 Quartz 到 Astro
关键词:个人博客;Astro;Quartz;Obsidian;静态网站
1 引言#
这个博客已经从 Hexo、VuePress、Hugo 一路换到了 Quartz,现在又换成了 Astro。回头看,确实迁移了太多次。每次都觉得这次应该能用很久,然后过一阵子,又会冒出新的想法(希望这次真的是最后一次)。
博客里的内容还是那些内容:一些学习笔记、工具配置,还有零零碎碎的记录。我仍然习惯在 Obsidian 里写 Markdown,也很喜欢沿着双链从一篇笔记读到另一篇的感觉。因此,这次想换掉的是网站的实现方式,而不是自己的写作习惯。
Quartz 已经提供了双链、链接预览、搜索、反向链接和知识图谱。对于把 Obsidian 笔记发布成网站这件事,它解决了很多问题,我也一直在使用这些功能。只是后来,对页面和交互的修改越来越多,我开始希望:除了写什么,笔记怎样被处理、最后怎样显示,也能由自己更直接地决定。
这就是这次迁移的出发点。接下来先说明为什么要换,再介绍新站怎样组织内容和生成页面,最后讨论目前验证到了什么、还有哪些没有做完。
2 迁移动机#
旧站积累了不少自己修改的样式、组件和 Markdown 转换逻辑。继续沿用 Quartz,就需要继续理解并配合它的插件体系:一项修改应该放在哪里,会不会受到其他插件或上游更新的影响,都需要考虑。我不太想再围绕 Quartz v5 的这套方式继续维护自己的站点。
不过,换成 Astro 并不会让这些工作自动消失。如果还想保留双链、嵌入和图谱,就得把原来由 Quartz 处理的事情接过来。所以,这次的目标不是重新做一个功能更多的 Quartz,也不是做一套给别人使用的通用主题,而是把两部分工作分清楚。
第一部分是笔记编译:读取 Markdown,判断哪些内容可以公开,弄清楚链接指向哪篇文章,再生成正文、反向链接、搜索索引和图谱数据。第二部分是网站页面:为这些内容安排地址、生成静态页面,并处理样式、资源和必要的浏览器交互。
我选择 Astro 负责第二部分,第一部分则用 TypeScript 单独实现。原来的 Markdown 不需要改写成 MDX,也不额外设计插件市场、主题适配器或自动加载插件的机制。
这样做的好处是,遇到问题时可以直接修改对应的代码;代价也很明确:以前可以交给 Quartz 的工作,现在要自己维护。Obsidian 里能用的功能,也不能因为换了一个框架,就默认在网站上同样可用。
3 系统设计#
3.1 仓库与内容管理#
网站代码和文章仍然分开保存。代码对应 virgiling/virgiling.github.io,文章对应 virgiling/blog_content,后者通过 Git submodule 放在网站项目的 content/ 目录下。
正式构建只读取这个目录,不扫描本机的 Obsidian 笔记库,也不依赖旧站在电脑上的绝对路径。主仓库会记下所使用的内容提交;构建时,子模块的实际版本必须与这个记录一致,并且不能有未提交的修改。这样,之后检查某次构建时,至少能确定它用的是哪一版文章,而不是碰巧读到了当时最新的内容。
3.2 哪些笔记可以发布#
对笔记网站来说,先弄清楚哪些内容能发出去,比先把页面做好看更重要。我保留了旧站的发布规则:只有明确写了 publish: true 的笔记,才允许生成页面。
| 笔记状态 | 是否生成页面 | 是否出现在列表、搜索等入口 |
|---|---|---|
publish: true,没有设为 unlisted | 是 | 是 |
publish: true,同时设为 unlisted: true | 是,可以通过地址直接访问 | 否 |
没有填写 publish,或 publish: false | 否 | 否 |
这里有两个容易混淆的地方。draft 只表示编辑状态,不能代替 publish;而 unlisted 也不等于私密。知道地址的人仍然可以打开页面,它只是不会出现在文章列表、搜索、全局图谱、RSS 和 sitemap 中。
因此,程序需要区分两组文章:一组可以生成页面,另一组还可以出现在这些公开入口中。搜索和全局图谱等功能只使用后一组,不能因为某篇笔记已经有了网页,就把它到处列出来。
另外,明确排除的目录和符号链接会在解析正文前被过滤掉。附件也只复制允许发布的引用所需的文件,而不是为了省事,把整个内容仓库原样放进网站目录。未公开的笔记同样不会因为被另一篇文章引用,就跟着发布。
3.3 保留文章地址与评论#
搬家之后,旧链接最好还能打开,评论也应该继续留在原来的文章下面。这些看起来不是页面上的大功能,却很容易在迁移时出问题。
普通文章继续根据源文件路径生成不带扩展名的 URL。目录导读则保留末尾的 /,对应目录下的 index.html。Astro 的 file-format 输出会把部分目录索引生成为同级 HTML 文件,因此构建结束后,还需要根据实际的导读地址恢复目录结构,而不是顺便把所有文章地址都换一遍。
评论仍然使用 giscus,通过 mapping="specific",用文章在正式网站上的完整 URL 找到对应的讨论 [6]。本地预览地址、开发端口和 /preview/ 不参与这个匹配。这样,即使是在本地打开文章,也可以加载正式网站上对应的评论,而不是把它认成另一篇文章。
不过,保留源文件路径并不等于处理好了所有旧地址。历史上使用过的别名和永久链接,仍然需要根据真实的地址清单逐项补充重定向;旧讨论是否全部对应正确,也不能只凭构建成功来判断。
4 实现#
4.1 技术栈#
本次迁移使用 Astro 7.3.5、Node 24.19.0 和 Bun 1.3.11。笔记编译使用 TypeScript 和 unified/remark/rehype,代码高亮交给 Shiki,公式在构建时由 MathJax 渲染;样式、动效和搜索分别使用 Tailwind CSS、Motion 和 FlexSearch [2][3][4][6]。
这些工具各自处理已经比较成熟的问题,自己的代码主要负责把它们接到笔记和页面之间。具体依赖以 package.json 和 bun.lock 为准,不让每次安装都自动追到 latest。
4.2 为什么要分两遍编译#
单独把一篇 Markdown 转成 HTML 并不复杂,麻烦的是文章之间的关系。比如,一个双链可能指向另一篇文章,也可能指向其中的标题或块;两篇文章可能重名;嵌入的内容还可能继续引用别的笔记。只读到一个链接就立刻生成结果,很容易处理不全。
因此,编译分成两遍。第一遍先整理每篇笔记的路径、元数据和语法树(AST),记录标题、块、别名以及原始链接。等这些信息齐全后,第二遍再确定跨文档链接的目标,展开允许嵌入的内容,并调整嵌入后的资源路径和脚注。
content/ 中固定版本的笔记
→ 排除不应读取的文件,筛选可发布内容与资源
→ 第一遍:解析笔记,收集标题、块、别名和链接
→ 第二遍:确定链接目标,展开嵌入,调整资源路径与脚注
→ 过滤后的 HTML、文章大纲、反向链接、图谱和搜索数据
→ Astro 静态页面,以及按需加载的浏览器交互
这样,正文、搜索和图谱使用的是同一套链接解析结果,而不是各自猜一遍。同名文章不能随便选第一个;嵌入别人的内容后,其中的链接也不能全算成当前文章自己写下的引用。
目前已经支持 CommonMark/GFM 的基础语法,以及常用的 Obsidian 写法 [1]:双链与别名、标题和顶层块链接、整篇或部分内容的嵌入、脚注、标签、Callout 和图片说明。嵌入有循环检查,避免两篇笔记互相嵌入时不停展开。Callout 支持 13 类、27 个标识,也保留了折叠和嵌套。
原生 HTML 则按照白名单过滤,危险 URL、脚本和事件属性不会直接进入公开页面。编辑器里能够显示,并不意味着就应该原样放到网站上。
这套实现还不是完整的 Obsidian。更复杂的 Obsidian Flavored Markdown(OFM)写法、Mermaid 渲染、Bases、Canvas、查询嵌入和社区插件,都需要分别实现和测试。把不支持的内容显示成一个代码块,也不能算已经支持了它。
4.3 阅读界面#
页面上,我仍然希望保留一点笔记本和 wiki 的感觉,不想让阅读变成在一堆组件之间找正文。
新站只使用浅色,不跟随系统切换深色,也没有单独的主题按钮。顶层导航是“主页、文章、足迹、关于”。桌面上以正文为主,右侧放辅助信息;窄屏则改成单栏。主页右侧依次是最近更新与热力图、关系图谱和大纲,文章页则是图谱、大纲和反向链接。
中文正文使用霞鹜文楷轻便版,英文使用 Linux Biolinum,代码优先使用设备上的 Monaco [7]。样式主要使用 Tailwind 的统一颜色、间距等设置、工具类和自己定义的组件样式,不启用它的 Preflight 样式重置,也不扫描文章正文来提取 CSS 类名 [4]。
文章归档按照实际目录逐层分组,不因为一篇文章有多个标签,就重复放上几张卡片。MoC(Map of Content,内容地图)本身就是目录导读,所以当一级目录能唯一对应到一篇公开且可列出的 *-toc.md 时,目录标题直接链接到这篇笔记,不再额外摆一张重复卡片。原来的文章页面、评论、搜索结果和图谱节点仍然保留。
交互也尽量不打断阅读。大纲会高亮当前标题;如果对应条目已经滚出了大纲区域,就把它移回可见位置,而不是带着正文一起滚动。链接预览保留普通 <a> 链接的跳转方式,用 Floating UI 避免浮窗超出屏幕 [4]。
网站自己添加的过渡动效统一交给 Motion。除了“能动起来”,也要处理动画被打断、组件移除,以及用户开启“减少动态效果”(reduced motion)后的情况。不然,动画做得再顺,也可能在一次快速点击后留下关不掉的浮窗。
4.4 局部图谱与全局图谱#
这两种图谱看起来相似,但用途不同。局部图谱让我知道“这篇笔记直接联系着谁”,全局图谱则用来看“整个知识库大致是怎样连接的”。因此,我没有把同一张图简单放大或缩小来用。
局部图谱只显示一层直接关系:当前笔记、它链接到的笔记、链接到它的笔记,以及本页明确写出的标签。邻居的标签、同标签下的其他文章,以及再隔一层的笔记,都不会自动加进来。这样至少不会点开一篇文章,旁边就出现半个知识库。
初始布局在构建时由 D3 计算 180 次迭代,再生成一个 300 × 250 的 SVG,当前笔记放在中心 (150, 125) [5]。节点可以拖动,但当前笔记松手后会平滑回到图心,周围节点重新找到平衡位置。这里不要求所有节点回到拖动前的坐标,只希望在探索连线后,仍然能一眼找到正在读的文章。
全局图谱只使用允许出现在公开入口中的内容,通过 Canvas 绘制。其核心改编自 starlight-site-graph@0.5.0 的 D3 布局和拖动实现,并保留 MIT 署名;没有把 Starlight、Pixi 和 GSAP 一起搬进项目 [5]。
全局图谱中,一个节点连接的不同邻居越多,节点就越大。设 degree 为不同邻居的数量,半径按下面的公式计算:
radius = min(12, 5 + 1.5 × sqrt(degree))
半径限制在 5–12 之间,避免连接很多的节点大到遮住其他内容。D3 自带的模拟计时器被停止,改由 Motion 统一安排更新;交互重新启动模拟后,最多再计算 180 次,而不是一直在后台运行。
4.5 图片与字体#
这部分最容易出现“页面看着差不多,实际还有问题”的情况。
正文经过自己的 unified 编译流程,再通过 set:html 放进页面,不会自动得到 Astro 图片组件的优化。因此,过滤后的正文还需要接上 Astro 的 getImage() 和 Sharp:保留原先的尺寸设置、alt 文本、图注和图片链接,按比例生成不同尺寸的 WebP,再通过 srcset 供浏览器选择,默认不裁剪,也不放大原图 [2]。
实现时还遇到过一个很小的问题:HTML 写着 400w,实际图片却只有 399 像素。这是尺寸计算中的舍入造成的。后来改为按宽度等比缩放,并在验证时直接读取生成图片的尺寸,而不是只检查 HTML 中有没有写上这些属性。
点击图片放大使用 medium-zoom 的 pure 入口,保留它已有的图片定位和克隆逻辑,把开合动画交给 Motion,并处理关闭中断、加载超时和退出后的清理 [6]。
字体也类似。旧的小字体子集没有覆盖“旅途还在继续”里的部分字符,只补上这几个字,下一篇文章仍然可能缺字。所以,新方案不再根据现有文章里出现过哪些字,来决定字体包含哪些字。
字体源文件统一放在 assets/fonts/:中文是 LXGW WenKai Lite 1.522,西文是 Linux Biolinum O,另保留 Biro Script Plus 原始 WOFF2 文件。Biro 保持原来的 OpenType 数据和 284,772 B 大小,不重新裁剪字形;它不属于 OFL 字体,不能把其他字体的开源许可套用到它身上。
字体生成流程参考了 astro-navfolio 的 UI 子集脚本 [7]。源文件及其 SHA256、FontTools 和 Brotli 的版本都固定下来,先生成界面常用字的小子集,再按 Unicode 范围拆分源字体支持的完整字表。这个过程不需要扫描文章正文或私密笔记。
字重也按字体实际提供的内容声明:中文和西文常规字体使用 400,西文粗体使用 700。没有另外提供真正的中文粗体,因此中文加粗仍可能由浏览器合成。分片能让浏览器按需加载,但不是没有代价:多个文件会重复保存部分字体信息,总存储量也会增加,不能简单把分片理解成“字体变小了”。
5 验证与讨论#
5.1 检查了什么#
能构建成功,只能说明页面生成这一步没有报错,不代表所有功能都能正常使用。迁移中的检查主要分为四类:
| 检查方式 | 主要检查内容 |
|---|---|
| Node 测试用例 | 发布规则、Markdown 解析、链接、图谱,以及交互中断和清理 |
| 编译与输出文件检查 | 页面地址、资源引用、字体哈希、图片实际尺寸和资源大小 |
| 独立 HTTP 检查 | 直接打开文章地址、资源响应类型与文件内容、404 页面 |
| 开发服务检查 | 通过实际的 Vite 模块请求执行图谱逻辑,检查拖动后回到图心、暂停恢复和减少动态效果 |
其中,开发服务也需要单独检查。有一次,全局图的数据和入口脚本都返回了 200,图谱却仍然打不开。继续检查才发现,入口引用的 Vite 预优化依赖返回了 504 Outdated Optimize Dep。如果只看数据接口,或者另外在 Node 中打包一份代码来测,就可能绕过真正出错的请求。
为避免开发和验证进程互相覆盖依赖缓存,现在会按命令、端口和 base 路径区分缓存。这个问题也提醒我:测试走的路径要尽量接近实际使用,否则测试通过了,页面还是可能有问题。
不过,上面的 Node、文件和 HTTP 检查仍然不能代替真实浏览器。它们不能证明页面一定流畅,也没有完成对最大内容绘制(LCP)、交互到下一次绘制(INP)、帧率、触屏和屏幕阅读器体验的全面验证。
5.2 构建结果与资源限制#
迁移时,固定版本的内容生成了 202 个 HTML 页面:144 个公开阅读页、55 个标签页,以及归档、足迹和 404 页面。
归档页显示的是“142 则笔记,48 个主题,慢慢生长。”其中,笔记入口由 138 张文章卡片和 4 个 MoC 目录标题组成;主题数只计算笔记中明确写出的标签。它与 HTML 文件总数统计的不是同一件事。
字体方面,OFL 字体共生成 167 个 WOFF2 文件,总计 7,619,272 B;加上原始 Biro 文件后为 7,904,044 B。这是网站保存的字体文件总量,不是打开一篇文章就会下载这么多。
为了避免后续加功能时不断增加页面负担,项目还设定了以下资源大小限制:
| 项目 | 上限 |
|---|---|
| 首屏 JavaScript,gzip 后 | 20 KiB |
| 共享 CSS,gzip 后 | 12 KiB |
| UI 字体子集 | 256 KiB |
| 单个字体分片 | 128 KiB |
| 完整 OFL 字体文件 | 8 MiB,Biro 另计 |
这些是构建时检查的限制,不是线上性能测试结果。文件小一些,并不能单独证明网站就比 Quartz 更快;字体总量和首屏下载量也不能混为一谈。本文没有做两个框架在相同内容、设备和网络条件下的对照测试,因此不据此判断谁的性能更好。
5.3 还没有解决的问题#
除了前面提到的 Obsidian 功能兼容,旧别名的完整重定向、线上评论对应关系,以及浏览器中的性能和可访问性,都还需要继续检查。
搜索也是如此。能够找到正文中的关键词,不等于搜索结果一定符合预期。要评价搜索质量,还需要整理一批自己真正会搜索的问题,标出希望找到的文章,再看结果是否合适。这部分目前还没有完成。
所以,这次迁移能够说明的是:新站已经按自己的规则组织和生成内容,常用功能也有了相应的检查;但不能把它写成“完整复现了 Obsidian”,或者“所有体验都已经验证过了”。
6 结论与展望#
这次从 Quartz 迁移到 Astro,最重要的变化不是换了框架的名字,而是笔记到网页之间的处理过程,现在可以由自己直接修改。内容仍然在 Obsidian 里写,文章之间仍然通过双链联系起来,网站也尽量保留原来那种可以顺着链接随处逛逛的感觉。
与此同时,维护工作并没有消失,只是换成了自己更愿意处理的方式。Astro 负责页面,成熟的库负责各自擅长的部分,而我自己的代码负责把笔记、发布规则和阅读习惯接起来。
后面主要还是三件事:补上确实需要的功能,修复实际使用中遇到的问题,以及按需更新依赖。Bases、地图、Mermaid 和全文预览都可以继续做,但要先想清楚要支持到什么程度、会不会带出不该公开的内容。修 bug 时,也要保住已有的链接、发布规则和交互行为,而不是修好一处又弄坏另一处。
至于还会不会有下一次迁移,现在不太敢保证。至少这一次,我更清楚自己为什么要换,也更清楚以后要维护些什么。希望接下来花在写内容上的时间,能比折腾博客本身多一点。
参考文献#
以下资料用于说明所使用的工具和格式,不作为本站性能的证明。
[1] Obsidian Help: Flavored Markdown, Internal links, Embeds, Callouts, Bases. https://help.obsidian.md/obsidian-flavored-markdown
[2] Astro Docs: Images; Static output and configuration reference. https://docs.astro.build/en/guides/images/
[3] unified. https://unifiedjs.com/ ;Shiki. https://shiki.style/ ;MathJax. https://www.mathjax.org/
[4] Tailwind CSS. https://tailwindcss.com/docs ;Motion. https://motion.dev/docs ;Floating UI. https://floating-ui.com/docs/dom
[5] starlight-site-graph 0.5.0. https://registry.npmjs.org/starlight-site-graph/0.5.0 ;D3 force. https://d3js.org/d3-force
[6] FlexSearch. https://github.com/nextapps-de/flexsearch ;giscus advanced usage. https://github.com/giscus/giscus/blob/main/ADVANCED-USAGE.md ;medium-zoom. https://github.com/francoischalifour/medium-zoom
[7] LXGW WenKai Lite. https://github.com/lxgw/LxgwWenKai-Lite ;Linux Biolinum(Libertine 发行包). https://mirrors.ctan.org/fonts/libertine.zip ;astro-navfolio 字体子集脚本. https://github.com/dodolalorc/astro-navfolio
本文由 Astra 整理并发布。
讨论
想法、补充,或只是打个招呼。