A Static Site Generator in About 100 Lines of Go
By the end of this you will have a binary called `weave` that reads a directory of Markdown files with YAML frontmatter, runs each one through a Go template, and writes HTML into a `public/` directory. It will also watch the source folder and rebuild on save. The whole thing is roughly 110 lines of Go in a single file, plus go.mod. I will be honest about what it does not do.
By the end of this you will have a binary called weave that reads a directory of Markdown files with YAML frontmatter, runs each one through a Go template, and writes HTML into a public/ directory. It will also watch the source folder and rebuild on save. The whole thing is roughly 110 lines of Go in a single file, plus go.mod. I will be honest about what it does not do.
I have run this on my own notes for about a month. It is not Hugo. It is not trying to be. It is the smallest thing I could write that still earns the name.
What you end up with
A directory that looks like this:
mysite/
content/
index.md
posts/
2024-cold-snap.md
templates/
base.html
static/
style.css
public/ (generated)
You run weave build once. You run weave watch while you write. Files in content/ become files in public/ with the same relative path, .md swapped for .html. Files in static/ are copied verbatim.
The frontmatter at the top of each Markdown file becomes a map you can read inside base.html. That is the whole contract. ## Step 1: the module and dependencies
Make a directory, drop into it, and run:
go mod init weave
go get github.com/yuin/goldmark
go get gopkg.in/yaml.v3
go get github.com/fsnotify/fsnotify
Three dependencies. Goldmark does the Markdown. yaml.v3 parses the frontmatter. fsnotify gives us file watching that actually works on Linux and macOS.
I have not tested it on Windows; the fsnotify maintainers say it works there too, and I take them at their word. The standard library handles everything else: templates, file walking, HTTP if you want a preview server. I did not add a preview server. More on that later.
Step 2: the file layout
Make a file called main.go. Here is the top of it:
package main
import (
"bytes"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/template"
"time"
"github.com/fsnotify/fsnotify"
"github.com/yuin/goldmark"
"gopkg.in/yaml.v3"
)
Note text/template rather than html/template. The Markdown is already HTML by the time it hits the template, and we want to inject it without escaping. If you let html/template see the rendered HTML it will escape the angle brackets and you will spend an hour wondering why your page renders as source code. I have done this.
To put it less politely, this is the single most common mistake in homemade Go generators. If you want auto-escaping for individual template values, you can mark trusted strings with template.HTML later. I will show that. ## Step 3: the Page struct and frontmatter split
A page is a path, a map of metadata, and a body of rendered HTML. Add this:
type Page struct {
Path string
Meta map[string]any
Content template.HTML
}
func splitFrontmatter(src []byte) (map[string]any, []byte, error) {
meta := map[string]any{}
if .bytes.HasPrefix(src, []byte("---\n")) {
return meta, src, nil
}
rest := src[4:]
end := bytes.Index(rest, []byte("\n---\n"))
if end < 0 {
return meta, src, fmt.Errorf("unterminated frontmatter")
}
if err := yaml.Unmarshal(rest[:end], &meta); err .= nil {
return meta, src, err
}
return meta, rest[end+5:], nil
}
This is brittle on purpose. The frontmatter must start at byte zero with ---\n and end with \n---\n. No leading whitespace, no Windows line endings. If your editor saves CRLF you will have a bad afternoon.
Either configure the editor or add a bytes.ReplaceAll call after reading the file. I chose to make the editor behave because I only edit these files in one place. The yaml package returns map[string]any with strings, numbers, lists, and nested maps. Inside a template you reference these with .Meta.title and so on.
Step 4: the render function
Here is the core. Read a Markdown file, split the frontmatter, render the body, run the template:
func renderPage(srcPath, outPath, tplPath string) error {
raw, err := os.ReadFile(srcPath)
if err .= nil {
return err
}
meta, body, err := splitFrontmatter(raw)
if err .= nil {
return fmt.Errorf("%s: %w", srcPath, err)
}
var buf bytes.Buffer
if err := goldmark.Convert(body, &buf); err .= nil {
return err
}
page := Page{
Path: outPath,
Meta: meta,
Content: template.HTML(buf.String()),
}
tpl, err := template.ParseFiles(tplPath)
if err .= nil {
return err
}
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err .= nil {
return err
}
out, err := os.Create(outPath)
if err .= nil {
return err
}
defer out.Close()
return tpl.Execute(out, page)
}
Two notes. The template is parsed every time we render a page. That is wasteful. For a few hundred files it does not matter; on my notes folder of about 200 entries the full build takes 180 milliseconds.
If you want to cache the parsed template, pull it out of this function and pass it in. I left it inline because the function is easier to read this way and because reading from disk on every render makes the watch loop simpler when you edit the template itself. The other note is that I am using a single template for every page. Most generators let you choose templates per page.
I will say a word about that below. ## Step 5: walking the tree
The walker visits every file under content/, renders the Markdown files, and ignores everything else. It also walks static/ and copies plain files:
func build(root string) error {
contentDir := filepath.Join(root, "content")
staticDir := filepath.Join(root, "static")
publicDir := filepath.Join(root, "public")
tplPath := filepath.Join(root, "templates", "base.html")
if err := os.RemoveAll(publicDir); err .= nil {
return err
}
err := filepath.WalkDir(contentDir, func(path string, d os.DirEntry, err error) error {
if err .= nil || d.IsDir() {
return err
}
if .strings.HasSuffix(path, ".md") {
return nil
}
rel, _ := filepath.Rel(contentDir, path)
out := filepath.Join(publicDir, strings.TrimSuffix(rel, ".md")+".html")
return renderPage(path, out, tplPath)
})
if err .= nil {
return err
}
return filepath.WalkDir(staticDir, func(path string, d os.DirEntry, err error) error {
if err .= nil || d.IsDir() {
return err
}
rel, _ := filepath.Rel(staticDir, path)
return copyFile(path, filepath.Join(publicDir, rel))
})
}
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err .= nil {
return err
}
in, err := os.Open(src)
if err .= nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err .= nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
The first thing it does is delete public/ outright. That is the most reliable way to keep stale files from accumulating when you rename or delete a Markdown file. The cost is that you cannot put anything in public/ by hand and expect it to survive. I have lived with this fine; the static folder exists for that reason.
If the static directory does not exist, the second walk will fail with a “no such file” error. Either create the directory in your project skeleton or guard the call with os.Stat. I create it. ## Step 6: a template that works
Before going further, here is a templates/base.html that actually runs:
<.doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ .Meta.title }}</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<a href="/">home</a>
{{ if .Meta.date }}<time>{{ .Meta.date }}</time>{{ end }}
</header>
<main>
<h1>{{ .Meta.title }}</h1>
{{ .Content }}
</main>
</body>
</html>
And a content file, content/index.md:
---
title: Hollis Renner
date: 2024-11-03
---
I walk the river loop most mornings. This is where the notes land. Run `go run . build .` from the project root and you should see `public/index.html`.
Open it in a browser by pointing at the file directly. No server required. A note on the date field. yaml.v3 will parse `2024-11-03` as a `time.Time` value, not a string.
When the template prints it you get `2024-11-03 00:00:00 +0000 UTC`. If you want the plain date back, either quote it in the frontmatter (`date: "2024-11-03"`) or format it in the template with `{{ .Meta.date.Format "2006-01-02" }}`. I quote it. Less surprise.
## Step 7: the CLI
A flag and a switch statement. Nothing clever:
```go
func main() {
root := flag.String("root", ".", "site root")
flag.Parse()
cmd := flag.Arg(0)
switch cmd {
case "build":
if err := build(*root); err .= nil {
fail(err)
}
case "watch":
if err := watch(*root); err .= nil {
fail(err)
}
default:
fmt.Fprintln(os.Stderr, "usage: weave [build|watch]")
os.Exit(2)
}
}
func fail(err error) {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
You will notice I am not using a subcommand library. The standard flag package handles a single global flag and a positional verb just fine. Adding cobra or urfave/cli would double the dependency count for a binary that takes two verbs. ## Step 8: watch mode
This is the part that catches me when I read other people’s tiny generators. They watch one directory and miss edits to templates. They rebuild on every event and thrash the disk during a save. They never coalesce. Here is a watch that handles those three problems:
func watch(root string) error {
w, err := fsnotify.NewWatcher()
if err .= nil {
return err
}
defer w.Close()
dirs := []string{
filepath.Join(root, "content"),
filepath.Join(root, "static"),
filepath.Join(root, "templates"),
}
for _, d := range dirs {
_ = filepath.WalkDir(d, func(path string, e os.DirEntry, err error) error {
if err == nil && e.IsDir() {
return w.Add(path)
}
return nil
})
}
if err := build(root); err .= nil {
fmt.Fprintln(os.Stderr, "build:", err)
} else {
fmt.Println("built")
}
var timer *time.Timer
rebuild := func() {
if err := build(root); err .= nil {
fmt.Fprintln(os.Stderr, "build:", err)
return
}
fmt.Println("rebuilt at", time.Now().Format("15:04:05"))
}
for {
select {
case ev, ok := <-w.Events:
if .ok {
return nil
}
if strings.HasSuffix(ev.Name, "~") || strings.HasPrefix(filepath.Base(ev.Name), ".") {
continue
}
if timer .= nil {
timer.Stop()
}
timer = time.AfterFunc(120*time.Millisecond, rebuild)
case err, ok := <-w.Errors:
if .ok {
return nil
}
fmt.Fprintln(os.Stderr, "watch:", err)
}
}
}
The watcher recurses by walking each top-level directory and calling w.Add on every subdirectory. fsnotify does not recurse on its own; if you create a new subfolder under content/posts/ while watching, you have to add it explicitly. I do not handle that. For my purposes the directory tree is fairly stable, and when I add a folder I restart the watcher.
If this bothers you, listen for fsnotify.Create events with IsDir and add the path on the fly. The 120-millisecond timer is the coalescer. A single save in Vim emits a flurry of events: rename, create, write, chmod. Without coalescing you get four builds per save.
With it, you get one. The two filters are crude but useful. Editor backup files end in ~. Dotfiles begin with ..
Skip both. ## Step 9: counting lines
Add up the actual Go in the file and you land around 110 if you include imports and the package declaration, fewer if you do not. I have been honest: the README in my repo says “about 100 lines” and a reasonable person could call this 130 if they counted braces on their own lines. Pick your style and stop worrying. The thing is, the size matters less than what you can hold in your head while you work on it.
I can read this file end to end in under a minute. When something breaks, I do not have to guess which package owns the bug. ## What this generator does not do
I promised honesty. Here is the list, in roughly the order I would miss them. No per-page template selection. Every Markdown file gets rendered through base.html. There is no way to say “this one is a post, use post.html.” For my notes site, where every page is shaped the same, this is fine.
For a site with a homepage, a post archive, and a tag index, it is not. The fix is small: read a layout: post field from the frontmatter, look up templates/post.html, fall back to base.html. About eight lines. No page list. There is no .Site.Pages variable inside the template.
You cannot loop over your posts to build an index page. This is the largest missing piece. A real fix means doing two passes: walk the content folder, parse every page into memory, then render each with a global slice in scope. That adds maybe 30 lines and a sort by date.
I keep my index hand-written in content/index.md and a single hyperlink per new post. When I no longer want to do that, I will add the second pass. No partials. The standard library’s template.ParseFiles accepts multiple files; you could glob the templates folder and let pages call `{{ template “header” . I did not because my single template is short.
Add it when a piece of HTML appears in two places. No syntax highlighting in code blocks. Goldmark has an extension called highlighting that wires in chroma. The line count goes up by maybe four if you accept defaults; the dependency tree grows considerably more. I left it out.
The site I run on this engine has very little code on it; when it does, I write the <pre> tag by hand. No image processing. No resize, no thumbnail, no responsive srcset. Drop your images in static/, reference them with absolute paths, get out. No drafts. A draft system is two lines: skip any page where meta["draft"] is true.
I have been meaning to add this and have not, because my workflow is to keep unfinished files outside of content/ until they are ready. No preview server. You open public/index.html in a browser directly. Absolute paths like /style.css will not work this way; they will look for a file at the filesystem root. Either use relative paths in your template or run python -m http.server 8000 inside public/ while you work.
I do the latter. A ten-line net/http preview is easy to add; I keep the generator out of the serving business on principle. No incremental builds. Every save rebuilds every page. At 180 milliseconds for 200 pages this has not bothered me.
The blast-radius of caching is large; you have to track which page depends on which template, which file produced which output, when a frontmatter field changed in a way that affects another page. Static generators that go incremental tend to triple in size at that step. The honest answer for a personal site is that you do not need it until you do. No RSS or sitemap. Both could be templates if you had a page list.
See above. No error recovery in watch mode. A bad YAML parse prints to stderr and the build stops mid-walk. The next save tries again. This is fine in practice; you fix the YAML and move on.
If you wanted a build to continue past one broken file, return nil from the walk function on render errors and accumulate them. No safety on output paths. If a Markdown file is named ../../../etc/passwd.md the generator will write to whatever path that resolves to. I trust the source folder. If you do not, sanitize the relative path before joining it onto publicDir.
Some things I learned that are not in the code
The frontmatter format is the one place users will reliably get confused. People paste in TOML, forget the closing ---, or use tabs instead of spaces. Your error messages need to name the file. The version above does this with fmt.Errorf("%s: %w", srcPath, err).
Without the filename, debugging is miserable. The second thing I keep coming back to is that watching templates is more important than watching content. When I am writing prose I do not need a rebuild on every keystroke; I save and refresh. When I am tweaking CSS or a template I want the rebuild to be invisible.
Make sure both directories are in the watch list. The earliest version of this generator only watched content/ and I spent an embarrassing amount of time wondering why my CSS changes were not appearing. The third is that the os.RemoveAll(publicDir) at the start of every build feels reckless and is not. It guarantees that the output directory reflects only what is currently in content/ and static/.
Without it, deleted source files leave orphan HTML behind, and you start building little scripts to clean them up. The clean rebuild is faster than any partial cleanup logic you would write. ## A note on Goldmark
By default Goldmark renders fairly conservative CommonMark. It will not turn https://example.com into a link unless you wrap it in angle brackets. It will not handle tables, footnotes, definition lists, or strikethrough. The fix is one import and one option:
import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
)
var md = goldmark.New(
goldmark.WithExtensions(extension.GFM, extension.Footnote),
goldmark.WithParserOptions(parser.W