Building the blog
I’ve decided to build a personal blog to share my technical projects. Because I have experience building frontends, backends, and setting up cloud infrastructure, I decided to build the whole thing from scratch. I want this blog to be easy to use, and simple enough to maintain as I’m working on other projects. Let’s see what we can put together in an afternoon with go!
Blog Format
Okay, so we’re going to use markdown files as the blog posts. We could create a database, but that adds another service we’d have to setup and configure. If I was planning on scaling the number of posts, a database would make sense. But instead, we can simplify by storing the blog posts as markdown files in a folder. So then the next question is how we can serve these files. The simplest possible solution, is to find the file, send it to the client, and have the client render the markdown in their browser. So let’s investigate markdown rendering in the browser. Searching through the possible packages, I found a package called ‘mark.’ It says it’s been built for speed, but there’s two big problems:
- Bundle size: its minified and compressed bundle size is 13.8kB.
- HTML sanitization: the marked package explicitly does not sanitize HTML.
Bundle size is our main problem. I want the blog to load fast! Therefore, I am going to use the 14kB heuristic. Essentially, TCP has a limit to how much data it can transmit before going back for another round-trip. Each round-trip adds loading time. Now that is variable based on the type of connection or configuration, but if you want your site to load fast, a 14kB initial bundle is a good target. And since the main point of this site is rendering these markdown files, that wouldn’t leave us with much room for styles or other interactivity. Therefore, I’ve elected to render the markdown on the server, and send the HTML directly to the client.
Okay, so what we’ll do is render the HTML before we serve the post.
So add the go markdown package: go get github.com/gomarkdown/markdown
And then we can render the HTML with this function:
func mdToHtml(md []byte) string {
// create markdown parser with extensions
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
p := parser.NewWithExtensions(extensions)
doc := p.Parse(md)
// create HTML renderer with extensions
htmlFlags := html.CommonFlags | html.HrefTargetBlank
opts := html.RendererOptions{Flags: htmlFlags}
renderer := html.NewRenderer(opts)
return string(markdown.Render(doc, renderer))
}
But, something doesn’t feel right. Because everytime someone wants to view a post, we’ll have to search a directory, load a file and render HTML. So instead, let’s pre-compute the HTML. That will avoid file reads every request.
type Posts struct {
Path string
posts []Post
template *template.Template
}
type Post struct {
Path string
Title string
Html string
}
These structs allow us to set a path to the posts directory and a post template. Then we’ll create a list of posts, that stores the HTML to insert into the template. We can define our endpoint like this:
const PostPath = "/posts/{filename}"
func (p *Posts) PostEndpoint(w http.ResponseWriter, r *http.Request) {
filename := r.PathValue("filename")
index := -1
for i, post := range p.posts {
if strings.HasSuffix(post.Path, filename) {
index = i
break
}
}
if index == -1 {
w.WriteHeader(404)
return
}
p.template.Execute(w, p.posts[index])
}
There, now when we get a request to the /posts/{filename} we search through an array, and inject the HTML into our existing template.
If we can’t find the correct file, we return a 404 error.
So now, the question is: how do we create a new post?
Well, I’m going to simply add a new post to the appropriate directory.
But how does the server know?
This is where we define the update function:
func (p *Posts) update() {
slog.Info("Scanning for new posts!")
entries, err := os.ReadDir(p.Path)
if err != nil {
panic(err.Error())
}
p.posts = make([]Post, len(entries))
for i, entry := range entries {
raw_md, err := os.ReadFile(filepath.Join(p.Path, entry.Name()))
if err != nil {
slog.Error("Error reading post!", "Error", err.Error())
continue
}
p.posts[i] = Post{
Path: "/posts/" + entry.Name(),
Title: fileNameToDisplayName(entry.Name()),
Html: mdToHtml(raw_md),
}
}
slog.Info("Loaded posts", "count", len(p.posts))
}
And then we can create a goroutine to run update at a given interval:
func (p *Posts) startWatcher(ctx context.Context, dt time.Duration) {
ticker := time.Tick(dt)
p.update()
for {
select {
case <-ticker:
p.update()
case <-ctx.Done():
return
}
}
}
Okay so now, we’ll define an options function to setup the watcher.
type PostOption func(*Posts)
And then the WithContentWatcher function:
func WithContentWatcher(
cancelCtx context.Context,
dt time.Duration,
path string,
) PostOption {
return func (p *Posts) {
p.Path = path
go p.startWatcher(cancelCtx, dt)
}
}
And to create a new posts representation:
func NewPosts(
opts ...PostOption,
) *Posts {
p := &Posts{}
for _, opt := range opts {
opt(p)
}
return p
}
You may have noticed, we haven’t defined the HTML templates, so let’s do that as well.
func (p *Posts) loadTemplates(fsys fs.FS, path string) {
tmpl, err := template.ParseFS(fsys, path)
if err != nil {
slog.Error("Error loading templates", "Error", err.Error())
return
}
p.template = tmpl
}
func WithHtmlTemplate(fsys fs.FS, path string) PostOption {
return func (p *Posts) {
p.loadTemplates(fsys, path)
}
}
This will load HTML templates given an embedded directory and a file name. So now to use the posts struct in our main server, can can simply do this:
p := posts.NewPosts(
posts.WithContentWatcher(
ctx, time.Second,
"./content",
),
posts.WithHtmlTemplate(
htmlTemplates,
"post.html",
),
)
mux := http.NewServeMux()
mux.HandleFunc(posts.PostPath, p.PostEndpoint)
And that’s all! Now if I add a markdown file to the ./content directory, the server will detect it and provide the /posts/{filename} endpoint!
Frontend
As you’ve already read, we want to make the frontend load as fast as possible. So we’re going make the HTML and CSS as minimal as possible. We’re not going use any javascript for now, because everything we need to do can be handled with HTML. Step one: a landing page.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Austin Hauter</title>
</head>
<body>
<header class="site-header">
<h1>Austin Hauter</h1>
<span class="tagline">Programming, Mathematics, Photography</span>
</header>
<main class="container">
<section class="content-card">
<h2>Recent Posts</h2>
<ul class="post-list">
{{ range . }}
<li>
<a href="{{.Path}}"><span>{{.Title}}</span></a>
</li>
{{ end }}
</ul>
</section>
</main>
<footer class="site-footer">
<p>© Austin Hauter</p>
</footer>
</body>
</html>
Nice, simple, human readable, but still does the things we need. Let’s render it with golang:
func loadTemplates(p *posts.Posts, fsys fs.FS, path string) (http.HandlerFunc, error) {
tmpl, err := template.ParseFS(fsys, path)
return func (w http.ResponseWriter, r *http.Request) {
ps := p.GetPosts()
slog.Info("Found posts", "count", len(ps))
tmpl.Execute(w, ps)
}, err
}
Perfect, so now our HTML is loaded as a go template, and we return an endpoint handler function. You’ll notice we include the posts object pointer, so we can insert a our recent posts into the landing page. Thanks for reading! Stay tuned for further improvements to the blog! I’m thinking we can inject CSS directly into the HTML, to reduce round-trips, add middlewares, and we also have to deploy the blog!