Building Static Sites with Neovim


I enjoy having a personal website.

I don’t enjoy Javascript front-end frameworks—especially for a site that’s mostly static. I previously used Sveltekit for this site, and while it was nice, I’ve been wanting to move to something simpler.

A couple of blogs I follow 1 have mentioned they use custom site generators built around Djot, which got me interested in creating my own. At first I wanted to write my own Djot parser, to make highlighting code blocks easier (and better), but I dropped it a few hours in after it became unfun.

A few weeks later, while reading about the new :TOhtml in nvim 0.10 news, I had a terrible fantastic idea: turning Neovim into a site generator.

What’s to stop me from wiring djot up to a janky neovim plugin?

The Plugin

Enter jolt.nvim.

The core plugin actions are exposed as vim user commands, which means they can be run from the command line as well as interactively.

From the command line:


$ nvim --headless +Jolt
$ nvim --headless "+Jolt watch"
$ nvim --headless "+Jolt clean"

or inside a Neovim session:


:Jolt
:Jolt watch
:Jolt clean

The Build “System”

It’s relatively straight forward:

  1. a user specified content directory is scanned:

    • .dj files are pages to be rendered with djot
    • .html files are templates to be filled with page content
    • any other file is static content to be copied to the output directory
  2. in a filtering pass, code blocks are highlighted (more on this below)

  3. results are written to the output directory

  4. results are cached, so future, single-file updates are quick to rebuild

Djot does most of the real work. My code mainly does highlighting and book keeping.

Highlighting

My options for highlighting were vim.tohtml and treesitter. Figuring vim.tohtml would be easier, I tried it first, extracting the style sheet and code block from the resulting HTML document. As you could probably guess, this was pretty janky, to say the least.

For starters, vim.tohtml only operates on valid winids; which means I have to keep a window around to use for highlighting. This is fine in headless usage, but opening and closing tons of windows sometimes resulted in ui layout shifts.

Layout issues could probably be mitigated with better window management, but there were also issues with the extracted content. I was being lazy and taking each line between <code></code> tags as a line of code, which wasn’t always the case. I’d have to properly parse the HTML to get what I want out of it, and at that point I might as well just generate the HTML myself.

That and wanting more control over the highlight style sheet pushed me to treesitter.

Neovim’s treesitter interface is quite nice actually. In just a few lines you can get an iterator over a query set’s captures:


local parser = vim.treesitter.get_string_parser(source, lang, {})
local queries = vim.treesitter.query.get(lang, "highlights")
local trees = parser:parse()
local root = trees[1]:root() -- returns a list in the case there's injections
for id, node in queries:iter_captures(root, lang) do
    -- ... do highlight stuff
end

Using treesitter is much better than tohtml. I can reliably generate the HTML I want, and I have more control over which highlight groups end up in the final style sheet. However, it’s not without its issues, as multi-line and nested captures can be tricky

Most multi-line nodes are easy to handle (e.g. multi-line strings), but some (e.g. rust doc comments) are not. The annoying thing is this is parser dependent; rust doc comment nodes always span multiple lines, even when they’re only a single line!


/// computes euclidean distance between two entities
pubpub fn dist_to(a: &Entity, b: &Entity) -> f32 {
}

pic of bad hl if above is fixed

whereas the zig parser gives the proper ranges


/// computes euclidean distance between two entities
pub fn dist_to(a: *const Entity, b: *const Entity) f32 {
}

Properly handling nested captures also makes the highlighting code more complex for,
honestly, very little gain. Having nice highlights for string escapes, shell substitutions, and complex type defs is quite nice in you editor. Though in my opinion, they’re not as necessary in blog-style code blocks where the code context window is small.


target=$HOME/.local/bin # no nesting
target="$HOME/.local/bin" # nested in @string

path=${path##*/} # no nesting
path="${path##*/}" # nested in @string

Plus I like the lighter syntax highlighting that skipping the nested nodes gives, so I’ve opted to skip them for now.

For longer examples, see here.

Watch Mode

Vite’s dev mode with file watching and hot reloading is really nice; I like it a lot. I wanted something similar for jolt.nvim. Sure I could use something like watchexec to rerun nvim --headless +Jolt. But where’s the fun in that? If we’re going to [ab]use Neovim this way, we might as well go all in!

Luckily Neovim exposes libuv to lua land, making this quite easy.

We just need to listen on a uv.fs_event, and, after filtering and debouncing for real changes, send the list of changed files to the builder for re-rendering. Since the builder caches the rendered pages, template changes are also supported! Changing a template in watch mode will trigger a re-render for all pages that use it.

Closing Thoughts

Even though it started out as a meme idea: “What if I used nvim to build my website”,
I’m actually really enjoying the workflow I’ve got setup.

  • All the tooling is inside my editor, so I can easily create keybinds for custom actions.
  • The code highlighting is accurate because it uses treesitter2 and not a bunch of regexes.
  • It’s cool to be able to highlight code on my site exactly the same as my editor.
  • I don’t have to deal with arcane templating languages; everything is either Djot or HTML.
  • And it just feels good to make and use my own tools.

It turns out making things for fun is, well, fun!

Source code for this website and jolt.nvim.

Thanks for reading!


  1. The wonderful blogs of matklad and natecraddock. Check them out!↩︎︎

  2. any inaccurate highlighting is result of my own half-baked query parsing lmao↩︎︎