2024-11-10

Smolblog

Earlier in the year, I felt the annual urge to build a blogging system from scratch, called smolblog. Of course, there are numerous of these and this is my disclaimer that you should probably use one of those. But here we are.

Previously, I had been using a homegrown static renderer that I had built in Rust as more of a learning exercise.

Design Goals

So what do I want out of something that makes my blog?

  1. Supports static site generation

This might be the most straightforward requirement, since once you have a site rendering with a server, you can use wget to crawl it and save the rendered pages. But most importantly, I want to host this for free, and that means using GitHub Pages.

  1. Supports Markdown

At this point, if it's hard to edit in Vim, I want no part of it.

  1. Supports "views"

I want to have something resembling a component, so I can reuse a header and footer, or even pieces that resemble a bit of a design system.

Enter the manifest

The heartbeat of smolblog is the manifest.json. It's a JSON file that has very little schema around it, but serves as the "database" of what gets rendered and where on your site. You pass this as a flag to the binary:

smolblog -manifest=manifest.json

At the top level, these are the structs that govern the layout:

// Manifest is the structure of the data driving the web server.
//
// It has two main pieces:
// - `layouts`, which are any templates that are globbed
// - `rotues`, which are registered as get routes and served by the handler
type Manifest struct {
	Layouts []string         `json:"layouts"`
	Routes  map[string]Route `json:"routes"`
}

// Route is a registered path that is run when a GET request is made to it.
type Route struct {
	// If the route is simply a static file
	StaticPath  string `json:"static_path"`
	ContentType string `json:"content_type"`
	// The name of the template to execute first
	Template string `json:"template"`
	// Any arbitrary arguments to be used in executing the template
	Args map[string]any `json:"args"`
}

For your topmost keys, there's layouts, which tell the server what templates to glob and parse, and routes, which are the paths that the server recognizes for GET calls and renders as content. You can see the manifest for my blog here.

On startup, the smolblog binary just starts a server that has a single ServeHTTP function. It disregards anything but a GET call. More importantly, it's at this point that the manifest is read and layouts are parsed, meaning any changes to your files will be reflected on the next request, similar to a --watch flag for other binaries.

Once a request is received and things parse, the server checks the manifest for a matching route. Here, we have two modes depending on what is filled out for the route:

  1. If StaticPath is filled out, the server will serve that file with the ContentType header. Useful for stuff like CSS or javascript.
  2. Otherwise, the server will execute the Template with the given arguments.

The second mode makes full use of Go's html/template package, which is not only very powerful, but also pretty easy to reason about if you keep it relatively flat. For example, here's the route that renders this blog post:

"/posts/smolblog.html": {
  "template": "post",
  "args": {
    "title": "Smolblog",
    "publish_date": "2024-11-10",
    "description": "The smallest webserver around Go templates powering my site.",
    "markdown": "./posts/2024_11_10_smolblog.md"
  }
},

We've got the template and args field filled out. Now here's the template for post:

{{define "post"}}
<!DOCTYPE HTML>
<html lang="en">

<head>
  <title>{{ .Args.title }}</title>
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta charset="UTF-8">
  <meta name="description" content="{{ .Args.description }}">
  <link rel="stylesheet" href="/static/css/main.css">
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">
  <link href="https://fonts.googleapis.com/css2?family=Roboto&display=swap" rel="stylesheet">
</head>

<body>
	{{- template "header" . }}

  <div class="post with-gutter">
    <div class="publish-date">{{ .Args.publish_date }}</div>
    <h1 class="post-title">{{ .Args.title }}</h1>

    {{ renderMarkdown .Args.markdown }}

  </div>

	{{- template "footer" }}
</body>

</html>
{{end}}

Honestly, I could probably clean this up a bit more by moving the head into the header template, but this makes for a good example. You'll notice a renderMarkdown call in there, which is a built-in function to smolblog to handle the markdown requirement. That might have been the simplest part of this all since it's a call to the github.com/yuin/goldmark package.

Rendering and Hosting

To render your site, just call smallblog with the --output flag, telling it where to save your generated site. Under the hood, it's using wget with a number of flags to properly use the incantation:

if output != "" {
		cmd := exec.Command("wget")
		cmd.Args = []string{
			"-nv",
			"-nH",
			"-P",
			output,
			"-r",
			"-E",
			fmt.Sprintf("0.0.0.0:%d", port),
		}
		cmd.Stderr = os.Stderr
		if err := cmd.Run(); err != nil {
			return fmt.Errorf("error running wget: %s", err)
		}

For this blog, that output is going to the docs directory, where Github Pages knows to serve it from.

Future stuff

I'll be honest: for a text-based, small website with only content to read and not write, there's not a whole lot of motivation for me to add stuff at this time. Buuuuuuttt of course there's a few things on the horizon that I'd like to add:

Plugins

I want there to be a way for folks to extend this and add functions to the template rendering that they need. To keep it simple, this will likely mean packaging up smolblog as a library and allowing other people to make their own binary and inject their own functions. Something like:

package main

import (
	"github.com/jdholdren/smolblog"
)

func main() {
	smolblog.Run(smolblog.Config{
		ManifestPath: "manifest.json",
		Plugins: []smolblog.Plugin{
			{
				Name: "myplugin",
				F: func() string {
					return "Hello from myfunc!"
				},
			},
		},
	})
}

I dunno. Something like that. I'm assuming the user is a Go person, so hopefully this is a reasonable hurdle to ask them to climb.

Images

When I want to include diagrams, I usually lean on ASCII art, but there's a future where I want to include images. Plus on the off-chance I feel like want to share publicly some of my photography, it'd be great to not bog down the site with the full resolution photos. I'll need to go back and look at how a static site could deliver responsive images, but it might be worth bundling in an image processor to ensure I'm not leaving too much burden on the user to figure all that out themselves.

That's it!

Give it a try if you think this fits your use case as well! Good luck and happy writing!