我已经有一个用 Quartz 搭建、可以正常发布的博客,最后还是重新做了这个网站。原因不是页面生成得不够好,而是旧网站要求我先把 Org 转成 Markdown。
我的写作和知识整理一直发生在 Emacs、Org 和 org-roam 里。Org 中的 ID、属性、链接和节点关系既是正文的一部分,也是知识库的结构;转换成 Markdown 后,我必须决定哪些语义值得保留,再在下游逐项补回。
2026 年 5 月,我在知识库里写过一句话:
我们不需要先转换为 Markdown,这对于 Org 文件来说是语义丢失。
这个想法最先落在了 Majutsu 的文档站上。 它用 ox-edn 和 Loam 编译 Org,再通过自定义 Astro content loader 把产物交给 Starlight。 这次实践证明内容层可以脱离 Markdown,也替我验证了 Org 编译器。
重新做个人网站时,问题因此变成了另一件事:什么样的 web shell(借用 desktop shell 的说法,指承载已编译内容、负责路由、布局和交互的这一层)适合项目页、长文、threads、backlinks 和逐步增加的交互?
我的答案不是再做一套内容系统,而是保留 ox-edn 和 Loam,只把 Starlight 换成 SvelteKit。 前端框架只是最外层的选择;真正想建立的是一条不被 Markdown 或 HTML 预先压扁的语义通道:只要 Org 能表达、Emacs 能识别,Web 层就可以使用。
我不想把 Org 当成一种比较奇怪的 Markdown
最顺手的方案是把 Org 转成 Markdown,再交给成熟的静态站点生成器。标题、段落、列表和代码块通常都能顺利通过,问题出在我实际依赖的那部分 Org。
我的 Org 文件里有 ID、CUSTOM_ID、property drawer、tags、TODO、source block、target、footnote,以及 id: / fuzzy / file 等不同类型的链接。org-roam 又把稳定的 ID 作为节点身份。Majutsu 的手册本身就是 Org;TouchDeck 的笔记会继续连到 niri、fcitx5 和 librime。这些内容共同组成一张持续变化的图。
如果中间加入 Markdown,我还要定义一套映射规则:
Org
ID在 Markdown 里如何表示?CUSTOM_ID和标题 slug 谁决定最终锚点?跨文件
id:链接在哪一层解析?反向链接根据哪一份数据生成?
原始文件里的源码区间能否继续追踪?
Org parser 的行为变化以后,哪一层负责同步?
ox-hugo 和 Quartz 已经证明这条路线可以工作。 放到我的网站里,Markdown 会成为一份需要长期维护的公共中间模型。 我更希望下游直接接收 Emacs 解析出来的 Org。
把 Org 的语义接到 Web
这套管线最重要的优势是自由度。 常见的 exporter 关心「怎样把一个 Org node 输出成目标格式」;一旦导出为 Markdown 或 HTML,下游通常只能看到目标格式保留下来的概念。 以后要增加一种 Web 能力,就得先把相应语义塞进 front matter、约定的 HTML 或另一套映射规则。
ox-edn 选择在目标格式之前停下来,把 Org AST、属性和源码位置变成稳定的数据。 只要 Emacs/Org 能识别一种语义,Loam 就可以索引、解析、渲染或把它投影成新的 Web 视图。
这个网站已经使用了其中几种:
ID、CUSTOM_ID和 link 同时驱动稳定页面身份、锚点、链接预览、反向链接与 graph;properties 和 headline 层级决定页面路径、发布状态以及一个 subtree 是否独立成为页面;
tags 同时生成 threads、related threads 和搜索入口;
source span 与 font-lock 信息分别支持精确的
view Org、修订计算和沿用 Emacs major mode 的代码高亮。
以后如果要把 TODO 状态、citation、某个新的 property,或者其他 Org 语义做成 Web 功能,不必先寻找一种 Markdown 表示;只需要在这条语义管线里增加相应的索引、渲染或投影。 这里的自由不只是「可以自定义 HTML 模板」,而是同一份 Org 语义可以同时产生正文、导航、关系、搜索和交互,并继续长出原先没有设计过的视图。
这条通道还带来几项直接结果:写作端只有一份 Org,页面关系只计算一次,内容编译器与 Web shell 可以分别演进,无法解释的语义则会在构建时失败,而不是在网页上静默丢失。
代价是这套管线比通用静态站点生成器更专用,构建也依赖我的 Emacs/Org 环境。 对我而言,这个代价换来的是:Web 能使用 Org 的完整表达能力,而不是只使用某个 exporter 预先挑选的子集。
发布边界留在知识库里
我的日常工作本来就在代码和 Org 之间来回。 Majutsu 有 Org 手册和 Elisp 源码,TouchDeck 有 Rust/Wayland 实现和与 niri、fcitx5、librime 相连的设计笔记。 很多文章在动笔之前,已经以代码、设计文档或 org-roam note 的形式存在。
网站不应该迫使我为了发布再维护一套内容,但也不意味着整个 ~/org 都应该公开。
现在 ~/org/roam/blog/ 是明确的发布边界。
它仍然位于 org-roam 知识库中,因此其他私人笔记可以直接通过 ID 引用公开文章;
构建时,脚本从正在运行的 Emacs 读取 org-directory 和 org-roam-directory,只把 blog/ 中的 Org 文件交给 ox-edn。
公开关系图也以这批输入为边界。 私人笔记指向博客的链接只存在于本地作者视图中,不会成为网站上的 backlink; 博客指向边界外的内部 ID 则会因为没有公开目标而令严格构建失败。
这样,知识库可以知道博客,博客却不会反向暴露知识库。
公开页面中隐藏的 drawer 和 comment 仍会保留在 /source/...org 里,因此放进 roam/blog/ 的文件本身就必须适合公开(读者也可以在 org 源文件里看到笔者特意隐藏的一些吐槽 bushi)。
我更愿意把这个网站叫作 personal technical workbench。 项目、长文、持续更新的 notes、backlinks、threads 和搜索,是同一批公开知识在不同阶段的视图。
三层管线各自负责什么
现在的构建过程是:
Org
│ Emacs / org-element + ox-edn
▼
Envelope v1 / EDN
│ Loam
▼
Manifest + Search + Graph + HTML + Org source
│ SvelteKit
▼
Static website
ox-edn 解析单份 Org,并把结果编码成 EDN;
Loam 汇总所有文档,决定页面边界,解析跨文件链接,再生成全站产物;
SvelteKit 读取这些产物,负责布局、交互和静态页面。
这三个边界也限定了变化的影响范围:Org 解析行为只影响 ox-edn 的 contract,页面关系只由 Loam 负责,布局和交互则可以在不重新解释内容的情况下修改。
ox-edn:让 Emacs 负责解释 Org
ox-edn 刻意只做一件事:调用 org-element 解析单份 Org,再把 AST 和必要的来源信息编码成 EDN。它的价值不在于重新实现一个 Org parser,而在于让后续工具不必各自实现一个。
核心入口仍然是 Org 自己的 parser:
(org-element-parse-buffer)
headline、property、link 和 target 先由正在运行的 Emacs/Org 解释,ox-edn 只负责把结果带出 Emacs。
生产构建输出带版本的数据结构 Envelope v1,大致长这样:
{:ox-edn/schema-version 1
:ox-edn/exporter
{:name "ox-edn"
:version "..."
:emacs-version "..."
:org-version "..."}
:ox-edn/source
{:path "roam/blog/posts/building-this-site.org"
:sha256 "..."
:encoding "utf-8"}
:ox-edn/document {...}
:ox-edn/diagnostics []}
对于 Org 能在当前文档中解析的 ID、custom ID、fuzzy 和 radio link,ox-edn 会附上目标 ID 与源码区间等信息。
代码高亮沿用 Emacs,HTML 留给 Loam
Org 的 HTML exporter 本来就可以借助 htmlize 处理代码高亮。
ox-html 进入对应语言的 major mode,让 font-lock 产生 face,再由 htmlize 输出 inline style 或 CSS class。
如果目标只是一份 Org 对应一个 HTML 页面,我会直接使用 ox-html / htmlize。
这个网站需要的边界更早一些。ox-edn 记录 face 及其字符区间,把 span 留给后面的渲染阶段:
{:provider :emacs-font-lock
:mode "svelte-ts-mode"
:runs [{:start 1
:end 7
:faces ["font-lock-keyword-face"]}]}
Emacs 在这里回答「哪些字符带有哪些 face」。 Loam 稍后把这些区间转成经过约束的 HTML class,网站样式表再决定颜色。 当前 Emacs 主题的 RGB 值不会被写进内容。
这条路径保留了 htmlize 最吸引我的部分:代码仍由实际使用的 major mode 处理。 代价也很明确,结果依赖本机安装的 major mode 和个人配置。
固定版本的 Shiki 或 Tree-sitter grammar 更适合追求环境无关的通用 CI; 但是我的目标是让网页尽量复用日常编辑时的语言高亮模式。
构建直接复用日常使用的 Emacs 环境
因此,Org parser、语言到 major mode 的映射( org-src-lang-modes )和我在 buffer 里实际看到的内容都来自同一个 Emacs 环境。
如果默认 server 不在运行,脚本会启动一个正常加载 Doom 配置的临时 daemon。
所有文件成为 Envelope 后,构建脚本一次性交给 Loam:
bb bundle --profile personal ...
Loam:从 Org AST 得到全站页面
ox-edn 回答一份 Org 写了什么,Loam 处理这些 Org 在同一个网站里的关系。这一层把页面身份、链接和派生视图编译成可复用的全站事实,而不是只生成一次性的 HTML。
严格编译管线大致是:
load
→ validate
→ normalize
→ partition
→ index
→ resolve
→ render
→ emit
其中最重要的一步是 partition。一棵 Org AST 不必对应一个网页:页面可以来自完整的 Org 文件,也可以来自某个文件中的一个 subtree,这两种形态也正好对应 org-roam 的文件节点和标题节点。
personal profile 目前采用两条页面归属规则:
文档根节点同时声明
ID和EXPORT_FILE_NAME时,整个 Org 文件构成一个页面;文档根节点没有声明这组属性时,带有页面身份和发布路径的一级 headline 各自构成一个页面,其 subtree 是该页面拥有的源码。
页面归属只能由一个 :partitioner 提供。这样,哪个 subtree 属于哪个 URL 不会受 extension 加载顺序影响。已有的 :page-partitioners 仍可在分页后追加转换,但不能重新定义页面身份。
文档页的 metadata 继续写在 Org 里:
:PROPERTIES: :ID: 0d3edb2c-00ce-4e89-8f69-bcd6acd34bb3 :EXPORT_FILE_NAME: posts/building-this-site :LOAM_KIND: post :LOAM_STATUS: stable :LOAM_PUBLISHED_AT: 2026-08-08 :END: #+FILETAGS: :org:emacs:sveltekit:
Loam 随后为页面、Org ID、CUSTOM_ID、headline、target 和 anchor 建立全局索引,再解析跨文件链接。正向链接、反向链接和 graph edge 都来自同一次解析,不需要由 Web 层重新推断;搜索、关系栏和 threads 因而不会各自维护一套链接规则。
最终构建产物包括:
manifest.json
build-report.json
search-index.json
graph.json
pages/*.html
source/**/*.org
manifest.json 记录 route、title、description、tags、status、源码区间、headings、backlinks 和 outgoing links;search-index.json 是搜索需要的精简数据;graph.json 只保留节点和边;source/**/*.org 保存每个公开页面实际拥有的 Org source。正文在这一层已经变成确定的 HTML fragment。
Tag 则被投影成 thread。/threads/emacs/、/threads/wayland/ 按 writing、project 和 field note 分组,related threads 来自 tag co-occurrence;搜索中输入 #emacs 时也优先匹配 tag。这些页面仍然来自同一份 Org corpus,不需要另一套内容数据库。
emit 之前,Loam 会递归检查公开 manifest、report 和 HTML fragment。发现机器绝对路径就拒绝发布。strict renderer 遇到未知 Org node 也不会只渲染 children 后继续构建,任意 HTML export block 同样不能直接穿透到页面。
这使错误集中在发布之前:编译器不能完整解释内容时,上一代可发布产物保持不变,网站不会混入一部分成功、一部分静默降级的新页面。
SvelteKit 只消费构建产物
Loam 生成完整的一代产物后,SvelteKit 只读取 .cache/loam。TypeScript 不读 EDN,也不重新解释 Org AST。catch-all route 从 manifest 枚举路径,构建时加载对应的 HTML fragment,再由 adapter-static prerender 成普通静态文件。
页面组件的核心接近这样:
<article class="org-content">
{@html data.html}
</article>
{@html} 的信任边界在构建阶段。
HTML 来自受控 Org source,并经过 Loam 的严格 renderer。
外部内容一旦进入这条管线,就必须重新设计这层安全边界。
为什么是 SvelteKit
分层的直接收益在这里出现:到了 Web 层,输入已经是 HTML 和 JSON,Web shell 不会再影响 Org 的内容模型。 Astro、Nuxt、Next.js,甚至一组手写模板都能接在 Loam 后面;换框架只改变页面呈现,不要求重写文章或链接数据。
我已经在 Majutsu 文档站里用过 Astro + Starlight,Starlight 提供文档侧栏、页面目录、搜索、前后页和主题切换。 对于文档站来说没有什么需要专门定制的组件。
而个人网站的信息架构更自由,我想要让首页、项目页、主题页、文章两侧的关系栏、内部链接预览、以及后来加的 revision history 都要按自己想象中的设计来呈现。
部署方式也很明确:最终产物必须是纯静态文件。
adapter-static 保留了 SvelteKit 的路由和数据加载方式,生产服务器却只需要提供 HTML、CSS、JavaScript 和 JSON,不必运行 Node。
在组件层面,Svelte、Vue 和 React 都能完成这些交互,最后的选择取决于交互的形状。 这个网站没有组件库,也没有复杂的全局状态;目前的交互只有搜索面板、链接预览、修订记录和公式复制,状态大多留在单个组件里。
Svelte 的单文件组件和局部 CSS 适合这种自定义页面。
搜索索引不会进入每个页面的 prerender payload。SearchPalette 第一次打开时才获取 /search-index.json;文章页也只携带当前页面需要展示的 outgoing target 摘要。新增文章不会仅仅因为搜索索引变化就改写每一篇旧文章的 HTML。
页面身份、内容修订和构建结果分开计算
文章发布以后,正文、链接关系和网站外壳会以不同速度变化。 只用一个文件哈希,要么会把 CSS 改动误记成文章更新,要么无法表达「正文未变,但新增了 backlink」这种情况。 把它们分开,读者看到的修订记录和部署系统需要的重建判断才能各自准确。
:ID: 的 UUID 是页面的稳定身份,:EXPORT_FILE_NAME: 决定稳定 URL。
:LOAM_PUBLISHED_AT: 记录第一次公开的日期,修改正文时保持不变;
只有读者值得注意的实质修改才设置 :LOAM_UPDATED_AT:。
错别字、CSS 和部署脚本的改动不必把文章标记为更新。
构建侧使用三个 digest:
sourceRevision公开 Org source 的 SHA-256。一文件一文章时,它覆盖完整文件;collection page 则只覆盖该页面拥有的 subtree。
contentRevision标题、公开 metadata 和渲染正文的内容版本。comment 等不进入公开内容的改动不会产生新的 editorial revision。
pageBuildDigest页面内容及其关系依赖的构建版本。正文没变,但另一篇文章新增了指向它的 backlink,这一项仍然会变化。
公开的页面更新历史只跟踪 editorial revision。
一个 jj change 可能同时修改网站代码和几篇文章,也可能只做重构。
每次正式部署会合并上一代的 revision-history.json 与当前 manifest;同一页面 UUID 的 contentRevision 变化时,才追加一条公开修订记录。
可复现构建与原子发布
SvelteKit 默认使用构建时间作为 app version。相同输入连续构建两次时,runtime chunk 名称和页面里的 bootstrap script 也可能变化。我改用 src/ 和 Svelte 配置计算稳定的 site-shell revision,并把搜索索引改成按需加载。在相同构建环境中,连续两次相同构建现在会生成字节一致的文章 HTML。
每次 release 仍然是一代完整、不可变、可以原子回滚的静态文件。部署时用:
rsync --checksum --link-dest=<previous-release> build/ <new-release>/
没有变化的文件直接 hardlink 到上一代,只有变化的文件需要传输。删除旧 release 时,只要新 release 仍引用相同 inode,文件数据就不会消失。完整 generation 与跨代复用可以同时成立。
真实内容也是回归测试
完整的语法展示放在单独的 Org 语法陈列室,通过正常内容管线发布,同时充当真实的回归测试。
本文走完整条管线
这篇文章的源文件是:
roam/blog/posts/building-this-site.org
每个页面还有对应的 /source/...org。文章页公开完整原始文件,collection page 公开原文件中的精确 subtree,不把 AST 重新导出成「看起来像 Org」的文本。
网页侧栏里的 view Org 指向本次构建实际发布的 /source/posts/building-this-site.org。这份源文件先由 Emacs 解析,经 ox-edn 编码成 EDN;Loam 为它分配 /posts/building-this-site/,解析链接并生成正文;SvelteKit 最后把页面 prerender 成静态 HTML。
这也是整套设计的优势:我只维护 Org;ox-edn 和 Loam 把语义、关系与发布错误收束在构建阶段;SvelteKit 则可以在不迁移内容模型的前提下继续改变网站的呈现。