Hot Reloading with Pandoc (Part 2)
2025-09-21
In part 1 we got a hot reloader for markdown files working in a basic way, but with the following issues:
- I have to save every time I want to see a change
- It takes a noticeable half second or second for the changes to render
- It opens a new tab in Firefox every time I save, so my browser is quickly flooded with open tabs of my blog post.
- Using
open
changes the focus from my terminal to Firefox, so I have to cmd+tab back to the terminal each time I save.
I’m going to start with issues 3 and 4 first.
It’d be nice if we could reload the page without opening a new tab on each change. But how?
Refreshing The Page
If we could send a command to the web page we’re on to
refresh instead of opening a new tab, we could refresh the page
using window.location.reload()
in Javascript.
If we start a WebSocket server locally and embed a script in the generated HTML that connects to the WebSocket server, we can establish a communication channel between our markdown watcher and the webpage. Then, when a change takes place, we send a message to our WebSocket server which sends a refresh message to the webpage.
So the first thing we need is a simple WebSocket server.
A Simple WebSocket server
Luckily, Bun provides a simple WebSocket server in their docs which we can modify slightly:
const server = Bun.serve({
: 9160,
portfetch(req, server) {
// open a websocket connection if the request
// starts with ws://
const success = server.upgrade(req);
if (success) {
return undefined;
}
.publish('blog', 'refresh');
serverreturn new Response();
,
}: {
websocketopen(ws) {
.subscribe('blog');
ws,
},
};
})
console.log(`Listening on ${server.hostname}:${server.port}`);
Now we need a client script to connect to web socket server.
Create one called ws_client.html
:
<script>var ws=new WebSocket('ws://127.0.0.1:9160');ws.onmessage=function(event){if(event.data==='refresh'){window.location.reload();ws.close();}};</script>
I don’t want to actually add it to my template file, since then it would be included in the final version I upload to my website. So we’ll use pandoc to add it to the end of the HTML:
pandoc -s --template=template.html -i $file -o $output -A ws_client.html
And then when I compile it to upload to my website, I’ll just
omit the -A
option.
The template.html also needs a small addition:
<body>
<!-- put this right before the end of the body -->
$for(include-after)$
$include-after$
$endfor$</body>
Now our watch.sh
script looks like this:
file=$1
filename="${file%.md}"
output="$filename.html"
# move open outside of the loop, to open once
# at the beginning
open $output
if [[ ! -e "$file" ]]; then
touch $file
fi
# run the websocket server in the background
bun run ws_server.ts &
fswatch -o $1 | while read num ;
do
pandoc -s --template=template.html -i $file -o $output -A ws_client.html
curl localhost:9160 # refresh
done
Let’s look at our remaining issues now:
- I have to save every time I want to see a change
- It takes a noticeable half second or second for the changes to render
It opens a new tab in Firefox every time I save, so my browser is quickly flooded with open tabs of my blog post.Usingopen
changes the focus from my terminal to Firefox, so I have to cmd+tab back to the terminal each time I save.
Updating without saving
It’s annoying to have to type :w
every time I
want to see the updated preview. We can see the changes to the
file without saving by using Vim autocommands. Autocommands
are a way to specify custom actions to be run when certain Vim
events occur.
What we want to do is tell Vim that whenever the user stops typing for a while, write the whole file to a temporary file. Then use pandoc to render the temporary file, instead of the original.
Here is the script we’ll use:
set updatetime=100
autocmd CursorHold,CursorHoldI ~/jakewilson/*.md call WriteFile()
function! WriteFile()
let l:temp_file = "~/jakewilson/blog_tmp.md"
silent! execute 'w! ' . l:temp_file
endfunction
Let’s go over this line by line, starting at line 2:
autocmd CursorHold,CursorHoldI ~/jakewilson/*.md call WriteFile()
Add an autocommand for the CursorHold
and
CursorHoldI
events; when those events fire, call
the WriteFile
function. The
~/jakewilson/*.md
says only apply this command in
files in the ~/jakewilson
directory that end in
.md
.
The CursorHold
and CursorHoldI
events are defined by Vim as:
|CursorHold| the user doesn't press a key for a while
|CursorHoldI| the user doesn't press a key for a while in Insert mode
What does “a while” mean? This takes us to line 1, where we define the time period as 100 milliseconds:
set updatetime=100
Finally, we have the WriteFile
function:
function! WriteFile()
let l:temp_file = "~/jakewilson/blog_tmp.md"
silent! execute 'w! ' . l:temp_file
endfunction
First, we set the filename to
~/jakewilson/blog_tmp.md
. Then we execute Vim’s
w
command, which means ‘write’, and we write to the
temp file. The silent!
prevents any output from
appearing on the screen.
By naming this script autosave.vim
and placing
it in ~/.vim/plugin/
, this script will run every
time Vim opens a file, but the autocommand will only apply to
markdown files in ~/jakewilson
.
All we need to do now is to use blog_tmp.md
as
the file to watch instead of whatever file we were editing:
$ ./watch.sh blog_tmp.md
Since autosave.vim
will always write to
blog_tmp.md
, we can simplify our
watch.sh
script considerably:
filename=blog_tmp
input="$filename.md"
output="$filename.html"
# create blog_tmp.html if it doesn't exist
if [[ ! -e "$output" ]]; then
touch $output
fi
open $output
bun run ws_server.ts &
fswatch -o $input | while read num ;
do
pandoc -s --template=template.html -i $input -o $output -A ws_client.html
curl localhost:9160
done
Now we have a functional hot reloader for markdown files.
Whenever I run watch.sh
and open a markdown file in
~/jakewilson
and start typing, a new tab is opened
in my browser and updates pretty quickly as I type. It is pretty
fast, but it is a bit slower than I would like.
We’ll tackle speeding it up in part 3!