Tools 2026-03-20

Introducing gohan — A Go Static Site Generator with Incremental Builds

A deep dive into gohan, a Go-based static site generator powering bmf-tech.com. Features SHA-256 manifest-driven incremental builds, i18n, Mermaid diagrams, OGP image generation, and a compiled plugin system (Amazon book cards, bookshelf page).

Read in: ja
Introducing gohan — A Go Static Site Generator with Incremental Builds

Introducing gohan — A Go Static Site Generator with Incremental Builds

Why I Built It

This site (bmf-tech.com) runs on gohan. I wanted a static site generator I could fully understand — one that regenerates only the pages that change. Most generators either rebuild everything unconditionally or depend on git diff, which becomes unreliable after branch switches or fresh clones. gohan persists a SHA-256 content-hash manifest, so incremental builds stay accurate without relying on Git history.

Quick Start

# 1. Create a project directory
mkdir myblog && cd myblog

# 2. Add config.yaml
cat > config.yaml << 'EOF'
site:
  title: My Blog
  base_url: https://example.com
  language: en
  github_repo: https://github.com/owner/repo  # enables "Edit this page" links
  github_branch: main

build:
  content_dir: content
  output_dir: public
  static_dir: static    # copied as-is to the output root
  per_page: 20          # articles per page (0 = pagination disabled)

theme:
  name: default

syntax_highlight:
  theme: github
  line_numbers: false

ogp:
  enabled: true
  width: 1200
  height: 630

i18n:
  locales: [en]
  default_locale: en
EOF

# 3. Create your first article
gohan new --title="Hello, World!" hello-world

# 4. Build the site
gohan build

# 5. Preview locally with live reload
gohan serve   # open http://127.0.0.1:1313

Architecture

System Design

graph TB A[Content Source] --> B[Parser Layer] B --> C[Processing Layer] C --> D[Template Engine] D --> E[Output Generator] B --> F[Diff Engine] F --> C G[Config] --> C H[Templates] --> D I[Assets] --> E

Directory Structure

Input

.
├── config.yaml
├── content/
│   ├── posts/        # Blog posts (list, tag, archive pages)
│   └── pages/        # Static pages (About, Contact, etc.)
├── themes/
│   └── default/
│       └── layouts/  # Template files
├── assets/           # CSS, images, and other static files
└── taxonomies/
    ├── tags.yaml
    └── categories.yaml

Output

public/
├── index.html
├── posts/
├── pages/
├── tags/
├── categories/
├── archives/
├── feed.xml
├── atom.xml
├── sitemap.xml
└── assets/

Incremental Build Engine

The core of the incremental build lives in internal/diff/git.go. The Detect() method compares the current working tree against a persisted BuildManifest.

func (g *GitDiffEngine) Detect(manifest *model.BuildManifest) (*model.ChangeSet, error) {
    current, err := hashAllFiles(g.rootDir)
    if err != nil {
        return nil, err
    }

    if manifest == nil {
        cs := &model.ChangeSet{}
        for path := range current {
            cs.AddedFiles = append(cs.AddedFiles, path)
        }
        return cs, nil
    }

    cs := &model.ChangeSet{}
    for path, hash := range current {
        if prev, ok := manifest.FileHashes[path]; !ok {
            cs.AddedFiles = append(cs.AddedFiles, path)
        } else if prev != hash {
            cs.ModifiedFiles = append(cs.ModifiedFiles, path)
        }
    }
    for path := range manifest.FileHashes {
        if _, ok := current[path]; !ok {
            cs.DeletedFiles = append(cs.DeletedFiles, path)
        }
    }
    return cs, nil
}

hashAllFiles() walks the content directory and computes a SHA-256 hex digest for every file. On the first build (or when no manifest exists), all files count as Added. Later builds detect three change types — Added, Modified, and Deleted — and regenerate only the affected HTML pages.

config.yaml itself gets hashed on every build. If it changes, gohan clears the cache automatically and runs a full rebuild. The --full flag forces the same behaviour explicitly.

Cache data lives in .gohan/cache/manifest.json.

.gohan/
└── cache/
    └── manifest.json   # file hash registry

Build Sequence (gohan build)

sequenceDiagram participant User participant CLI as gohan CLI participant Cache as Cache Manager participant Diff as Diff Engine participant Parser participant Processor participant Plugin participant Generator participant FS as File System User->>CLI: gohan build CLI->>CLI: load config.yaml CLI->>Cache: ReadManifest(.gohan/cache/manifest.json) Cache-->>CLI: previous manifest alt --full or config changed CLI->>Cache: ClearCache() Note over CLI: treat all files as full build else incremental build CLI->>Diff: Detect(manifest) Diff->>FS: compute & compare SHA-256 hashes FS-->>Diff: hash map Diff-->>CLI: changeSet end CLI->>Parser: ParseAll(contentDir) Parser-->>CLI: []Article CLI->>Processor: Process(articles) Processor-->>CLI: []ProcessedArticle CLI->>Plugin: Enrich(site) / EnrichVirtual(site) Plugin-->>CLI: done CLI->>Generator: Generate(site, changeSet) Generator->>FS: write HTML files (changeSet only) CLI->>Generator: GenerateSitemap / GenerateFeeds Generator->>FS: write sitemap.xml, atom.xml CLI->>Cache: WriteManifest(newManifest) CLI-->>User: build: N articles, 0 errors, Xs

Dev Server — Live Reload (gohan serve)

sequenceDiagram participant User participant CLI as gohan CLI participant Builder as Build Pipeline participant Watcher as fsnotify Watcher participant HTTP as HTTP Server participant SSE as SSE Handler participant Browser User->>CLI: gohan serve CLI->>Builder: full build (initial) Builder-->>CLI: done CLI->>HTTP: start HTTP server (static files + /sse) CLI->>Watcher: watch content/, themes/, config.yaml User->>Browser: open http://localhost:<port> Browser->>HTTP: GET / HTTP-->>Browser: index.html Browser->>SSE: GET /sse (EventSource connect) SSE-->>Browser: connected Note over User: save article.md Watcher->>CLI: FileChanged event CLI->>Builder: incremental build Builder-->>CLI: done CLI->>SSE: send "reload" event SSE-->>Browser: data: reload Browser->>Browser: location.reload() Browser->>HTTP: GET / (reload) HTTP-->>Browser: updated index.html

Features

Beyond incremental builds, gohan ships with many capabilities out of the box.

Plugin System

gohan deliberately avoids Go's standard plugin package and a library-style design, instead opting for a compiled-in approach. The reason is straightforward: the priority is the simplest, shortest path to a working SSG. Dynamic loading or external library dependencies add friction to installation, builds, and distribution. The compiled-in model serves well enough until there is a concrete reason to change it.

Plugins compile into the gohan binary and activate per-project via config.yaml. No recompilation needed by users. The plugin interfaces live in internal/plugin/plugin.go.

type Plugin interface {
    Name() string
    Enabled(cfg map[string]interface{}) bool
    TemplateData(article *model.ProcessedArticle, cfg map[string]interface{}) (map[string]interface{}, error)
}

type SitePlugin interface {
    Name() string
    Enabled(cfg map[string]interface{}) bool
    VirtualPages(site *model.Site, cfg map[string]interface{}) ([]*model.VirtualPage, error)
}

Plugin (article-level) exposes extra data for a single article through the template as .PluginData.<name>. SitePlugin (site-level) runs after all articles have processed and can produce virtual pages — pages with no Markdown source.

The built-in registry ships two plugins.

func DefaultRegistry() *Registry {
    return &Registry{
        plugins: []Plugin{
            amazonbooks.New(),
        },
        sitePlugins: []SitePlugin{
            bookshelf.New(),
        },
    }
}

amazon_books generates Amazon affiliate book card data (image, URL, title) from ASIN values in article frontmatter. bookshelf aggregates book frontmatter across the whole site and produces a virtual /bookshelf page.

Example config.yaml setup:

plugins:
  amazon_books:
    enabled: true
    tag: "your-associate-tag-22"
  bookshelf:
    enabled: true

CLI Reference

gohan build

gohan build [--full] [--config=path] [--output=dir] [--parallel=N] [--dry-run]
Flag Description
--full Force a full rebuild, ignoring the previous manifest
--config Path to the config file (default: ./config.yaml)
--output Override the output directory
--parallel Number of parallel workers (default: number of CPUs)
--dry-run Print files that would be rebuilt without writing any output
--draft Include draft articles (draft: true) in the build

gohan new

gohan new [--title="Title"] [--type=post|page] <slug>

gohan serve

gohan serve [--port=N] [--host=addr]
Flag Description
--port Port number (default: 1313)
--host Host address (default: 127.0.0.1)

Install and Usage

# Homebrew (macOS/Linux)
brew install bmf-san/tap/gohan

# Go install
go install github.com/bmf-san/gohan/cmd/gohan@latest

# Build
gohan build

# Dev server with live reload
gohan serve

User Guide

For detailed configuration options and template usage, see the documentation.

Guide Description
Getting Started Installation, first site, build & preview
Configuration All config.yaml fields and Front Matter
Templates Theme templates, variables, built-in functions
Taxonomy Tags, categories, and archive pages
CLI Reference All commands and flags

Closing

gohan is the engine behind this site. SHA-256 manifest-driven incremental builds keep iteration fast. The compiled plugin system keeps the binary self-contained. From i18n to OGP to Mermaid, everything runs at build time with no client-side JavaScript required.

Tags: Golang SSG Architecture
Share: 𝕏 Post Facebook Hatena
✏️ View source / Discuss on GitHub
☕ Support

If you enjoy this blog, consider supporting it. Every bit helps keep it running!


Related Articles