cmd / blog

I build this blog with a Go static site generator in the same repo as the articles. The program is one main.go of about 500 lines.

CLI

Install the blog CLI:

go install ./...

The CLI provides two subcommands:

usage:
  blog serve
  blog build

It expects a directory layout like this:

.
├── articles
│   └── example.md
└── ui
    ├── _header.hml
    ├── article.hml
    ├── css
    │   └── site.css
    ├── font
    │   ├── et-book-bold-line-figures.woff
    │   ├── et-book-display-italic-old-style-figures.woff
    │   ├── et-book-roman-line-figures.woff
    │   ├── et-book-roman-old-style-figures.woff
    │   └── et-book-semi-bold-old-style-figures.woff
    ├── images
    │   └── favicon.ico
    └── index.hml

Write

Edit articles/example.md. It is a GitHub-Flavored Markdown file with no front matter.

The first line of the file is the article title. It must be an <h1> tag:

# Example Article

Each heading gets an ID for deep linking.

Preview at http://localhost:2000 with:

blog serve

A middleware logs each request with its timing:

func loghttp(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rw := &responseWriter{w, http.StatusOK}
		next.ServeHTTP(rw, r)

		fmt.Printf("%8.1fms %d %s %s\n",
			float64(time.Since(start).Nanoseconds())/1e6,
			rw.statusCode,
			r.Method,
			r.URL.Path)
	})
}

It prints:

32.1ms 200 GET /cmd/blog
 0.0ms 404 GET /.well-known/appspecific/com.chrome.devtools.json

Add images to ui/images/. Refer to them in articles:

![alt text](/images/example.png)

Modify UI

The build copies ui/images, ui/font, and ui/css to public.

The pages are hml templates. The generator renders ui/article.hml with locals like these:

map[string]any{
	"title":      "Example Article",
	"updated_on": "April 15, 2018", // from git log
	"body":       hml.SafeString("<p>Hello, world.</p>"),
	"css_path":   "/css/site-a1b2c3d4.css", // fingerprinted in production
}

body is hml.SafeString because Go already rendered the markdown. The template escapes every other local.

The generator parses each template once and renders it per article. = render "header" resolves to ui/_header.hml, which reads its caller's locals. ui/index.hml is static.

A production build fingerprints CSS with an MD5 hash:

hash := fmt.Sprintf("%x", md5.Sum(content))
fpName := fmt.Sprintf("site-%s.css", hash[:8])

The CDN article does the same hashing in-process for a long-running Go server.

How it works

Syntax highlighting runs at build time. A goldmark renderer hands each code block to highlight, which emits CSS classes:

func (r *codeBlockRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) {
	if !entering {
		return gast.WalkContinue, nil
	}
	codeBlock := node.(*gast.FencedCodeBlock)
	lang := strings.TrimSpace(string(codeBlock.Language(source)))
	syntaxHighlight(w, extractCode(source, codeBlock.Lines()), lang)
	return gast.WalkSkipChildren, nil
}

An AST transformer rewrites relative links and images to absolute URLs, so the same Markdown works in a feed:

func (t *absoluteURLTransformer) Transform(node *gast.Document, reader text.Reader, pc parser.Context) {
	_ = gast.Walk(node, func(node gast.Node, entering bool) (gast.WalkStatus, error) {
		if !entering {
			return gast.WalkContinue, nil
		}
		switch node := node.(type) {
		case *gast.Link:
			node.Destination = prefixRelativeURL(node.Destination, t.prefix)
		case *gast.Image:
			node.Destination = prefixRelativeURL(node.Destination, t.prefix)
		}
		return gast.WalkContinue, nil
	})
}

main.go writes the surrounding pre and code. ui/css/site.css styles the classes. blog build renders every article concurrently.

The "updated" date comes from git log. blog build reads every date with one git log process over articles/:

cmd := exec.Command("git", "-c", "core.quotePath=false", "log",
	"--format=date %cd", "--date=format:%B %d, %Y",
	"--name-only", "--relative", "--", ".")
cmd.Dir = dir

git log lists the newest commit first. The first date it prints for a path is the date of that file's last commit, and updatedDates stores that date.

blog serve builds one article for each request, so it runs git log -1 for that single file.

Deploy

The repo lives on sockeye. I push a branch as a change and merge it when its checks pass. The merge runs the repo's deploy hook on the server. The hook clones the repo at the merged commit, runs go run main.go build, and moves a symlink to the new public directory. Caddy serves that directory behind Cloudflare.

The hook makes a full clone, not a work-tree checkout. The build reads the updated dates from git log, so it needs the whole history.

← All articles