十天前我写了一篇文章介绍这个博客的构建方案:Node.js 脚本、模板字符串拼 HTML、Markdown-it 渲染、Cloudflare Pages 托管。那时候选 Node.js 的原因很简单——我想让博客先跑起来。一个刚起步的个人博客不需要什么复杂的构建工具。

现在构建管线被一个 Rust 写的 CLI 编译器替换了。原因同样简单——需求变了。我想做一个标准化的、可复用的网站编译器。

两种选择都没有错。它们只是回答了不同阶段的问题。

为什么换

不是因为 Node.js 方案不好。

博客写了十来天,内容逐渐积累,我开始觉得之前那种字符串拼 HTML 的方式不够优雅。不是不能用——是我想做一个更标准化的东西。一个独立的网站编译器,给它 Markdown 和配置,它输出 HTML。模板可以覆盖但不能改源码。编译器和内容项目各自迭代,互不影响。

所以动机很简单:想做一个标准化的网站编译器。用 Node.js 也能做出同样架构的东西,语言是实现选择,不是动机。

kiln 的架构

kiln 是一个单二进制的 CLI 工具。核心流程:

site.config.toml + content/*.md + site/templates/*.html + site/styles.css
    → kiln build
    → dist/ (HTML + CSS + RSS + sitemap)

编译器内部按职责拆成几个模块:

模块 职责
cli.rs clap 子命令(build, serve)
config.rs TOML 配置加载、路径解析、校验
content.rs Markdown 文件 glob、frontmatter 解析、slug 推导
render.rs comrak 渲染 Markdown,注入 heading anchor、table wrapper
engine.rs Tera 模板引擎,外部模板覆盖内嵌默认
site.rs 构建编排:渲染文章/页面/首页,复制静态资源,CSS cache-busting
rss.rs RSS 2.0 XML 生成
sitemap.rs Sitemap XML 生成
serve.rs 开发服务器,tiny_http + notify 文件监听

几个设计决策值得展开。

模板内嵌为 fallback。 编译器用 include_str! 把四个默认模板编译进二进制——layout.html, home.html, post.html, page.html。内容项目可以在 site/templates/ 下放同名文件覆盖它们。这意味着 kiln 拿到一个空的站点目录也能构建,你只需要提供 Markdown 和配置,模板有默认值。

CSS cache-busting 自动化。 构建时对 styles.css 的内容做 SHA-256 hash,取前 12 个 hex 字符,输出为 dist/assets/styles.<hash>.css。所有模板里的 stylesheet 引用自动指向这个带 hash 的路径,同时往 _headers 文件追加 immutable cache 规则。全自动,不需要手动干预。

配置校验分两层。 第一层在路径解析之前:检查 base_url 格式、styles 路径不能包含 ..。第二层在路径解析之后:检查 content 目录是否存在、styles 文件是否存在。错误信息都带配置文件路径和字段名,不用猜哪里配错了。

使用 kiln:从零搭建一个站点

架构说完了。下面是实际用法。假设你想用 kiln 搭一个个人博客,读完这一节就能动手。

安装 kiln

kiln 通过 GitHub Release 分发预编译的二进制文件。支持 macOS(x86_64 和 Apple Silicon)和 Linux(x86_64),Windows 暂不支持。

以 macOS Apple Silicon 为例:

curl -L -o kiln https://github.com/<user>/<repo>/releases/download/kiln-v0.1.0/kiln-aarch64-darwin
chmod +x kiln
sudo mv kiln /usr/local/bin/

验证:

kiln --version

打印出 kiln 版本号就完成了。kiln 是静态编译的单二进制文件,不需要装 Rust 工具链,没有其他依赖。

创建项目结构

kiln 对目录布局有一个最小约定。在你的项目根目录下:

my-blog/
├── site.config.toml
├── styles.css
├── content/
│   ├── posts/
│   └── pages/
└── site/
    ├── templates/    # 可选
    └── public/       # 可选
  • site.config.toml:站点配置。告诉 kiln 你的站点叫什么、URL 是什么、内容在哪。
  • styles.css:站点样式。构建时自动加 hash 做 cache-busting。
  • content/posts/:博客文章,每篇一个 Markdown 文件。文件名格式 YYYY-MM-DD-slug.md
  • content/pages/:独立页面,比如"关于"。
  • site/templates/:自定义模板。不创建的话 kiln 用内置默认模板。
  • site/public/:静态资源——favicon、_headersrobots.txt 等。构建时原样复制到输出目录。

最小化起步只需要前三样:site.config.tomlstyles.csscontent/posts/ 下至少一篇文章。

编写配置文件

site.config.toml 是 TOML 格式。最少只需要写两个字段:

[site]
title = "我的博客"
base_url = "https://example.com"

这就够了。kiln 会使用所有默认值:内容目录 content/,模板目录 templates/,RSS 输出 20 篇,语言默认英文。

一个更完整的配置长这样:

[paths]
content = "content"
templates = "templates"
public = "public"
styles = "styles.css"

[site]
title = "我的博客"
subtitle = "写点东西的地方"
description = "一个关于技术和生活的个人博客"
language = "zh-CN"
base_url = "https://example.com"

[author]
name = "张三"
email = "hi@example.com"

[feed]
item_count = 20

[[nav]]
label = "About"
href = "/about/"

[[nav]]
label = "RSS"
href = "/rss.xml"

[[footer_links]]
label = "GitHub"
href = "https://github.com/zhangsan"

[[footer_links]]
label = "Email"
href = "mailto:hi@example.com"

[home_image]
enabled = true
src = "https://example.com/cover.jpg"
alt = "封面图描述"
width = 1200
height = 630

intro = "你好,我在这里写一些关于技术和生活的笔记。"
email = "hi@example.com"

几点说明:

site.titlesite.base_url 是必填的。base_url 必须以 http://https:// 开头,末尾不要带斜杠。

[paths][author] 整个 section 可以省略,省略时走默认值。[[nav]][[footer_links]] 用双层方括号是因为 TOML 的数组语法,可以有多个。

[home_image] 控制首页是否显示一张大图。enabled = false 或不写这个 section 就不会显示。

intro 是首页标题下方的一段介绍文字。

所有路径都相对于 site.config.toml 所在目录解析。content = "content" 指的是配置文件旁边那个 content/ 目录。

配置有两层校验。第一层检查 base_url 格式和路径安全——比如 styles 路径不能包含 ..。第二层检查 content 目录和 styles 文件是否真实存在。配错了直接报错,错误信息包含配置文件路径和字段名。

写第一篇文章

文章放在 content/posts/ 下,文件名格式 YYYY-MM-DD-slug.md。日期部分用于排序,slug 部分成为 URL 路径。

创建 content/posts/2026-06-03-hello-world.md

---
title: "你好,世界"
date: "2026-06-03"
description: "第一篇博客文章"
featured: true
draft: false
tags: ["Blog", "生活"]
---

## 这是第一篇文章

Markdown 正文写在这里。kiln 使用 comrak 渲染,支持 GitHub Flavored Markdown——表格、任务列表、删除线、脚注都可以用。

代码块也正常支持:

\`\`\`rust
fn main() {
    println!("Hello, kiln!");
}
\`\`\`

frontmatter 用 YAML 格式,写在 --- 之间。支持的字段:

字段 必填 说明
title 文章标题
date 发布日期,YYYY-MM-DD
description 摘要,为空时自动从正文提取
featured true 时在首页展示
draft 草稿,默认不构建
tags 标签数组
slug 自定义 URL,默认从文件名推导

slug 推导规则:文件名 2026-06-03-hello-world.md → slug 为 hello-world,文章 URL 为 /posts/hello-world/。如果你想自定义,在 frontmatter 里写 slug: my-custom-path

draft 文章默认不构建,也不会出现在首页、归档和 RSS 里。想预览草稿,加 --drafts

kiln build --drafts

创建独立页面

独立页面放在 content/pages/ 下。和文章的区别:没有日期,URL 不带 /posts/ 前缀。

创建 content/pages/about.md

---
title: "关于"
description: "关于这个博客和作者"
---

你好,我是张三,一名软件工程师。

这个博客记录我的技术思考和生活碎片。

构建后 URL 为 /about/。文件名 about.md,slug 就是 about

第一次构建

装好了 kiln,写好了配置和文章,现在构建:

kiln build

默认读取 site/site.config.toml,输出到 dist/。如果你把配置文件放在项目根目录:

kiln build --config site.config.toml

dist/ 目录的产出:

dist/
├── index.html
├── rss.xml
├── sitemap.xml
├── robots.txt
├── _headers
├── posts/
│   └── hello-world/
│       └── index.html
├── about/
│   └── index.html
└── assets/
    └── styles.a1b2c3d4e5f6.css

posts/hello-world/index.html 对应 URL /posts/hello-world/about/index.html 对应 /about/。每个页面输出为 <path>/index.html,静态托管服务能正确处理 clean URL。

index.html 是首页,包含 featured 文章列表和按年归档。rss.xml 是 RSS 2.0 feed,默认最新 20 篇。sitemap.xml 给搜索引擎。assets/styles.<hash>.css 的 hash 是 CSS 内容的 SHA-256 前 12 位 hex。

自定义模板

默认模板够用,但如果你想调整样式和布局,在 site/templates/ 下创建同名文件覆盖即可。

kiln 用 Tera 模板引擎,语法和 Jinja2 类似。四个可覆盖的模板:

  • layout.html:HTML 外壳——<head>、导航、页脚
  • home.html:首页内容
  • post.html:文章页——标题、日期、标签、正文
  • page.html:独立页面

模板覆盖是部分替换。你只覆盖 home.html,其他三个继续用默认的。从改一个文件开始就行。

举个常见场景:想给页脚加备案号。默认 layout.html 的页脚是这样的:

<footer>
  <p>&copy; {{ now() | date(format="%Y") }} {{ config.author.name }}</p>
</footer>

site/templates/layout.html 里写一个完整的新 layout,把页脚换成你想要的:

<footer>
  <p>&copy; {{ now() | date(format="%Y") }} {{ config.author.name }}</p>
  <p><a href="https://beian.miit.gov.cn/">京ICP备xxxxxxxx号</a></p>
</footer>

各模板能用的变量:

layout.html

  • {{ config }}:整个站点配置对象,config.titleconfig.author.nameconfig.nav
  • {{ title }}:当前页面标题
  • {{ description }}:当前页面描述
  • {{ body }}:页面主体 HTML,用 | safe 输出
  • {{ path }}:URL 路径
  • {{ og_type }}:文章是 "article",首页是 "website"

post.html

  • {{ page.title }}{{ page.slug }}{{ page.description }}
  • {{ page.body_html }}:渲染后的 Markdown 正文,| safe 输出
  • {{ page.iso_date }}2026-06-03
  • {{ page.long_date }}2026 年 6 月 3 日
  • {{ page.short_date }}2026-06-03
  • {{ page.tags }}:标签数组

home.html 额外可用:

  • {{ featured_posts }}:标记为 featured 的文章列表
  • {{ archive }}:按年分组的文章归档

想知道默认模板长什么样?最直接的办法是先用默认模板构建一次,看产出的 HTML 结构,然后按同样结构写你自己的版本。

样式与静态资源

styles.css 放在项目根目录。构建时 kiln 自动做 hash,输出的 HTML 引用带 hash 的版本:

<link rel="stylesheet" href="/assets/styles.a1b2c3d4e5f6.css">

每次改样式,hash 都会变,浏览器重新下载。不需要手动给 CSS 改名。

site/public/ 下的文件构建时原样复制到 dist/ 根目录。这里放 _headersfavicon.svgrobots.txt404.html 等。注意 404 页面如果引用了样式表,kiln 会自动把路径替换成带 hash 的版本。

public/ 的复制在页面渲染之前执行——这样如果 public/ 下有一个 index.html,生成的首页会覆盖它。这个顺序是故意的:构建产物的优先级高于静态副本。

本地预览

kiln serve

默认监听 127.0.0.1:4173。启动时先做一次完整构建,然后监听文件变化——你改了 Markdown、模板、样式或配置,自动重新构建。刷新浏览器就能看到最新内容。

指定端口:

kiln serve --port 8080

开发服务器用 tiny_httpnotify crate,依赖很轻。没有热更新(HMR),需要手动刷新。对于一个内容站点来说,这足够了——你不是在调 React 组件,不需要毫秒级热替换。

一个小提示:kiln serve 走默认构建流程,draft 文章不会出现。想预览草稿,先用 kiln build --drafts 构建到 dist/,再 kiln serve

部署

dist/ 目录就是完整的静态站点。没有任何服务端运行时,没有数据库,任何静态文件托管服务都能用。

Cloudflare Pages:在 Dashboard 创建 Pages 项目,连接 Git 仓库。构建命令填 kiln build --config site/site.config.toml,输出目录填 dist。每次 push 自动构建部署。

或者手动部署:

npx wrangler pages deploy dist/

Netlify / Vercel:同样,设置构建命令和输出目录,或者直接拖 dist/ 目录上传。

GitHub Pages:把 dist/ 内容推到 gh-pages 分支,或用 GitHub Actions 自动构建部署。

不管你选哪个平台,kiln 产出的就是纯 HTML + CSS + XML,不存在兼容性问题。

踩过的坑

迁移过程中遇到的问题比写编译器本身更有记录价值。

serde 的 Default 陷阱。 Rust 里用 serde 反序列化配置,optional 字段加 #[serde(default)] 很常见。问题是 #[serde(default)] 走的是 derive macro 生成的 Default 实现——所有字段都是空字符串。如果你想让某个字段有特定的默认值,得手动写 #[serde(default = "default_fn")] 指向一个返回默认值的函数,或者给整个 struct 手动实现 Default trait。

这不是 serde 的 bug,是它的设计。但如果你不知道这个行为,debug 的时候会很困惑——配置文件里没写的字段为什么不是你期望的默认值?

RSS CDATA 注入。 文章描述放进 RSS 的 <description> 标签时用了 CDATA 包裹。如果描述里包含 ]]>,CDATA 就提前闭合了,后面的内容变成无效 XML。

修复是一行代码:description.replace("]]>", "]]&gt;")。但找到这个问题花了一个晚上——RSS 订阅器只说"解析失败",不告诉你具体哪行哪列出问题。

构建顺序 bug。 site/public/ 下的静态文件需要在渲染之前复制到 dist/,这样生成的页面可以覆盖静态文件。如果顺序反了——先生成页面再复制 public——public 里的 index.html 会覆盖生成的首页。

表现是:构建成功,但首页变成了空白页面。排查方向完全跑偏,最后发现是 copy_dir 和渲染的执行顺序写反了。代码里特意加了注释提醒自己。

CI 下载私有仓库的 Release asset。 最初用 curl 下载 GitHub Release 的预编译 binary,私有仓库返回的是 "Not Found" HTML 页面而不是 binary。curl 不报错,忠实地把 HTML 写进了文件。换成 gh release download 后解决——它自带认证。

Cloudflare Workers vs Pages。 之前的项目建成了 Workers 类型而不是 Pages 类型,wrangler pages deploy 找不到对应项目直接报错。需要在 Cloudflare Dashboard 里单独创建 Pages 项目。报错信息不会告诉你"项目类型不对",只会说"project not found"。

CI 设计

CI 分成两个 workflow:ci.yml 在每次 push 或 PR 时从 GitHub Release 下载预编译的 kiln binary 构建站点并部署;release.yml 在 compiler/ 目录有变化时编译 Rust 并上传 binary 到 GitHub Release。

这样设计是把"编译 Rust"和"构建站点"解耦。内容变更只触发 ci.yml,几十秒完成。编译器变更触发 release.yml,两分钟编译完,下次 ci.yml 自动用新版本。

编译器未来拆到独立仓库后,CI 里只需要改下载 URL。

代价

重写带来了好处,也引入了新的成本。

编译慢。 release build 大约两分钟。cargo run 也要十几秒才启动。和 Node.js 的即时启动是两个体验。开发服务器用文件监听 + 增量构建缓解了这个问题,但首次启动还是慢。

门槛提高。 不是所有人都熟悉 Rust。团队项目选 Rust 做构建工具需要慎重。这是个人项目,这个成本我一个人承担了。

CI 复杂度。 之前 npm run build,一行搞定。现在需要管理 Release binary 版本,两个 workflow 协同。

这些代价是真实的。Node.js 方案的启动速度、热更新体验、生态便利性,Rust 方案比不了。Rust 方案的类型安全、产物干净、编译器完全解耦,Node.js 方案也没有。选哪个取决于你更在意什么。

哪些没变

迁移只动了构建管线。以下东西原封不动:

  • 内容格式:还是 Markdown + YAML frontmatter,字段完全一样。
  • 模板结构:layout、home、post、page 四个模板的逻辑没变,只是从 JavaScript 函数变成了 Tera 模板文件。
  • 样式:同一个 styles.css,没有任何改动。
  • 部署目标:还是 Cloudflare Pages,还是静态文件。
  • URL 结构/posts/<slug>//<page-slug>/,没有变。

迁移的边界是构建工具,不是站点本身。读者不会感知到任何变化。

如果现在重新面对同样的场景

我不会改变当时的决定。一开始就该用最简单的方案让博客先跑起来——Node.js 做这件事很合适。当内容积累到一定量、构建需求变复杂时,把构建逻辑从内容项目里拆出来是自然的下一步。

这个判断和语言无关。用 Node.js 写一个独立的 CLI 编译器也能达到同样的效果。Rust 只是我在这个场景下的选择,不是必要条件。

如果你也在维护一个手写的静态站点,构建脚本和内容项目的边界值得注意。它们变更是不同步的——内容天天改,构建逻辑几个月才动一次。把不同步的东西放在同一个耦合层里,迟早会互相拖累。