版权页

本站使用一个用 Rust 编写的自定义静态网站生成器构建。与大多数将内容建模为带元数据的文件的静态网站生成器不同,这个生成器以 RDF 图作为核心数据模型。每个内容文件都会变成内存图数据库中的一组三元组,实体之间的关系——文章、主题、人物、来源——都是一等图边,而非临时拼凑的元数据。

图数据库

系统的核心是 oxigraph,一个用 Rust 编写的 RDF 三元组存储和 SPARQL 查询引擎。在构建时,每个内容文件都被加载到内存中的 oxigraph 存储里。前置元数据的键通过词汇表配置映射到 RDF 谓词,使用的是 Dublin CoreFOAFSKOS 等标准本体。当一篇文章声明 subject: ['@topics/ai'] 时,这会变成一个 dc:subject 三元组,将文章链接到 AI 主题实体,同时自动生成一个反向的 subject_of 三元组链接回来。

模板在渲染时通过 SPARQL 查询图数据库,这意味着“关于这个主题的所有文章“或“这个人的所有来源“这样的关系是从图中派生出来的,而不是硬编码的。

构建过程还会从根类型(文章和页面)出发,通过广度优先搜索计算一个“引用集“。从任何已发布文章都无法到达的实体会自动从列表、RSS 和站点地图中隐藏——它们的页面仍然存在,但没有直接 URL 就无法发现。这意味着我不需要手动管理哪些主题或人物出现在网站上;发布一篇引用它们的文章就足够了。

内容模型

内容文件是带有 YAML 前置元数据的 Markdown,由 pulldown-cmark(一个用 Rust 编写的 CommonMark 解析器)解析。每个内容文件都有一个类型,类型形成层次结构:postprose 的一种,prose 又是 entity 的一种。模板解析会沿着这个链条查找,因此特定类型可以覆盖通用模板。

本站支持一种自定义的内联实体引用语法,可以在正文中同时创建超链接和图边。例如,写 @[Dario Amodei]("@people/dario-amodei" rel="subject") 会渲染为指向该人物页面的普通链接,同时在图中创建一个 dc:subject 边。一个自定义语法预处理器通过逐字符扫描和可扩展的处理器注册表来处理这些语法(以及短代码),在内容到达 CommonMark 解析器之前替换 markdown 正文。

模板与渲染

模板使用 MiniJinja,这是 Armin Ronacher(Flask 和 Jinja2 的创建者)为 Rust 编写的轻量级 Jinja2 兼容模板引擎。自定义模板函数对图执行 SPARQL 查询,自定义过滤器处理日期格式化、通过 ICU4X(Unicode 官方的 Rust 国际化库)进行语言感知排序,以及将内联实体引用占位符解析为最终 URL。

国际化

本站支持双语:英文和中文。每个内容文件都是一个包含所有语言的单一文件。本地化的前置元数据字段使用 .lang 后缀(如 title.entitle.zh),正文使用 @lang 围栏来分隔不同语言的内容。所有用户界面字符串通过 YAML 翻译文件处理。只有语言中性的字段(引用、日期、布尔值)创建图边;本地化字符串成为带语言标签的 RDF 字面量。ICU4X 处理语言感知的排序规则,确保两种语言的内容排序正确。

CSS 和资源

一个 CSS 文件提供所有样式,在构建时由 Lightning CSS(基于 Mozilla 的 cssparser crate 构建的 Rust CSS 解析器和压缩器,与 Firefox 使用相同的解析器)处理。输出经过压缩并使用 SHA-256 哈希进行指纹处理以便缓存失效。明暗主题通过 CSS 自定义属性和 [data-theme] 属性切换来实现。

基础设施

开发服务器使用 axum(Tokio 团队的 Web 框架),通过 notify crate 监视文件变化,在内容更改时自动重新构建。源代码托管在 Codeberg,通过 wrangler 部署到 Cloudflare Pages

其他依赖

还有一些值得一提的 crate:clap 用于命令行界面,serde 用于序列化,anyhow 用于错误处理,sha2 用于资源指纹处理,walkdir 用于文件系统遍历。

为什么要构建自定义的静态网站生成器?

主要是因为我想要的内容模型——带有一等关系、反向谓词和自动可发现性的类型化实体图——无法干净地映射到任何现有的静态网站生成器上。上一版网站使用的是 Hugo,花在与其分类系统斗争上的时间比写作还多。构建自定义工具意味着我可以完全按照自己的想法建模,而 Rust 使其足够快,完整构建(解析、图构建、SPARQL 查询、模板渲染、CSS 压缩、RSS、站点地图)在一秒内完成。