markdown-preview.nvim is a vim and neovim plugin that opens the markdown files in your browser, keeps them in sync with your cusor and hot reloads when those files change. It supports KaTeX, Mermaid, PlantUML, etc.
I was facing a couple of issues with it. It also hasn't had a commit since July 2024, so I decided to fork it and fix all the existing issues - sammaji/markdown-preview.nvim.
one binary
The plugin has three parts. The editor side is Vimscript that watches your buffer. A server runs in the background and holds the current content. A web page renders it and talks to the server over a socket.
In the original, the server was a Node.js program. To use the plugin you needed Node, and a build step that ran yarn install inside the plugin's directory, or downloaded a pre-packaged binary. I replaced it with single rust binary, which runs the server and serves html as well.
The server is now a single Rust binary, built on axum and tokio. It does three things:
- Talks to the editor over stdin and stdout. The editor starts the binary as a job. Neovim speaks msgpack-RPC on that pipe, and Vim speaks JSON lines over a channel. The binary handles both, which is why Vim 8.1+ still works.
- Serves the preview page over HTTP.
- Pushes content to the page over a WebSocket at
/ws.
The flow on every edit is short. The editor sends a refresh_content notification with the buffer number. The server asks the editor for the lines and cursor position, then sends them to every page showing that buffer. The page renders the markdown itself, so the server never parses any of it.
- No CDN dependency, everything is shipped with the binary itself.
- Works offline.
- You can use the binary directly, outside your editor, like a standalone CLI tool.
any website could read your buffer
This was the part I didn't go looking for.
Browsers don't apply CORS to WebSockets. Any page you have open, on any site, can run new WebSocket("ws://localhost:8090/ws"), and the connection goes through. The original server accepted every connection and sent it the content of whatever you were previewing. So if you were editing a private document with a preview open, any tab could read it. The plugin picks a port between 8080 and 9079, so a page only had to try a thousand of them, which takes a few seconds.
The fix is small. A browser always sends an Origin header with a WebSocket handshake, so the server compares it with the Host it was reached on and refuses a mismatch:
/// A request without an Origin header does not come from a web page.
fn same_origin(headers: &HeaderMap) -> bool {
let Some(origin) = headers.get(header::ORIGIN) else {
return true;
};
// ...
origin_host.is_some() && origin_host == host
}
I found two related problems while I was there:
- Sharing the preview served any file on the machine.
g:mkdp_open_to_the_worldlets you view the preview from a phone or another machine on your network. The endpoint for local images served any file the server could read, so/_local_image_/etc/passwdworked for anyone on the same Wi-Fi. It now serves only images, audio and video. - The shared preview had no access control. Opening it to the network now generates a random token, which goes in the URL the plugin opens. The page keeps it in a cookie, and the server answers pages, images and the WebSocket only with it. Share the whole URL and it works; guess the port and it doesn't.
the page
Most of the visible changes are in the preview page, since that's what you look at all day.
- Themes from shadcn. The page is styled with shadcn/ui theme variables, and
g:mkdp_theme_csstakes any theme exported from shadcn or tweakcn, fonts included. The default is shadcn's neutral theme with GitHub's code colors. - Diagrams follow the theme. Mermaid, flowchart.js, sequence diagrams, Graphviz and PlantUML all used to draw black on white, which was unreadable in dark mode. They now use the theme's colors and redraw when you switch.
- A full-screen Mermaid viewer, with zoom, pan and download as SVG, for the diagrams that don't fit in a column.
- Diagrams that survive typing. If an edit breaks a Mermaid diagram halfway through a line, the page keeps the last drawing that worked and shows the error above it, instead of flashing an error on every keystroke.
- GitHub alerts,
> [!NOTE]through> [!CAUTION], and YAML front matter in a collapsible panel. <details>stay open while you type. Before, every keystroke re-rendered the page and closed them.- Dependencies are current: Mermaid 12, KaTeX 0.18, highlight.js 11, markdown-it 15.
links between files
This was the oldest open request, iamcco#58, from 2019. You preview a README, click [guide](docs/guide.md), and get a 404.
The preview opened at /page/<bufno>, where bufno is the buffer number, and a relative link from there points nowhere. Now the URL is the file's path, relative to the editor's working directory (e.g., /files/README.md). A link to docs/guide.md resolves to /files/docs/guide.md like it would on any static site. The server then either asks the editor for that buffer, if it's open, with your unsaved edits, or reads the file from disk.
Only files under the working directory can be opened, so a link can't wander off to ../../.ssh.
WSL
On WSL the preview often never opened. WSL reports itself as plain Linux, so the plugin ran xdg-open, which usually has nothing to hand the URL to. There were several upstream issues about it (iamcco#55, iamcco#517, iamcco#639, iamcco#710).
The binary now checks /proc/sys/kernel/osrelease for microsoft and, on WSL, tries a chain of openers until one starts:
wslview, from wslu, which opens the URL in your Windows default browser.cmd.exe /c start, if the WindowsPATHis appended to the Linux one./mnt/c/Windows/System32/cmd.exe, for whenappendWindowsPathis turned off andcmd.exeisn't on thePATH.xdg-open, which works if you run a Linux browser through WSLg.
If g:mkdp_browser names a browser, it's first run as a Linux program, then passed to Windows' start. If nothing works, the error lists every command it tried and the URL, so you can at least open it yourself.
g:mkdp_browser also takes a list now, like ["firefox", "-P", "work"], which runs as is with the URL added at the end.
keeping the feel
There's another active fork, selimacerbas/markdown-preview.nvim, and it's pretty cool. It goes the other way - a Lua server inside Neovim, the buffer written to a cache file for a watcher to pick up (slow), and libraries loaded from CDNs.
I wanted to keep my plugin a drop in replacement of the original. Every g:mkdp_* option and command still works, so switching means changing the repo name in your plugin spec.
Here's the full changelog.
If you use the original, try the fork - github.com/sammaji/markdown-preview.nvim, docs at mkdp.sammaji.com.
All credit to @iamcco for the plugin.