Developing this blog in Go and HTMX
Sunday 10 September 2023 ยท 34 mins read ยท Viewed 41 timesTable of contents ๐
Motivation ๐
I want to create, write and maintain a simple blog about personal and technical discoveries. I will self-host this blog on my Raspberry Pi cluster with Kubernetes.
This already adds some constraints:
- Self-hostable.
- Lightweight: under 64Mo of RAM usage.
- Multi-arch: ARM64 and AMD64.
- Low dependencies: for long term maintenance.
- Simplicity: simple articles, nothing special.
- Maintainable at long term.
These are the minimal constraints.
Because SSR permits partial update of the HTML documentation, I want SSR as a requirement to accelerate the rendering. Therefore, the requirements are:
- Writing and publishing articles in Markdown.
- SSR for routing.
- Index page.
State of the art and Inspiration ๐
There are a lot of existing solution to write a blog. I will only cite the solutions that are close to fill all requirements.
Docusaurus ๐
Docusaurus is a library on top of React that makes writing "content" very easy. It's lightweight, fast, has SSR and is also extensible.
However, the disadvantages are:
- Dependencies: Too many dependencies. The latest React is not even officially compatible with Docusaurus. This basically could involve maintenance hell, since I cannot assure that each dependency (especially in the npm ecosystem) that any of them will be maintained.
- React: That's my opinion, but I think React is complex for nothing. State management is hellish, component creation is conflicting (class or function?!), and its performance is unsatisfactory. React may be "cool" for people who do a lot of web development, but for me, it's just an overkill. Alternatively, SvelteKit is already an excellent replacement for React by removing the VDOM and by having a compiler to check errors.
SvelteKit or SveltePress ๐
SvelteKit is an extremely fast JS framework for web development. It shines particularly at SSR, and is "fun" to write code with. SvelteKit is more "HTML"-first than its competitor Next.js, React, etc.
SveltePress is a library on top of SvelteKit which simplifies the writing of "content" thanks to its supports for Markdown, an equivalent of Docusaurus.
Thanks to Vite, the resulting "bundle" is quite light and fast.
There's no downside to saying, and that would have been my solution. And to be honest, I'm pretty sure it would have got me to the final product a lot quicker.
Why didn't I choose this solution?
Because I don't want a framework telling me how to write my code. It's still too much for a blog.
HTML-only ๐
As with motherfuckingwebsite, HTML alone would have been a good solution. It's obviously SSR, it's readable, and it's simple. I mean, I can write HTML after all, so why not?
It's simple: Markdown is better.
Taking inspiration of my favorite blog structure: The Go dev blog. ๐
The Go Blog is a simple blog:
- There is an index, with titles, dates and authors.
- There are articles with content.
It uses the Go HTTP server to serve pages, and pages are in Markdown.
The only thing missing is SSR routing.
Hugo ๐
Hugo is a framework for building static, content-centric websites. It uses Go under the hood. It's fast, lightweight, etc... Also, why is Hugo documentation slow/weird?
Anyway, it's a pass for me.
HTMX and Go ๐
HTMX is a JS library that abstracts the use of JS to manipulate HTML documentation. It also allows the use of hypermedia as a medium between client and server. There's no need to encode/decode JSON. This is particularly interesting for SSR where latency is important.
Go is a programming language. It is compiled, has a garbage collection system, structures and is OOP-compatible. Go stands out for its standard library, which allows easy concurrency and rapid development. Go's syntax is also fairly strict and explicit, so it's easy to read other developers' code. In fact, Go is so simple that most of the time it allows only one type of solution, thus achieving the Zen of Python better than Python itself.
The reason I chose Go over C, Rust, C++, Java, ... is that static cross compilation is easy. Also, I write Go superfast and I don't have to fight with the language to choose a solution on "how I want to handle a string" (String
, char[]
, std::string
?!, give me one please!).
This is the solution I've chosen. Basically, the idea is as follows:
- I build my own HTTP server, similar to The Go Blog
- I use HTMX to change pages quickly.
- I can use some of Hugo's Markdown rendering technologies.
And bingo! I've got my blog!
Simple, isn't it? (I'm going to regret this.)
Development ๐
Proof of Concept ๐
Before I even start building a blog with HTMX, I have to certify HTMX's capabilities. This is very important because, if a technology is too young, it means it's very sensitive to breaking changes. When you're actively working on a project, disruptions can be managed quite easily. However, this blog is a side project, there are times when I simply don't maintain this project, or worse, I haven't completed the project and simply throw it in the garbage can.
Keep it simple stupid.
My PoC is to write a simple authentication page with a counter behind it. It uses Go, HTMX and OAuth2. It's more complex than a blog, but this PoC tests HTMX in depth.
Testing with OAuth2 is a good PoC because it tests:
- The UX of login flow (login, logout).
- A small state management ("is logged").
- The authorization ("count only if is logged").
The conclusion I draw from this experiment is as follows:
- HTMX is good enough for only rendering HTML. If I had an Android application, I'd still need to use JSON or Protobuf.
- Error handling with HTMX is not that simple. Some say it's just different, which I can agree.
- HTMX is ready for production.
Which means it's great for writing a blog!
Architecture ๐
Content directory ๐
I followed the SvelteKit/Sveltepress directory structure. It's battle-tested, and sufficiently explicit.
Basically, there is a pages
directory which accepts .md
files and .svelte
files. .md
with front matter are converted in html using a Markdown rendering engine.
Templates and Components ๐
With Go, templates can be named. With this, we can define components inside the components
directory. These templates are passed to the template engine.
There is also the base.html
and base.htmx
templates for the initial request and SSR request with HTMX.
The final template is markdown.tmpl
, which is used with the Markdown renderer to arrange the page. It essentially surrounds the output of the Markdown renderer.
Static directory ๐
Like any HTTP server, there is a static
directory where we can store unprocessed assets like images, icons, etc. This is served like any Go file server.
Implementation ๐
Initial Request and Server-Side rendering ๐
The initial request happens when using the user uses the URL to seek a page. The initial request must load the HTMX library and application CSS. To do this, I've created a base.html
template.
base.html
1{{ define "base" }}
2<!DOCTYPE html>
3<html lang="en">
4 <head>
5 <meta hx-preserve="true" charset="UTF-8" />
6 <meta
7 hx-preserve="true"
8 name="viewport"
9 content="width=device-width, initial-scale=1"
10 />
11 <script hx-preserve="true" src="https://unpkg.com/htmx.org@1.9.5"></script>
12 <script
13 hx-preserve="true"
14 src="https://unpkg.com/htmx.org@1.9.5/dist/ext/head-support.js"
15 ></script>
16 <link
17 hx-preserve="true"
18 rel="stylesheet"
19 href="https://cdn.jsdelivr.net/npm/@picocss/pico@next/css/pico.classless.min.css"
20 />
21 <link hx-preserve="true" rel="stylesheet" href="/static/app.css" />
22 {{ template "head" . }}
23 </head>
24 <body hx-ext="head-support" hx-boost="true">
25 {{ template "body" . }}
26 </body>
27</html>
28{{ end }}
This is very similar to SvelteKit's app.html
.
Pages in the pages
directory define the head
and body
template. Therefore, when reaching any URL, the template will be rendered base on the path
When the user has loaded the initial page, clicking on any a
element will make a HTMX request to server thanks to hx-boost
.
The hx-boost
attribute will replace the body
when the user clicks on a a
element. I've also added the head-support
for HTMX so that we can also replace head
when doing SSR. This is used to dynamically change the title
and CSS.
Example:
1<div hx-boost="true">
2 <a href="/page1">Go To Page 1</a>
3 <a href="/page2">Go To Page 2</a>
4</div>
Clicking on Go To Page 1
will make a HTMX request to the server GET /page1
with the HTTP Header Hx-Boosted: true
. The response of the server will replace the entire body
element.
Thanks to the header Hx-Boosted: true
, the server can identify if the client is doing an initial request or an SSR request:
main.go
1r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
2 var base string
3 if r.Header.Get("Hx-Boosted") != "true" {
4 // Initial Rendering
5 base = "base.html"
6 } else {
7 // SSR
8 base = "base.htmx"
9 }
10
11 // TODO: render template
12})
The base.htmx
is just simply the body
and head
.
base.htmx
1{{ define "base" }}
2<head>
3 {{ template "head" . }}
4</head>
5
6{{ template "body" . }} {{ end }}
Markdown rendering ๐
Like Hugo, we will use the Goldmark markdown rendering engine, coupled with chroma for syntax highlighting:
Example:
1var cssBuffer strings.Builder
2markdown := goldmark.New(
3 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
4 goldmark.WithExtensions(
5 highlighting.NewHighlighting(
6 highlighting.WithStyle("onedark"),
7 highlighting.WithCSSWriter(&cssBuffer),
8 highlighting.WithFormatOptions(
9 chromahtml.WithLineNumbers(true),
10 chromahtml.WithClasses(true),
11 ),
12 ),
13 extension.GFM,
14 meta.Meta,
15 &anchor.Extender{},
16 ),
17)
18
19var out strings.Builder
20ctx := parser.NewContext()
21err := markdown.Convert(content, &out, parser.WithContext(ctx))
The resulting string can be passed as the body:
1if err := t.Execute(w, struct {
2 Style string
3 Body string
4}{
5 Style: cssBuffer.String(),
6 Body: out.String(),
7}); err != nil {
8 log.Fatal().Err(err).Msg("generate file from template failure")
9}
Compile-time rendering ๐
Since a blog is primarily static, I want to render the markdown and index page at compile-time.
Go doesn't have any comptime
like Zig, but we can at least use go generate
.
The idea is to create a Go "script" which can be executed by go generate
.
build.go:
1//go:build build
2
3package main
4
5func main() {
6 // TODO: compile time stuff
7 processPages()
8 index.Generate()
9}
main.go:
1//go:generate go run -tags build build.go
2
3package main
4
5func main() {
6 // TODO: runtime time stuff
7}
The processPages
function executes the markdown rendering and outputs the resulting files in the gen/
directory.
Example: pages/blog/2023-09-09-hello-world/page.md
โถ gen/pages/blog/2023-09-09-hello-world/page.tmpl
.
As I said earlier, the HTTP server uses the body
and head
templates defined in the gen/pages/blog/2023-09-09-hello-world/page.tmpl
for the initial request or SSR. This is how we can render the body
and head
templates with the base
template.
main.go
1//go:embed gen components base.html base.htmx
2var html embed.FS
3
4// ... in the main function
5r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
6 // ...
7 // base = "base.html" or "base.htmx"
8 path := filepath.Clean(r.URL.Path)
9 // TODO: check if it's page or an asset
10 path = filepath.Clean(fmt.Sprintf("gen/pages/%s/page.tmpl", path))
11
12 t, err := template.New("base").
13 Funcs(sprig.TxtFuncMap()).
14 ParseFS(html, base, path, "components/*")
15 if err != nil {
16 if strings.Contains(err.Error(), "no files") {
17 http.Error(w, "not found", http.StatusNotFound)
18 } else {
19 log.Err(err).Msg("template error")
20 http.Error(w, err.Error(), http.StatusNotFound)
21 }
22 return
23 }
24 if err := t.ExecuteTemplate(w, "base", struct {}{}); err != nil {
25 http.Error(w, err.Error(), http.StatusInternalServerError)
26 }
27})
Index page and pagination ๐
This page is also generated at compile-time.
Therefore, at compile-time, we need a function that fetches the list of blog pages, and group them.
index.go
1func buildPages() (index [][]Index, err error) {
2 // Read the blog pages
3 entries, err := os.ReadDir("gen/pages/blog")
4 if err != nil {
5 return index, err
6 }
7
8 // Sort the pages in reverse order
9 sort.SliceStable(entries, func(i, j int) bool {
10 return entries[i].Name() > entries[i].Name()
11 })
12
13 // Markdown Parser
14 markdown := goldmark.New(
15 goldmark.WithExtensions(
16 meta.New(
17 meta.WithStoresInDocument(),
18 ),
19 ),
20 )
21
22 index = make([][]Index, 0, len(entries)/elementPerPage+1)
23 i := 0
24 for _, entry := range entries {
25 // Should be a dir
26 if !entry.IsDir() {
27 continue
28 }
29 page := i / elementPerPage
30 if page >= len(index) {
31 index = append(index, make([]Index, 0, elementPerPage))
32 }
33 // Should contains a page.md
34 f, err := os.Open(filepath.Join("pages/blog", entry.Name(), "page.md"))
35 if err != nil {
36 log.Debug().
37 Err(err).
38 Str("entry", entry.Name()).
39 Msg("ignored for index, failed to open page.md")
40 continue
41 }
42 finfo, err := f.Stat()
43 if err != nil {
44 log.Debug().
45 Err(err).
46 Str("entry", entry.Name()).
47 Msg("ignored for index, failed to stat page.md")
48 continue
49 }
50 if finfo.IsDir() {
51 continue
52 }
53
54 // Fetch metadata
55 b, err := io.ReadAll(f)
56 if err != nil {
57 log.Fatal().Err(err).Msg("read file failure")
58 }
59 document := markdown.Parser().Parse(text.NewReader(b))
60 metaData := document.OwnerDocument().Meta()
61
62 index[page] = append(index[page], Index{
63 EntryName: entry.Name(),
64 Title: fmt.Sprintf("%v", metaData["title"]),
65 Description: fmt.Sprintf("%v", metaData["description"]),
66 PublishedDate: date.Format("Monday 02 January 2006"),
67 Href: filepath.Join("/blog", entry.Name()),
68 })
69 i++
70 }
71
72 return index, nil
73}
Then, we generate a .go
that contains the index:
index.tmpl
1{{define "index"}}
2package index
3
4type Index struct {
5 Title string
6 Description string
7 Href string
8 EntryName string
9}
10
11const PageSize = {{ .PageSize }}
12
13var Pages = [][]Index{
14 {{- range $page := .Pages}}
15 {
16 {{- range $i, $value := $page}}
17 {
18 EntryName: {{ $value.EntryName | quote }},
19 Title: {{ $value.Title | quote }},
20 Description: {{ $value.Description | quote }},
21 Href: {{ $value.Href | quote }},
22 },
23 {{- end}}
24 },
25 {{- end}}
26}
27{{- end}}
28
29
index.go (used to render the template)
1func Generate() {
2 pages, err := buildPages()
3 if err != nil {
4 log.Fatal().Err(err).Msg("index failure")
5 }
6
7 out := "gen/index/index.go"
8 if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
9 log.Fatal().Err(err).Msg("mkdir failure")
10 }
11
12 func() {
13 f, err := os.Create(out)
14 if err != nil {
15 log.Fatal().Err(err).Msg("generate file from template failure")
16 }
17 defer f.Close()
18
19 var buf bytes.Buffer
20 t := template.Must(
21 template.New("index").
22 Funcs(sprig.TxtFuncMap()).
23 ParseFS(indexTmpl, "templates/index.tmpl"),
24 )
25 if err := t.ExecuteTemplate(&buf, "index", struct {
26 Pages [][]Index
27 PageSize int
28 }{
29 Pages: pages,
30 PageSize: len(pages),
31 }); err != nil {
32 log.Fatal().Err(err).Msg("template failure")
33 }
34
35 formatted, err := format.Source(buf.Bytes())
36 if err != nil {
37 fmt.Println(buf.String())
38 log.Fatal().Err(err).Msg("format code from template failure")
39 }
40
41 if _, err = f.Write(formatted); err != nil {
42 log.Fatal().Err(err).Msg("write failure")
43 }
44 }()
45}
46
Conclusion ๐
To summary, when I write a page of the blog, I create a page.md
file in the pages/blog/<date-url>/
directory like SveltePress. Then I compile the pages into html by running go generate
which go run build.go
. The build.go
script generates the html pages from markdown, and also creates the gen/index/index.go
file which contains the necessary data for the index page.
After compiling the pages, the server can run and accepts two type of request: the initial request and the SSR request with HTMX. Based on the request, we can create a router which executes SSR and allow quick rendering without the need to redownload everything (like the HTMX library, CSS, and other "app"-time assets...).
With the implementation of these features, my blog has reached the minimum viable product. Missing from this article are discussions on:
- The Pager.
- Escaping
{{
to avoid weird behavior with the templating engine. - CSS, which is just PicoCSS.
Was it worth it? Hell yeah, I control everything in this blog, and most problems are indicated at compile time. With HTMX, PicoCSS and Goldmark, I'm no longer writing HTML, CSS and JS to write an article, how crazy is that?
How difficult was it? It's not really "hard", but there are certainly more steps than bootstrapping SveltePress or Docusaurus. It's also more prone to bugs than an established product like SveltePress or Docusaurus.
Can it be a library like Hugo? No, I'm not ready for that.
Anyway, this is like installing Gentoo: it's for learning, having full-control and being ultra-optimized.