我开发了一个静态网站生成器:Zest — 模板即代码,由 F# 驱动的 SSG
如您所见,这个博客是我用 Eleventy 搭建的。在众多静态网站生成器中,11ty 一直是我最喜欢的那一个——它不像 Hugo 那样绑死 Go,也不像 Jekyll 那样绑死 Ruby,而是把选择权完全交给你。想用什么模板语言都是自由的,想怎么组织文件都是随意的。它不会在你不知情的时候往页面里塞多余的 div 或 inline script。
而在 11ty 支持的茫茫多的模板语言里,有一个最另类的存在——.11ty.js。这个模板语言的特点是"模板即代码":它本质上就是一个可被 Node.js 执行的 JavaScript 文件,你在里面写函数、做循环、导入 npm 包,最后 return 一段 HTML 字符串,11ty 把它丢进页面就完事了。我第一次见到这种模式时脑子里只有一个想法:这才是模板该有的样子。
但我用的还是 Nunjucks。原因很简单:.11ty.js 写起来远没有看起来那么优雅。你要用纯字符串拼接来构造 HTML,得小心翼翼地处理每一个引号的转义,手写每一个闭合标签。没有语法高亮,没有 JSX 那样的声明式结构,写起来像是在用 document.write 构建整个网站。于是我又回到了 Nunjucks 的怀抱,享受着那些熟悉的 {% if %} 和 {{ variable }},然后在每一个需要写辅助函数的时刻叹气。
因为普通的模板语言本质上是一种被限制在图灵完备以下的 DSL。你离不开循环、条件、变量——这些是模板的命脉——但每学到一条新指令就得翻文档。更糟的是模板语言和宿主语言之间存在一道不可逾越的鸿沟:你没法在模板里定义一个辅助函数、导入一个库、或者执行一段任意的计算逻辑。你只能在框架划定的圈子里跳舞,跨出一步就是 syntax error。当你在 Nunjucks 里试图做稍微复杂一点的数据变换时,那种无力感就像被人按住了写代码的右手。
所以我决定自己写一个。名字叫 Zest,全称 Zenith Efficient Static Toolkit——当然这个名字目前还是待定状态,因为它听起来多少有点中二,等以后想到更好且符合递归缩写传统的名字再换。
核心设计:模板就是程序,程序就是模板
Zest 的核心命题很简单:去掉模板语言这个中间层。你的模板文件就是 .zest.fsx——这压根不是什么模板语法,这是货真价实的 F# 脚本。构建时 Zest 通过 dotnet fsi(F# 的交互式脚本执行器)来运行这些文件,跑的就是原汁原味的 F# 代码,没有任何中间解释层,没有任何语法转换。
这意味着你在模板里能做任何 F# 能做的事:
// @title Hello World
// @layout default
// @description My first Zest page
let pageTitle = "Hello from F#"
let items = ["F#"; "Zest"; "SSG"]
render [
h1 [ text pageTitle ]
p [ text "This page is generated by real F# code at build time." ]
ul [ for i in items -> li [ text i ] ]
]
看到那个 for 循环了吗?它不是模板引擎的 for 标签,不需要查文档来确认语法是 {% for %} 还是 {{#each}} 还是 @for——这就是 F# 自己的列表推导式,你在任何一本 F# 教程里都会学到的那种。模式匹配、字符串插值、管道操作符、高阶函数、递归——整个 F# 标准库都是你的工具箱。想计算今天的日期?System.DateTime.Now 直接拿来用。想读取一个 JSON 文件?System.IO.File.ReadAllText 就在那儿等着你。没有学习成本,因为你学的就是 F# 本身。
而且 Zest 做了个很聪明的优化:多个 F# 页面脚本在同一个 FSI 进程中批量求值,而不是每页启动一个独立的进程。这意味着页面之间可以共享状态,重复的计算只需要做一次,构建速度也因此有了质的飞跃。
HTML DSL:声明式构造,编译时检查
有了"模板即代码"的思想基础,下一步自然是怎么用 F# 优雅地描述 HTML。我设计了一套内建的 HTML DSL,让你用纯 F# 代码以声明式的方式构造 DOM 树:
div [ class' "container"; id "main" ] [
h1 [ text "Welcome" ]
p [ text "Built with F# at compile time." ]
]
这里每一个元素都是一个 F# 函数,属性是传给函数的列表参数,子元素是另一个列表参数。你可以在列表里随意使用条件逻辑、列表推导、模式匹配——所有 F# 的控制流都是你的布局工具。最让我满意的是,这套 DSL 在编译时就能捕获错误:括号不匹配?编译器会告诉你。属性名拼错了?类型系统会拒绝通过。没有运行时崩溃,没有诡异的模板渲染空白页,一切都发生在你按下回车键的那一瞬间。
你可能注意到了 class' 这个写法——因为 class 在 F# 里是保留关键字,所以 DSL 里所有和语言关键字冲突的属性名都在末尾加了一个单引号。这是一个有意的 trade-off:宁可记住几个带引号的属性名,也比在字符串里写错类名然后花半小时调试来得强。
ZSS:当我写 CSS 时我到底在想什么
构建模板的过程中我顺带解决了一个困扰我已久的问题:CSS 预处理器的语法割裂。Sass、Less、Stylus——它们各自的语法都与主语言截然不同。我在一个项目里可能用 F# 写业务逻辑,切换到 Sass 写样式时就得把大脑切换到另一套语法系统:$variable、@mixin、@include、lighten()——每一段 Sass 代码都是一次心智切换。写多了,脑子在不同语言之间来回跳跃,时间都浪费在了语法的上下文切换上。
ZSS(Zest Style Sheets)是我对这个问题的回应——一种融合了 F# 风格的 CSS 超集,同时也是 Zest 项目中倾注心血最多的子工程。
三种语法风格
ZSS 2.0 支持三种写法,你可以在同一个文件中自由混用,解析器会根据缩进和大括号自动判断模式。
传统的 SCSS 大括号风格:
.card {
bgc: #fff
bdr: 0.5r
p: 1.5r
}
Python 风格的缩进模式:
.card
bgc: #fff
bdr: 0.5r
p: 1.5r
以及我最喜欢的 F# 风格——用等号赋值代替冒号,用 let 声明变量:
let primary = #3b82f6
let space1 = 0.25r
let space4 = space1 * 4
let primary-light = primary |> lighten(45%)
.tag
c: $primary
bgc: $primary-light
py: $space4
bdr: 9999px
这段代码编译成标准的 CSS:
.tag {
color: #3b82f6;
background-color: #adf4ff;
padding-block: 1rem;
border-radius: 9999px;
}
变量与数学运算
变量声明支持两种方式:SCSS 兼容的 $name: value 和 F# 风格的 let name = value。引用变量统一使用 $name 语法。ZSS 内置了完整的数学运算引擎——你可以直接在值里写 let x = 0.25r * 4,单位缩写 r 代表 rem,p 代表百分比。编译器会自动推导单位,把 1.5r 转成 1.5rem,把 50p 转成 50%。
颜色函数全家桶
颜色操作是我在 ZSS 里做得最过瘾的部分。lighten()、darken()、mix()、alpha()、transparentize()、tint()、shade()、adjust-hue()、complement()、grayscale()、invert()、saturate()、desaturate()——我把能想到的颜色函数全都塞了进去。配合 F# 风格的管道操作符 |>,你可以写出极具可读性的颜色变换链:
let base = #6c63ff
.btn-primary
bgc: base |> lighten(10%)
bdc: base |> darken(20%) |> alpha(0.3)
bxsh: #000 |> mix(base, 25%) |> alpha(0.1)
这比嵌套函数调用 alpha(darken(base, 20%), 0.3) 要直观太多了——你从左读到右,每一步变换都清晰可见。
属性缩写系统
ZSS 有一套完整的属性缩写表。py 等于 padding-block,mx 等于 margin-inline,bgc 等于 background-color,bdr 等于 border-radius(或 border——取决于值的类型)。整套缩写覆盖了 CSS 中几乎所有常用属性。你写 fs: 1.2r 就会自动展开成 font-size: 1.2rem,写 jc: center 就会变成 justify-content: center。
bdr 简写还有一个歧义解析规则:如果值包含 border-style 关键字(solid、dashed、dotted 等),就解析为 border;否则解析为 border-radius。所以 bdr: 1px solid #ccc 会变成 border: 1px solid #ccc,而 bdr: 8px 则是 border-radius: 8px。用起来不需要记规则,直觉上怎么写都对。
嵌套与模块系统
嵌套支持缩进和大括号两种模式,&:hover 这样的父选择器引用自然也是支持的。除此之外,ZSS 还有一个完善的 @use 模块系统:你可以 @use "zest:palette" 来引入一套完整的调色板变量,@use "zest:animations" 来获得一组开箱即用的动画关键帧,@use "zest:gradients" 来引入渐变色工具类,甚至 @use "zest:all" 一次性引入所有内置工具箱。混入(mixin)支持参数和默认值,@apply 指令可以让你在规则集中直接应用预定义的工具类,@each 和 @for 做循环生成,@if/@else 做条件判断——基本上你能想到的 CSS 预处理功能,ZSS 都有。
响应式断点也有简写:@sm、@md、@lg、@xl、@2xl,分别对应从 640px 到 1536px 的断点。你可以在规则集里直接写:
.grid
d: grid
gtc: 1fr
@md
gtc: repeat(2, 1fr)
@lg
gtc: repeat(3, 1fr)
ZSS 还会自动为需要浏览器前缀的属性添加 -webkit-、-moz-、-ms- 前缀,backdrop-filter 和 user-select 这样的属性完全不需要你操心兼容性。
整个 ZSS 的设计目标很简单:写 CSS 的感觉应该和写 F# 一样自然,而不是在两种完全不同的语法体系之间反复横跳。
无须 Node.js
这是 Zest 从一开始就定下的铁律:不依赖 Node.js 生态。没有 npm install,没有 node_modules,没有 package.json,没有 bundler 配置。JavaScript 只存在于浏览器端,做它该做的事——交互、动画、客户端搜索。构建时完全不碰 JS,一行都不碰。
你只需要 .NET SDK 和一个终端。Windows、Linux、macOS 都支持,包括 ARM64 架构。
# 构建整个站点
zest build
# 开发模式,带文件监视和自动热重载
zest serve --port 8080
# 预览已经构建好的站点
zest preview
# 用脚手架生成一个新项目
zest init my-site
zest serve 的开发模式下,你修改任何一个 .zest.fsx、.md 或 .zss 文件,浏览器都会自动刷新。这背后是 Zest.Infra 中的文件监视器在工作,它使用 .NET 的 FileSystemWatcher,比 Node.js 生态中常见的轮询方案更高效、更省资源。
项目架构与构建优化
Zest 分为四个子项目,每个各司其职:
- Zest.App (C#) — CLI 入口,命令路由,参数解析。所有的终端交互都从这里开始。
- Zest.Engine (F#) — 核心引擎。构建流程编排、HTML DSL 的运行时、ScriptRunner(负责和
dotnet fsi进程通信)、Markdown 渲染、ZSS 编译器——所有重活都在这儿。 - Zest.Dsl (F#) — 预编译的 DSL 辅助库。它在构建前就被编译成 DLL,FSI 脚本执行时直接引用,不需要在每次构建时重复编译。
- Zest.Infra (C#) — 基础设施。TOML 配置文件的加载和解析、文件变更监视、内嵌的开发服务器(基于 .NET 的
HttpListener)。
说到构建优化,这个过程颇有些故事可讲。第一版原型写完后,我随手测了一下完整构建的时间——17.8 秒。对于一个静态网站生成器来说,这个数字实在不太体面。用户改一行 Markdown 要等将近 20 秒才能看到效果,这无论如何也说不过去。
我开始分析瓶颈。首先发现的是正则表达式——ZSS 编译器中大量使用了正则来做词法分析,每次编译都要重新构造和编译这些正则,这其实是不必要的开销。我加了一层缓存,把已经编译过的 Regex 对象存起来。然后我注意到 F# 的抽象语法树——每次构建时对同一个 .fsx 文件的解析结果是可以复用的,前提是你得知道文件没变过。我引入了增量构建机制:通过对比文件的修改时间和哈希值,只有真正变更过的页面才会被重新编译。最后我把多个 F# 页面脚本的求值合并到同一个 FSI 进程中——之前每个页面启动一个独立进程的做法实在是太奢侈了。
一轮优化下来,冷构建从 17.8 秒降到了 3.1 秒,增量构建更是只需要 0.5 秒。从"喝杯咖啡等它构建"到"眨个眼就完事了",这个跨越花了不少功夫,但每一毫秒的缩减都是值得的。
设计哲学
Zest 不是一个通用的静态网站生成器。市面上已经有太多 SSG 了——Hugo、Jekyll、Eleventy、Next.js、Astro——它们各有长处,也各有取舍。Zest 是我对特定约束的特定回答,它不是为了取悦所有人而生的。
F# 即模板。 在我的方案里,模板就是程序,程序就是模板。不存在"模板语言"这种东西——只有一种语言,F#,贯穿整个构建流程。没有第二套语法需要学习,没有第二本文档需要查阅,没有"在模板里能不能写这个"的疑问。如果你会 F#,你便会 Zest 的一切。
ZSS 即布局引擎。 我不称 ZSS 为 CSS 预处理器,因为预处理器暗示它是在"预处理"别人的东西。ZSS 是一个会发射标准 CSS 的布局引擎——变量、计算、函数、颜色操作、嵌套——这些在 ZSS 里是一等公民。它和 F# 模板共享同一套语法直觉,写起来不需要额外的心智切换。
TOML 即契约。 配置文件用 TOML,数据文件用 TOML。不用 YAML。永远不用。这个"永远"我写进代码里了——Zest 不包含任何 YAML 解析器。TOML 的语法比 YAML 清晰得多,没有那些让人头疼的缩进歧义、没有各种值的隐式类型转换。一个配置文件的格式不该成为调试的对象。
无 Node.js 即秩序。 没有 node_modules 的构建流程是清爽的。你不会在项目根目录下看到那个著名的黑洞目录,不会在 CI 流水线上花两分钟等依赖安装完毕,不需要在每次 npm audit 后处理一堆安全警告。JavaScript 只存在于浏览器中,为浏览器服务——这就是它应有的位置。
少即是多。 Zest 为那些热爱 F#、厌恶 YAML、偏好简单工具的人而生。如果你不属于这个群体,那 Zest 大概不适合你,这完全没问题。我写 Zest 首先是为了满足自己的需求,其次才是与志同道合的人分享。
结语
写一个 SSG 并不能让我更快地发布文章。事实上,它让我花了更多时间——多得多的时间。从设计 DSL 的语法细节到调试 ZSS 编译器的边界情况,从优化构建性能到编写文档,每一件事都在消耗时间而不是节省时间。但"自己的工具自己做"这种体验本身就是无可替代的回报:每一行代码都是你写的,每一个设计决定都是你做的,每一毫秒的构建时间优化都让你更懂你自己的工具链。你知道每一个组件为什么以某种方式存在,知道哪些取舍是深思熟虑的结果,哪些只是因为当时太困了随手写下的。
(不过目前我仍然在用 11ty 运行这个博客,原因很简单——11ty 很成熟,而 Zest 目前还只是我自娱自乐的玩具。等到 Zest 的 API 稳定下来、生态初步成型、并且我确信它比 11ty 更适合我的时候,自然会切换过来。)
Zest 的代码以 Apache 2.0 协议完全开源,项目仓库在 github.com/zest-ssg/zest。如果你也是 F# 爱好者、对现有 SSG 的模板机制感到不满、或者单纯想看看一个 SSG 从零开始怎么写——欢迎来看看,提 issue、开 PR、或者只是在 Discussions 里聊聊天。一个玩具如果能引来几个同样喜欢捣鼓工具的人,那它也就不再只是个玩具了。