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:

  1. I have to save every time I want to see a change
  2. It takes a noticeable half second or second for the changes to render
  3. It opens a new tab in Firefox every time I save, so my browser is quickly flooded with open tabs of my blog post.
  4. 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({
  port: 9160,
  fetch(req, server) {
    // open a websocket connection if the request
    // starts with ws://
    const success = server.upgrade(req);
    if (success) {
      return undefined;
    }

    server.publish('blog', 'refresh');
    return new Response();
  },
  websocket: {
    open(ws) {
      ws.subscribe('blog');
    },
  },
});

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:

  1. I have to save every time I want to see a change
  2. It takes a noticeable half second or second for the changes to render
  3. It opens a new tab in Firefox every time I save, so my browser is quickly flooded with open tabs of my blog post.
  4. Using open 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!