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:

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:
- Repository:
https://github.com/croaky/blog - Production branch:
main - Build command:
git fetch --unshallow && go run main.go build - Build output directory:
public
The build needs the full git history for the updated dates. Use the latest Cloudflare build environment.
Commit and push to main to deploy.