Author: Seth
A few years back I registered a handful of domains with the intention of throwing a hacked together site on, but never really had time to do it. During the holidays I managed to set aside some time to finally whip something up and I figured there may be some value in capturing my thoughts as I put things together from scratch. Honestly, this could be done with a Hugo in minutes, but I'm a fan of building small things from scratch to dust off some cobwebs. Also, sometimes it's fun to write things from scratch when you have some spare time 🤷.
The only things I knew I wanted to include was some kind of markdown rendered "posts" (for writings like this) and possibly some way to link to public contributions and/or my background. Pretty much everything I need can be done with the Go standard library, but I am in no position to write any kind of markdown rendering libraries so I grabbed goldmark for markdown parsing and rendering because of it's capability, simplicity, and extensions.
I also wanted this to look... okay? I don't touch much client side anything these days; I chose Tailwind as a CSS framework because it has a very nice developer workflow for html only pages.
I wanted to minimize the rabbit holes (ironically sending me down more) so I had a few "guiding principles" to keep things on track:
* I think there's a lot of discussion to be had here and I think to scale this, I'd probably change my tune. For my purposes, at least right now, this is totally fine.
Everything will render server side and, as I pointed out earlier, the intention is to bundle as much into the binary as possible. There's three ingredients:
/static
; css, favicon, possibly images, etc.)/templates
)/posts
).
|____go.mod
|____posts
| |____post1.md
| |____post2.md
|____tailwind.config.js
|____static
| |____favicon.svg
|____templates
| |____posts.tmpl
| |____post.tmpl
| |____layout
| | |____root.tmpl
| | |____foot.tmpl
| | |____nav.tmpl
| |____index.tmpl
| |____input.css
| |____error.tmpl
|____main.go
The template
directory structure outlines how I plan to render pages; it has all page templates and the layout
subdirectory has reusable "components" that make up parts of the page (core layout, navigation heading, and footer).
Initially I was using the "Play CDN" to make sure everything worked as I expected, but as noted in the documentation this isn't suitable for production use. I opted to compile the css using the standalone Tailwind CLI.
1# install the CLI and initialize the config
2❯ brew install tailwindcss
3❯ tailwindcss init
4#
5# Created Tailwind CSS config file: tailwind.config.js
To start I need to setup the config to point at the specific templates I'm compiling and add the necessary plugins.
1// tailwind.config.js
2/** @type {import('tailwindcss').Config} */
3module.exports = {
4 content: ["./templates/**/*.tmpl"],
5 theme: {
6 extend: {
7 typography: {
8 DEFAULT: {
9 css: {
10 'code::before': {
11 content: '""',
12 },
13 'code::after': {
14 content: '""',
15 },
16 },
17 },
18 },
19 },
20 },
21 plugins: [
22 require('@tailwindcss/typography'),
23 ],
24}
25
Breaking down what this does:
content
theme.extend.typography
`content`
) for inline code snippetsplugins
Lastly, I just need to add the tailwind css directives to my main CSS style sheets and compile it. In this case the input style sheets is only the tailwind directives since I don't have anything custom
1/* templates/input.css */
2@tailwind base;
3@tailwind components;
4@tailwind utilities;
To compile the css I target the static
directory to make sure my fileserver handler
can serve it
1❯ tailwindcss -i ./templates/input.css -o ./static/output.css --minify
2
3# Rebuilding...
4#
5# Done in 166ms.
note while I was writing page templates, I was also passing the --watch
argument to recompile the css to make
sure anything new I may have added was compiled into the output styles.
In the interest of keeping things fairly simple for this I've stripped the templates down to the bare minimum for styling and content rendering. The idea being that if someone were to follow along they could tweak things as needed.
1{{/* core.tmpl */}}
2{{define "core"}}
3<!DOCTYPE html>
4<html lang="en">
5 <head>
6 <meta charset="UTF-8" />
7 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8 <link rel="icon" type="image/x-icon" href="/static/favicon.svg">
9 <link href="/static/output.css" rel="stylesheet">
10 <title>{{block "title" .}}Page Title{{end}}</title>
11 </head>
12 <body class="flex flex-col min-h-screen">
13 {{ template "nav" }}
14 <div class="container mx-auto">
15 {{ block "content" .}}
16 <div>Page content</div>
17 {{end}}
18 </div>
19 </body>
20 {{ template "foot" }}
21</html>
22{{ end }}
23
24{{/* nav.tmpl */}}
25{{define "nav"}}
26 <nav>
27 <div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
28 <a href="/">
29 <span class="text-xl">My Site</span>
30 </a>
31 <div class="block w-auto">
32 <ul
33 class="font-medium flex flex-row p-0 space-x-8 mt-0"
34 >
35 <li>
36 <a
37 href="/"
38 class="block font-normal text-gray-500 hover:text-gray-300 p-0"
39 >Index</a
40 >
41 </li>
42 <li>
43 <a
44 href="/posts"
45 class="block font-normal text-gray-500 hover:text-gray-300 p-0"
46 >Posts</a
47 >
48 </li>
49 </ul>
50 </div>
51 </div>
52 </nav>
53{{end}}
54
55{{/* foot.tmpl */}}
56{{define "foot"}}
57 <footer class="container mt-auto mx-auto">
58 <div class="flow-root mt-4">
59 <span class="float-left text-sm">
60 © Copy-foot
61 </span>
62 <span class="float-right py-1">
63 <a href="https://example.com" target="_blank">
64 <span class="text-sm">🔗</span>
65 </a>
66 </span>
67 </div>
68 </footer>
69{{end}}
With these template definitions any page can be rendered with some simple HTML, for example the root index page
1{{define "title"}}Index page{{end}}
2{{define "content"}}
3<h1 class="text-4xl font-bold">This is my page</h1>
4<div class="prose max-w-full">
5Just a little bit of info on the index page.
6</div>
7{{end}}
8{{ template "core" .}}
I'll build up the pieces of the backend piece by piece, starting from a boilerplate Go http server
1package main
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "net/http"
8 "os"
9)
10
11func main() {
12 port := 8081
13
14 logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
15 slog.SetDefault(logger)
16
17 mux := http.NewServeMux()
18 mux.Handle("GET /", http.HandlerFunc(indexHandler))
19
20 srv := &http.Server{
21 Addr: fmt.Sprintf(":%d", port),
22 Handler: mux,
23 }
24 slog.Info("starting server on :8081")
25 if err := srv.ListenAndServe(); err != nil {
26 slog.Error("error starting server", "error", err.Error())
27 os.Exit(1)
28 }
29}
30
31func indexHandler(w http.ResponseWriter, req *http.Request) {
32 fmt.Fprintf(w, "Path: %s", req.URL)
33}
This doesn't do a whole lot; for all GET
requests simply write Path: {url}
1❯ curl localhost:8081/testing
2Path: /testing
The static handler will serve all content in the static
project directory containing the generated tailwind css
(output.css
) and the favicon (favicon.svg
). This is the simplest handler for this web server is the static handler
since it can be done in one line and doesn't require any special template rendering logic
1mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
http.StripPrefix
here looks a little wonky, but it's there to strip the incoming /static
prefix before forwarding
the request to the filesystem handler. Without it the handler for /static
that returns a filesystem
handler with /static
as the root which won't ever find anything in the static directory unless there was another
static subdirectory. This is what the handler returns without StripPrefix
1❯ curl localhost:8081/static/
2404 page not found
3
4❯ curl localhost:8081/static/output.css
5404 page not found
6
7❯ curl localhost:8081/static/static/
8404 page not found
9
10❯ mkdir -p static/static
11
12❯ find static
13static
14static/static
15static/output.css
16static/favicon.svg
17
18# Returns an empty directory
19❯ curl localhost:8081/static/
20<!doctype html>
21<meta name="viewport" content="width=device-width">
22<pre>
23</pre>
24
25❯ touch static/static/testfile.tmp
26
27❯ find static
28static
29static/static
30static/static/testfile.tmp
31static/output.css
32static/favicon.svg
33
34❯ curl localhost:8081/static/
35<!doctype html>
36<meta name="viewport" content="width=device-width">
37<pre>
38<a href="testfile.tmp">testfile.tmp</a>
39</pre>
As mentioned above I want to embed the majority of content in the build so I there's a bit more needed
1//go:embed static/*
2var staticFS embed.FS
3
4func main() {
5 // ...
6 staticFSSub, err := fs.Sub(staticFS, "static")
7 if err != nil {
8 // try not to panic, but this is good enough
9 // to stop startup
10 panic(err)
11 }
12 mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFSSub))))
13}
Now static content, like the favicon and stylesheets, can be reached directly at /static
1❯ curl localhost:8081/static/favicon.svg
2<!-- favicon.svg -->
3<svg xmlns="http://www.w3.org/2000/svg">
4 <text y="32" font-size="32">👋</text>
5</svg>
The index handler will handle any request that doesn't have handler that takes precedence, as all paths are a subpath of
/
. This means it should handle 404 redirection for all unknown paths in addition to handling the root page. Typically,
this looks something like
1func rootHandler(w http.ResponseWriter, req *http.Request) {
2 ctx := req.Context()
3 switch req.URL.String() {
4 case "/":
5 // render the root template
6 default:
7 // render the not found template
8 }
9}
Rendering pages largely looks the same for any page (as will be seen for the post handlers); the constituent template files just need to be parsed and then the template executed with some provided data;
1func renderPageWithStatus(w http.ResponseWriter, page string, status int, templateData any) {
2 pageTemplate := fmt.Sprintf("%s.tmpl", page)
3 templatePath := fmt.Sprintf("templates/%s", pageTemplate)
4 tpl, err := template.New(page).ParseGlob("templates/layout/*.tmpl")
5
6 if err != nil {
7 slog.Error("failed to parse layout templates", "page", page, "error", err.Error())
8 http.Error(w, "Oh no!", http.StatusInternalServerError)
9 return
10 }
11
12 tpl, err = tpl.ParseFiles(templatePath)
13 if err != nil {
14 slog.Error("failed to parse page template", "page", page, "error", err.Error())
15 http.Error(w, "Oh no!", http.StatusInternalServerError)
16 return
17 }
18
19 buf := &bytes.Buffer{}
20 err = tpl.ExecuteTemplate(buf, pageTemplate, templateData)
21 if err != nil {
22 slog.Error("failed to execute template", "page", page, "error", err.Error())
23 http.Error(w, "Oh no!", http.StatusInternalServerError)
24 return
25 }
26
27 // ignore errors writing to ResponseWriter
28 w.WriteHeader(status)
29 buf.WriteTo(w)
30}
There's a couple notable things happening above: taking an extra step in writing to a bytes.Buffer
before writing the
template to the http.ResponseWriter
and explicitly writing the header status. It seems a bit awkward and unnecessary
at first, but something I've overlooked in the past is how the http.ResponseWriter
implicitly writes a Status OK if
explicit. The motivation above is that writing to a buffer allows catching internal template execution errors before
the templates are written to the client and avoids writing a status code that may not necessarily be accurate.
Basically, this lets us set a 404 (or some other non-200) status then write the content buffer when I'm most confident
the page will render properly. By the time the headers and buffer are written, control over what the
client has received is essentially lost so the error is intentionally ignored.
Again, this works nicely when reading directly from the filesystem, however, if the templates need to be bundled into
the build this requires a bit more care. Like the static filesystem, the template directory needs a
go:embed
and instead of using template.ParseGlob
and template.ParseFiles
, both are replaced by template.ParseFS
1//go:embed templates/*
2var templateFS embed.FS
3
4func renderPageWithStatus(w http.ResponseWriter, page string, status int, templateData any) {
5 pageTemplate := fmt.Sprintf("%s.tmpl", page)
6 templatePath := fmt.Sprintf("templates/%s", pageTemplate)
7 tpl, err := template.ParseFS(templateFS, "templates/layout/*.tmpl", templatePath)
8
9 if err != nil {
10 slog.Error("failed to parse template", "page", page, "error", err.Error())
11 http.Error(w, "Oh no!", http.StatusInternalServerError)
12 return
13 }
14
15 buf := &bytes.Buffer{}
16 err = tpl.ExecuteTemplate(buf, pageTemplate, templateData)
17 if err != nil {
18 slog.Error("failed to execute template", "page", page, "error", err.Error())
19 http.Error(w, "Oh no!", http.StatusInternalServerError)
20 return
21 }
22
23 // ignore errors writing to ResponseWriter
24 w.WriteHeader(status)
25 buf.WriteTo(w)
26}
Putting the pieces together
1func rootHandler(w http.ResponseWriter, req *http.Request) {
2 ctx := req.Context()
3 var err error
4 switch req.URL.String() {
5 case "/":
6 err = renderPageWithStatus(w, "root", http.StatusOK, nil)
7 default:
8 status := http.StatusNotFound
9 err = renderPageWithStatus(w, "error", status, struct {
10 Status int
11 }{
12 Status: status,
13 })
14 }
15}
Handling post data is where things get a bit more interesting since we have to read content from the filesystem and convert the markdown to HTML that can be rendered.
I prefaced this article with
Avoid building abstractions unless I actually need them or if it makes sense
Well... This is somewhere I think this kinda makes sense, namely around listing and reading posts, for a couple reasons:
With that in mind the core API surface looks like
1var ErrPostNotFound = errors.New("post not found")
2
3type Post struct {
4 // Slug is a user-friendly path segment
5 Slug string `yaml:"slug"`
6 // Title is the post title
7 Title string `yaml:"title"`
8 // Description is a short description of the post content
9 Description string `yaml:"description"`
10 // Author is the post author
11 Author string `yaml:"author"`
12 // Date is the date the post was created
13 Date string `yaml:"date"`
14 // Content is the raw string content of the post
15 Content string
16}
17
18type PostReader interface {
19 List() ([]Post, error)
20 Get(slug string) (Post, error)
21}
I went out of my way to introduced a "not found" error because if the read path is decoding posts on every request (which it will, at least for the first pass) I want to distinguish decoding errors from specifically handle-able errors in the presentation layer without any kind of reflection / introspection. Effectively, I want these errors to be part of the reader contract.
The Post
struct fields have some yaml struct tags attached which will be important for how we integrate with
goldmark
to decode front-matter metadata.
The implementation is pretty straightforward, and knowing that I'll want to read from either an embedded filesystem or
os
I have a reasonable sense abstraction to use around fs.FS
1type FSPostReader struct {
2 fsys fs.FS
3}
4
5func NewFSPostReader(fsys fs.FS) *FSPostReader {
6 return &FSPostReader{
7 fsys: fsys,
8}
9
10func (pr FSPostReader) List() ([]Post, error) {
11 files, err := fs.Glob(pr.fsys, "*.md")
12 if err != nil {
13 return nil, fmt.Errorf("failed to glob posts: %w", err)
14 }
15
16 var posts []Post
17
18 for _, path := range files {
19 _, slug := filepath.Split(path)
20 slug, _ = strings.CutSuffix(slug, ".md")
21 post, err := pr.readPostFromFile(path)
22 if err != nil {
23 slog.Warn("could not decode post content", "slug", slug, "error", err.Error())
24 continue
25 }
26 post.Slug = slug
27 posts = append(posts, post)
28 }
29 return posts, nil
30}
31
32func (pr FSPostReader) Get(slug string) (Post, error) {
33 path := fmt.Sprintf("%s.md", slug)
34 switch post, err := pr.readPostFromFile(path); {
35 // error wrapping handles the filesystem errors
36 case err != nil && errors.Is(err, fs.ErrNotExist):
37 return post, ErrPostNotFound
38 case err != nil:
39 return post, fmt.Errorf("failed to retrieve post %s: %w", slug, err)
40 default:
41 post.Slug = slug
42 return post, nil
43 }
44}
45
46func (pr FSPostReader) readPostFromFile(path string) (Post, error) {
47 f, err := pr.fsys.Open(path)
48 if err != nil {
49 return Post{}, fmt.Errorf("failed to open file: %w", err)
50 }
51 defer f.Close()
52 b, err := io.ReadAll(f)
53 if err != nil {
54 return Post{}, fmt.Errorf("failed to read file content: %w", err)
55 }
56
57 post, err := pr.decodePost(b)
58 if err != nil {
59 return Post{}, fmt.Errorf("failed to decode post content: %w", err)
60 }
61 return post, nil
62}
63
64func (pr FSPostReader) decodePost(b []byte) (Post, error) {
65 return Post{}, nil
66}
The long and short of the above is that the filesystem is globbed for all *.md
files and individual files are read
into a byte array. I've specifically left the decodePost
function a stub since it relies on the markdown library for
parsing and converting to .
goldmark
The code above is structured in such a way that it would be fairly straightforward to swap out the markdown parsing and
HTML conversion, so I decided to simply make goldmark
a hard dependency on the implementation. goldmark
offers a
very extendable API for markdown parsing and conversion and there's many community projects that offer commonly used
extensions. In particular, I grabbed goldmark-highlighting) and
goldmark-frontmatter to handle code block rendering and front-matter
parsing.
Adding this to my FSPostReader
implementation was very straightforward
1***************
2*** 47,52 ****
3--- 47,64 ----
4 func NewFSPostReader(fsys fs.FS) *FSPostReader {
5 return &FSPostReader{
6 fsys: fsys,
7+ md: goldmark.New(
8+ goldmark.WithExtensions(
9+ highlighting.NewHighlighting(
10+ highlighting.WithStyle("solarized-dark"),
11+ highlighting.WithFormatOptions(
12+ chromahtml.TabWidth(8),
13+ chromahtml.WithLineNumbers(true),
14+ ),
15+ ),
16+ &frontmatter.Extender{},
17+ ),
18+ ),
19 }
20 }
21
22***************
23*** 105,109 ****
24 }
25
26 func (pr FSPostReader) decodePost(b []byte) (Post, error) {
27! return Post{}, nil
28 }
29--- 117,134 ----
30 }
31
32 func (pr FSPostReader) decodePost(b []byte) (Post, error) {
33! post := Post{}
34! var buf bytes.Buffer
35! parseCtx := parser.NewContext()
36! if err := pr.md.Convert(b, &buf, parser.WithContext(parseCtx)); err != nil {
37! return post, fmt.Errorf("failed to parse content: %w", err)
38! }
39!
40! // Decode frontmatter meta into post then attach content
41! metaDecoder := frontmatter.Get(parseCtx)
42! if err := metaDecoder.Decode(&post); err != nil {
43! return post, fmt.Errorf("could not decode metadata: %w", err)
44! }
45! post.Content = buf.String()
46! return post, nil
47 }
decodePost
is just taking the byte array from the file and pumping it through the goldmark
converter. Since there's
native support for metadata, the frontmatter
extension allows us to decode the yaml at the beginning of the markdown
directly into the Post
struct. This maps the front-matter yaml
1---
2title: Test Page With Code
3description: Doing some testing
4date: 2025-01-01
5author: Seth
6---
directly to
1Post{
2 Title: "Test Page With Code"
3 Description: "Doing some testing"
4 Date: "2025-01-01"
5 Author: "Seth"
6}
All that's needed is to attach the slug, which by convention I've decided is the same as the markdown filename.
With reading and parsing the markdown out of the way, the handlers are basically identical to the rootHandler
except
they're parameterized by the provided PostReader
1//go:embed posts/*
2var postFS embed.FS
3
4const postPathSegment = "slug"
5
6func main() {
7 // other boilerplate
8
9 postFSSub, err := fs.Sub(postFS, "posts")
10 if err != nil {
11 // try not to panic, but this is good enough
12 // to stop startup
13 panic(err)
14 }
15 mux.Handle("GET /posts/", http.HandlerFunc(postsHandler(postReader)))
16 mux.Handle(fmt.Sprintf("GET /posts/{%s}", postPathSegment), http.HandlerFunc(postHandler(postReader)))
17}
18
19
Similar to the embedded static filesystem I opted to take the posts
subtree to make the implementation more consistent
and readable. The postsHandler
and postHandler
functions look very similar to rootHandler
, except now there's the
extra layer of processing from the PostReader
. For brevity only postHandler
is included here since it has the
additional handling based on the error returned from PostReader.Get
1func postHandler(postReader PostReader) http.HandlerFunc {
2 return func(w http.ResponseWriter, req *http.Request) {
3 slug := req.PathValue(postPathSegment)
4 post, getErr := postReader.Get(slug)
5 if getErr != nil {
6 status := http.StatusInternalServerError
7 if errors.Is(getErr, ErrPostNotFound) {
8 status = http.StatusNotFound
9 }
10
11 renderPageWithStatus(w, "error", status, struct {
12 Status int
13 }{
14 Status: status,
15 })
16 } else {
17 renderPageWithStatus(w, "post", http.StatusOK, struct {
18 Title string
19 Author string
20 Content template.HTML
21 }{
22 Title: post.Title,
23 Author: post.Author,
24 Content: template.HTML(post.Content),
25 })
26 }
27 }
28}
Examining the post template
1{{define "title"}}{{ .Title }} | Seth{{end}}
2{{define "content"}}
3<h1 class="text-5xl font-bold">{{.Title}}</h1>
4<div class="mt-2">
5 <p class="text-gray-500">Author: {{.Author}}</p>
6</div>
7<div class="mt-4 prose max-w-full">{{.Content}}</div>
8{{end}}
9{{ template "core" .}}
The data from the struct passed into the page rendering is mapped into specific tags. The end result is something like this
Now we have a functional static site that can render markdown posts! You can find all the code for this post here.
This is functional, but far from optimal. I also omitted a lot of overlooked pieces, like how this can use os filesystem or the embedded. A handful of specific improvements that could be easily made:
/metrics
from :6060
)That's a few improvements, but I'm sure there's many many more.