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.
So what do I want out of something that makes my blog?
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.
At this point, if it's hard to edit in Vim, I want no part of it.
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.
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:
StaticPath
is filled out, the server will serve that file with the ContentType
header.
Useful for stuff like CSS or javascript.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.
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.
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:
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.
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.
Give it a try if you think this fits your use case as well! Good luck and happy writing!