我已经有一个用 Quartz 搭建、可以正常发布的博客,最后还是重新做了这个网站。
页面生成早已有成熟答案。麻烦来自内容模型:我的写作和知识整理一直发生在 Emacs、Org 和 org-roam 里,旧网站接收的却是转换后的 Markdown。两边使用不同的内容模型,我就得决定哪些 Org 语义值得保留,再在下游逐项补回 ID、链接和页面关系。
2026 年 5 月,我在知识库里写过一句话:
我们不需要先转换为 Markdown,这对于 Org 文件来说是语义丢失。
这个想法最先落在了 Majutsu 的文档站上。那套尚未上线的站点已经使用 ox-edn 和 Loam 编译 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 目前采用两条页面归属规则:
文档根节点同时声明
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 层重新推断。
最终构建产物包括:
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 也提醒,SETUPFILE 和 INCLUDE 等外部依赖变化时可能需要强制 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-blog 和 weblorg 把文章列表、归档、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-html | GNU Emacs Org | Org 文件 | HTML | sitemap / index 内置,其余可由 Elisp 扩展 | subtree 页面与关系依赖需要自己实现 |
org-static-blog / weblorg | GNU Emacs Org | Org 文章文件 | HTML | 以 blog / site 为中心 | 管线简单,但页面模型以文章为主 |
| lazyblorg | Python parser + Pandoc fallback | Org 文件中选出的 heading | HTML | persistent page、backlink、TagTrees、source | 使用自己的 parser 和发布约定 |
ox-hugo → Quartz | Emacs export + 下游 Markdown parser | Org 文件或 EXPORT_FILE_NAME subtree | Hugo Markdown + front matter | Quartz 提供 backlink、tag 和 graph | 同时维护两层内容模型 |
| Firn / Orga | Orgize / Orga parser | 目录中的 Org 文件 | AST / HTML | 适合 wiki 和 Web 集成 | 编辑器与构建 parser 可能分叉 |
| org-roam-ui publishing | org-roam DB + Org files | org-roam node | graph / search data | graph 和 backlink 是主模型 | 主要优化 graph browsing |
| ox-edn + Loam | 我正在使用的 Emacs/Org | 单个 Org 文件,或某个 Org 文件中的一个 subtree | EDN | ID、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。
本文直接使用自己的源文件走完整条管线,也成了这个网站的第一份端到端构建测试。