WSA(7)
NAME
wsa - 本站的构建与部署架构
SYNOPSIS
posts/*.md
-> src/markup.rs 解析一次,产出事件流
├─> src/html.rs 首页、文章页
├─> src/roff.rs manual page
└─> src/feed.rs RSS
-> public/
-> rsync
-> /var/www/thinker
零外部依赖。读者收到静态文件。
HISTORY
这套架构改过三次。每一次都不是为了更强,而是为了更少。
第一代用 Pandoc、template.html、feed.xml.sh 和一个 Makefile 把 Markdown 拼成网页。它能工作,但它是一组彼此知道太多的小脚本:模板知道文章长什么样,脚本知道模板长什么样,改一处要跟着改三处。
第二代把这些收进一个 Rust 生成器,用 pulldown-cmark 解析 Markdown。边界变清楚了:作者写 Markdown,生成器出 HTML 和 RSS,服务器发文件。但它留下一个没被看见的失衡——自有代码三百多行,依赖三万五千行,而那三万五千行里为本站服务的不到一成。
第三代删掉了那个依赖。
DESCRIPTION
现在的判据是:整个二进制里的每一行都算本站的代码。
按这个口径,pulldown-cmark 是最大的冗余源。它实现完整的 CommonMark,而本站四年十四篇只用到十种构造,且全部是平的——没有嵌套列表,引用块里只有段落。
于是文法收窄成一套封闭语法,由 src/markup.rs 自己实现。
这不是"一个不完整的 Markdown 解析器",是"一个小语言的完整实现"。区别在于边界是否可论证。
GRAMMAR
文法的边界由"手册页是什么"决定,不由用量决定。
roff 是一个有主见的目标格式:.SH、.SS、.IP、.EX、.TS 各自对应一件确定的事。超出这套宏的构造,在手册页这个概念里没有位置。
例如标题只到三级,不是因为没用过第四级,而是因为 roff 只有 .SH 和 .SS 两层。
文法之外的写法不会被静默渲染,而是构建时报错,并指出文件和行号。
posts/2026-07-26-x.md:42: 不支持嵌套列表
这条让"没有冗余代码"从口号变成可判定的性质:每段解析代码对应一条产生式,没有产生式对应的代码就是冗余,可以删;反过来,语法之外的输入不会悄悄走到任何代码路径上。
完整清单在 AUTHORING.md (external) (external)。
GENERATOR
九个文件,一千三百行,零依赖。
markup.rs 是唯一懂语法的地方。它不知道 HTML,也不知道 roff。
两个后端各自消费同一份事件流。解析发生一次。
这是加第三个后端只需要新增一个文件的原因,也是这套代码没有互相纠缠的原因。
OUTPUT
public/
index.html
YYYY-MM-DD-*.html
feed.xml
style.css
assets/
man/man7/*.7
man.tar.gz
manual page 不是比喻。装上之后 man thinker 可用,man -k thinker 能检索全站——后者是意外收获:每篇文章的 NAME 分区本来就是按手册体例写的,apropos 直接就能用。
BUILD
make check 依次做六件事:格式检查、跑测试、构建、校验 RSS、用 mandoc 校验全部 manual page、确认生成 HTML 里没有 <script。
最后一条现在其实是多余的——裸 HTML 在新文法下是硬错误,进不来。留着是纵深防御。
测试有一百一十一个,其中包含提交进仓库的黄金文件:一份覆盖全部构造的样例,和它在两个后端下的预期输出。渲染结果一旦变化,测试就会失败。
之前"输出与旧实现字节一致"只是手工验证过一次,仓库里没有任何东西守着它。现在有了。
同样的输入永远得到同样的输出——生成器不读时钟,lastBuildDate 取自最新一篇文章的日期,而不是构建时刻。
SERVER
服务器只托管 public/。
它不理解文章,也不负责构建。它只是发送文件。
CI/CD
推送到 main,GitHub Actions 跑 make check,通过后用 rsync 部署。Pull Request 只检查不部署。
NOTES
三代下来,真正变化的不是能力,是边界的清楚程度。
第一代的复杂度散在脚本之间,谁都知道一点别人的事。第二代把它收进一个程序,但把三万五千行外部代码当成了地基。第三代把地基也拆了——不是因为那些代码不好,而是因为它们为本站服务的部分不到一成,其余九成永远用不上。
窄规则没有变过:
复杂度属于作者和构建过程;读者只接收安静的静态文档。
变的只是"复杂度"这个词涵盖多少。第二代认为依赖不算,第三代认为算。