Article · 2026-02-03

One Night of Vibe Coding: Migrating Static Site Generator to Deno

Last night I migrated my static site generator from shell scripts to Deno and finished by morning. Here's what changed.

Migration Results

The Problem

I built my own shell script-based static blog generator for yuxu.ge and it worked, but comparing it against mainstream solutions revealed a structural problem: too many dependencies. The current stack requires Node.js, ImageMagick, and LibreOffice. That's a barrier to adoption.

Feature-wise, the tool already exceeds what mainstream generators offer:

Hugo Jekyll Astro Gatsby StaticFlow
Build Speed Fast Slow Fast Slow Fast
Formats MD MD MD MD 40+
Notebook Plugin Plugin Native
Search Hybrid
AI Features RAG + i18n

The gap wasn't features; it was deployment friction.

Why Deno

curl -fsSL https://xxx/install.sh | sh
staticflow build
# Done. Zero dependencies.
# One command to compile to single binary
deno compile -A -o staticflow scripts/cli.ts

Deno compiles to a single binary. No runtime needed. This solves the core problem—the tool can work out of the box.

Code Refactoring

The original architecture mixed shell and Node.js with tangled dependency management and poor cross-platform support.

Clean Structure

Old architecture:
├── build.sh          # Main build script
├── compress.sh       # Image compression
├── convert-heic.sh   # HEIC conversion
├── blog/build.ts     # Node.js script
└── Various scattered scripts...
New architecture:
├── scripts/
│   ├── cli.ts              # Unified CLI entry
│   ├── config.ts           # Config loading
│   ├── build.ts            # Build logic
│   ├── build-static.ts     # Static HTML generation
│   ├── build-posts-json.ts # Blog index
│   ├── build-gallery-json.ts # Gallery index
│   ├── compress-gallery.ts   # Image compression
│   ├── convert-heic.ts     # HEIC conversion
│   └── index-builder.ts    # Search index builder
├── staticflow.config.yaml  # Unified config
└── deno.json               # Deno task config

All code now lives in TypeScript. One configuration file drives everything.

Configuration Design

# 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

The frontend reads a features.json config to dynamically enable or disable feature modules:

// Generate features.json at build time
const featuresJson = JSON.stringify(config.features, null, 2);
await Deno.writeTextFile(join(distDir, "features.json"), featuresJson);

Deployment: The Git Worktree Optimization

The biggest win in this migration came from changing how deployment works.

Original Bottleneck

1. Create temp directory
2. Clone gh-pages branch to temp (slow!)
3. Clear temp directory
4. Copy dist/* to temp (slow!)
5. Commit & push
6. Cleanup temp directory

Deployment took 12 seconds, mostly cloning and copying files.

Solution

Git worktree allows checking out multiple branches into separate directories within the same repository:

# Checkout gh-pages branch to dist directory
git worktree add dist gh-pages

Instead of copying, the dist directory becomes the gh-pages worktree. Build output goes directly there; no file shuffling needed.

Implementation

Detecting a worktree

const distGitFile = join(distDir, ".git");
if (existsSync(distGitFile)) {
  const content = await Deno.readTextFile(distGitFile);
  if (content.includes("gitdir:")) {
    // It's a worktree, check the branch
    const branch = await getBranch(distDir);
    if (branch === "gh-pages") {
      distIsWorktree = true;
    }
  }
}

A worktree's .git is a file (not a directory) with content like:

gitdir: /path/to/repo/.git/worktrees/dist

Auto-setup on first deployment

if (!existsSync(distDir) || !isWorktree(distDir)) {
  console.log("Setting up dist/ as gh-pages worktree...");
  await setupDeploy();
}

setupDeploy implementation

async function setupDeploy() {
  // Check if local gh-pages branch exists
  const localExists = await branchExists("gh-pages");

  if (!localExists) {
    // Check remote
    const remoteExists = await remoteBranchExists("gh-pages");
    if (remoteExists) {
      // Fetch remote branch
      await run("git", ["fetch", "origin", "gh-pages:gh-pages"]);
    } else {
      // Create orphan branch and push
      await createOrphanBranch("gh-pages");
    }
  }

  // Create worktree
  await run("git", ["worktree", "add", distDir, "gh-pages"]);
}

Skip .git during builds

async function copyDir(src: string, dest: string) {
  for await (const entry of Deno.readDir(src)) {
    if (entry.name === ".git") continue;  // Skip!
    // ... copy files
  }
}

Copying theme files while a worktree is active will break the worktree relationship. The skip is essential.

Optimized deploy sequence

if (distIsWorktree) {
  // Operate directly in dist, no copying needed
  console.log("dist/ is gh-pages worktree, deploying directly...");

  // Sync with remote
  await run("git", ["pull", "--rebase", "origin", "gh-pages"], distDir);

  // Commit
  await run("git", ["add", "-A"], distDir);
  await run("git", ["commit", "-m", message], distDir);

  // Push
  const pushResult = await run("git", ["push", "origin", "gh-pages"], distDir);

  if (!pushResult.success) {
    // Ask for force push on conflict
    const answer = await prompt("Force push? [y/N]");
    if (answer === "y") {
      await run("git", ["push", "--force", "origin", "gh-pages"], distDir);
    }
  }
}

Timing Comparison

Operation Before After
Check branch clone (slow) git ls-remote (fast)
Prepare directory copy files use worktree directly
Total 12 seconds 3 seconds

CLI Design

Following Unix principles, the interface is minimal:

# Build
staticflow build              # Full build
staticflow build --static     # Static HTML only
staticflow build --gallery     # Process images only

# Development
staticflow serve              # Dev server :8080
staticflow serve --port=3000  # Custom port

# Deploy
staticflow deploy             # Deploy to gh-pages
staticflow deploy --build     # Build and deploy (recommended)
staticflow deploy -m "msg"    # Custom commit message

# Setup
staticflow setup              # Check dependencies
staticflow setup-deploy       # Manually setup worktree
staticflow init               # Initialize project
staticflow clean              # Clean generated files

One command does everything:

staticflow deploy --build
# Auto: setup worktree → build → commit → push

Current Capabilities

The tool handles multiple content formats natively:

Remaining External Dependencies

A few advanced features still require external tools:

Feature Current Planned
Image compression ImageMagick WASM
HEIC decoding ImageMagick libde265 WASM
Office → PDF LibreOffice TBD
LaTeX → PDF pdflatex TeX WASM

The next step is to extract core format-conversion code, trim it, compile to WASM, and embed it into a single ~15MB binary.

For example: libde265, a HEVC decoder library, is 50,000 lines of C. The plan is to strip multi-threading, SIMD, and encoding—features unnecessary for decoding—reducing it to 15,000 lines, then compile to ~300KB WASM.

AI-Assisted Development

This migration relied heavily on Claude Code as an AI partner. The most effective approach:

  1. Describe the constraint, not the solution – "Deployment is slow because of file copying" leads to the worktree insight; saying "use worktree" doesn't
  2. Test immediately – Run staticflow deploy --build after every change
  3. Iterate incrementally – Core functionality first, optimization second
  4. Maintain context – The AI can refer back to earlier design decisions

This pace of iteration would have been difficult without a tool that preserves conversation context.

Lessons Learned

Compiled binaries don't auto-refresh

After code changes, the old staticflow command still runs:

# Must recompile
deno task compile

I configured Deno to auto-delete stale artifacts in deno.json:

{
  "tasks": {
    "compile": "rm -f /opt/staticflow/bin/staticflow && deno compile -A -o /opt/staticflow/bin/staticflow scripts/cli.ts"
  }
}

Stale worktree blocks creation

If a previous worktree wasn't cleaned up, the next creation fails:

// Prune stale worktrees first
await run("git", ["worktree", "prune"]);
await run("git", ["worktree", "add", distDir, "gh-pages"]);

WASM requires correct MIME type

The dev server must declare WASM files correctly, or browsers refuse to load them:

const mimeTypes = {
  ".wasm": "application/wasm",  // Not octet-stream!
};

Next: Open Source

Release coming soon.


#Deno #TypeScript #StaticSiteGenerator #VibeCoding #OpenSource

© 2026 Yuxu Ge ·