Tutorial: Live HTML Preview in Brave While Editing Markdown in Neovim

tutorial · 20% AI

This tutorial sets up a workflow where Neovim renders your Markdown to HTML on every save and Brave auto-reloads to show the result. The setup is designed for a site that uses a custom Python renderer (like archerships.com’s render-post.py), but the pattern applies to any static site generator.

At the end you will have:


Prerequisites

Install entr if missing:

brew install entr

Verify browser-sync is reachable via npx:

npx browser-sync --version

Expected output: a version string like 3.0.4. If this fails, install Node.js first (brew install node).


1. Fix Word Wrap for Markdown Files

Neovim’s default wrap setting is typically off globally (good for code and tables). For Markdown prose, you want soft word wrap that breaks at word boundaries.

Add this to ~/.config/nvim/init.lua:

vim.api.nvim_create_autocmd("FileType", {
  pattern = "markdown",
  callback = function()
    vim.opt_local.wrap = true
    vim.opt_local.linebreak = true   -- break at word boundaries, not mid-word
    vim.opt_local.breakindent = true -- indent wrapped lines to match the line above
  end,
})

This only applies when Neovim detects a Markdown filetype. It does not affect .lua, .py, or any other filetype.

Verify: open any .md file and type a long line. The text should wrap at the window edge and break at spaces, not in the middle of words.


2. Auto-Render on Save

Add a BufWritePost autocmd that detects when a .md file inside src/essays/SLUG/ is written and runs the site renderer asynchronously.

vim.api.nvim_create_autocmd("BufWritePost", {
  pattern = "*/src/essays/*/*.md",
  callback = function()
    local md = vim.fn.expand("%:p")
    local proj = vim.fn.fnamemodify(md, ":h:h:h")  -- 3 levels up: SLUG/ -> essays/ -> src/ -> project root
    local script = proj .. "/scripts/render-post.py"
    if vim.fn.filereadable(script) == 1 then
      vim.fn.jobstart({ "python3", script, md }, { detach = true })
    end
  end,
})

The pattern */src/essays/*/*.md matches any .md file two levels under a src/essays/ directory – matching the project layout src/essays/SLUG/SLUG.md. Files outside this path are ignored.

vim.fn.jobstart with detach = true runs the renderer in the background without blocking Neovim or producing output in the editor.

Path calculation

The project root is derived by walking up three directories from the .md file:

src/essays/SLUG/SLUG.md
           ↑ :h      → src/essays/SLUG/
       ↑ :h:h        → src/essays/
   ↑ :h:h:h          → src/

Adjust the :h count if your project layout differs.

Note on auto-save

If you have auto-save.nvim configured to save on InsertLeave and TextChanged, the BufWritePost autocmd fires automatically as you type – you do not need to manually press :w.


3. The preview-essay Script

This script does three things: renders the essay once immediately, starts browser-sync watching the src/ directory, and opens Brave at the correct localhost URL.

Save this as ~/av/bin/preview-essay and make it executable:

#!/usr/bin/env bash
# preview-essay -- render an essay .md and open it with browser-sync auto-reload in Brave.
# Usage: preview-essay [path/to/SLUG.md]
set -euo pipefail

MD="${1:-}"

# If no arg, find the most recently modified essay .md
if [[ -z "$MD" ]]; then
    MD=$(find "$HOME/av/prj/archerships.com/src/essays" -name "*.md" \
         ! -name "contact-snippet*" -newer "$HOME/av/prj/archerships.com/src/essays" \
         2>/dev/null | head -1)
fi

[[ -z "$MD" ]] && { echo "[ERROR] No essay .md found. Pass path as argument." >&2; exit 1; }

MD=$(realpath "$MD")
SLUG_DIR=$(dirname "$MD")
SLUG=$(basename "$SLUG_DIR")
PROJ=$(cd "$SLUG_DIR/../../.." && pwd)   # archerships.com root
SRC="$PROJ/src"

# Initial render
echo "[preview-essay] Rendering $SLUG..."
python3 "$PROJ/scripts/render-post.py" "$MD"

# Start browser-sync serving src/ in background
echo "[preview-essay] Starting browser-sync on http://localhost:3000"
npx browser-sync start \
    --server "$SRC" \
    --files "$SRC/**/*.html" "$SRC/**/*.css" \
    --no-open \
    --logLevel silent &
BS_PID=$!

sleep 1

# Open the essay in Brave
URL="http://localhost:3000/essays/$SLUG/$SLUG.html"
echo "[preview-essay] Opening $URL"
open -a "Brave Browser" "$URL"

echo "[preview-essay] Watching for changes. Ctrl-C to stop."
trap "kill $BS_PID 2>/dev/null; echo '[preview-essay] Stopped.'" EXIT
wait $BS_PID

Make it executable:

chmod +x ~/av/bin/preview-essay

Verify ~/av/bin is on your PATH:

which preview-essay

If not found, add export PATH="$HOME/av/bin:$PATH" to your ~/.zshrc.

How browser-sync works

browser-sync serves the src/ directory at http://localhost:3000 and injects a small script into each page. When a watched file changes (any .html or .css under src/), it signals all connected browsers to reload. No browser extension is needed.

Because the BufWritePost autocmd re-renders the .html file on every save, the sequence is:

type in Neovim
  → auto-save fires (InsertLeave / TextChanged)
    → BufWritePost fires → render-post.py updates the .html
      → browser-sync detects the .html change → Brave reloads

The full loop typically completes in under one second.


4. Neovim Keybind

Add a keybind to launch preview-essay for whatever .md file is currently open:

vim.keymap.set("n", "<leader>pw", function()
  local md = vim.fn.expand("%:p")
  vim.fn.jobstart({ vim.fn.expand("$HOME") .. "/av/bin/preview-essay", md }, { detach = true })
  vim.notify("preview-essay: opening Brave at localhost:3000", vim.log.levels.INFO)
end, { desc = "Preview essay in Brave with auto-reload" })

With vim.g.mapleader = " ", this binds to <Space>pw (Space → p → w).


5. Complete init.lua Additions

For reference, the three blocks added to ~/.config/nvim/init.lua:

-- Markdown: word wrap per filetype
vim.api.nvim_create_autocmd("FileType", {
  pattern = "markdown",
  callback = function()
    vim.opt_local.wrap = true
    vim.opt_local.linebreak = true
    vim.opt_local.breakindent = true
  end,
})

-- archerships.com: re-render essay HTML on every .md save
vim.api.nvim_create_autocmd("BufWritePost", {
  pattern = "*/src/essays/*/*.md",
  callback = function()
    local md = vim.fn.expand("%:p")
    local proj = vim.fn.fnamemodify(md, ":h:h:h")
    local script = proj .. "/scripts/render-post.py"
    if vim.fn.filereadable(script) == 1 then
      vim.fn.jobstart({ "python3", script, md }, { detach = true })
    end
  end,
})

-- <leader>pw: launch preview-essay for the current file
vim.keymap.set("n", "<leader>pw", function()
  local md = vim.fn.expand("%:p")
  vim.fn.jobstart({ vim.fn.expand("$HOME") .. "/av/bin/preview-essay", md }, { detach = true })
  vim.notify("preview-essay: opening Brave at localhost:3000", vim.log.levels.INFO)
end, { desc = "Preview essay in Brave with auto-reload" })

Place these blocks after the require("lazy").setup({...}) call and before any filetype-specific config.


6. Day-to-Day Workflow

  1. Open the essay in Neovim:
nvim src/essays/2026-06-06-my-essay/2026-06-06-my-essay.md
  1. Press <Space>pw to launch the preview. Brave opens at localhost:3000/essays/....

  2. Edit in Neovim. Each time you pause (triggering auto-save), the HTML re-renders and Brave reloads automatically.

  3. When done, press Ctrl-C in the terminal where preview-essay is running (or kill it with pkill -f browser-sync).


7. Verification

After setup, confirm each piece works:

[ ] nvim opens .md with wrap enabled (long lines fold at word boundaries)
[ ] editing and pausing causes the .html to update (check file mtime: ls -la *.html)
[ ] preview-essay starts without error and prints the localhost URL
[ ] Brave opens at localhost:3000 and shows the rendered essay
[ ] editing in nvim triggers a Brave reload within ~1 second

Troubleshooting

preview-essay: command not found~/av/bin is not on PATH. Add export PATH="$HOME/av/bin:$PATH" to ~/.zshrc and reload with source ~/.zshrc.

browser-sync: 404 on the essay URL – The PROJ path calculation may be wrong for your directory layout. Run realpath "$SLUG_DIR/../../.." manually and confirm it points to the archerships.com root (the directory containing src/ and scripts/).

Brave does not reload – Confirm browser-sync is running (curl -s http://localhost:3000 | head -5). If the HTML file is not changing, check that the BufWritePost autocmd is matching: run :autocmd BufWritePost in Neovim and look for the pattern.

render-post.py not found – The filereadable(script) check silently skips files outside the archerships.com project. Confirm the script path with :lua print(vim.fn.fnamemodify(vim.fn.expand("%:p"), ":h:h:h") .. "/scripts/render-post.py").

Text still wrapping mid-word – Confirm linebreak is set: :set linebreak? should return linebreak. If not, the FileType autocmd may not be firing – check :set filetype? returns markdown.


Want to stay in touch?

If you’d like to support my work:

If there is a topic you’d like me to cover, please let me know!

Questions, comments, and suggestions are welcome.