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.
- I want a site with highlighted code blocks
- Djot has a lua implementation
-
Neovim has treesitter and
:TOhtml
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:
-
a user specified content directory is scanned:
-
.dj
files are pages to be rendered withdjot
-
.html
files are templates to be filled with page content - any other file is static content to be copied to the output directory
-
-
in a filtering pass, code blocks are highlighted (more on this below)
-
results are written to the output directory
-
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!
-
The wonderful blogs of matklad and natecraddock. Check them out!↩︎︎
-
any inaccurate highlighting is result of my own half-baked query parsing lmao↩︎︎