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
- ✅ Removed all shell script dependencies
- ✅ Eliminated Node.js entirely
- ✅ Unified TypeScript codebase
- ⏳ Still resolving external program dependencies
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:
- Multi-format input – Markdown, Jupyter Notebook, LaTeX, Office documents
- Hybrid search – BM25 keyword search plus Voy vector semantic search (WASM)
- AI chat – RAG-based question answering
- Photo gallery – Automatic compression, HEIC conversion, AI-generated descriptions
- Translation – AI-powered content in multiple languages
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:
- Describe the constraint, not the solution – "Deployment is slow because of file copying" leads to the worktree insight; saying "use worktree" doesn't
- Test immediately – Run
staticflow deploy --buildafter every change - Iterate incrementally – Core functionality first, optimization second
- 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