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:

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.