文章

介绍一下我开发的 Zest 静态网站生成器项目

介绍一下我开发的 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 / .njkZestucks 模板——兼容 Nunjucks 语法的 markup。
.zest.fsxF# 脚本——数据加载、逻辑和复杂计算。

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/yesyes布局,按 stem 名解析。
_includes/yesyes裸引用 partial。
content/yesyes变成有路由的页面。
对它的引用layout.ztklayout.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.AppC#CLI 入口和宿主:命令路由、配置、开发服务器、文件监视、日志、内置 starter。
Zest.CompilerF#把源变成站点:内容管线、Zestucks 模板、ZCSS、FSI 脚本。
Zest.MarkupF#页面作者写代码所针对的 API 表面:HTML/ZCSS markup 构建器、SEO 和 feed 助手、页面查询。
Zest.CoreF#无依赖的原语,被编译器和 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/ZcssZCSS 编译器:tokenizer、parser、evaluator、CSS writer。
Zest.Compiler/BuildFront matter 解析、permalink、页面管线、生成页面、构建入口。
Zest.Compiler/ZestucksZestucks 模板引擎,端到端。
Zest.Compiler/RenderingHTML 节点、HTML 写入、格式化、Markdown、页面构造。
Zest.Compiler/Executiondotnet fsi 执行、页面索引,以及 Zest 的模板过滤器。
Zest.App/RuntimeCLI 进程的运行时设施:HTTP 服务器、监视、日志。
Zest.App/Cli参数解析、选项、帮助文本。
Zest.App/Command每个子命令一个类型(build、clean、init、serve)。
Zest.App/Starterzest init 复制出来的单一脚手架站点。

参考文档

文件类型

Zest 区分两种 F# 脚本。只有一种会成为页面。

文件模式用途处理
*.ztk / *.htmlZestucks 原生模板(过滤器、宏、继承、Zest API)由 Zestucks 引擎渲染
*.njk兼容 Nunjucks 的模板——与 .ztk 同一引擎由 Zestucks 引擎渲染
*.zest.fsxZest Pages——带路由语义的 F# 脚本模板通过 dotnet fsi 编译,然后赋予 URL
*.fsx普通 F# 脚本——Zest 不会给它们路由发现时忽略
*.md标准 Markdown渲染为 HTML
*.zcssZCSS 样式表(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/ 之外的任何路径——所以它可以重塑构建输出,但永远不能改项目源码。

注入的上下文:

绑定类型值
siteSiteInfotitle url description author language version
pagesPageInfo list每个有路由的内容页面:route output title source
buildBuildInfoduration_ms page_count asset_count output_bytes started_at
output_dirstring构建输出的绝对路径

助手:

函数用途
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 flagfalse 记录钩子错误但不使构建失败。

输出塑形——旧 [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

设计哲学

  1. 内容即代码,代码即内容。 F# DSL 和模板引擎共享同一个数据模型,所以边界上没有任何损失。
  2. 没有魔法。 每个转换都是源码里可读的普通管线阶段。没有隐藏运行时,没有隐式依赖。
  3. 输出就是交付物。 静态 HTML,不需要 JavaScript,托管在任何地方。

未来计划

目前路线图上的事项:

  1. 扩展原生 Markdown 语法,添加更多扩展语法并增强与 Zestucks 的互调性。
  2. 内置处理图像的 API 钩子。
  3. 为 _prebuild.fsx 和 _finalize.fsx 脚本添加更多功能。
  4. 添加完整的单元测试。

欢迎来尝试和贡献

Zest 目前仍处于内测阶段,架构刚刚经历一次大重构,不少功能还在逐步恢复和完善中。但核心的构建管线、F# DSL、ZCSS、Zestucks 模板引擎以及构建钩子都已经可用,足够用来搭建个人博客、文档站或者小型静态站点。

如果你对 F#、.NET 或者静态站点生成器感兴趣,欢迎来试试:

遇到 bug、有功能建议、想改进文档,或者直接想贡献代码,都欢迎提 Issue 和 PR。Zest 还很年轻,任何形式的参与都会让它变得更好。

本文由作者按照 CC BY 4.0 进行授权