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:
- Word wrap in Neovim for Markdown files (text stays on screen)
- Auto-render on every save via a
BufWritePostautocmd - A
preview-essayshell script that starts browser-sync and opens Brave - A
<Space>pwkeybind to launch the preview from inside Neovim
Prerequisites
- Neovim with
lazy.nvimfor plugin management auto-save.nvim(saves onInsertLeaveandTextChanged)npm/npxavailable on PATH (for browser-sync)entrinstalled (brew install entr)- A custom site renderer script (e.g.
scripts/render-post.py) - Brave Browser installed at
/Applications/Brave Browser.app
Install entr if missing:
brew install entrVerify browser-sync is reachable via npx:
npx browser-sync --versionExpected 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_PIDMake it executable:
chmod +x ~/av/bin/preview-essayVerify ~/av/bin is on your PATH:
which preview-essayIf 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
- Open the essay in Neovim:
nvim src/essays/2026-06-06-my-essay/2026-06-06-my-essay.mdPress
<Space>pwto launch the preview. Brave opens atlocalhost:3000/essays/....Edit in Neovim. Each time you pause (triggering auto-save), the HTML re-renders and Brave reloads automatically.
When done, press
Ctrl-Cin the terminal wherepreview-essayis running (or kill it withpkill -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?
- Join my Signal announce-only group to be notified when I have a new essay up and other important announcements.
- For discussion, join the libertygardeners Signal group.
- Subscribe to my mailing list.
- Email: [email protected]
- Signal: archerships.43
- Website: archerships.com
- Other social media: Substack | Twitter | Facebook | Nostr | Odysee
If you’d like to support my work:
- Share my posts.
- Become a subscriber to my newsletter.
- Attend my live events (dinner parties, conferences, pop-up cities, etc).
- Introduce me to like-minded people.
- Make a one-time donation to support my work: Crypto | Fiat
- Hire me for privacy / crypto / censorship consulting.
If there is a topic you’d like me to cover, please let me know!
Questions, comments, and suggestions are welcome.