介绍一下我开发的 Zest 静态网站生成器项目
断更了很长一段时间,最近终于有时间来更新我开发的 Zest SSG 了。说干就干,我又做了一次大规模重构,顺带做了一批破坏性更新——反正现在还是在内测阶段,可以自由地大刀阔斧地重构。重构的结果有好有坏:代码架构确实清晰了,但原先那些面条代码的脆弱也一并暴露出来,迁移之后不少功能是被破坏掉的,最后只好推倒重来。
代价先说在前面:现在的 Zest 仍在内测,接口和行为随时都可能会随着下一次更新变化。如果你已经装好 .NET 10,直接 dotnet tool install -g zest 就能全局安装。
这篇文章把 Zest SSG 整体介绍一遍:它是什么、为什么这样设计、怎么用、现在能做什么,以及还缺什么。
Zest:模板即代码的静态站点生成器
Zest(全称 Zest: Easy Static-site Toolkit,这个全称前后改过几版,最后定在这个)是一个用 F# + C# 混合编写的静态网站生成器。它最核心的设计理念只有一句话:
模板就是真正的代码,而不是字符串。
这句话针对的是模板语言的普遍处境。多数静态站点生成器会自带一门模板语言,你得在其中拼字符串、绕开类型检查、为循环和条件寻找各种变通方案。Zest 把这件事交回宿主语言:直接用 F# 写页面,循环、条件、函数、模式匹配、字符串插值全是原生语法,类型检查照常生效。写文章这件事,Markdown 仍然更舒服,所以 .md 一样支持。
整个项目建立在一个前提上:模板语言和宿主语言应该是同一个东西。 后面所有设计,都是从这个前提推出来的。
为什么我要开发 Zest
选一个生成器,本质上是在选它替你做了哪些决定。Zest 的决定可以概括为四条。
页面就是普通的 F# 程序。用类型安全的 HTML DSL 组合结构,循环、条件、函数、数据都是语言原生能力,不需要任何模板语言的变通方案;当散文比代码更合适时,Markdown 依然可用。
模板格式只保留两种:Zestucks(.ztk,是我开发的一个用 F# 实现的 Nunjucks 兼容层),语法兼容了我曾经在 Eleventy 中使用过的 Nunjucks;以及脚本层 .zest.fsx。这两者边界划分得很清晰:模板负责页面结构,脚本负责逻辑,当然,.zest.fsx 本身也内置了用于生成 HTML 的 DSL,因此也可以用作模板,但写法上仍旧是 F# 自身的语法,与传统 HTML 相去甚远。
构建产物是纯静态的 HTML,可以托管在任何提供静态网站托管服务的地方,不依赖特定运行时。
一个 dotnet CLI 工具搞定 init、build、serve、clean 和 preview,从脚手架到预览都在同一条命令线上。
安装与快速开始
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 全局安装(需要先装好 .NET 10)
dotnet tool install -g zest
# 脚手架一个新项目
zest init my-site
# 开发,带 live reload
cd my-site && zest serve --port 8080
# 生产构建
zest build
# 预览构建后的站点
zest preview
功能一览
- Template as Code —
.zest.fsx文件是真正的 F# 脚本,在构建时通过dotnet fsi执行。完整的 F# 可用:列表推导、模式匹配、字符串插值、任意计算。 - HTML DSL — 声明式地组合 HTML,例如
render [ h1C []; p [] ]。 - 内联 Markdown — 在
.zest.fsx页面里用md助手直接写 Markdown,并与 HTML DSL 混用:md """# Title\n**bold**"""。 - Markdown 文章 — 标准
.md文件,支持 frontmatter。 - ZCSS — 一个 CSS 超集,支持嵌套、F# 风格的
let绑定、数学表达式、颜色函数和 mixin,编译为标准 CSS。 - Zestucks 模板 —
.ztk文件支持完整的 Nunjucks 兼容特性:变量、过滤器、{% if %}/{% for %}、通过{% extends %}/{% block %}的模板继承、{% include %}、宏,以及 Zest API(site、page、pages、tags、collections)。 _prebuild.fsx— 可选的预构建脚本(每次构建前运行),用于注入动态数据、加载 JSON/TOML、读取环境变量。_finalize.fsx— 可选的构建后脚本(站点写入后运行),用于链接检查、搜索索引、构建报告,以及输出塑形,比如 HTML 美化或压缩。它读取构建结果,并且只能写入_site/内部。- TOML 配置 — 零配置默认值;通过
_config.toml和_data/*.toml自定义。没有 YAML。 - Live reload —
zest serve监视变化并自动重建。 - 批量求值 — 多个 F# 页面脚本在单个 FSI 进程中求值,构建更快。
- 增量构建 — 文件变化检测会跳过未变化的页面和资源。(待完善)
- 跨平台 — 支持 Windows x64、Linux x64/ARM64 和 macOS ARM64。
写内容
Markdown 文章
1
2
3
4
5
6
7
+++
title = "Hello, world"
date = 2026-01-15
tags = ["zest", "fsharp"]
layout = "post"
+++
This is a blog post.
页面即 F#(.zest.fsx)
1
2
3
4
5
6
// @title About
// @layout default
render [
h1C [ text "About this site" ]
pC [ text "Written in F#, rendered as HTML." ]
]
引擎会为 .zest.fsx 页面预打开 DSL(同时也会打开 Values、Seo 和 Feeds)。在普通 FSI 中,仅 open Zest.Markup 就足以使用 markup 和 CSS DSL——Dsl、DslSugar、Stylesheet、InlineStyle、Components、Compound 和 Collections 都是 [<AutoOpen>]——而 Values 中的内容助手(md、chunk 等)、Seo 和 Feeds 仍然需要显式 open。
顺带解释一个命名:h1C 里的 C 是 Class 的缩写,属于语法糖。实际开发中建议使用完整的 h1Class;不过怎么顺手怎么来,不必过于拘束,比如我自己就更喜欢简写。
示例:一个 .zest.fsx 页面
1
2
3
4
5
6
7
8
9
10
11
// @title Hello World
// @layout default
// @description My first Zest page
let pageTitle = "Hello from F#"
let items = ["F#"; "Zest"; "SSG"]
render [
h1C [ text pageTitle ]
pC [ text "This page is generated by real F# code at build time." ]
ulC [ for i in items -> li [ text i ] ]
]
示例:在 .zest.fsx 页面里内联 Markdown
md 字段把 Markdown 字符串渲染成 HTML 字符串,所以 Markdown 和 F# HTML DSL 可以在同一个页面里混用。和其他 DSL 构建器一样,md 返回一个普通的 string。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// @title About
// @layout default
open Zest.Markup
render [
divC "about" [
md """
# About
This page is a **native template** written in `.zest.fsx` (real F#), where
Markdown and the HTML DSL live side by side.
Learn more at the [Zest repository](https://github.com/zest-ssg/zest).
"""
]
]
自创的 CSS 预处理器: ZCSS(.zcss)
// F# 风格的 let 绑定,带数学表达式
let primary = #3b82f6
let space1 = 0.25r
let space4 = space1 * 4 // 1rem
let primary-light = primary |> lighten(45%)
.tag [
color: $primary
background-color: $primary-light
padding-block: $space4
border-radius: 9999px
]
编译为:
1
2
3
4
5
6
.tag {
color: #3b82f6;
background-color: #adf4ff;
padding-block: 1rem;
border-radius: 9999px;
}
数据
_prebuild.fsx 在每次构建前运行,可以注入全局数据:
1
2
3
addGlobal "socials" [
{| label = "GitHub"; url = "https://github.com/zest"; icon = "github" |}
]
模板里这样读取:{{ site.socials }}。
模板:Zestucks 与 F# 脚本
Zest 只有两种创作格式:
| 格式 | 用途 |
|---|---|
.ztk / .njk | Zestucks 模板——兼容 Nunjucks 语法的 markup。 |
.zest.fsx | F# 脚本——数据加载、逻辑和复杂计算。 |
Zestucks 是 Zest 给自家 Nunjucks 兼容引擎起的名字,它的语法和 Nunjucks 完全一致——变量、过滤器、if / for、继承、block、include 和宏——所以现有的 Nunjucks 模板可以原样工作。名字本身来自产生它的挫败感:我们曾经被 Nunjucks 基于 Node 的工具链 卡住(stuck),于是用 F# 重写了引擎,-ucks 结尾保留了对 Nunjucks 的致敬。
.ztk 和 .njk 是同一种语言,只是扩展名不同:新模板用 .ztk,从 Nunjucks 移植过来的文件继续用 .njk,中间没有翻译步骤。用 .njk 还有一个实际好处——不必另外配置 IDE 高亮,直接用编辑器原生的 Nunjucks 高亮即可。
布局和 partial 是 .ztk 或 .njk 文件(直接使用 HTML 也行:.html 文件实际上也是经过 Zestucks 引擎处理的)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html lang="{{ site.language }}">
<head>
<meta charset="utf-8">
<title>{{ site.title }}</title>
<link rel="stylesheet" href="/assets/css/main.css">
</head>
<body>
{{ include header.ztk }}
<main>
{{ content | safe }}
</main>
{{ include footer.ztk }}
</body>
</html>
支持的 Zestucks 构造包括 {{ include }}、{{ content }}、{% if %} / {% for %}、{% assign %}、过滤器(| t、| date、| readingTime),以及来自 _locales/*.toml 的 i18n 字符串。
.ztk 与 .njk 的放置与引用
| 位置 | .ztk | .njk | 含义 |
|---|---|---|---|
_layouts/ | yes | yes | 布局,按 stem 名解析。 |
_includes/ | yes | yes | 裸引用 partial。 |
content/ | yes | yes | 变成有路由的页面。 |
| 对它的引用 | layout.ztk | layout.njk | 两种拼写都行。 |
引用可以完全省略扩展名:{% include "head" %} 和 {% extends "base" %} 会先对 _includes/ 再对 _layouts/ 解析,先试 .ztk 再试 .njk。
项目布局
1
2
3
4
5
6
7
8
9
10
11
.
├── _config.toml # 可选:站点元数据和构建选项
├── _prebuild.fsx # 可选:预构建脚本(全局数据、钩子)
├── _finalize.fsx # 可选:构建后脚本(验证、索引)
├── _layouts/ # 布局:.ztk (Zestucks) 或 .zest.fsx (F# DSL)
├── _includes/ # 用 {{ include }} 引入的 partial
├── _data/ # 全局数据(nav.toml 等)
├── _locales/ # i18n 字符串表(en.toml 等)
├── assets/ # 样式(ZCSS)、图片、字体
├── content/ # 页面(.zest.fsx)和文章(.md)
└── _site/ # 构建输出
上面每一项都是可选的。完全没有 _config.toml 时站点仍然能构建:标题回退到默认值,内容在 content/ 存在时从那里读取,否则从项目根目录读取。目录名是约定,永远不是配置。
配置
_config.toml 只识别两个表。
| 表 | 键 |
|---|---|
[site] | title url description language author version content_dir default_layout permalink_format dev_server_port live_reload_port log_level log_to_file log_timestamps |
[build] | output parallel incremental cache_busting finalize_on_error |
输出塑形——格式化或压缩 HTML、CSS 和 JS——不在这里配置。它是构建后在 _finalize.fsx 里做的工作,作者可以选择具体应用什么;参见构建钩子。(注:新版接口将所有输出塑形 API 统一转移至构建后 _finalize.fsx 中供调用了)
没有列出的东西——[[taxonomies]]、[menu.*]、[[defaults]]、[pagination]、[params]、include、exclude、[template.zestucks] compatibility——都在顶层读取。
命令
| 命令 | 描述 |
|---|---|
zest init [path] | 从内置 starter 脚手架一个新站点。 |
zest init --empty | 只脚手架目录布局。 |
zest build | 构建站点到 _site/。 |
zest serve | 构建并启动带 live reload 的开发服务器。 |
zest preview | 服务构建后的站点,不重建。 |
zest clean | 移除构建输出。 |
架构
| 项目 | 语言 | 职责 |
|---|---|---|
| Zest.App | C# | CLI 入口和宿主:命令路由、配置、开发服务器、文件监视、日志、内置 starter。 |
| Zest.Compiler | F# | 把源变成站点:内容管线、Zestucks 模板、ZCSS、FSI 脚本。 |
| Zest.Markup | F# | 页面作者写代码所针对的 API 表面:HTML/ZCSS markup 构建器、SEO 和 feed 助手、页面查询。 |
| Zest.Core | F# | 无依赖的原语,被编译器和 markup 共享:slug、散文度量、日期。 |
C# 负责进程与外部交互的部分,F# 负责构建源码的部分;两者之间的边界就是 Zest.Markup 这一层公开 API。
仓库布局
| 路径 | 用途 |
|---|---|
src/ | 随 zest 工具发布的三个项目:Zest.App (C#)、Zest.Compiler (F#)、Zest.Markup (F#)。 |
libs/ | 无依赖的共享库,不属于发布的工具项目。目前是 Zest.Core。 |
.config/zest/ | CLI 配置,按关注点拆分:meta.toml(身份、品牌)和 help.toml(所有帮助文案)。作为清单资源嵌入工具。 |
.claude/ | Claude Code 设置和权限。工程契约本身在根目录的 CLAUDE.md。 |
.devcontainer/ | 可复现的开发容器:devcontainer.json 加 .NET SDK Dockerfile。 |
.husky/ | Git 钩子(pre-commit、commit-msg)和设置 core.hooksPath 的安装器。 |
源码布局
目录按照语义命名,在 Zest.Compiler 中目录名也是命名空间(包括最后一段)。Zest.Markup 保持单一扁平命名空间——它的子目录只组织文件,因为它的模块名是页面作者写代码所针对的公共 DSL 表面。
1
2
3
4
src/Zest.App/ C# Program, Cli/, Command/, Config/, Runtime/, Starter/
src/Zest.Compiler/ F# Model/ Zcss/ Build/ Zestucks/ Rendering/ Execution/
src/Zest.Markup/ F# Dsl/ Primitives/ Css/ Component/ Query/ Metadata/
libs/Zest.Core/ F# SlugFormatter, TextMetrics, DateFormatter
| 目录 | 存放 |
|---|---|
Zest.Compiler/Model | 无行为的记录:页面、front matter、配置、文件类型。 |
Zest.Compiler/Zcss | ZCSS 编译器:tokenizer、parser、evaluator、CSS writer。 |
Zest.Compiler/Build | Front matter 解析、permalink、页面管线、生成页面、构建入口。 |
Zest.Compiler/Zestucks | Zestucks 模板引擎,端到端。 |
Zest.Compiler/Rendering | HTML 节点、HTML 写入、格式化、Markdown、页面构造。 |
Zest.Compiler/Execution | dotnet fsi 执行、页面索引,以及 Zest 的模板过滤器。 |
Zest.App/Runtime | CLI 进程的运行时设施:HTTP 服务器、监视、日志。 |
Zest.App/Cli | 参数解析、选项、帮助文本。 |
Zest.App/Command | 每个子命令一个类型(build、clean、init、serve)。 |
Zest.App/Starter | zest init 复制出来的单一脚手架站点。 |
参考文档
文件类型
Zest 区分两种 F# 脚本。只有一种会成为页面。
| 文件模式 | 用途 | 处理 |
|---|---|---|
*.ztk / *.html | Zestucks 原生模板(过滤器、宏、继承、Zest API) | 由 Zestucks 引擎渲染 |
*.njk | 兼容 Nunjucks 的模板——与 .ztk 同一引擎 | 由 Zestucks 引擎渲染 |
*.zest.fsx | Zest Pages——带路由语义的 F# 脚本模板 | 通过 dotnet fsi 编译,然后赋予 URL |
*.fsx | 普通 F# 脚本——Zest 不会给它们路由 | 发现时忽略 |
*.md | 标准 Markdown | 渲染为 HTML |
*.zcss | ZCSS 样式表(CSS 超集) | 编译为 .css |
*.toml | 配置和数据(无 YAML) | 构建时解析 |
_config.toml | 站点配置——仅项目根目录 | 构建前解析 |
_prebuild.fsx | 预构建脚本——仅项目根目录,永不路由 | 构建前通过 dotnet fsi 执行 |
_finalize.fsx | 构建后脚本——仅项目根目录,永不路由 | _site/ 写入后通过 dotnet fsi 执行 |
*.fsx 脚本对构建不可见:它们永远不会被扫描、求值或赋予 URL。用它们做页面不需要的事情——数据生成、部署助手、草稿工作。把它们放在页面旁边也没问题。
_config.toml、_prebuild.fsx 和 _finalize.fsx 是 Zest 唯一识别的三个特殊文件。都在项目根目录,按精确名称匹配——没有递归扫描,没有其他脚本名,没有兼容别名。其他一切都是约定,永远不是配置。
.zest.fsx 路由
Zest Page 是内容目录里任何 *.zest.fsx 文件。它相对于该目录的路径——去掉 .zest.fsx 后缀——就是它的路由。页面永远不会自己声明 URL,除非设置 @permalink。
1
2
3
4
5
6
7
8
9
content/
├── index.zest.fsx → /
├── 404.zest.fsx → /404
├── about.zest.fsx → /about
├── blog/
│ ├── index.zest.fsx → /blog
│ └── post.zest.fsx → /blog/post
└── scripts/
└── build.fsx → 普通 F# 脚本,无路由
Zest 生成目录风格 URL,所以上面每个路由都写成 <route>/index.html,并以尾斜杠服务——about.zest.fsx 是 /about/,blog/index.zest.fsx 是 /blog/,等等。/ 是唯一没有进一步段的路由。/about 和 /about/ 在开发服务器和普通静态主机上都会解析到 about/index.html。
由此有三个细节:
index命名其目录。index.zest.fsx(以及default.zest.fsx)折叠到父目录的路由,所以blog/index.zest.fsx是/blog,不是/blog/index。@permalink覆盖一切。 当 URL 必须精确时使用它——starter 的 feed 就这么做(// @permalink /rss.xml),一个必须是/404.html而不是/404/的 404 页面也会这么做。.zest.fsx语义等同于路由。 把scripts/build.fsx重命名为scripts/build.zest.fsx会把它发布到/scripts/build/。不应该存在的 URL 就留作普通.fsx。
当两个文件会产生相同输出时——比如 about.ztk 旁边有 about.zest.fsx——Zest 不做仲裁。构建引擎选择后者写入的页面,所以给每个路由恰好一个源文件。
ZCSS 参考
| 特性 | 语法 / 示例 |
|---|---|
| 变量(SCSS) | $name: value; |
| 变量(F#) | let name = value |
| 数学 | let x = 0.25r * 4 |
| 颜色函数 | lighten(#hex, %)、darken(#hex, %)、mix(a, b, %) |
| 管道操作符 | value \|> fn(args) → fn(value, args) |
| 单位简写 | r → rem,p → % |
| 属性简写 | py → padding-block,mx → margin-inline,bgc → background-color |
| 嵌套 | 缩进(Python/SCSS 风格)或大括号(F# 风格)模式 |
| Mixin | @mixin、@include |
| 循环 | @each、@for |
| 条件 | @if、@else |
| 内置模块 | @use "zest:utilities"、@use "zest:palette" 等 |
布局路由
Zest 总是用 Zestucks 渲染,所以路由只取决于文件扩展名:
| 布局扩展名 | 处理 |
|---|---|
.zest.fsx、.fsx | 由 dotnet fsi 作为 F# 脚本求值。 |
.ztk、.njk | 由 Zestucks 渲染——同一种语言,两个扩展名。 |
.html、.htm | 当存在 {{ }} / {% %} 语法时由 Zestucks 渲染,否则原样复制。 |
这个表只描述 _layouts/。布局文件没有路由,所以普通的 .fsx 布局没问题——在内容目录里它根本不会是页面(见 文件类型)。
Zestucks 兼容模式
_config.toml 中的 [template.zestucks] compatibility 控制 Zestucks 多严格地镜像 Nunjucks(注意:此处属于是 AI 过度设计,实际开发中两者区别不大,下一次版本更新中将直接移除这个选项,自动注册所有扩展过滤器):
| 值 | 含义 |
|---|---|
zest | 默认。Zest 扩展过滤器(pages_by_tag、recent、by_collection、search)可用。 |
strict | 只注册 Nunjucks 兼容的过滤器集。 |
HTML DSL 参考
整个 DSL 中有两种拼写是等价的:<tag>Class 和 <tag>C(divC ≡ divClass,imgC ≡ imgClass,aC ≡ aClass)。两者产生逐字节相同的 HTML——选一个,在一个文件里保持一致。对于文本助手,<tag>Text 接受文本内容,而 <tag>TextClass / <tag>TextC 接受 CSS class 加文本内容(pText vs pTextClass)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 元素
h1 [ text "Title" ]
p [ text "Paragraph" ]
a [ href "https://example.com"; text "Link" ]
// 文本快捷方式
divText "Plain" // <div>Plain</div>
pTextClass "lead" "Intro" // <p class="lead">Intro</p>
aTextC "btn btn-primary" "/about" "About" // <a href="/about" class="btn btn-primary">About</a>
// 属性
div [ cls "container"; id' "main"; data' "section" "hero" ] [ ... ]
// CSS class 快捷方式
divClass "card" [ p [ text "Content" ] ] // <div class="card">
spanC "badge" [ text "New" ] // <span class="badge">
// 列表推导
ul [ for item in items -> li [ text item ] ]
// 条件
if condition then
p [ text "Yes" ]
else
p [ text "No" ]
构建钩子
Zest 最多运行两个动态脚本。两者都是可选的,都在项目根目录下,名称完全如下,都不会被路由或发布。
1
defaults → _config.toml → _prebuild.fsx → render + write _site/ → _finalize.fsx
这三个文件各有一个职责,拆分由每个文件实际能看到什么来强制:
| 文件 | 运行时机 | 职责 | 不能 |
|---|---|---|---|
_config.toml | 一切之前 | 声明站点 是什么。 | 运行代码。 |
_prebuild.fsx | 任何渲染之前 | 注入模板读取的数据、过滤器和值。 | 触碰构建输出。 |
_finalize.fsx | _site/ 完成后 | 检查、索引和重塑最终输出。 | 注入模板数据;写 _site/ 之外。 |
没有第三个钩子,也没有 afterBuild 命令列表:构建后运行外部工具就是在 _finalize.fsx 里 exec。失败的钩子会被报告并导致构建失败,除非用 setFailOnError false 选择退出。(理论上你可以利用 _finalize.fsx 做一些更高级的事情,例如构建完成后自动推送到远程 git 仓库,亦或是自动通过终端上传到 Cloudflare/Netlify,毕竟这是一个功能完备的 .NET FSI 交互环境,具备完整的编程能力。我们正在考虑要不要把这些功能做成内置接口以简化流程,如果你有更好的意见,欢迎在社区或是进群发表自己的一些想法,我们会纳入参考意见的。)
_prebuild.fsx API
在构建前运行。这里添加的数据对模板可见,形式为 {{ site.<key> }}。
| 函数 | 用途 |
|---|---|
addGlobal key value | 向全局数据注入键值对。 |
loadJson path | 把 JSON 文件解析为字典、数组和标量。 |
loadToml path | 解析 TOML 文件。 |
loadEnv key | 读取环境变量为 string option。 |
consoleLog msg | 向 stderr 输出调试信息。 |
exec cmd args | 运行 shell 命令;返回 { code; stdout; stderr }。 |
loadJson、loadToml、loadEnv、consoleLog 和 exec 在两个钩子里是相同的函数,签名相同。
_finalize.fsx API
站点完全写入 _site/ 后运行一次。它写的一切都必须落在输出目录内——writeFile 拒绝 _site/ 之外的任何路径——所以它可以重塑构建输出,但永远不能改项目源码。
注入的上下文:
| 绑定 | 类型 | 值 |
|---|---|---|
site | SiteInfo | title url description author language version |
pages | PageInfo list | 每个有路由的内容页面:route output title source |
build | BuildInfo | duration_ms page_count asset_count output_bytes started_at |
output_dir | string | 构建输出的绝对路径 |
助手:
| 函数 | 用途 |
|---|---|
readFile path | 读取文件(相对路径对项目根解析)。 |
writeFile path content | 在 _site/ 内写文件(相对路径在那里解析)。 |
exists path | 文件或目录存在时为 true。 |
listFiles path | 递归列出文件,作为相对于 path 的路径。 |
sizeOf path | 文件或目录树的字节大小。 |
toJson value | 把值序列化为缩进 JSON。 |
loadJson path | 把 JSON 文件解析为字典、数组和标量。 |
loadToml path | 解析 TOML 文件。 |
loadEnv key | 读取环境变量为 string option。 |
consoleLog msg | 向 stderr 输出调试信息。 |
exec cmd args | 运行 shell 命令;返回 { code; stdout; stderr }。 |
setFailOnError flag | false 记录钩子错误但不使构建失败。 |
输出塑形——旧 [build] 格式化标志的替代品:
| 函数 | 用途 |
|---|---|
rewriteFiles ext transform | 对 _site/ 下该扩展名的每个文件应用 transform。 |
formatHtml html | 格式化 HTML。 |
minifyHtml html | 压缩 HTML。 |
formatCss css | 格式化 CSS(2 空格缩进)。 |
minifyCss css | 压缩 CSS。 |
formatJs js | 格式化 JavaScript(2 空格缩进)。 |
minifyJs js | 压缩 JavaScript。 |
一个完整的“格式化 + 压缩”构建,也就是被移除的四个 _config.toml 键过去做的事情:
1
2
3
rewriteFiles ".html" formatHtml
rewriteFiles ".css" formatCss
rewriteFiles ".js" formatJs
把 formatHtml 换成 minifyHtml,把 format* 助手换成 minify* 助手,就变成压缩。除非文本真的变了,否则什么都不写,所以一个 no-op 钩子会让输出保持不变。
示例——检查内部链接、索引站点,并报告构建:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 报告每个正文引用了未渲染路由的页面。
let routes = pages |> List.map (fun p -> p.route) |> Set.ofList
let broken =
pages
|> List.collect (fun p ->
let html = readFile p.output
Regex.Matches(html, "href=\"(/[^\"]*)\"")
|> Seq.map (fun m -> m.Groups.[1].Value)
|> Seq.filter (fun href -> not (Set.contains href routes))
|> Seq.map (fun href -> sprintf "%s -> %s" p.route href)
|> List.ofSeq)
if not broken.IsEmpty then
consoleLog (sprintf "Found %d broken link(s)" broken.Length)
broken |> List.iter consoleLog
setFailOnError true
// 在渲染页面旁边写一个搜索索引。
let index =
pages
|> List.map (fun p -> {| route = p.route; title = (p.title |> Option.defaultValue "") |})
writeFile "search-index.json" (toJson index)
// 报告构建。
consoleLog (sprintf "Done: %d pages, %d ms, %d bytes"
build.page_count build.duration_ms build.output_bytes)
// 有外部工具时交接出去。
let purge = exec "node" [ "scripts/purge-cdn.js"; site.url ]
if purge.code <> 0 then consoleLog (sprintf "CDN purge failed: %s" purge.stderr)
finalize_on_error(默认 true)控制当主构建已经报告错误时钩子是否仍然运行,这正是“即使构建报错,也能验证已写入内容”成为可能的原因。
从源码构建
1
2
3
4
5
6
7
8
git clone https://github.com/zest-ssg/zest
cd zest
dotnet build zest.sln
# 为你的平台发布
dotnet publish src/Zest.App/Zest.App.csproj -c Release -r win-x64 --self-contained false
# Linux: -r linux-x64
# macOS: -r osx-arm64
设计哲学
- 内容即代码,代码即内容。 F# DSL 和模板引擎共享同一个数据模型,所以边界上没有任何损失。
- 没有魔法。 每个转换都是源码里可读的普通管线阶段。没有隐藏运行时,没有隐式依赖。
- 输出就是交付物。 静态 HTML,不需要 JavaScript,托管在任何地方。
未来计划
目前路线图上的事项:
- 扩展原生 Markdown 语法,添加更多扩展语法并增强与 Zestucks 的互调性。
- 内置处理图像的 API 钩子。
- 为
_prebuild.fsx和_finalize.fsx脚本添加更多功能。 - 添加完整的单元测试。
欢迎来尝试和贡献
Zest 目前仍处于内测阶段,架构刚刚经历一次大重构,不少功能还在逐步恢复和完善中。但核心的构建管线、F# DSL、ZCSS、Zestucks 模板引擎以及构建钩子都已经可用,足够用来搭建个人博客、文档站或者小型静态站点。
如果你对 F#、.NET 或者静态站点生成器感兴趣,欢迎来试试:
- 给项目点个 Star:https://github.com/zest-ssg/zest
- 在官方社区发起讨论:https://github.com/orgs/zest-ssg/discussions
- 加入 Discord:https://discord.gg/ggrU3gWJUY
- 或者来 QQ 群聊聊:https://qm.qq.com/q/O7CiBfOVSa
遇到 bug、有功能建议、想改进文档,或者直接想贡献代码,都欢迎提 Issue 和 PR。Zest 还很年轻,任何形式的参与都会让它变得更好。
