Home

Adding swag to my zero-dependency static website generator

Updated . Added a section on the swag.nvim plugin.

A month ago I wrote about building this site with nothing but Emacs and Org mode. I called it a zero-dependency static website generator. This site now builds with a tool I wrote instead. It is called swag, short for Static Website Assembler and Generator. The switch made me count what the site really depends on, and the old count was wrong.

What changed

The posts are now Markdown files, and I write them in neovim. A single Go binary called swag turns the content/ directory into the docs/ directory that GitHub Pages serves. Every URL from the Org version still works, so old links and the RSS feed did not move.

The repository looks like this now:

content/    Markdown pages and posts
layouts/    HTML templates that wrap each page
static/     CSS, images and CNAME, copied as is
docs/       generated site served by GitHub Pages
swag.conf   site title, URL and description

The config is a plain key = value file:

site.title        = Nathan Tebbs
site.url          = https://vetr0s.dev
site.description  = Notes on software and the things I learn while building.
params.stylesheet = /style.css

Every post starts with a front matter block:

---
title: Adding swag to my zero-dependency static website generator
date: 2026-10-23
description: Swapping Emacs for a small Go program.
---

That is not YAML. Each line is a key, a colon and a value. swag has no YAML parser, and it doesn't need one.

Why I left Org Publish

My editor changed. I moved my day-to-day work from Emacs to neovim, and the build had to move out of the editor with it. The Org setup only ran inside Emacs, either through M-x or through a batch Emacs process. Keeping Emacs installed just to publish a blog felt backwards.

I also wanted a few things Org Publish made awkward:

I did not want a framework. swag has no plugin system, no themes, no tags and no pagination. It is about 2,200 lines of Go plus 1,400 lines of tests, and I can read the whole thing in an afternoon.

Counting dependencies honestly

"Zero dependency" was never literal. Here is how I count now.

Level 0 is the platform the generator runs on. Every option needs one. Before, the platform was Emacs. Org mode ships with Emacs, so I counted Org Publish as free. That was only true because Emacs was already my editor. Syntax highlighting also needed htmlize, which is a third-party package. Doom Emacs happened to install it for me, so I never noticed.

Now the platform is Go. Go compiles swag, and swag uses only the standard library. Its go.mod has no require lines:

module github.com/vetr0s/swag

go 1.27.3

A test fails if that ever changes. The compiled result is one 13 MB binary, and running it needs no Go toolchain.

That leaves one real addition, which is pandoc. swag contains no Markdown parser. For every .md file it runs a command from the config, writes the post body to that command's stdin, and reads HTML back from its stdout. The default command is pandoc:

render.md = pandoc --from=gfm --to=html5

Pandoc also does the syntax highlighting, so htmlize is gone. A site written in plain .html files would not need pandoc at all.

So the honest before and after:

before:  Emacs (platform) + Org (bundled) + htmlize
after:   Go (platform)    + pandoc

I swapped one platform for another and traded htmlize for pandoc. The count did not drop to zero. It stayed at one, and this time I can name it.

How I use it

Writing a post starts with swag new. It prints only the path of the file it creates, so it composes with the editor:

nvim "$(swag new 'Adding swag to my zero-dependency static website generator')"

The new file starts as a draft. While I write, swag serve runs in another terminal. It builds into a temporary directory, never into docs/, and serves the result on localhost. It checks for changes every 500 milliseconds and reloads the browser after each rebuild.

Inside neovim, swag build works as a make program:

:set makeprg=swag\ build
:make

Errors come out as path:line: message, so a bad date or a broken link lands in the quickfix list:

content/posts/about-me.md:3: invalid date "2026-13-01": use YYYY-MM-DD
content/posts/colophon.md: broken link "/posts/old.html"

A full build of this site takes under a second. Nearly all of that is pandoc rendering this post, which is the longest one. swag builds into a temporary directory and replaces docs/ only after every step has passed. A broken post can't leave half a site behind.

Publishing did not change. I run swag build, commit docs/ and push, and GitHub Pages serves the files.

The neovim plugin

Since writing this I added swag.nvim, a small plugin that runs swag from inside neovim. It replaces the makeprg setup above. It is about 150 lines of Lua and has no dependencies beyond swag itself.

It adds four commands:

:SwagNew {title}    create a draft post and open it (:SwagNew! makes a page)
:SwagBuild          build in the background; errors go to the quickfix list
:SwagServe          start the preview server
:SwagStop           stop it

While :SwagServe runs, every rebuild replaces the quickfix list with that rebuild's errors. A bad date shows up about a second after I save, and the list empties when I fix it. Broken links show up as warnings until I build for real. Every command runs swag from the current buffer's directory, so it finds the site from any file in it.

Neovim 0.12 has a built-in package manager, so installing it is one line in my config:

local gh = function(repo) return 'https://github.com/' .. repo end

vim.pack.add({
  gh('vetr0s/swag.nvim'),
})

There is no setup() call. If you want keys for the commands, map them like any other:

vim.keymap.set('n', '<leader>wn', ':SwagNew ', { desc = 'New post' })
vim.keymap.set('n', '<leader>wb', '<cmd>SwagBuild<CR>', { desc = 'Build the site' })
vim.keymap.set('n', '<leader>ws', '<cmd>SwagServe<CR>', { desc = 'Preview the site' })

Where it lives

swag is on GitHub at vetr0s/swag under the MIT license. The plugin is at vetr0s/swag.nvim, and the source for this site is at vetr0s/vetr0s.dev. If you have Go installed, this gets you the same binary:

go install github.com/vetr0s/swag@latest

I built swag for one site and one maintainer. If it fits your site too, take it.