Design Document
1. Background & Motivation
Why Build a Custom SSG?
Despite the existence of many static site generators such as Hugo, Jekyll, Gatsby, and Next.js, we chose to build our own for the following reasons:
- Pursuit of simplicity: Existing tools are feature-rich but excessive for personal blogs
- Leveraging Go: Taking advantage of Go's concurrency performance and simplicity
- Optimized differential builds: Efficient incremental builds for processing large numbers of articles
- Reduced learning curve: Avoiding the complex configuration and plugin systems of existing tools to achieve intuitive usability
Requirements Not Met by Existing Tools
- Hugo: Complex configuration with a high learning cost for customization
- Jekyll: Ruby dependency results in slow builds
- Gatsby / Next.js: The Node.js ecosystem is heavy and excessive for simple blogs
Design Intent for GitHub-based Content Management
- Leveraging GitHub for version control of Markdown files
- However, to avoid GitHub dependency, the tool also works with the local filesystem
- Naturally enables article history management and collaborative review workflows
2. Goals
Primary Goals
- Provide a simple, fast SSG for personal blogs
- Minimal configuration files
- Reduced build times (differential builds within 30 seconds)
- Focused on static file generation
Technical Goals
-
Efficient operation through differential builds
- Regenerate only changed articles
- Automatic impact calculation (tag pages, archive pages, etc.)
- Minimize build time
-
Ensuring extensibility and maintainability through Go implementation
- Plugin architecture
- Testable code design
- Clear separation of concerns
3. Non-goals
The following are out of scope for gohan:
- Dynamic content generation / Server-side rendering: gohan is dedicated to pure static file generation
- Web admin UI (CMS features): Content management is done via text editor and Git
- JavaScript bundling / CSS minification: This is the responsibility of frontend build tools (e.g., Vite)
- Automatic image resizing / optimization: Intended to be handled by CDN or separate tools
- Authentication / membership / comment backend: Outside the scope of static files
- Plugin marketplace: A plugin API is provided, but no distribution platform will be built
4. Scope
Target Users
- Individual bloggers: Tech blogs, diaries, portfolio sites
- Small teams: Tech blogs, project documentation
Scale Assumptions
- Article count: 500–10,000
- Build time: Full build within 5 minutes, differential build within 30 seconds
- Article size: Average 3,000–5,000 characters, maximum ~15,000 characters
5. Use Cases
UC-1: Build a site (incremental)
Actor: Developer
Precondition: config.yaml exists; at least one Markdown article exists
Trigger: Run gohan build
Main Flow:
- gohan loads
config.yamland resolves the project root. - gohan reads the diff manifest (
.gohan/cache/manifest.json) from the previous build. - gohan calls
git diffto detect which Markdown files have changed since the last build. - gohan parses only the changed articles (Front Matter + Markdown -> HTML).
- gohan calculates the impact set (tag pages, category pages, archive page, index page, sitemap, feed).
- gohan renders the impact set through the template engine and writes HTML files to the output directory.
- gohan updates the manifest and prints a build summary (elapsed time, article count).
Alternative Flow - no Git repo: Step 3 falls back to comparing file modification times against the manifest.
Postcondition: Output directory contains up-to-date HTML, sitemap.xml, and atom.xml.
UC-2: Force a full build
Actor: Developer
Trigger: Run gohan build --full
Main Flow: Same as UC-1 except steps 2-3 are skipped; all articles are treated as changed.
Postcondition: All HTML is regenerated from scratch; manifest is rewritten.
UC-3: Create a new article
Actor: Developer
Trigger: Run gohan new "My Article Title"
Main Flow:
- gohan receives the slug from the argument (
--titlecan also be specified). - The output directory is
content/pages/when--type=pageis given, otherwisecontent/posts/(does not readconfig.yaml). - gohan creates
content/posts/<slug>.md(or pages) with a Front Matter template (title, date,draft: true). - gohan displays the path of the created file.
Postcondition: A new Markdown file is ready for editing; it is marked draft: true so it is excluded from builds until published.
UC-4: Start the development server
Actor: Developer
Trigger: Run gohan serve
Main Flow:
- gohan performs an initial full build into a temporary output directory.
- gohan starts an HTTP server serving the output directory.
- gohan starts a file watcher (
fsnotify) on the content, theme, and config directories. - The developer opens
http://localhost:<port>in a browser; the browser connects to the SSE endpoint (/sse). - The developer saves a Markdown file.
- gohan detects the change, runs an incremental build, and sends a
reloadevent via SSE. - The browser reloads the page automatically.
Postcondition: The browser always reflects the latest content without manual refresh.
UC-5: Publish an article
Actor: Developer
Trigger: Edit Front Matter of a draft article and change draft: false, then run gohan build
Main Flow:
Same as UC-1. Because the article file changed (or --full is used), it is now included in the build output.
Postcondition: The article appears in the generated HTML, tag/category pages, archive, sitemap, and feed.
6. Directory Structure
Input Side
.
├── config.yaml # Site configuration
├── content/
│ ├── posts/ # Blog articles (subject to list/tag/archive processing)
│ │ └── my-post.md
│ └── pages/ # Static pages (About, Contact, etc.)
│ └── about.md
├── themes/
│ └── default/
│ └── layouts/ # Template files
│ ├── base.html
│ ├── post.html
│ ├── list.html
│ └── partials/
│ ├── header.html
│ └── footer.html
├── assets/ # Static files such as CSS and images
└── taxonomies/
├── tags.yaml # Tag master
└── categories.yaml # Category master
Output Side
public/
├── index.html
├── posts/
│ └── my-post/
│ └── index.html
├── pages/
│ └── about/
│ └── index.html
├── tags/
│ └── go/
│ └── index.html
├── categories/
│ └── tech/
│ └── index.html
├── archives/
│ └── 2026/
│ └── 02/
│ └── index.html
├── feed.xml # RSS 2.0
├── atom.xml # Atom 1.0
├── sitemap.xml
└── assets/ # Copy of static files
Notes
- The
themes/<name>/structure allows future theme switching with a single line change inconfig.yaml - Separating
content/posts/andcontent/pages/clearly limits the targets for list/tag/archive processing - Output paths can be overridden with the
slugFront Matter field
7. Functional Requirements
7.1 Core Features
Markdown Processing
- Markdown parsing: CommonMark compliant
- Front Matter support: YAML-formatted metadata
Template Features
- Template engine: Uses Go's standard
html/template - Template discovery: Automatic discovery and loading of user-defined templates
- Flexible template structure: Templates can be defined with arbitrary filenames and directory structures
- Template variables: Provides article data, site configuration, and navigation information
- Custom functions: Helper functions for date formatting, tag link generation, Markdown conversion, etc.
- Template selection: Templates can be individually specified via Front Matter
Content Management
- Tag / category management: Master file-based list management and validation
- Article list generation: By date, tag, and category
- Archive generation: Monthly archives
- Static pages: Static pages such as About and Contact
Code Highlighting
- Syntax highlighting: Language-specific highlighting for code blocks
- Line number display: Optional setting
Diagram Support
- Mermaid diagrams: Flowcharts, sequence diagrams
Feed Generation
- RSS 2.0: Full article feed
- Atom 1.0: Standards-compliant feed
- Category feeds: Per-tag and per-category feeds
7.2 CLI Interface
See Section 11 for details. The primary commands are:
gohan build [--full] [--config=path] [--output=dir]
gohan new <slug>
gohan serve
8. Non-functional Requirements
8.1 Performance Requirements
Build Performance
- Initial full build: 1,000 articles within 5 minutes
- Differential build: 10 changes within 30 seconds
- Parallelism: Parallel article processing based on CPU count
8.2 Scalability
- Article count: Stable operation up to 10,000 articles
8.3 Reproducibility
- Deterministic builds: Guarantees identical output for identical input
- Cache invalidation: Accurate detection of file changes
- Cross-platform: Identical output on Windows / macOS / Linux
8.4 Maintainability & Extensibility
Code Quality
- Test coverage: 80% or higher
- Static analysis:
golangci-lint(coversgo vet,staticcheck, andgosec) - Dependencies: Minimize external dependencies
- Documentation: godoc-compliant comments
Architecture
- Separation of concerns: Parser, Generator, Renderer
- Interface design: Testable abstractions
- Externalized configuration: YAML configuration file
- Plugin API: Interfaces for feature extension
8.5 Operability
Logging & Monitoring
- Log format: Human-readable by default (switchable to JSON with
--log-format=jsonoption) - Log levels: DEBUG, INFO, WARN, ERROR
- Metrics: Build time, number of generated articles, and error count output as a summary to stdout upon build completion
- Error tracking: Errors with stack traces
Configuration Management
- Environment-specific configuration: development / production
- Configuration validation: Configuration value checks at startup
- Default values: Zero-config operation
8.6 Security
- Input validation: Validation of Markdown and configuration files
- Path traversal prevention: File path normalization
- Dependency scanning: Detection of vulnerable dependencies
8.7 Portability
- Cross-platform: Windows / macOS / Linux support
- Go-only dependency: No external runtime required, single binary
- Binary distribution: Automated releases and distribution via GoReleaser
- Package manager support: Homebrew, Scoop, APT, etc.
- CI/CD integration: Automated testing and releases via GitHub Actions
9. Architecture Overview
9.1 System Architecture
9.2 Data Flow
Input
- Article files:
content/posts/*.md - Static pages:
content/pages/*.md - Configuration:
config.yaml - Templates:
themes/default/layouts/ - Static assets:
assets/
Processing Flow
-
Content Parsing
- Reading Markdown files
- Parsing Front Matter
- Building the dependency graph
-
Diff Detection
- For Git repositories: Identifying changed files via Git diff
- For non-Git environments: Identifying changed files via file hash comparison (no differential build; falls back to full build)
- Calculating the impact scope
-
Page Generation
- Converting Markdown to HTML
- Applying templates
- Optimization through parallel processing
-
Asset Processing
- Copying static files
- Preserving directory structure
-
Output Placement
- Creating directory structure
- Copying and generating files
- Generating sitemap and feeds
Output
- HTML files: Each article and page
- List pages: Index, tags, categories
- Feed files: RSS, Atom
- Sitemap:
sitemap.xml - Static assets: CSS, images
9.3 Component Design
Parser Layer
type Parser interface {
// Parse reads the file at filePath and returns a fully populated Article.
Parse(filePath string) (*Article, error)
// ParseAll walks contentDir and returns one Article per Markdown file found.
ParseAll(contentDir string) ([]*Article, error)
}
Processing Layer
type Processor interface {
// Process converts a slice of Articles into ProcessedArticles.
Process(articles []*Article, cfg Config) ([]*ProcessedArticle, error)
// BuildDependencyGraph constructs the full DependencyGraph from all ProcessedArticles.
BuildDependencyGraph(articles []*ProcessedArticle) (*DependencyGraph, error)
// BuildTaxonomyRegistry collects all tags and categories and validates against taxonomy YAML files.
BuildTaxonomyRegistry(articles []*ProcessedArticle, cfg Config) (*TaxonomyRegistry, error)
// BuildTranslationMap links articles sharing a TranslationKey by populating Translations.
BuildTranslationMap(articles []*ProcessedArticle)
}
Template Engine
type TemplateEngine interface {
// Load reads all templates under templateDir.
Load(templateDir string, funcMap template.FuncMap, defaultLocale string) error
Render(templateName string, data interface{}) ([]byte, error)
}
Diff Engine
type NodeType int
const (
NodeTypeArticle NodeType = iota
NodeTypeTag
NodeTypeCategory
NodeTypeArchive
NodeTypePage
)
// ChangeSet holds the result of diff detection: lists of modified, added, and deleted file paths.
type ChangeSet struct {
ModifiedFiles []string
AddedFiles []string
DeletedFiles []string
}
type DiffEngine interface {
Detect(manifest *BuildManifest) (*ChangeSet, error)
Hash(filePath string) (string, error)
}
Output Generator
type OutputGenerator interface {
// Generate writes all HTML pages, copies static assets, and generates OGP images.
// When changeSet is nil, all pages are written.
Generate(site *Site, changeSet *ChangeSet) error
}
// Sitemap and feed generation are package-level functions:
// GenerateSitemap(outDir, baseURL string, articles []*ProcessedArticle, ...) error
// GenerateFeeds(outDir, baseURL, title string, articles []*ProcessedArticle, ...) error
10. Sequence Diagrams
10.1 Incremental Build (gohan build)
10.2 Development Server & Live Reload (gohan serve)
10.3 New Article Creation (gohan new)
11. Data Model Design
11.1 Article Data Structure
Article is the raw data read from a file. ProcessedArticle holds derived data generated at build time.
// Article: input data generated by the parser
type Article struct {
FrontMatter FrontMatter
RawContent string
FilePath string
LastModified time.Time
}
// ProcessedArticle: derived data generated by the renderer at build time
type ProcessedArticle struct {
Article
HTMLContent template.HTML
Summary string
OutputPath string
ContentPath string // source-relative path (for GitHub edit links)
Locale string // i18n locale code
URL string // canonical URL path
Translations []LocaleRef // translation links
PluginData map[string]interface{} // plugin-injected data
}
type FrontMatter struct {
Title string `yaml:"title"`
Date time.Time `yaml:"date"`
LastMod time.Time `yaml:"lastmod"`
Draft bool `yaml:"draft"`
Tags []string `yaml:"tags"`
Categories []string `yaml:"categories"`
Description string `yaml:"description"`
Author string `yaml:"author"`
Slug string `yaml:"slug"`
Template string `yaml:"template"`
TranslationKey string `yaml:"translation_key"`
ListingSlugs []string `yaml:"listing_slugs"`
Extra map[string]interface{} `yaml:",inline"` // plugin configuration
}
11.2 Taxonomy System
TaxonomyRegistry is the master data loaded from tags.yaml / categories.yaml. The Type field is not held because it is clear from the structure of the registry.
type Taxonomy struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
// TranslationKey links taxonomies that represent the same concept across
// locales (e.g. EN "Application" and JA "アプリケーション").
TranslationKey string `yaml:"translation_key"`
URL string `yaml:"-"` // set at render time
// Translations maps locale → URL for every other locale's taxonomy that
// shares the same TranslationKey. Populated at render time on
// CurrentTaxonomy only.
Translations map[string]string `yaml:"-"`
}
type TaxonomyRegistry struct {
Tags []Taxonomy `yaml:"tags"`
Categories []Taxonomy `yaml:"categories"`
}
11.3 Site Configuration
Define the Config type to match the top-level structure of config.yaml, containing SiteConfig and other types within it.
type Config struct {
Site SiteConfig `yaml:"site"`
Build BuildConfig `yaml:"build"`
Theme ThemeConfig `yaml:"theme"`
SyntaxHighlight SyntaxHighlightConfig `yaml:"syntax_highlight"`
OGP OGPConfig `yaml:"ogp"`
Plugins map[string]interface{} `yaml:"plugins"`
I18n I18nConfig `yaml:"i18n"`
}
type SiteConfig struct {
Title string `yaml:"title"`
Description string `yaml:"description"`
BaseURL string `yaml:"base_url"`
Language string `yaml:"language"`
GitHubRepo string `yaml:"github_repo"`
GitHubBranch string `yaml:"github_branch"`
}
type BuildConfig struct {
ContentDir string `yaml:"content_dir"`
OutputDir string `yaml:"output_dir"`
AssetsDir string `yaml:"assets_dir"`
StaticDir string `yaml:"static_dir"`
ExcludeFiles []string `yaml:"exclude_files"`
Parallelism int `yaml:"parallelism"`
PerPage int `yaml:"per_page"`
}
type ThemeConfig struct {
Name string `yaml:"name"`
Dir string `yaml:"dir"`
Params map[string]any `yaml:"params"`
}
type SyntaxHighlightConfig struct {
Theme string `yaml:"theme"`
LineNumbers bool `yaml:"line_numbers"`
}
type OGPConfig struct {
Enabled bool `yaml:"enabled"`
LogoFile string `yaml:"logo_file"`
Width int `yaml:"width"`
Height int `yaml:"height"`
}
11.4 Build Manifest
The build history saved to .gohan/cache/manifest.json, used for hash-based diff detection.
type BuildManifest struct {
Version string `json:"version"` // gohan version
BuildTime time.Time `json:"build_time"` // time of last build
LastCommit string `json:"last_commit"` // repository HEAD commit hash at build time
FileHashes map[string]string `json:"file_hashes"` // input file path -> SHA-256
Dependencies map[string][]string `json:"dependencies"` // file path -> list of dependent file paths
OutputFiles []OutputFile `json:"output_files"` // list of generated output files
}
type OutputFile struct {
Path string `json:"path"`
Hash string `json:"hash"`
Size int64 `json:"size"`
LastModified time.Time `json:"last_modified"`
ContentType string `json:"content_type"`
}
12. Differential Build Strategy
12.1 Diff Detection Mechanism
Implementation
Gohan's diff detection always uses SHA-256 content hashing, regardless of whether the directory is a Git repository. There is no os/exec-based git diff call.
// GitDiffEngine is entirely SHA-256 hash-based; it has no Git dependency.
func (g *GitDiffEngine) Detect(manifest *model.BuildManifest) (*model.ChangeSet, error) {
current, err := hashAllFiles(g.rootDir)
if err != nil {
return nil, err
}
if manifest == nil {
// First build or no manifest: treat every file as Added.
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
}
config.yaml is itself hashed on every build. When a change is detected, the cache is cleared automatically and a full rebuild runs (diff.CheckConfigChange()).
12.2 Impact Scope Calculation
Dependency Graph
type DependencyGraph struct {
nodes map[string]*Node
edges map[string][]string
}
type Node struct {
Path string
Type NodeType // NodeTypeArticle, NodeTypeTag, NodeTypeCategory, NodeTypeArchive
Dependencies []string
Dependents []string
LastModified time.Time
}
func (g *DependencyGraph) CalculateImpact(changedFiles []string) []string {
impacted := make(map[string]bool)
for _, file := range changedFiles {
g.traverseDependents(file, impacted)
}
result := make([]string, 0, len(impacted))
for k := range impacted {
result = append(result, k)
}
return result
}
Impact Scope Examples
- Update Article A → Article A, tag pages, category pages, archive pages, RSS
- Update tag master → All tag pages, related article pages, navigation
- Update templates → All pages (full build)
12.3 Cache Strategy
Cache Storage Location
.gohan/
└── cache/
└── manifest.json # file hash registry
.gohan/ is automatically generated in the project root. When .gohan/ is created on the first gohan build run, a message is displayed recommending that it be added to .gitignore.
Cache Invalidation
- File changes: Invalidate the corresponding cache when a file's SHA-256 hash changes
- Configuration changes: Clear all caches when the hash of
config.yamlchanges
13. CLI Specification
13.1 Basic Commands
build - Site Build
# Basic build (differential)
gohan build
# Full build
gohan build --full
# Specify configuration file
gohan build --config=config.production.yaml
# Specify output directory
gohan build --output=dist
# Specify parallelism
gohan build --parallel=8
# Dry run
gohan build --dry-run
new - Article Skeleton Generation
# Generate a new article (created under content/posts/)
gohan new my-first-post
# Specify a title
gohan new --title="My First Post" my-first-post
# Generate as a static page
gohan new --type=page about
serve - Local Development Server
# Start on default port (1313)
gohan serve
# Specify port
gohan serve --port=8080
# Specify host
gohan serve --host=0.0.0.0 --port=8080
13.2 Configuration Files
Global Configuration
# ~/.gohan/config.yaml
defaults:
theme: default
Project Configuration
# config.yaml
site:
title: "My Blog"
description: "A personal blog"
base_url: "https://example.com"
language: "en"
build:
content_dir: "content"
output_dir: "public"
assets_dir: "assets"
parallelism: 4
theme:
name: "default"
dir: "themes/default"
14. Development Server
14.1 Overview
A local HTTP server for development launched by gohan serve. Uses Go's standard net/http for file serving. Uses the external library fsnotify for file change detection (a development server-only dependency).
14.2 Basic Specification
// FileWatcher is the file change detection interface. The implementation uses fsnotify.
type FileWatcher interface {
Add(path string) error
Events() <-chan string
Close() error
}
type DevServer struct {
Host string
Port int
OutDir string
Watcher FileWatcher
}
func (s *DevServer) Start() error {
// Serve public/ as static files
fs := http.FileServer(http.Dir(s.OutDir))
mux := http.NewServeMux()
mux.Handle("/", fs)
return http.ListenAndServe(fmt.Sprintf("%s:%d", s.Host, s.Port), mux)
}
14.3 File Change Detection and Live Reload
- Change detection: Use
fsnotifyto watchcontent/,themes/, andassets/ - Differential build integration: Automatically run a differential build upon change detection
- Live reload: Notify the browser via SSE (Server-Sent Events) after build completion for automatic reload
- Injection method: Dynamically append a
<script>snippet to each HTML response (output files are not modified)
14.4 Operation Flow
File change
→ Detected by fsnotify
→ Run differential build
→ Notify browser via SSE
→ Browser reloads
15. Binary Distribution & Release Strategy
Automated releases using GoReleaser.
15.1 Distribution Channels
- GitHub Releases: Binaries for all platforms
- Go Install:
go install github.com/bmf-san/gohan@latest
15.2 Release Workflow
- Release notes: Automatically generated and GitHub Release created
- Automated build: GoReleaser executed via GitHub Actions
- Artifact generation: Binaries for each platform
16. Testing Strategy
16.1 Unit Tests
Target components and test perspectives:
| Component | Test Perspectives |
|---|---|
| Markdown parser | CommonMark-compliant conversion, edge cases |
| Front Matter parser | YAML parsing, errors on missing required fields |
| Dependency graph | Node addition, edge addition, accuracy of impact scope calculation |
| Template engine | Variable expansion, custom functions, error template handling |
| Diff detection | Accurate detection of modified/added/deleted files |
| Configuration loader | YAML parsing, validation, default value application |
16.2 Integration Tests
- Full build test: Verify HTML output in
public/using fixture content and templates as input - Differential build test: Confirm that only files within the impact scope are updated after rebuilding with partial file changes
- CLI test: Verify exit codes, stdout, and stderr for each subcommand
16.3 Test Fixture Structure
testdata/
├── content/
│ ├── posts/
│ │ ├── simple-post.md # Basic article
│ │ ├── post-with-tags.md # Article with tags
│ │ └── draft-post.md # Article with draft: true
│ └── pages/
│ └── about.md
├── themes/
│ └── test/
│ └── layouts/
│ └── base.html
├── config.yaml
└── expected/ # Snapshot of expected HTML output
└── public/
16.4 Coverage Goals
- Overall: 80% or higher
- Parser / renderer layer: 90% or higher (priority testing for core logic)
- Run
go test -coverin CI and fail the build if below the threshold
17–21. Feature Specifications
Detailed feature specifications are maintained as separate documents:
| Feature | Document |
|---|---|
| Pagination | docs/features/pagination.md |
| OGP Image Generation | docs/features/ogp.md |
| Plugin System | docs/features/plugin-system.md |
| GitHub Source Link | docs/features/github-source-link.md |
| i18n (Multi-language) | docs/features/i18n.md |
| Related Articles | docs/features/related-articles.md |
22. Technical Debt Management
20.1 Continuous Quality Management
- Dependency updates: dependabot automatically creates PRs on a weekly basis
- Static analysis & testing: Run
golangci-lintandgo test -race -coverin CI for every PR - Coverage threshold: Verify
go test -coverprofileresults with a script and fail CI if below 80%
20.2 Performance Monitoring
- Benchmarks: Measure processing time for parser, renderer, and differential build using
go test -bench - Regression detection: Record benchmark result comparisons against the previous run in CI and alert on significant degradation
20.3 Known Limitations
- Live reload in
gohan servedepends onfsnotify, so event detection may not work in some NFS or Docker volume environments - Git calls via
os/execdo not work in environments where Git is not installed (only affects differential builds; full builds still work)