This Site From Scratch

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

What do I want this site to be?

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.

Some guard rails

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.

Scaffold

Project Layout

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:

.
|____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).

Tailwind

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:

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.

Layout Templates

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">&#128279;</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" .}}
index

Result of above index template

Web Server Code

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

Static

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>

Index

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 templates

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}

Posts

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.

Reading and Parsing Posts

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:

  1. Writing these is iterative with fairly tight loops so I want to have a different implementation while writing locally so I can see my changes immediately
  2. I don't think it's out of the realm of possibility that I may want to change where posts come from in the future. For example, I may move these to an object store, like S3, or god forbid a database of some kind.
  3. I can write routing / rendering tests using test doubles instead of relatively complicated dependency setup

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.

Reading

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 .

Parsing with 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.

HTTP Handler

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

post-ex

Result of above post template

Conclusion

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:

That's a few improvements, but I'm sure there's many many more.