一夜 Vibe Coding:将静态站点生成器迁移到 Deno
昨晚开始迁移,今早完成。用一夜时间把静态站点生成器从 Shell 脚本改到 Deno。以下是成果。
迁移成果
- ✅ 移除所有 Shell 脚本依赖
- ✅ 消除 Node.js 代码
- ✅ 统一的 TypeScript 代码库
- ⏳ 仍在处理外部工具依赖
问题
自己写的基于 Shell 脚本的博客生成器在 yuxu.ge 上用得不错,但和主流方案对比后发现了核心问题:依赖太多。当前需要 Node.js、ImageMagick 和 LibreOffice。这对用户是个门槛。
从功能看,这个工具已经超过主流生成器:
| Hugo | Jekyll | Astro | Gatsby | StaticFlow | |
|---|---|---|---|---|---|
| 构建速度 | 快 | 慢 | 快 | 慢 | 快 |
| 内容格式 | MD | MD | MD | MD | 40+ 格式 |
| Notebook | ✗ | ✗ | 插件 | 插件 | 原生支持 |
| 搜索 | ✗ | ✗ | ✗ | ✗ | 混合搜索 |
| AI 功能 | ✗ | ✗ | ✗ | ✗ | RAG + 翻译 |
差距不在功能,在部署。
为什么选 Deno
curl -fsSL https://xxx/install.sh | sh
staticflow build
# Done. 零依赖。
# 一行命令编译成单文件
deno compile -A -o staticflow scripts/cli.ts
Deno 编译成单个二进制文件,无需安装运行时。这正好解决问题——工具开箱即用。
代码重构
原来的架构混用 Shell 和 Node.js,依赖管理混乱,跨平台支持差。
清晰的结构
旧架构:
├── build.sh # 主构建脚本
├── compress.sh # 图片压缩
├── convert-heic.sh # HEIC 转换
├── blog/build.ts # Node.js 脚本
└── 各种零散脚本...
新架构:
├── scripts/
│ ├── cli.ts # 统一 CLI 入口
│ ├── config.ts # 配置加载
│ ├── build.ts # 构建逻辑
│ ├── build-static.ts # 静态 HTML 生成
│ ├── build-posts-json.ts # 博客索引
│ ├── build-gallery-json.ts # 相册索引
│ ├── compress-gallery.ts # 图片压缩
│ ├── convert-heic.ts # HEIC 转换
│ └── index-builder.ts # 搜索索引构建
├── staticflow.config.yaml # 统一配置
└── deno.json # Deno 任务配置
所有代码用 TypeScript 写,一个配置文件驱动一切。
配置设计
# staticflow.config.yaml
site:
name: "My Blog"
url: "https://yuxu.ge"
paths:
posts: "content/posts"
photos: "content/gallery"
output: "dist"
theme: "themes/default"
static: "static"
features:
search: true
vectorSearch: true
gallery: true
chat: true
translation: false
build:
imageCompression: true
maxImageWidth: 2000
imageQuality: 85
前端通过 features.json 读取配置,动态启用或禁用功能模块:
// 构建时生成 features.json
const featuresJson = JSON.stringify(config.features, null, 2);
await Deno.writeTextFile(join(distDir, "features.json"), featuresJson);
部署优化:Git Worktree
这次迁移最大的优化来自改变部署方式。
原来的瓶颈
1. 创建临时目录
2. clone gh-pages 分支到临时目录 (慢!)
3. 清空临时目录
4. 复制 dist/* 到临时目录 (慢!)
5. commit & push
6. 清理临时目录
部署耗时 12 秒,大部分时间在克隆和复制文件。
解决方案
Git worktree 允许在同一仓库中同时检出多个分支到不同目录:
# 将 gh-pages 分支 checkout 到 dist 目录
git worktree add dist gh-pages
dist 目录直接成为 gh-pages 分支的工作目录。构建输出直接写入,部署时不需复制。
实现
检测 worktree
const distGitFile = join(distDir, ".git");
if (existsSync(distGitFile)) {
const content = await Deno.readTextFile(distGitFile);
if (content.includes("gitdir:")) {
// 是 worktree,检查分支
const branch = await getBranch(distDir);
if (branch === "gh-pages") {
distIsWorktree = true;
}
}
}
Worktree 的 .git 是一个文件(不是目录),内容类似:
gitdir: /path/to/repo/.git/worktrees/dist
首次部署时自动设置
if (!existsSync(distDir) || !isWorktree(distDir)) {
console.log("Setting up dist/ as gh-pages worktree...");
await setupDeploy();
}
setupDeploy 实现
async function setupDeploy() {
// 检查本地是否有 gh-pages 分支
const localExists = await branchExists("gh-pages");
if (!localExists) {
// 检查远程
const remoteExists = await remoteBranchExists("gh-pages");
if (remoteExists) {
// fetch 远程分支
await run("git", ["fetch", "origin", "gh-pages:gh-pages"]);
} else {
// 创建孤儿分支并推送
await createOrphanBranch("gh-pages");
}
}
// 创建 worktree
await run("git", ["worktree", "add", distDir, "gh-pages"]);
}
构建时跳过 .git
async function copyDir(src: string, dest: string) {
for await (const entry of Deno.readDir(src)) {
if (entry.name === ".git") continue; // 跳过!
// ... 复制文件
}
}
有活跃 worktree 时复制主题文件会破坏 worktree 关系。跳过是必要的。
优化的部署流程
if (distIsWorktree) {
// 直接在 dist 目录操作,无需复制
console.log("dist/ is gh-pages worktree, deploying directly...");
// 同步远程
await run("git", ["pull", "--rebase", "origin", "gh-pages"], distDir);
// 提交
await run("git", ["add", "-A"], distDir);
await run("git", ["commit", "-m", message], distDir);
// 推送
const pushResult = await run("git", ["push", "origin", "gh-pages"], distDir);
if (!pushResult.success) {
// 冲突时询问是否 force push
const answer = await prompt("Force push? [y/N]");
if (answer === "y") {
await run("git", ["push", "--force", "origin", "gh-pages"], distDir);
}
}
}
时间对比
| 操作 | 优化前 | 优化后 |
|---|---|---|
| 检查分支 | clone (慢) | git ls-remote (快) |
| 准备目录 | 复制文件 | 直接使用 worktree |
| 总耗时 | 12 秒 | 3 秒 |
CLI 设计
遵循 Unix 哲学,界面最小化:
# 构建
staticflow build # 完整构建
staticflow build --static # 仅生成静态 HTML
staticflow build --gallery # 仅处理图片
# 开发
staticflow serve # 开发服务器 :8080
staticflow serve --port=3000 # 自定义端口
# 部署
staticflow deploy # 部署到 gh-pages
staticflow deploy --build # 构建并部署(推荐)
staticflow deploy -m "msg" # 自定义提交信息
# 设置
staticflow setup # 检查依赖
staticflow setup-deploy # 手动设置 worktree
staticflow init # 初始化项目
staticflow clean # 清理生成文件
一条命令完成全部:
staticflow deploy --build
# 自动: setup worktree → build → commit → push
当前功能
工具原生支持多种内容格式:
- 多格式输入 – Markdown、Jupyter Notebook、LaTeX、Office 文档
- 混合搜索 – BM25 关键词搜索加 Voy 向量语义搜索(WASM)
- AI 聊天 – 基于 RAG 的问答
- 相册 – 自动压缩、HEIC 转换、AI 生成描述
- 翻译 – AI 驱动的多语言内容
仍需外部工具
少数高级功能还依赖外部程序:
| 功能 | 当前 | 计划 |
|---|---|---|
| 图片压缩 | ImageMagick | WASM |
| HEIC 解码 | ImageMagick | libde265 WASM |
| Office → PDF | LibreOffice | 待定 |
| LaTeX → PDF | pdflatex | TeX WASM |
下一步:提取核心格式转换代码,精简,编译为 WASM,嵌入单个约 15MB 的二进制。
例如:libde265 是 HEVC 解码库,50,000 行 C 代码。计划剥离多线程、SIMD 和编码功能(解码不需要),减到 15,000 行,再编译为约 300KB 的 WASM。
AI 辅助开发
这次迁移大量依赖 Claude Code 作为开发伙伴。最有效的做法:
- 描述约束,不描述方案 – "部署太慢因为要复制文件"会引出 worktree 方案;直接说"用 worktree"不会
- 立即测试 – 每次改动后马上
staticflow deploy --build验证 - 增量迭代 – 先实现核心功能,再优化
- 保持上下文 – AI 能回顾之前的设计决策
这种迭代速度没有对话记忆支持很难实现。
踩坑记录
编译的二进制不会自动刷新
修改代码后,旧版本的 staticflow 命令仍在运行:
# 必须重新编译
deno task compile
在 deno.json 中配置自动删除旧工件:
{
"tasks": {
"compile": "rm -f /opt/staticflow/bin/staticflow && deno compile -A -o /opt/staticflow/bin/staticflow scripts/cli.ts"
}
}
废弃的 worktree 阻止创建新的
上次 worktree 没清理干净,新建会失败:
// 先 prune 清理残留
await run("git", ["worktree", "prune"]);
await run("git", ["worktree", "add", distDir, "gh-pages"]);
WASM 需要正确的 MIME 类型
开发服务器必须正确声明 WASM 文件,否则浏览器拒绝加载:
const mimeTypes = {
".wasm": "application/wasm", // 不是 octet-stream!
};
后续:开源
项目即将发布。
#Deno #TypeScript #StaticSiteGenerator #VibeCoding #OpenSource