文章 · 2026-02-03

一夜 Vibe Coding:将静态站点生成器迁移到 Deno

昨晚开始迁移,今早完成。用一夜时间把静态站点生成器从 Shell 脚本改到 Deno。以下是成果。

迁移成果

问题

自己写的基于 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

当前功能

工具原生支持多种内容格式:

仍需外部工具

少数高级功能还依赖外部程序:

功能 当前 计划
图片压缩 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 作为开发伙伴。最有效的做法:

  1. 描述约束,不描述方案 – "部署太慢因为要复制文件"会引出 worktree 方案;直接说"用 worktree"不会
  2. 立即测试 – 每次改动后马上 staticflow deploy --build 验证
  3. 增量迭代 – 先实现核心功能,再优化
  4. 保持上下文 – 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

© 2026 Yuxu Ge ·