cmd / site

I build this site 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 Go, then run:

go install ./...

This installs the site CLI:

usage:
  site serve
  site build

It expects a file 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:

site 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/site
 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. site build renders every article concurrently.

The "updated" date comes from git log. site 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. So the first date it prints for a path is the date of that file's last commit, and updatedDates keeps only that one. Before, the build ran one git log -1 for each article, and each process walked the full history. One process cut a local build from 3.5 to 1.4 seconds.

site serve builds one article for each request, so it still runs git log -1 for that article alone.

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