cmd / blog

I edit this blog using a simple Go static site generator co-located with the articles. The whole thing is one main.go under 500 lines.

CLI

Install Go, then run:

go install ./...

This installs a blog command-line program:

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

Markdown headings automatically get IDs for deep linking. Clicking any <h2> navigates to its anchor.

Preview at http://localhost:2000 with:

blog serve

Articles are built on-demand when accessed during development. Requests are logged with timing by a small middleware:

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)
	})
}

which prints:

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

Add images to the images directory. Refer to them in articles:

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

Modify UI

All ui/public files are copied to public.

The pages are hml templates. ui/article.hml is rendered 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 the markdown is already rendered and highlighted in Go. Every other local is escaped.

Each page is parsed once and rendered per article. = render "header" resolves to ui/_header.hml, which reads its caller's locals.

The ui/index.hml template is static. It is up to the author to decide how to lay out their index and link to their articles.

CSS files are fingerprinted with an MD5 hash during production builds for cache-busting:

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

This is the build-time version of the technique in CDN, which does the same hashing in-process for a long-running Go server.

How it works

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

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 during parsing, so the same Markdown works in a feed or preview:

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, and styles the emitted classes in ui/css/site.css.

blog build renders every article concurrently. blog serve builds each article on demand as it is requested.

The "updated" date on each article comes from git log, not front matter:

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

Use the latest Cloudflare build environment.

To deploy the site, commit and push to the main branch of the GitHub repo.

View deploy logs in the Cloudflare web interface.

← All articles