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 under 500 lines.

CLI

Install Go, then run:

go install ./...

This installs the blog CLI:

usage:
  blog serve
  blog 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:

blog serve

blog serve builds each article on request. 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 all ui/public files 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])

CDN 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:

cmd := exec.Command("git", "log", "-1", "--format=%cd", "--date=format:%B %d, %Y", "--", path)
updatedOn, err := cmd.Output()

Cloudflare Pages

Create a static site on Cloudflare Pages:

The build needs the full git history for the updated dates. Use the latest Cloudflare build environment.

Commit and push to main to deploy.

← All articles