post stable

从 Org 到 Web:建站记

我为什么没有采用 Org → Markdown,以及 ox-edn、Loam 和 SvelteKit 如何把 Org 变成一个网站。

我已经有一个用 Quartz 搭建、可以正常发布的博客,最后还是重新做了这个网站。

页面生成早已有成熟答案。麻烦来自内容模型:我的写作和知识整理一直发生在 Emacs、Org 和 org-roam 里,旧网站接收的却是转换后的 Markdown。两边使用不同的内容模型,我就得决定哪些 Org 语义值得保留,再在下游逐项补回 ID、链接和页面关系。

2026 年 5 月,我在知识库里写过一句话:

我们不需要先转换为 Markdown,这对于 Org 文件来说是语义丢失。

这个想法最先落在了 Majutsu 的文档站上。那套尚未上线的站点已经使用 ox-ednLoam 编译 Org,再通过自定义 Astro content loader 把产物交给 Starlight。它证明了内容层可以脱离 Markdown,也让我看到另一个边界:文档站的侧栏、目录、搜索和前后页有成熟答案,个人网站的页面结构却需要自己决定。

到 2026 年 8 月 7 日,我已经不需要重新验证内容编译器了。新的问题是,什么样的 Web shell(借用 desktop shell 的说法,指承载已编译内容、负责路由、布局和交互的那一层)适合项目页、长文、threads、backlinks 和逐步增加的交互。于是我保留 ox-edn 和 Loam,把 Starlight 换成了 SvelteKit。

这篇文章记录的就是这三层边界。

我不想把 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
 │  Emacs / org-element + ox-edn
 ▼
Envelope v1 / EDN
 │  Loam
 ▼
Manifest + Search + Graph + HTML + Org source
 │  SvelteKit
 ▼
Static website

三层各自只处理一类问题:

  • ox-edn 解析单份 Org,并把结果编码成 EDN;

  • Loam 汇总所有文档,决定页面边界,解析跨文件链接,再生成全站产物;

  • SvelteKit 读取这些产物,负责布局、交互和静态页面。

TypeScript 不解析 Org,Emacs 不决定页面长什么样,Loam 也不维护浏览器组件。多出来的两层就是用来守住这些边界的。

ox-edn:让 Emacs 负责解释 Org

ox-edn 的边界很窄:调用 org-element 解析单份 Org,再把 AST 和导出信息编码成 EDN。它不决定 URL,也不生成网站。

核心入口仍然是 Org 自己的 parser:

(org-element-parse-buffer)

我没有在 JavaScript、Rust 或 Clojure 里再实现一个「足够像 Org」的 parser。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 "content/posts/building-this-site.org"
  :sha256 "..."
  :encoding "utf-8"}

 :ox-edn/document {...}
 :ox-edn/diagnostics []}

这里有三条刻意收紧的约束:

  • source path 必须是相对仓库根目录的 POSIX path,/home/... 之类的机器绝对路径不能进入公开产物;

  • ox-edn 按字节读取源文件,验证内容可以无损往返 UTF-8,并计算 SHA-256,下游因此能确认 AST 对应的是哪一份源文件;

  • 生产导出默认使用 strict diagnostic policy,源码区间越界、property 读取失败、未知节点和不可序列化的值都会让导出失败,已有产物不会被半成品覆盖。

链接也按同样的边界处理。对于 Org 能在当前文档中解析的 ID、custom ID、fuzzy 和 radio link,ox-edn 会附上目标 ID 与源码区间等提示。跨文档解析留给 Loam,因为它需要看到完整的内容集合。

代码高亮沿用 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;我的目标则是让网页尽量复用日常编辑时的语言配置。

Loam:从 Org AST 得到全站页面

ox-edn 回答一份 Org 写了什么,Loam 处理这些 Org 在同一个网站里的关系。

严格编译管线大致是:

load
  → validate
  → normalize
  → partition
  → index
  → resolve
  → render
  → emit

其中最重要的一步是 partition。一棵 Org AST 不必对应一个网页:页面可以来自完整的 Org 文件,也可以来自某个文件中的一个 subtree,这两种形态也正好对应 org-roam 的文件节点和标题节点。

personal profile 目前采用两条页面归属规则:

  • 文档根节点同时声明 IDEXPORT_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 层重新推断。

最终构建产物包括:

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。

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

到了这一层,输入已经是 HTML 和 JSON,Web shell 不会再影响 Org 的内容模型。Astro、Nuxt、Next.js,甚至一组手写模板都能接在 Loam 后面。

我已经在 Majutsu 文档站里用过 Astro + Starlight。那是一个合适的组合:Starlight 直接提供文档侧栏、页面目录、搜索、前后页和主题切换,我的工作集中在接入 Loam 的 loader 和少数组件上。

个人网站的信息架构更自由。首页、项目页、thread、文章两侧的关系栏、revision history 和内部链接预览都要按自己的内容模型设计。继续使用 Starlight,接下来的工作会变成逐项替换它的文档外壳;预设更少的 SvelteKit 做起来更省力。

部署方式也很明确:最终产物必须是纯静态文件。adapter-static 保留了 SvelteKit 的路由和数据加载方式,生产服务器却只需要提供 HTML、CSS、JavaScript 和 JSON,不必运行 Node。

在组件层面,Svelte、Vue 和 React 都能完成这些交互,最后的选择取决于交互的形状。这个网站没有组件库,也没有复杂的全局状态;目前的交互只有搜索面板、链接预览、修订记录和公式复制,状态大多留在单个组件里。Svelte 的单文件组件和局部 CSS 适合这种自定义页面,$state / $derived 又刚好对应局部状态和派生数据。

Vue 是最接近的组件层备选。Vue SFC、Composition API 和 Nuxt 的静态生成同样能完成这些工作。我没有做一轮框架 benchmark;选择依据是自己更喜欢 Svelte 在小型交互上的写法,也打算让将来的 Org component islands 使用 Svelte。这个网站不依赖组件库,React 庞大的生态在这里帮不上太多忙。

还有一个原因来自前两层的设计:内容已经由 Loam 编译完成,我不需要前端框架再解析 Markdown、管理 front matter 或建立一套 content collection。SvelteKit 只接收构建产物,处理路由、组件和静态导出。

SvelteKit 是一个有前情的务实选择,替换成本也被限制在 Web 层。以后即使换掉它,Org 的页面身份、链接和修订模型也不需要重做。

构建复用我日常使用的 Doom Emacs

构建脚本递归查找 content/ 下的 .org 文件,再通过 emacsclient 把整批导出交给我日常使用的 Doom Emacs。Org parser、个人配置和语言到 major mode 的映射因此来自同一个 Emacs 环境。如果默认 server 不在运行,脚本会启动一个正常加载 Doom 配置的临时 daemon。

所有文件成为 Envelope 后,构建脚本一次性交给 Loam:

bb bundle --profile personal ...

搜索索引不会进入每个页面的 prerender payload。SearchPalette 第一次打开时才获取 /search-index.json;文章页也只携带当前页面需要展示的 outgoing target 摘要。新增文章不会仅仅因为搜索索引变化,就改写每一篇旧文章的 HTML。

把这套架构放回 Org publishing 生态里

比较 ox-edn 时,需要把视角从代码高亮扩展到完整的 Org publishing:谁解释 Org,什么构成公开页面,全站关系又在哪一层建立。

Worg 的 Blogs and Wikis with Org 收录了不少不同年代的方案。我用它观察各个项目如何安排 Org parser、页面边界和 Web 层,功能数量只作参考。

Emacs 直接生成 HTML

org-publish + ox-html 是最完整的官方 baseline。Org manual 的 Publishing 章节把它定义为一套面向互链 Org 文件的发布系统:project 可以组合多个 component,HTML 之外的附件可以一同复制,file link 中的 heading、custom ID 和 target 也会转换成对应的网页锚点。它还内置 sitemap、跨文件 index 和基于 timestamp 的增量发布。公开内容天然是一份 Org 文件对应一份网页时,这套能力已经很完整。

它的核心抽象始终是「选择一组文件,对每个文件调用 publishing function」。默认页面边界由文件决定。我的内容模型还需要另一层:完整文件可以成为页面,文件中的 subtree 也可以拥有独立 ID、URL 和精确 source。org-publish 支持写 Elisp 改造 publishing function,但这套 logical page 模型需要自己实现。

全站关系也会改变增量构建的含义。假设 B 新增一条指向 A 的链接,B 的源文件发生了变化,A 的 backlink 面板也要随之更新。基于源文件 timestamp 的发布模型只会自然地选中 B;目标页 A 的反向依赖需要额外追踪。Org manual 也提醒,SETUPFILEINCLUDE 等外部依赖变化时可能需要强制 republish。Loam 会先建立完整索引,再把 backlinks、outgoing links 和前后页等关系计入每个页面的 pageBuildDigest

HTML 作为主产物也限制了下游能看到的信息。搜索、graph、thread 和 revision history 需要结构化的页面 metadata 与链接边,单独遍历最终 HTML 很难还原 Org ID、源码区间和链接类型。org-publish 提供 preparation function、completion function 和自定义 exporter,这些扩展点都能承载上述逻辑。代价是 partition、全局索引、依赖失效和多种构建产物会逐渐堆进同一套 Elisp 发布配置。

org-static-blogweblorg 把文章列表、归档、Feed、tag pages、模板和主题等常见站点需求继续补齐。如果只写传统博客,这条管线比 ox-edn → Loam → SvelteKit 短得多。我的网站则把 corpus compiler 单独放进 Loam,让 ox-html 和 Web shell 都保持较窄的职责。

从现有 Org 工作流中选择页面

lazyblorg 是与我的需求最接近的现成方案之一。文章可以位于普通 Org 文件的不同位置;除了与日期绑定的 temporal pages,它还提供持续更新的 persistent pages,以及 backlinks、TagTrees、Feed 和原始 Org source。

它使用自己的 Python parser,并在部分语法上回退到 Pandoc,也有一套围绕 :blog: tag、ID 和 DONE/LOGBOOK 的发布约定。这些约定与 Karl Voit 的工作流高度契合。我的工作流则要求网站复用正在运行的 Emacs/Org,包括自己的 Org fork 和 Doom 中的语言映射。

Org 转成 Markdown

这是我实际用过最久的一条路线。ox-hugo 支持 one-post-per-file 和 one-post-per-subtree,也会利用 property、tag inheritance 和 TODO state 生成 Hugo front matter。它对 Org 的支持远比「转一下 Markdown」这句话听起来完整。

进入 Hugo 或 Quartz 后,系统里仍然会同时存在 Org 和 Hugo-flavored Markdown 两套内容模型:

Org / org-roam
      ↓ ox-hugo
Hugo-flavored Markdown + front matter
      ↓ Hugo / Quartz
Web content model

我遇到的麻烦主要发生在写作阶段。为了让服务于 org-roam、agenda 和项目管理的 Org 同时满足 Hugo 的页面模型,我会提前考虑 heading 能否继续嵌套、subtree 是否必须成为 export leaf,以及某个 property 究竟服务于哪一层。

Loam 的 partitioner 仍然需要决定页面边界,但它直接在原始 Org AST 上完成这一步,不再生成另一份过渡 Org 或 Markdown。

独立 Org parser 和 graph-first 工具

Firn 使用 Rust 和 Orgize 从 Org 文件生成静态站点;Orga / orga-build 把 Org AST 带进 JavaScript、Unified 和 JSX 生态。它们不要求构建机启动 Emacs,更容易进入普通 CI。相应的取舍是,编辑器和构建系统分别由两个 parser 解释 Org。

另一类工具直接以 graph 为主界面。publish-org-roam-ui 可以从 org-roam files + database 生成静态 digital garden;org-roam-ui-lite 也把静态导出作为主要能力。如果目标是把 org-roam graph 放到网上浏览,它们比重做一套个人网站直接得多。我的网站以长文和项目页为主,graph 处在辅助位置。

把这些方案放在一起,我会这样比较:

方案Org 语义由谁解释公开页面来自哪里Org 后的主要表示全站能力对我的取舍
org-publish + ox-htmlGNU Emacs OrgOrg 文件HTMLsitemap / index 内置,其余可由 Elisp 扩展subtree 页面与关系依赖需要自己实现
org-static-blog / weblorgGNU Emacs OrgOrg 文章文件HTML以 blog / site 为中心管线简单,但页面模型以文章为主
lazyblorgPython parser + Pandoc fallbackOrg 文件中选出的 headingHTMLpersistent page、backlink、TagTrees、source使用自己的 parser 和发布约定
ox-hugo → QuartzEmacs export + 下游 Markdown parserOrg 文件或 EXPORT_FILE_NAME subtreeHugo Markdown + front matterQuartz 提供 backlink、tag 和 graph同时维护两层内容模型
Firn / OrgaOrgize / Orga parser目录中的 Org 文件AST / HTML适合 wiki 和 Web 集成编辑器与构建 parser 可能分叉
org-roam-ui publishingorg-roam DB + Org filesorg-roam nodegraph / search datagraph 和 backlink 是主模型主要优化 graph browsing
ox-edn + Loam我正在使用的 Emacs/Org单个 Org 文件,或某个 Org 文件中的一个 subtreeEDNID、backlink、thread、graph、source、revision自己维护 compiler,复杂度最高

这套架构用最高的维护成本换来了我最在意的 authoring model:日常的 Emacs / org-roam 工作流先存在,网站从中选出明确的公开内容,再沿用同一套 Org 语义生成 Web 页面。只写传统博客时,我会直接选择更短的管线。

发布边界:知识库与公开网站分开管理

我的日常工作本来就在代码和 Org 之间来回。Majutsu 有 Org 手册和 Elisp 源码,TouchDeck 有 Rust/Wayland 实现和与 niri、fcitx5、librime 相连的设计笔记。很多文章在动笔之前,已经以代码、设计文档或 org-roam note 的形式存在。

网站不应该迫使我为了发布再维护一套内容。但这也不意味着整个 ~/org 都应该公开。

目前 personal-site/content/ 是明确的发布边界:知识库负责思考和积累,content 负责选择哪些内容成为公开作品。以后可以加入更方便的选取、引用和 transclusion,但自动化不能取消这条安全边界。公开页面中隐藏的 drawer 和 comment 仍会保留在 /source/...org 里,因此放进 content 的文件本身就必须适合公开。

我更愿意把这个网站叫作 personal technical workbench。项目、长文、持续更新的 notes、backlinks、threads 和搜索,是同一批知识在不同阶段的视图。

页面身份、内容修订和构建结果分开计算

文章发布以后,正文、链接关系和网站外壳会以不同速度变化。只用一个文件哈希无法同时表达这些变化。

: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,这一项仍然会变化。

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 与跨代复用可以同时成立。

公开的页面更新历史只跟踪 editorial revision。一个 jj change 可能同时修改网站代码和几篇文章,也可能只做重构。每次正式部署会合并上一代的 revision-history.json 与当前 manifest;同一页面 UUID 的 contentRevision 变化时,才追加一条公开修订记录。

每个页面还有对应的 /source/...org。文章页公开完整原始文件,collection page 公开原文件中的精确 subtree,不把 AST 重新导出成「看起来像 Org」的文本。

Tag 则被投影成 thread。/threads/emacs//threads/wayland/ 按 writing、project 和 field note 分组,related threads 来自 tag co-occurrence;搜索中输入 #emacs 时也优先匹配 tag。这些页面仍然来自同一份 Org corpus,不需要另一套内容数据库。

完整的语法展示有两百多行,覆盖链接、列表、special block、LaTeX、source block、表格、脚注和 drawer。全部塞进正文会把建站文章变成 renderer fixture。

这些内容现在放在单独的 Org 语法陈列室。那个页面通过正常内容管线发布,同时充当真实的回归测试;这里则只保留与架构有关的例子。

这篇文章就是一次端到端构建

这篇文章的源文件是:

content/posts/building-this-site.org

网页侧栏里的 view Org 指向本次构建实际发布的 /source/posts/building-this-site.org。这份源文件先由 Emacs 解析,经 ox-edn 编码成 EDN;Loam 为它分配 /posts/building-this-site/,解析链接并生成正文;SvelteKit 最后把页面 prerender 成静态 HTML。

本文直接使用自己的源文件走完整条管线,也成了这个网站的第一份端到端构建测试。