Developing this blog in Go and HTMX

Sunday 10 September 2023 ยท 34 mins read ยท Viewed 41 times

Table 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.

References ๐Ÿ”—