One Night of Vibe Coding: Migrating Static Site Generator to Deno
Started last night, finished today. One night of vibe coding, and I've successfully migrated my static site generator from shell scripts to the Deno platform.
Migration Results
- ✅ Completely removed all shell script dependencies
- ✅ Eliminated Node.js code entirely
- ✅ Clean, modern TypeScript codebase
- ⏳ Still working on removing some external program dependencies
Why Migrate?
I've been using my own shell script-based static blog generator (powering yuxu.ge) for a while, and it works pretty well. But after comparing with mainstream solutions, I found a critical problem:
| 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 |
Feature-wise, it already surpasses mainstream solutions. But there's a fatal flaw: too many dependencies.
Currently requires Node.js + ImageMagick + LibreOffice. Asking users to install all these? Unacceptable.
Deno Migration Plan
The goal is clear:
curl -fsSL https://xxx/install.sh | sh
staticflow build
# Done. Zero dependencies.
Feature parity with Astro/Gatsby. UX parity with Hugo.
Why Deno
# One command to compile to single binary
deno compile -A -o staticflow scripts/cli.ts
Generates a single binary file, no runtime installation needed. Exactly what I need.
Code Refactoring
Before: Chaotic State
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...
Shell and Node.js mixed together, messy dependency management, poor cross-platform compatibility.
After: Clean Structure
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-photos-json.ts# Gallery index
│ ├── compress-photos.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 unified in TypeScript, one config file controls everything.
Unified Configuration Design
# staticflow.config.yaml
site:
name: "My Blog"
url: "https://yuxu.ge"
paths:
posts: "content/posts"
photos: "content/photos"
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
Frontend reads config via features.json to dynamically enable/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 Optimization: Git Worktree Approach
This is the biggest optimization in this migration.
Problem: File Copying Too Slow
The original deployment flow:
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
Each deployment took 12 seconds, most time spent on cloning and copying files.
Solution: Worktree
Git Worktree allows checking out multiple branches to different directories in the same repo:
# Checkout gh-pages branch to dist directory
git worktree add dist gh-pages
Now dist directory IS the gh-pages branch working directory. Build outputs directly here, no copying needed for deploy!
Implementation Details
1. Detect if dist is 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), containing something like:
gitdir: /path/to/repo/.git/worktrees/dist
2. Auto-setup worktree
Automatically detect and setup on first deploy:
if (!existsSync(distDir) || !isWorktree(distDir)) {
console.log("Setting up dist/ as gh-pages worktree...");
await setupDeploy();
}
3. 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"]);
}
4. Skip .git during build
Critical: Must skip .git when copying theme files, otherwise worktree breaks:
async function copyDir(src: string, dest: string) {
for await (const entry of Deno.readDir(src)) {
if (entry.name === ".git") continue; // Skip!
// ... copy files
}
}
5. Optimized deploy flow
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);
}
}
}
Performance Comparison
| Operation | Before | After |
|---|---|---|
| Check branch | clone (slow) | git ls-remote (fast) |
| Prepare directory | copy files | use worktree directly |
| Total time | 12 seconds | 3 seconds |
CLI Design
Following Unix philosophy, commands are concise:
# Build
staticflow build # Full build
staticflow build --static # Static HTML only
staticflow build --photos # 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 it all:
staticflow deploy --build
# Auto: setup worktree → build → commit → push
Current Features
The project is already quite functional:
- Multi-format content - Markdown, Jupyter Notebook, LaTeX, Office documents
- Hybrid search - BM25 keyword + Voy vector semantic search (WASM)
- AI chat assistant - RAG-based Q&A
- Photo gallery - Auto-compression, HEIC conversion, AI-generated descriptions
- Multi-language translation - AI-powered content translation
Remaining External Dependencies
Some features still depend on external programs:
| Feature | Current Dependency | Planned Solution |
|---|---|---|
| Image compression | ImageMagick | WASM |
| HEIC conversion | ImageMagick | libde265 WASM |
| Office to PDF | LibreOffice | TBD |
| LaTeX compilation | pdflatex | TeX WASM |
Next step: Trim core format conversion code and compile to WASM, embedding into a single ~15MB binary.
For example, HEIC decoding: plan to trim libde265 (HEVC decoder) from 50K lines of C code to 15K lines, removing multi-threading, SIMD, encoder modules, compiling to ~300KB WASM.
AI-Assisted Development
This migration heavily used Claude Code as an AI pair programming partner. Key takeaways:
- Describe intent, not implementation - Say "deployment is slow because of file copying", AI suggests worktree approach
- Incremental iteration - Implement basic functionality first, optimize after tests pass
- Immediate testing - Run
staticflow deploy --buildafter every change - Maintain context - AI remembers previous discussions, can say "continue with the previous approach"
Completing this migration overnight wouldn't be possible without AI.
Lessons Learned
1. Compiled binary needs regeneration
After code changes, staticflow command still runs old version:
# Must recompile
deno task compile
I configured auto-deletion of old files in deno.json:
{
"tasks": {
"compile": "rm -f /opt/staticflow/bin/staticflow && deno compile -A -o /opt/staticflow/bin/staticflow scripts/cli.ts"
}
}
2. Stale worktree causes creation failure
If previous worktree wasn't cleaned up, creating new one fails:
// Prune stale worktrees first
await run("git", ["worktree", "prune"]);
await run("git", ["worktree", "add", distDir, "gh-pages"]);
3. WASM MIME type
Dev server must correctly configure MIME types, otherwise browser refuses to load:
const mimeTypes = {
".wasm": "application/wasm", // Not octet-stream!
};
Open Source Plan
Open source release coming soon! Stay tuned 🔜
#Deno #TypeScript #StaticSiteGenerator #VibeCoding #OpenSource