post stable

从 Org 到 Web:建站记

为什么不把 Org 压成 Markdown,以及一条开放的语义管线如何把 Org 的任何语义接到 Web。

我已经有一个用 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 则可以在不迁移内容模型的前提下继续改变网站的呈现。