忘れてください

从 Quartz 到 Astro

22 min read

为什么要离开 Quartz#

先说清楚:不是 Quartz 不好用。双链、链接预览、搜索、反向链接、知识图谱,它都已经做好了,我也一直在用。

真正让我想搬的是另一件事。旧站上我自己改的样式、组件和 Markdown 转换逻辑越来越多,往后就越绕不开它的插件体系:一处改动该放哪、会不会被别的插件或上游更新带崩,都得先想一遍。我不太想再围着 Quartz v5 那套方式维护自己的站点了。

但换框架不等于这些活会消失。想留住双链、嵌入和图谱,原来 Quartz 干的就得自己接过来。所以这次的目标不是"再造一个功能更多的 Quartz",也不是做一个给别人用的通用主题,而是把两件事拆开:

  • 笔记编译:读 Markdown、判断哪些能公开、搞清楚链接指向谁,再产出正文、反向链接、搜索索引和图谱数据。
  • 网站页面:安排地址、生成静态页面,处理样式、资源和浏览器里那点交互。

后面那件事交给 Astro,前面那件事我自己用 TypeScript 写。Markdown 不用改写成 MDX,也不搞插件市场、主题适配器那套。

好处是出问题能直接去改对应的代码;代价也很直白——以前丢给 Quartz 的活,现在得自己扛,Obsidian 里能用的功能也不会因为换了个框架就自动在网站上也能用。

先把内容保住#

两个仓库分开存#

网站代码和文章还是分开的:代码在 virgiling/virgiling.github.io,文章在 virgiling/blog_content,后者用 Git submodule 挂在网站的 content/ 下。

构建只认这个目录里记的版本,不扫我本机的 Obsidian 笔记库,也不碰旧站在电脑上的绝对路径。主仓库会记下用的是哪次内容提交,构建时子模块的版本必须和它对得上,还不能有没提交的改动。这样以后回头查某次构建,至少知道当时用的是哪一版文章,而不是"碰巧"读到了最新的。

哪些笔记能发出去#

对笔记网站来说,先想清楚哪些能发,比先把页面做好看更重要。规则还是沿用旧的:只有明确写了 publish: true 的笔记,才允许生成页面。

笔记状态会不会生成页面会不会进列表、搜索
publish: true,没写 unlisted会会
publish: true,同时 unlisted: true会,有地址就能直接看不会
没写 publish,或 publish: false不会不会

有两个地方容易混。draft 只是编辑状态,不能拿来代替 publish;unlisted 也不等于私密——知道地址的人照样能打开,它只是不会出现在文章列表、搜索、全局图谱、RSS 和 sitemap 里。

所以程序里其实要分两组文章:一组能生成页面,另一组还能进这些公开入口。搜索和全局图谱只吃后一组,不能因为某篇笔记已经有网页了,就到处给它露脸。

另外,明确排除的目录和符号链接会在解析正文之前就被过滤掉;附件也只复制被允许引用的那几个文件,而不是图省事把整个内容仓库原样拷进网站目录。没公开的笔记,也不会因为被别的文章引用了就跟着发布。

旧地址和评论#

搬完家,旧链接最好还能打开,评论也得留在原来那篇文章底下。这些看着不算大功能,却是迁移时最容易翻车的地方。

普通文章还是按源文件路径生成不带扩展名的 URL;目录导读保留结尾的 /,对应目录下的 index.html。Astro 的 file-format 输出会把一部分目录索引拍成同级 HTML,所以构建收尾还得按真实的导读地址把目录结构恢复回去,而不是顺手把所有文章地址都改一遍。

评论用的是 giscus,后来把匹配键从完整 URL 换成了文章标题:mapping="og:title" 加上 data-strict="1",按标题的 SHA-1 去找对应的讨论。这样移动目录、改文件名、换域名都不会断评论;回链还是指向正式网站上的完整 URL,只是不再拿它当匹配键了,localhost、开发端口和 /preview/ 也照旧不进回链。代价是标题变成了身份——改标题(或者当兜底用的文件名)还是得迁移标记,开评论的页面也必须标题唯一,静态检查会拒绝重名,免得两篇文章挤进同一个讨论。

不过,保住源文件路径不等于旧地址就都处理好了。历史上用过的别名和永久链接,还得对着真实的旧地址清单一条条补重定向;旧讨论是不是都对上了,也不能只看构建成功。

技术栈#

当前版本由锁文件钉死:Astro 7.3.5、Node 24.19.0、Bun 1.3.11;笔记编译这边是 TypeScript 加 unified/remark/rehype,代码高亮用 Shiki,公式在构建期用 MathJax 渲染;样式、动效、搜索分别是 Tailwind CSS、Motion、FlexSearch。搬家后补的功能也各找了成熟库:地图用 Leaflet,友链近况用 rss-parser,引用用 Citation.js 和 rehype-citation。具体依赖以 package.json 和 bun.lock 为准,不每次装都追 latest。

这些库各管各自已经比较成熟的问题,我自己的代码主要把它们和笔记、页面接起来。

编译:为什么要分两遍#

单把一篇 Markdown 转成 HTML 不复杂,麻烦的是文章之间的关系。一个双链可能指向另一篇文章,也可能指向其中的某个标题或块;两篇文章可能重名;嵌入的内容还可能继续引用别的笔记。读到一个链接就马上出结果,很容易漏。

所以编译分两遍。第一遍先把每篇笔记的路径、元数据和语法树(AST)理出来,记下标题、块、别名和原始链接;等这些齐了,第二遍再确定跨文档链接到底指向谁、展开允许的嵌入、把嵌入后的资源路径和脚注调好。

content/ 里固定版本的笔记
  → 排除不该读的文件,筛出可发布内容与资源
  → 第一遍:解析笔记,收集标题、块、别名和链接
  → 第二遍:确定链接目标,展开嵌入,调整资源路径与脚注
  → 过滤后的 HTML、文章大纲、反向链接、图谱和搜索数据
  → Astro 静态页面,以及按需加载的浏览器交互

这样正文、搜索和图谱用的是同一套链接解析结果,不用各自猜一遍。同名文章不能随便挑第一个;嵌入别人的内容后,里面的链接也不能全算成当前文章自己写的引用。

现在已经支持的:CommonMark/GFM 基础语法,加上常用的 Obsidian 写法(OFM)——双链和别名、标题和顶层块链接、整篇/部分内容嵌入、脚注、标签、Callout、图片说明。嵌入有循环检查,免得两篇互相嵌入时无限展开;Callout 支持 13 类、27 个标识,折叠和嵌套也留着。

行内标签单独说一下。这里把两类标签分开了:YAML 里的 tags 才算文章归属,会进分类徽章、标签归档、搜索筛选和图谱的分类边;正文、标题、摘要里写的行内标签(比如 #主题/计算机)单独记,只用来跳到对应的分类页,不把文章算进那个分类,也不产生文章链接、反向链接或图谱边。正文里的行内标签会渲染成和普通标签一样的方框徽章并链到分类页,但代码、数学、URL、显式转义和已经写好的链接不动它。只有被提到、还没有归属文章的标签,也保留一张空分类页,免得链接失效。

文献引用也补上了。固定读内容仓库根部的 content/ref.bib,正文里用 [@citekey] 这种写法引用。语法节点交给 remark-cite,BibTeX/BibLaTeX 用 Citation.js 解析,再在构建期生成文内引用和文后条目(配合 rehype-citation),不搞客户端文献引擎。.bib 还是被忽略的资源类型:只读这一个固定文件,拒绝符号链接,条目只取必要的书目字段并过一遍消毒,也不允许 frontmatter 指定任意 bibliography/CSL 路径或网络地址。有有效引用的文章会自动多出一个 References 小节并进大纲,只列这篇实际引用过的条目,按首次引用顺序排。

原生 HTML 走白名单过滤,危险 URL、脚本和事件属性不会进公开页面——编辑器里能显示,不代表就该原样放到网站上。

链接失效的处理后来也修过一次。未公开、不存在、有歧义或找不到章节的内部目标,现在保留作者写下的显示文字——双链沿用别名或目标文字,普通链接保留标签和行内格式——而不是替换成一句"该内容不可公开或不可用"。构建期就能确定不可用的本地图片(双链、Markdown、引用式或原生 HTML)直接隐藏,不插文件名也不插占位提示,作者单独写的图注留着;远程图片则不在构建期探测可用性。

当然,这还不是完整的 Obsidian。更复杂的 OFM 写法、Mermaid 渲染、地图以外的 Bases 视图与嵌入、矢量底图、Canvas、查询嵌入、各种社区插件,都还得一项项实现和测。把不支持的内容显示成一个代码块,也不算"已经支持了"。

阅读界面#

界面上我还是想留一点笔记本和 wiki 的感觉,不想让读东西变成在一堆组件里找正文。

整站只用浅色,不跟系统切深色,也没有单独的主题按钮。顶部导航是"主页、文章、足迹、友链、关于"。桌面以正文为主、右边放辅助信息,窄屏收成单栏:主页右栏是最近更新加一张热力图、关系图谱和大纲,文章页则是图谱、大纲和反向链接。窄屏下正文二三级标题改成自然换行,标题末尾那个 # 锚点藏起来,免得变成孤零零一个符号;标题 ID 和深链不变。

中文正文用霞鹜文楷轻便版,英文用 Linux Biolinum,代码优先用设备上的 Monaco。样式主要用 Tailwind 的色板、间距、工具类和自定义组件,不启用它的 Preflight 重置,也不扫正文去提取 CSS 类名。

文章归档按真实目录分层分组,不会因为一篇文章挂了几个标签就重复摆几张卡片。MoC(内容地图)本身就是目录导读,所以当一级目录能唯一对应到一篇公开且可列出的 *-toc.md 时,目录标题就直接链到那篇笔记,不再多摆一张重复卡片;MoC 原来的页面、评论、搜索和图谱身份都保留。除了按目录分组的归档,还多了一个只按"最近更新"倒序的页面,方便看最近改了什么。

交互尽量不打断阅读。大纲会高亮当前标题,如果当前条目滚出了大纲区域,就把它挪回可见位置,而不是带着正文一起滚。链接预览还是保留普通 <a> 的跳转,用 Floating UI 防浮窗出屏;悬浮或键盘聚焦时才按需去取文章 HTML,旁边有个图钉能把预览钉住变成可滚动的全文,窄屏也能打开,预览里自带滚动区。正文提取思路来自 Quartz 的 MIT popover 组件,补了相对资源、锚点和脚注的重映射;我核过的几个 Astro 方案要么只给元数据卡片,要么是外链截图或 AI 摘要,都不提供可滚动全文。

站里自己加的过渡动效统一交给 Motion。除了"能动起来",还得处理动画被打断、组件被移除,以及用户开了"减少动态效果"的情况——不然动画再顺,也可能在快速点几下之后留个关不掉的浮窗。

图谱:局部和全局#

这两张图看着像,用途不一样。局部图回答"这篇笔记直接连着谁",全局图用来看"整个知识库大概怎么连的"。所以我没拿同一张图放大缩小了事。

局部图只显示一层直接关系:当前笔记、它链出去的、链回它的,以及本页明确写出的标签。邻居的标签、同标签下的其他文章、再隔一层的笔记,都不会自动加进来——不然点开一篇文章,旁边就冒出来半个知识库。初始布局在构建时用 D3 算 180 次迭代,生成一张 300 × 250 的 SVG,当前笔记放在中心 (150, 125)。节点能拖,但当前笔记松手后会平滑回图心、周围节点重新找平衡位置;这里不要求每个点都回到拖之前的坐标,只求探索完连线后,还能一眼找到正在读的这篇。

全局图只吃允许进公开入口的内容,用 Canvas 画,核心是改编自 starlight-site-graph@0.5.0 的 D3 布局和拖动,保留了它的 MIT 署名,没把 Starlight、Pixi、GSAP 一起搬进来。

全局图里,一个节点连的不同邻居越多就越大。设 degree 为不同邻居数,半径按这个算:

radius = 0.85 × min(30, 6 + 4 × sqrt(degree))

半径整体缩了 15%,要到度数 36 才碰到上限,免得常见连接数提前饱和。全局布局的中心、排斥、连接、目标距离这些参数集中放在一处,构建期的初始布局和客户端重热共用同一份。D3 自带的模拟时钟停掉了,改由 Motion 统一调度:全局重热后最多再算 240 次,局部还是 180 次,不会一直在后台跑。

足迹地图和友链#

/journey 读的是 site.config 里指定的一个 Base 视图,目前只支持 type: map。YAML 校验、视图筛选、公式计算都在构建期做完,直接复用 Quartz 的 @quartz-community/bases-page 编译器,而不是把它的浏览器解释器一起搬进来;查询和文件属性只吃能公开发现的内容——有坐标不等于同意公开,没有有效地点就老实显示空状态。地图本身用 astro-leaflet / Leaflet 包了一层:不指定底图就用 OpenStreetMap,只接受 HTTPS 栅格瓦片,明确拒绝矢量样式 URL。所以这里能说的是"做了个栅格地图视图",不是"复刻了 Obsidian Maps"。缩放、平移、弹窗对焦都交给共享的 Motion 时钟,指端捏合让地图自己管,页面别处的滚动不受影响。

友链沿用 content/link.md:源文件在 Markdown 里就是一张普通表格,站点把它转成友链卡片,简介直接用表格里的文字,头像只用明确填的地址,不去抓网站的简介或 favicon。在这基础上,构建时还会聚合每位朋友填的 RSS/Atom,生成一个静态的"近况"列表(思路参考 Navfolio 的 Friend Circle,解析用 rss-parser)。RSS 只在构建时请求,页面不做跨域读取,也不把第三方的正文 HTML 灌进本站;只访问明确写出来的公开 HTTPS 地址,每个源最多留 12 篇有有效标题、日期、链接的文章,合并去重后按日期倒序,整页最多显示最近 10 篇。单个源挂了不会拖垮整站构建:有旧快照就显示旧的并注明,第一次失败就保留卡片、不编造更新。

图片和字体#

图片#

图片这块后来改过一次方向。一开始是接上 Astro 的 getImage() 和 Sharp,构建期把正文图片按需转成几档 WebP 再用 srcset 输出;但这步会在构建时下载和转码图片,图片源网络一抖就可能把整站构建卡住。现在改成直接用原始 URL:正文图片保留原地址,让浏览器自己去请求,本地附件还是走 media/ 路由。构建期不再下载远程图、不探元数据,也不生成 WebP、srcset 或图片代理。

尺寸适配只发生在 HTML/CSS:图片最大宽度不超过正文,高度自动保持原始比例;Obsidian 的 |400、|400x300 和 HTML 宽高都当显示边界,而不是裁剪或转码请求,不知道原尺寸就不硬编一个。代价也很直接——能不能显示图片取决于图片源当时在不在,构建成功不再保证图片一定加载得出来。对应的静态检查也改成验证页面里的原始 URL 没被改写、不再出现生成图片和 srcset,而不是去比生成图的尺寸。

点图放大用 medium-zoom 的 pure 入口,保留它原本的定位和克隆逻辑,开合动画交给 Motion,顺手补了关闭中断、加载超时和退出后的清理。

字体#

字体也是类似的问题。旧的小字体子集没覆盖"旅途还在继续"里的几个字,只补这几个字,下篇文章照样可能缺。所以新方案不再看现有文章里出现过哪些字,来决定字体放哪些字。

字体源文件统一放在 assets/fonts/:中文是 LXGW WenKai Lite 1.522,西文是 Linux Biolinum O,另外留着 Biro Script Plus 的原始 WOFF2。Biro 保持原来的 OpenType 数据和 284,772 B 大小,不重新裁字形;它不属于 OFL 字体,不能把别的字体的开源许可往它身上套。

字体生成参考了 astro-navfolio 的 UI 子集脚本:源文件、SHA256 和 FontTools、Brotli 的版本都钉死,先生成界面常用字的小子集,再按 Unicode 范围拆出源字体支持的完整字表。这个过程不用扫文章正文,也不用扫私密笔记。

字重按字体实际提供的内容来声明:中文和西文常规都用 400,西文粗体 700;没另外做真正的中文粗体,所以中文加粗还是可能由浏览器合成。分片能让浏览器按需加载,但不是白拿的:多个文件会重复存一部分字体信息,总存储也会变大,别把"分片"直接理解成"字体变小了"。

检查了什么#

能构建成功只说明页面生成这步没报错,不代表功能都能用。迁移里的检查大致分四类:

检查方式主要看什么
Node 测试用例发布规则、Markdown 解析、链接、图谱,以及交互中断和清理
编译与输出文件检查页面地址、资源引用、字体哈希、原始图片 URL 和资源大小
独立 HTTP 检查直接打开文章地址、资源响应类型和内容、404 页面
开发服务检查通过真实的 Vite 模块请求跑图谱逻辑,看拖动后回图心、暂停恢复、减少动态效果;地图另查真实页面投影和依赖编译

开发服务也得单独查。有一次全局图的 JSON 和入口脚本都是 200,图却怎么都打不开,继续查才发现入口引用的 Vite 预优化依赖返回了 504 Outdated Optimize Dep。要是只看数据接口,或者在 Node 里另外打一份代码来测,就正好绕开了真正出错的请求。

为了不让开发和验证进程互相覆盖依赖缓存,现在会按命令、端口和 base 路径分开缓存。这事也提醒我:测试走的路径得尽量贴近实际使用,不然测试过了,页面还是可能有问题。

不过上面的 Node、文件和 HTTP 检查都替代不了真实浏览器。它们证明不了页面一定流畅,也没做 LCP、INP、帧率、触屏和读屏体验的全面验证。

构建结果和限制#

现在固定版本的内容一共生成 205 个 HTML 页面:145 个公开阅读页、56 个标签页,加上归档、更新、足迹和 404。

归档页显示的是"142 则笔记,48 个主题,慢慢生长。"——笔记入口是 138 张文章卡片加 4 个 MoC 目录标题,主题数只算笔记里明确写出来的标签。它和 HTML 文件总数不是一回事。

字体这边,OFL 字体一共生成 167 个 WOFF2,合计 7,626,524 B,其中三个界面分片加起来 131,756 B、最大一片 92,424 B;加上原始 Biro 后是 7,911,296 B。这是网站存的字体文件总量,不是打开一篇文章就得下这么多。

为了以后加功能别把页面越堆越重,定了几个大小上限:

项目上限
首屏 JavaScript(gzip 后)20 KiB
共享 CSS(gzip 后)12 KiB
地图 JavaScript(可见时按需加载的完整链路,gzip 后)80 KiB
地图 CSS(延迟加载,gzip 后)8 KiB
UI 字体子集256 KiB
单个字体分片128 KiB
完整 OFL 字体文件8 MiB,Biro 另计

这些是构建时检查的限制,不是线上性能测试。文件小一点并不能单独证明比 Quartz 快,字体总量和首屏下载量也不是一回事;地图要是在首屏里,照样会很快去请求那些延迟资源。这篇没做两个框架在相同内容、设备和网络下的对照,就不比谁快谁慢了。

还没解决的#

除了前面说的 Obsidian 功能兼容,地图以外的 Bases 视图与嵌入、矢量底图、更完整的 OFM、历史别名的完整重定向,都还得慢慢补;线上讨论是不是都对上了,浏览器里的性能和可访问性,也都还要继续看。

搜索也一样。能搜到正文里的关键词,不等于搜出来的结果就符合预期。要评价搜索质量,得先攒一批自己真的会去搜的问题、标出想找的是哪篇,再看结果行不行——这块还没做。

所以这次迁移能说明的是:新站已经按自己的规则在组织和生成内容,常用功能也有了对应的检查;但别把它写成"完整复现了 Obsidian",或者"所有体验都验证过了"。

写在最后#

这次从 Quartz 换到 Astro,最大的变化其实不是换了框架的名字,而是笔记到网页之间那一段,现在能自己直接改了。内容还在 Obsidian 里写,文章之间还是靠双链连着,网站也尽量留着原来那种顺着链接到处逛的感觉。搬完之后,足迹地图、全文预览、文献引用、友链近况这些又陆续补了回来,中间也顺便踩出一些新问题。

维护活没少,只是换成了自己更愿意处理的方式:Astro 管页面,成熟的库各管各擅长的,我自己的代码只把它们和笔记、发布规则、阅读习惯接起来。

往后还是老三样:补该补的功能、修实际用出来的 bug、按需升级依赖。地图以外的 Bases 视图、Mermaid、更完整的 OFM 都能继续做,但得先想清楚要支持到什么程度、会不会顺带把不该公开的东西带出去。修 bug 的时候也得守住已有的链接、发布规则和交互,别修好一处又弄坏一处。

至于还会不会有下一次搬家,现在真不敢保证。至少这次我比较清楚自己为什么要换、以后要维护什么。希望接下来花在写内容上的时间,能比折腾博客本身多一点。


本文由 Astro 整理并发布。

CONVERSATION

讨论

想法、补充,或只是打个招呼。

关系图谱 ↓

全局关系图谱

页面标签
笔记
↑ ↓ 选择↵ 打开Esc 关闭